From 723de2c332aa88975eb56b7dc5b865e9278d3ac4 Mon Sep 17 00:00:00 2001 From: goober Date: Fri, 21 Aug 2026 15:40:38 +0300 Subject: [PATCH] feat: model cartridge set bonuses --- data-src/guides.json | 1194 ++++++++++++++++++++++++++++++++++++- data-src/set-bonuses.json | 176 ++---- src/domain/guides.ts | 15 + src/domain/setbonus.ts | 53 +- src/solver/job.ts | 5 +- src/solver/protocol.ts | 4 +- src/solver/single.ts | 6 +- src/ui/CharacterView.tsx | 40 ++ src/ui/TeamTab.tsx | 5 + src/ui/styles.css | 14 +- tests/domain.test.ts | 52 +- tests/guides.test.ts | 51 +- tests/host.test.ts | 16 + tests/solver.test.ts | 13 +- tests/team.test.ts | 1 + tools/check-guides.ts | 28 +- 16 files changed, 1486 insertions(+), 187 deletions(-) diff --git a/data-src/guides.json b/data-src/guides.json index 7fcbbe1..22e5d68 100644 --- a/data-src/guides.json +++ b/data-src/guides.json @@ -1,13 +1,1197 @@ { "format": "nte-guides", "format_version": 1, - "_source": "Prydwen build guides, entered by hand and reviewed before committing.", - "_why_not_scraped": "Prydwen's character pages are client-rendered, so a plain fetch returns only navigation chrome, and their Gatsby /page-data/ endpoint answers 410 Gone. Their robots.txt does allow content pages with a 10 second crawl-delay, so a headless render would be permitted - it is just not something the build can do unattended. Entries below are typed in from the published guide.", + "_source": "https://www.prydwen.gg/neverness-to-everness/characters/ - the Build tab, read on 2026-08-21 through a real browser session at their stated Crawl-delay of 10s.", + "_priority_is_substats": "`priority` is Prydwen's Sub Stats ranking, which is what the field documents. Their separate Main Stats ranking is not merged in: combining two published rankings into one would be our construction, not theirs. A main-stat-only priority (usually the elemental DMG bonus) therefore keeps its published target but sits at weight 1.", + "_ties": "Prydwen writes ties as `A = B` and emphasis as `>>`. Both are flattened to a plain ordering that keeps the printed left-to-right order, because the schema forbids duplicate ranks.", + "_targets_omit_totals": "Prydwen's `HP:`, `ATK:` and `DEF:` endgame lines are character TOTALS. The vector the solver scores is gear + Arc only and carries no character base stats, so those three are not comparable and are left out rather than mapped to the flat gear slots, which would be a units error. Every other published line has a zero or negligible base component and is carried through as published. Note Crit DMG still includes the character's ~50% base, so that target is conservative rather than exact.", + "_ranges": "Where a range or a conditional was published (`50% ~ 80%+`, `64-74%+`, `100%+ before 80% from Arc buff`) the lower bound is used, since a target is a floor to reach.", + "_absent": "A character with no published build data is absent, not guessed: linko.", "_shape": { "key": "the GA__* ability key, so it joins to a capture directly", - "variants": "one per published priority set (Main DPS, SubDPS, Break, DoT)", + "sets": "Prydwen's ranked cartridge set recommendation. Recorded as published; the solver still enumerates every owned set itself.", + "variants": "one per published priority set", "targets": "recommended endgame stats, raw stat ids, percentages as fractions", - "priority": "substat ranking, best first; weights are derived from position" + "priority": "substat ranking, best first; weights are derived from position", + "updated": "the date this entry was read from Prydwen, not their revision date" }, - "characters": [] + "characters": [ + { + "key": "Adler", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/adler", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Kingdom's Guard" + }, + { + "rank": 2, + "name": "Speedy Hedgehog" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.05 + }, + { + "stat": "CritDamageBase", + "target": 0.5 + }, + { + "stat": "MagBase", + "target": 50.0 + }, + { + "stat": "UnbalIntensityBase", + "target": 75.0 + } + ], + "priority": [ + "DefUp", + "DefAdd", + "UnbalIntensityBase", + "MagBase", + "DamageUpGeneralBase" + ] + } + ] + }, + { + "key": "Mitsuki", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/aurelia", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Devil's Blood: Curse" + }, + { + "rank": 2, + "name": "Lost Radiance" + }, + { + "rank": 3, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.6 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpPsycheBase", + "target": 0.3 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd" + ] + } + ] + }, + { + "key": "Cang", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/baicang", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Crimson: Twin Butterflies" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.75 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "UnbalIntensityBase", + "target": 25.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpIncantationBase", + "target": 0.475 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd", + "UnbalIntensityBase" + ] + } + ] + }, + { + "key": "Chaos071", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/chaos", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Street Boxer" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.64 + }, + { + "stat": "CritDamageBase", + "target": 1.6 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpLakshanaBase", + "target": 0.675 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Chiichan073", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/chiz", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Lost Radiance" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.4 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.2 + }, + { + "stat": "DamageUpCosmosBase", + "target": 0.55 + } + ], + "priority": [ + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Daffodill", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/daffodil", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Diabolos" + } + ], + "variants": [ + { + "name": "Awakening 0 (No Dupes)", + "targets": [ + { + "stat": "CritBase", + "target": 0.45 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpChaosBase", + "target": 0.5 + }, + { + "stat": "UnbalIntensityBase", + "target": 150.0 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd", + "UnbalIntensityBase" + ] + }, + { + "name": "Awakening 2+ (Using Awakening 3 and 5)", + "targets": [ + { + "stat": "CritBase", + "target": 0.3 + }, + { + "stat": "CritDamageBase", + "target": 0.6 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpChaosBase", + "target": 0.4 + }, + { + "stat": "UnbalIntensityBase", + "target": 350.0 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd", + "UnbalIntensityBase" + ] + } + ] + }, + { + "key": "Edgar", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/edgar", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Thea's Night Tavern" + }, + { + "rank": 2, + "name": "Speedy Hedgehog" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.05 + }, + { + "stat": "CritDamageBase", + "target": 0.5 + }, + { + "stat": "MagBase", + "target": 50.0 + }, + { + "stat": "UnbalIntensityBase", + "target": 50.0 + } + ], + "priority": [ + "HPMaxUp", + "HPMaxAdd", + "MagBase", + "UnbalIntensityBase" + ] + } + ] + }, + { + "key": "Fadia", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/fadia", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Tiny Big Adventure" + }, + { + "rank": 2, + "name": "Quiet Manor" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.05 + }, + { + "stat": "CritDamageBase", + "target": 0.5 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.2 + } + ], + "priority": [ + "HPMaxUp", + "HPMaxAdd", + "DamageUpGeneralBase", + "CritBase", + "CritDamageBase" + ] + } + ] + }, + { + "key": "Haniel", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/haniel", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Speedy Hedgehog" + }, + { + "rank": 2, + "name": "Fireflies and the Forest" + }, + { + "rank": 3, + "name": "Devil's Blood: Curse" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.5 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpPsycheBase", + "target": 0.2 + } + ], + "priority": [ + "CritDamageBase", + "DamageUpGeneralBase", + "CritBase", + "AtkUp", + "UnbalIntensityBase", + "AtkAdd" + ] + } + ] + }, + { + "key": "Hathor", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/hathor", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Street Boxer" + }, + { + "rank": 2, + "name": "Devil's Blood: Curse" + }, + { + "rank": 3, + "name": "Fireflies and the Forest" + }, + { + "rank": 4, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.3 + }, + { + "stat": "CritDamageBase", + "target": 1.2 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpLakshanaBase", + "target": 0.625 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Jin", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/hotori", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Lost Radiance" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.7 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpCosmosBase", + "target": 0.5 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Oneiroi", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/iroi", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Speedy Hedgehog" + }, + { + "rank": 2, + "name": "Fireflies and the Forest" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.3 + }, + { + "stat": "CritDamageBase", + "target": 0.7 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "MagBase", + "target": 150.0 + } + ], + "priority": [ + "MagBase", + "CritDamageBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp" + ] + } + ] + }, + { + "key": "Kuhara", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/jiuyuan", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Fireflies and the Forest" + }, + { + "rank": 2, + "name": "Devil's Blood: Curse" + }, + { + "rank": 3, + "name": "Street Boxer" + }, + { + "rank": 4, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.8 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpNatureBase", + "target": 0.35 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd" + ] + } + ] + }, + { + "key": "Lacrimosa", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/lacrimosa", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Diabolos" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.5 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpChaosBase", + "target": 0.5 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Mint019", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/mint", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Fireflies and the Forest" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.6 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpNatureBase", + "target": 0.475 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd" + ] + } + ] + }, + { + "key": "Nanally", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/nanally", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Fireflies and the Forest" + }, + { + "rank": 2, + "name": "Shadow Creed" + }, + { + "rank": 3, + "name": "Lost Radiance" + }, + { + "rank": 4, + "name": "Devil's Blood: Curse" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.85 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpNatureBase", + "target": 0.475 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "AtkUp", + "DamageUpGeneralBase", + "AtkAdd" + ] + } + ] + }, + { + "key": "Sagiri", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/sakiri", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Speedy Hedgehog" + }, + { + "rank": 2, + "name": "Crimson: Twin Butterflies" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.5 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "UnbalIntensityBase", + "target": 50.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpIncantationBase", + "target": 0.54 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "AtkUp", + "AtkAdd", + "UnbalIntensityBase", + "MagBase" + ] + } + ] + }, + { + "key": "Shinku", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/shinku", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Lost Radiance" + }, + { + "rank": 2, + "name": "Street Boxer" + }, + { + "rank": 3, + "name": "Fireflies and the Forest" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.75 + }, + { + "stat": "CritDamageBase", + "target": 1.5 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpCosmosBase", + "target": 0.1 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Skia", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/skia", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Street Boxer" + }, + { + "rank": 2, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.3 + }, + { + "stat": "CritDamageBase", + "target": 1.35 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.15 + }, + { + "stat": "DamageUpLakshanaBase", + "target": 0.375 + } + ], + "priority": [ + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Zankou", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/zankou", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Crimson: Twin Butterflies" + }, + { + "rank": 2, + "name": "Lost Radiance" + }, + { + "rank": 3, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Recommended", + "targets": [ + { + "stat": "CritBase", + "target": 0.6 + }, + { + "stat": "CritDamageBase", + "target": 2.0 + }, + { + "stat": "MagBase", + "target": 200.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpIncantationBase", + "target": 0.1 + } + ], + "priority": [ + "CritDamageBase", + "MagBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp", + "UnbalIntensityBase" + ] + }, + { + "name": "SubDPS", + "targets": [ + { + "stat": "CritBase", + "target": 0.6 + }, + { + "stat": "CritDamageBase", + "target": 2.0 + }, + { + "stat": "MagBase", + "target": 200.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "DamageUpIncantationBase", + "target": 0.1 + } + ], + "priority": [ + "CritDamageBase", + "MagBase", + "DamageUpGeneralBase", + "CritBase", + "AtkUp", + "UnbalIntensityBase" + ] + } + ] + }, + { + "key": "Female046", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/zero", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Speedy Hedgehog" + }, + { + "rank": 2, + "name": "Lost Radiance" + }, + { + "rank": 3, + "name": "Fireflies and the Forest" + }, + { + "rank": 4, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Day Off", + "targets": [ + { + "stat": "CritBase", + "target": 0.45 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "MagBase", + "target": 50.0 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + }, + { + "name": "The Rain That Shook the World", + "targets": [ + { + "stat": "CritBase", + "target": 0.45 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "MagBase", + "target": 250.0 + } + ], + "priority": [ + "MagBase", + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + }, + { + "key": "Female051", + "source": "https://www.prydwen.gg/neverness-to-everness/characters/zero", + "updated": "2026-08-21", + "sets": [ + { + "rank": 1, + "name": "Speedy Hedgehog" + }, + { + "rank": 2, + "name": "Lost Radiance" + }, + { + "rank": 3, + "name": "Fireflies and the Forest" + }, + { + "rank": 4, + "name": "Shadow Creed" + } + ], + "variants": [ + { + "name": "Day Off", + "targets": [ + { + "stat": "CritBase", + "target": 0.45 + }, + { + "stat": "CritDamageBase", + "target": 1.0 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "MagBase", + "target": 50.0 + } + ], + "priority": [ + "CritDamageBase", + "CritBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + }, + { + "name": "The Rain That Shook the World", + "targets": [ + { + "stat": "CritBase", + "target": 0.45 + }, + { + "stat": "CritDamageBase", + "target": 0.9 + }, + { + "stat": "DamageUpGeneralBase", + "target": 0.1 + }, + { + "stat": "MagBase", + "target": 250.0 + } + ], + "priority": [ + "MagBase", + "CritBase", + "CritDamageBase", + "DamageUpGeneralBase", + "AtkUp", + "AtkAdd" + ] + } + ] + } + ] } diff --git a/data-src/set-bonuses.json b/data-src/set-bonuses.json index fa7d14c..b2e5b3c 100644 --- a/data-src/set-bonuses.json +++ b/data-src/set-bonuses.json @@ -1,127 +1,55 @@ { - "format": "nte-set-bonuses", - "format_version": 1, - "note": "Bonus VALUES are not in any capture and not on everness: items.json carries only the cartridge boxes. Each tier stays unknown until read in game or scraped, and an unknown tier contributes nothing rather than a guessed number.", - "sets": { - "Crimson: Twin Butterflies": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Devil's Blood: Curse": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Diabolos": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Fireflies and the Forest": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Kingdom's Guard": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Lost Radiance": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Quiet Manor": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Shadow Creed": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Speedy Hedgehog": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Street Boxer": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Thea's Night Tavern": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } - }, - "Tiny Big Adventure": { - "2": { - "unknown": true, - "stats": [] - }, - "4": { - "unknown": true, - "stats": [] - } + "format": "nte-set-bonuses", + "format_version": 2, + "source": "Prydwen, read 2026-08-21; page updated 2026-04-26", + "sets": { + "Crimson: Twin Butterflies": { + "2": {"description": "Incantation DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpIncantationBase", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "ATK +6% per nearby team Incantation DMG trigger, up to 6 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 6, "stats": [{"stat": "AtkUp", "value": 0.06}], "why": "Wearer ATK stacks to 6 and remains active off-field."} + }, + "Devil's Blood: Curse": { + "2": {"description": "Psyche DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsycheBase", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "DMG +18%, increased to 36% against units affected by Nova or Stain.", "mode": "always", "stats": [{"stat": "DamageUpGeneralBase", "value": 0.18}], "why": "The unconditional 18% is modelled; the target-conditional increase is not assumed."} + }, + "Diabolos": { + "2": {"description": "Chaos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpChaosBase", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "Ignores 12% Chaos RES, increased to 24% for 20s after Nova or Scorch.", "mode": "unmodellable", "stats": [], "why": "Chaos RES is not DEF; mapping this to DefIgnore would model a different quantity."} + }, + "Fireflies and the Forest": { + "2": {"description": "Anima DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpNatureBase", "value": 0.1}], "why": "Anima is named Nature in the stat ids."}, + "4": {"description": "CRIT DMG +8% per nearby team Anima DMG trigger, up to 7 stacks; each lasts 10s.", "mode": "stacks", "max_stacks": 7, "stats": [{"stat": "CritDamageBase", "value": 0.08}], "why": "Wearer CRIT DMG stacks to 7 and remains active off-field."} + }, + "Kingdom's Guard": { + "2": {"description": "DEF +15%", "mode": "always", "stats": [{"stat": "DefUp", "value": 0.15}], "why": "Unconditional wearer stat."}, + "4": {"description": "Shields +20%", "mode": "unmodellable", "stats": [], "why": "Shield strength has no stat-vector slot."} + }, + "Lost Radiance": { + "2": {"description": "Cosmos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpCosmosBase", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "Ignores 25% DEF for 20s after Ultimate.", "mode": "duration", "stats": [{"stat": "DefIgnore", "value": 0.25}], "why": "Timed post-Ultimate stat; uptime needs rotation data, so it is reported but not scored."} + }, + "Quiet Manor": { + "2": {"description": "Mental DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.1}], "why": "User-decided circumstantial mapping: DamageUpPsychicallyBase is the only non-element damage id, appears on 19 real items, and Fadia's guide pairs Mental DMG with Quiet Manor. Not sheet-confirmed; check in game."}, + "4": {"description": "Mental DMG +12% per Basic Attack, up to 3 stacks; each lasts 6s.", "mode": "stacks", "max_stacks": 3, "stats": [{"stat": "DamageUpPsychicallyBase", "value": 0.12}], "why": "Uses the same user-decided Mental DMG mapping as the 2-piece tier; stacks to 3."} + }, + "Shadow Creed": { + "2": {"description": "ATK +10%", "mode": "always", "stats": [{"stat": "AtkUp", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "ATK +25% for 20s after Skill.", "mode": "duration", "stats": [{"stat": "AtkUp", "value": 0.25}], "why": "Timed post-Skill stat; uptime needs rotation data, so it is reported but not scored."} + }, + "Speedy Hedgehog": { + "2": {"description": "Charge Efficiency +12%", "mode": "always", "stats": [{"stat": "ChargeGetEfficiencyBase", "value": 0.12}], "why": "Unconditional wearer stat."}, + "4": {"description": "All allies' ATK +15% for 20s after wearer uses Ultimate.", "mode": "unmodellable", "stats": [], "why": "Buff applies to allies, not the wearer being optimised."} + }, + "Street Boxer": { + "2": {"description": "Lakshana DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpLakshanaBase", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "CRIT Rate +14%.", "mode": "always", "stats": [{"stat": "CritBase", "value": 0.14}], "why": "Prydwen's reaction sentence repeats 14% from 14%; treated as a source typo, not a second value."} + }, + "Thea's Night Tavern": { + "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "Healing Bonus +20%", "mode": "always", "stats": [{"stat": "HealUp", "value": 0.2}], "why": "Unconditional wearer stat."} + }, + "Tiny Big Adventure": { + "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."}, + "4": {"description": "Max HP +4% when taking damage, up to 10 stacks; each lasts 10s; Ultimate grants 10.", "mode": "stacks", "max_stacks": 10, "stats": [{"stat": "HPMaxUp", "value": 0.04}], "why": "Wearer Max HP stacks to 10; targeting the 4-piece tier uses its stated maximum."} + } } - } } diff --git a/src/domain/guides.ts b/src/domain/guides.ts index 0ab3f88..9decf29 100644 --- a/src/domain/guides.ts +++ b/src/domain/guides.ts @@ -21,11 +21,26 @@ export interface GuideVariant { priority: string[]; } +/** A recommended cartridge set, in the order the guide ranks them. */ +export interface GuideSet { + rank: number; + /** The published name, which is also its key in `set-bonuses.json`. */ + name: string; +} + export interface CharacterGuide { /** The `GA__*` ability key, so it joins to a capture directly. */ key: string; source: string; updated: string; + /** + * The guide's ranked cartridge recommendation, recorded as published. + * + * Nothing in the solve reads this: the solver enumerates every set the player + * owns and is free to disagree, which is the point of running it. It is kept + * so the published recommendation can be shown beside the solved build. + */ + sets?: GuideSet[]; variants: GuideVariant[]; } diff --git a/src/domain/setbonus.ts b/src/domain/setbonus.ts index 061d139..7c050ee 100644 --- a/src/domain/setbonus.ts +++ b/src/domain/setbonus.ts @@ -1,23 +1,18 @@ -/** - * Cartridge set bonuses. - * - * The bonus *values* are the one piece of the model no available source - * carries: the capture never sends them, and everness's `items.json` holds only - * the cartridge boxes, not their effects. They have to be read in game or - * scraped from a guide. - * - * Until then every tier is explicitly unknown and contributes **nothing**. The - * requirements are firm that the app never invents numbers, so a missing bonus - * shows as a gap in the UI rather than as a plausible-looking zero the player - * cannot tell apart from a real one. - */ +/** Cartridge set bonuses, classified with the same modes as Arc effects. */ import { activeTiers, type SetName, type Tier } from "./cartridges.ts"; import type { ShapeId } from "./shapes.ts"; -import { vectorFrom, type StatPair } from "./statvec.ts"; +import { vectorFrom, type StatId, type StatPair } from "./statvec.ts"; + +export type BonusMode = "always" | "toggle" | "stacks" | "duration" | "unmodellable"; export interface TierBonus { - unknown: boolean; - stats: StatPair[]; + description: string; + mode: BonusMode; + stats: Array<{ stat: StatId; value: number }>; + why: string; + max_stacks?: number; + /** Kept for future source gaps; v2 classifies every current tier. */ + unknown?: boolean; } export interface SetBonusTable { @@ -26,11 +21,17 @@ export interface SetBonusTable { sets: Record>; } +export interface OmittedTier { + tier: Tier; + mode: "duration" | "unmodellable"; + why: string; +} + export interface SetBonusResult { tiers: Tier[]; vector: Float32Array; - /** Tiers that are active but whose values nobody has measured yet. */ unknownTiers: Tier[]; + omittedTiers: OmittedTier[]; } /** What a board's set bonuses contribute, and what is missing from that answer. */ @@ -38,29 +39,41 @@ export function setBonus( table: SetBonusTable, set: SetName, shapesOnBoard: readonly ShapeId[], + targetTier: Tier = 4, ): SetBonusResult { const tiers = activeTiers(set, shapesOnBoard); const entry = table.sets[set]; const pairs: StatPair[] = []; const unknownTiers: Tier[] = []; + const omittedTiers: OmittedTier[] = []; for (const tier of tiers) { + if (tier > targetTier) continue; const bonus = entry?.[String(tier) as "2" | "4"]; if (!bonus || bonus.unknown) { unknownTiers.push(tier); continue; } - pairs.push(...bonus.stats); + if (bonus.mode === "duration" || bonus.mode === "unmodellable") { + omittedTiers.push({ tier, mode: bonus.mode, why: bonus.why }); + continue; + } + + // ponytail: selecting 2+4 means aiming for stated max stacks; add a stack control only if users need partial uptime. + const multiplier = bonus.mode === "stacks" ? bonus.max_stacks ?? 0 : 1; + pairs.push(...bonus.stats.map(({ stat, value }) => ({ stat, value: value * multiplier }))); } - return { tiers, vector: vectorFrom(pairs), unknownTiers }; + return { tiers, vector: vectorFrom(pairs), unknownTiers, omittedTiers }; } /** Sets with at least one unmeasured tier, for the UI to flag up front. */ export function incompleteSets(table: SetBonusTable): SetName[] { const out: SetName[] = []; for (const [set, tiers] of Object.entries(table.sets)) { - if (tiers["2"].unknown || tiers["4"].unknown) out.push(set as SetName); + if (!tiers["2"] || !tiers["4"] || tiers["2"].unknown || tiers["4"].unknown) { + out.push(set as SetName); + } } return out.sort(); } diff --git a/src/solver/job.ts b/src/solver/job.ts index 61aa526..9ac6d78 100644 --- a/src/solver/job.ts +++ b/src/solver/job.ts @@ -7,7 +7,7 @@ * host and the worker host run byte-identical code and cannot drift. */ import { boardFromSlots, boardKey } from "../domain/board.ts"; -import type { SetName } from "../domain/cartridges.ts"; +import type { SetName, Tier } from "../domain/cartridges.ts"; import { buildPool, type ExportedItem } from "../domain/items.ts"; import { compile, type ScoringConfig } from "../domain/scoring.ts"; import type { SetBonusTable } from "../domain/setbonus.ts"; @@ -29,6 +29,8 @@ export interface CharacterJob { scoring: ScoringConfig; /** Restrict to these sets. Omit for every set the account owns. */ sets?: SetName[]; + /** Highest set-bonus tier to score: 2, or both 2 and 4. */ + targetTier?: Tier; /** * Instances this character may not use - R2's "use equipped items" toggle * turned off, meaning items another character is wearing are off limits. @@ -129,6 +131,7 @@ export function* runJob(job: SolveJob): Generator base: baseVector(character), trait: character.trait, setBonuses: job.setBonuses, + targetTier: character.targetTier ?? 4, scoring: compile(character.scoring), excluded, beamWidth: options.beamWidth, diff --git a/src/solver/protocol.ts b/src/solver/protocol.ts index 9fa0455..bb60094 100644 --- a/src/solver/protocol.ts +++ b/src/solver/protocol.ts @@ -33,8 +33,10 @@ export interface Build { * The UI must render the distinction rather than implying certainty. */ proven: boolean; - /** Active set tiers whose values nobody has measured yet. */ + /** Selected active set tiers still lacking source data. */ unknownTiers: Tier[]; + /** Selected tiers that were classified but cannot be scored honestly. */ + omittedTiers: Array<{ tier: Tier; mode: "duration" | "unmodellable"; why: string }>; } export interface SolveResult { diff --git a/src/solver/single.ts b/src/solver/single.ts index 52c5199..b99afa4 100644 --- a/src/solver/single.ts +++ b/src/solver/single.ts @@ -29,6 +29,7 @@ * for free. */ import type { ConsoleTrait } from "../domain/stats.ts"; +import type { Tier } from "../domain/cartridges.ts"; import type { ItemPool } from "../domain/items.ts"; import type { SetBonusTable } from "../domain/setbonus.ts"; import type { Tiling } from "../domain/tilings.ts"; @@ -46,6 +47,7 @@ export interface SingleRequest { base: Float32Array; trait: ConsoleTrait | null; setBonuses: SetBonusTable; + targetTier?: Tier; scoring: CompiledScoring; /** Pool indices held by other characters and therefore off limits. */ excluded?: ReadonlySet; @@ -151,6 +153,7 @@ export function* solveSingle( base, trait, setBonuses, + targetTier = 4, scoring, excluded = new Set(), beamWidth = DEFAULT_BEAM_WIDTH, @@ -201,7 +204,7 @@ export function* solveSingle( fixed.set(base); const traitStat = traitContribution(trait, tiling.pieces); if (traitStat) vectorFrom([traitStat], fixed); - const bonus = setBonus(setBonuses, tiling.set, tiling.pieces); + const bonus = setBonus(setBonuses, tiling.set, tiling.pieces, targetTier); for (let i = 0; i < SLOT_COUNT; i += 1) fixed[i] = fixed[i]! + bonus.vector[i]!; // Hardest groups first: fewer choices near the root prunes the most. @@ -270,6 +273,7 @@ export function* solveSingle( vector, proven: false, unknownTiers: bonus.unknownTiers, + omittedTiers: bonus.omittedTiers, }; } if (bestForState === null) continue; diff --git a/src/ui/CharacterView.tsx b/src/ui/CharacterView.tsx index 63178c6..d21bb61 100644 --- a/src/ui/CharacterView.tsx +++ b/src/ui/CharacterView.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts"; import { boardFromSlots } from "../domain/board.ts"; +import { SET_NAMES, type SetName, type Tier } from "../domain/cartridges.ts"; import { compile, explain, type StatTarget } from "../domain/scoring.ts"; import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts"; import { compareSheet, predictSheet } from "../domain/sheet.ts"; @@ -89,6 +90,11 @@ export function CharacterView({ // R2. Off by default: taking gear off another character is a real cost, so it // is opted into rather than assumed. const [useEquipped, setUseEquipped] = useState(false); + const [setName, setSetName] = useState(() => { + const recommended = guide?.sets?.[0]?.name; + return recommended && recommended in data.setBonuses.sets ? (recommended as SetName) : ""; + }); + const [targetTier, setTargetTier] = useState(2); const handle = useRef(null); const worn = equipmentOf(db, characterId); @@ -154,6 +160,8 @@ export function CharacterView({ trait, base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0), scoring: { targets }, + ...(setName ? { sets: [setName] } : {}), + targetTier, excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]), }, ], @@ -318,6 +326,33 @@ export function CharacterView({ )} +
+ + +
+