feat: model cartridge set bonuses

This commit is contained in:
goober 2026-08-21 15:40:38 +03:00
parent bddc0817ad
commit ad2d9b229f
16 changed files with 1486 additions and 187 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,127 +1,55 @@
{ {
"format": "nte-set-bonuses", "format": "nte-set-bonuses",
"format_version": 1, "format_version": 2,
"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.", "source": "Prydwen, read 2026-08-21; page updated 2026-04-26",
"sets": { "sets": {
"Crimson: Twin Butterflies": { "Crimson: Twin Butterflies": {
"2": { "2": {"description": "Incantation DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpIncantationBase", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Devil's Blood: Curse": { "Devil's Blood: Curse": {
"2": { "2": {"description": "Psyche DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpPsycheBase", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Diabolos": { "Diabolos": {
"2": { "2": {"description": "Chaos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpChaosBase", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Fireflies and the Forest": { "Fireflies and the Forest": {
"2": { "2": {"description": "Anima DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpNatureBase", "value": 0.1}], "why": "Anima is named Nature in the stat ids."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Kingdom's Guard": { "Kingdom's Guard": {
"2": { "2": {"description": "DEF +15%", "mode": "always", "stats": [{"stat": "DefUp", "value": 0.15}], "why": "Unconditional wearer stat."},
"unknown": true, "4": {"description": "Shields +20%", "mode": "unmodellable", "stats": [], "why": "Shield strength has no stat-vector slot."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Lost Radiance": { "Lost Radiance": {
"2": { "2": {"description": "Cosmos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpCosmosBase", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Quiet Manor": { "Quiet Manor": {
"2": { "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."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Shadow Creed": { "Shadow Creed": {
"2": { "2": {"description": "ATK +10%", "mode": "always", "stats": [{"stat": "AtkUp", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Speedy Hedgehog": { "Speedy Hedgehog": {
"2": { "2": {"description": "Charge Efficiency +12%", "mode": "always", "stats": [{"stat": "ChargeGetEfficiencyBase", "value": 0.12}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Street Boxer": { "Street Boxer": {
"2": { "2": {"description": "Lakshana DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpLakshanaBase", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Thea's Night Tavern": { "Thea's Night Tavern": {
"2": { "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "4": {"description": "Healing Bonus +20%", "mode": "always", "stats": [{"stat": "HealUp", "value": 0.2}], "why": "Unconditional wearer stat."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
}, },
"Tiny Big Adventure": { "Tiny Big Adventure": {
"2": { "2": {"description": "HP +10%", "mode": "always", "stats": [{"stat": "HPMaxUp", "value": 0.1}], "why": "Unconditional wearer stat."},
"unknown": true, "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."}
"stats": []
},
"4": {
"unknown": true,
"stats": []
}
} }
} }
} }

View file

@ -21,11 +21,26 @@ export interface GuideVariant {
priority: string[]; 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 { export interface CharacterGuide {
/** The `GA_<key>_*` ability key, so it joins to a capture directly. */ /** The `GA_<key>_*` ability key, so it joins to a capture directly. */
key: string; key: string;
source: string; source: string;
updated: 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[]; variants: GuideVariant[];
} }

View file

@ -1,23 +1,18 @@
/** /** Cartridge set bonuses, classified with the same modes as Arc effects. */
* 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.
*/
import { activeTiers, type SetName, type Tier } from "./cartridges.ts"; import { activeTiers, type SetName, type Tier } from "./cartridges.ts";
import type { ShapeId } from "./shapes.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 { export interface TierBonus {
unknown: boolean; description: string;
stats: StatPair[]; 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 { export interface SetBonusTable {
@ -26,11 +21,17 @@ export interface SetBonusTable {
sets: Record<string, Record<"2" | "4", TierBonus>>; sets: Record<string, Record<"2" | "4", TierBonus>>;
} }
export interface OmittedTier {
tier: Tier;
mode: "duration" | "unmodellable";
why: string;
}
export interface SetBonusResult { export interface SetBonusResult {
tiers: Tier[]; tiers: Tier[];
vector: Float32Array; vector: Float32Array;
/** Tiers that are active but whose values nobody has measured yet. */
unknownTiers: Tier[]; unknownTiers: Tier[];
omittedTiers: OmittedTier[];
} }
/** What a board's set bonuses contribute, and what is missing from that answer. */ /** What a board's set bonuses contribute, and what is missing from that answer. */
@ -38,29 +39,41 @@ export function setBonus(
table: SetBonusTable, table: SetBonusTable,
set: SetName, set: SetName,
shapesOnBoard: readonly ShapeId[], shapesOnBoard: readonly ShapeId[],
targetTier: Tier = 4,
): SetBonusResult { ): SetBonusResult {
const tiers = activeTiers(set, shapesOnBoard); const tiers = activeTiers(set, shapesOnBoard);
const entry = table.sets[set]; const entry = table.sets[set];
const pairs: StatPair[] = []; const pairs: StatPair[] = [];
const unknownTiers: Tier[] = []; const unknownTiers: Tier[] = [];
const omittedTiers: OmittedTier[] = [];
for (const tier of tiers) { for (const tier of tiers) {
if (tier > targetTier) continue;
const bonus = entry?.[String(tier) as "2" | "4"]; const bonus = entry?.[String(tier) as "2" | "4"];
if (!bonus || bonus.unknown) { if (!bonus || bonus.unknown) {
unknownTiers.push(tier); unknownTiers.push(tier);
continue; continue;
} }
pairs.push(...bonus.stats); if (bonus.mode === "duration" || bonus.mode === "unmodellable") {
omittedTiers.push({ tier, mode: bonus.mode, why: bonus.why });
continue;
} }
return { tiers, vector: vectorFrom(pairs), unknownTiers }; // 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, omittedTiers };
} }
/** Sets with at least one unmeasured tier, for the UI to flag up front. */ /** Sets with at least one unmeasured tier, for the UI to flag up front. */
export function incompleteSets(table: SetBonusTable): SetName[] { export function incompleteSets(table: SetBonusTable): SetName[] {
const out: SetName[] = []; const out: SetName[] = [];
for (const [set, tiers] of Object.entries(table.sets)) { 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(); return out.sort();
} }

View file

@ -7,7 +7,7 @@
* host and the worker host run byte-identical code and cannot drift. * host and the worker host run byte-identical code and cannot drift.
*/ */
import { boardFromSlots, boardKey } from "../domain/board.ts"; 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 { buildPool, type ExportedItem } from "../domain/items.ts";
import { compile, type ScoringConfig } from "../domain/scoring.ts"; import { compile, type ScoringConfig } from "../domain/scoring.ts";
import type { SetBonusTable } from "../domain/setbonus.ts"; import type { SetBonusTable } from "../domain/setbonus.ts";
@ -29,6 +29,8 @@ export interface CharacterJob {
scoring: ScoringConfig; scoring: ScoringConfig;
/** Restrict to these sets. Omit for every set the account owns. */ /** Restrict to these sets. Omit for every set the account owns. */
sets?: SetName[]; 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 * Instances this character may not use - R2's "use equipped items" toggle
* turned off, meaning items another character is wearing are off limits. * turned off, meaning items another character is wearing are off limits.
@ -129,6 +131,7 @@ export function* runJob(job: SolveJob): Generator<JobProgress, JobResult, void>
base: baseVector(character), base: baseVector(character),
trait: character.trait, trait: character.trait,
setBonuses: job.setBonuses, setBonuses: job.setBonuses,
targetTier: character.targetTier ?? 4,
scoring: compile(character.scoring), scoring: compile(character.scoring),
excluded, excluded,
beamWidth: options.beamWidth, beamWidth: options.beamWidth,

View file

@ -33,8 +33,10 @@ export interface Build {
* The UI must render the distinction rather than implying certainty. * The UI must render the distinction rather than implying certainty.
*/ */
proven: boolean; proven: boolean;
/** Active set tiers whose values nobody has measured yet. */ /** Selected active set tiers still lacking source data. */
unknownTiers: Tier[]; unknownTiers: Tier[];
/** Selected tiers that were classified but cannot be scored honestly. */
omittedTiers: Array<{ tier: Tier; mode: "duration" | "unmodellable"; why: string }>;
} }
export interface SolveResult { export interface SolveResult {

View file

@ -29,6 +29,7 @@
* for free. * for free.
*/ */
import type { ConsoleTrait } from "../domain/stats.ts"; import type { ConsoleTrait } from "../domain/stats.ts";
import type { Tier } from "../domain/cartridges.ts";
import type { ItemPool } from "../domain/items.ts"; import type { ItemPool } from "../domain/items.ts";
import type { SetBonusTable } from "../domain/setbonus.ts"; import type { SetBonusTable } from "../domain/setbonus.ts";
import type { Tiling } from "../domain/tilings.ts"; import type { Tiling } from "../domain/tilings.ts";
@ -46,6 +47,7 @@ export interface SingleRequest {
base: Float32Array; base: Float32Array;
trait: ConsoleTrait | null; trait: ConsoleTrait | null;
setBonuses: SetBonusTable; setBonuses: SetBonusTable;
targetTier?: Tier;
scoring: CompiledScoring; scoring: CompiledScoring;
/** Pool indices held by other characters and therefore off limits. */ /** Pool indices held by other characters and therefore off limits. */
excluded?: ReadonlySet<number>; excluded?: ReadonlySet<number>;
@ -151,6 +153,7 @@ export function* solveSingle(
base, base,
trait, trait,
setBonuses, setBonuses,
targetTier = 4,
scoring, scoring,
excluded = new Set<number>(), excluded = new Set<number>(),
beamWidth = DEFAULT_BEAM_WIDTH, beamWidth = DEFAULT_BEAM_WIDTH,
@ -201,7 +204,7 @@ export function* solveSingle(
fixed.set(base); fixed.set(base);
const traitStat = traitContribution(trait, tiling.pieces); const traitStat = traitContribution(trait, tiling.pieces);
if (traitStat) vectorFrom([traitStat], fixed); 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]!; 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. // Hardest groups first: fewer choices near the root prunes the most.
@ -270,6 +273,7 @@ export function* solveSingle(
vector, vector,
proven: false, proven: false,
unknownTiers: bonus.unknownTiers, unknownTiers: bonus.unknownTiers,
omittedTiers: bonus.omittedTiers,
}; };
} }
if (bestForState === null) continue; if (bestForState === null) continue;

View file

@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react";
import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts"; import { arcContribution, arcControls, MAX_ARC_LEVEL, MAX_REFINEMENT } from "../domain/arcs.ts";
import { boardFromSlots } from "../domain/board.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 { compile, explain, type StatTarget } from "../domain/scoring.ts";
import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts"; import { guideFor, targetsFromGuide, variantFor } from "../domain/guides.ts";
import { compareSheet, predictSheet } from "../domain/sheet.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 // R2. Off by default: taking gear off another character is a real cost, so it
// is opted into rather than assumed. // is opted into rather than assumed.
const [useEquipped, setUseEquipped] = useState(false); const [useEquipped, setUseEquipped] = useState(false);
const [setName, setSetName] = useState<SetName | "">(() => {
const recommended = guide?.sets?.[0]?.name;
return recommended && recommended in data.setBonuses.sets ? (recommended as SetName) : "";
});
const [targetTier, setTargetTier] = useState<Tier>(2);
const handle = useRef<SolveHandle | null>(null); const handle = useRef<SolveHandle | null>(null);
const worn = equipmentOf(db, characterId); const worn = equipmentOf(db, characterId);
@ -154,6 +160,8 @@ export function CharacterView({
trait, trait,
base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0), base: arcResult ? Array.from(arcResult.vector) : new Array(SLOT_COUNT).fill(0),
scoring: { targets }, scoring: { targets },
...(setName ? { sets: [setName] } : {}),
targetTier,
excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]), excludedInstances: useEquipped ? [] : heldByOthers(db, [characterId]),
}, },
], ],
@ -318,6 +326,33 @@ export function CharacterView({
<ReadOnlyTargets targets={targets} source={guide!.source} updated={guide!.updated} /> <ReadOnlyTargets targets={targets} source={guide!.source} updated={guide!.updated} />
)} )}
<div className="arc-row">
<label className="arc">
Cartridge set
<select
value={setName}
disabled={running}
onChange={(event) => setSetName(event.target.value as SetName | "")}
>
<option value="">Any owned set</option>
{SET_NAMES.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</label>
<label className="arc">
Set bonuses to aim for
<select
value={targetTier}
disabled={running}
onChange={(event) => setTargetTier(Number(event.target.value) as Tier)}
>
<option value={2}>2-piece only</option>
<option value={4}>2-piece + 4-piece</option>
</select>
</label>
</div>
<label className="toggle"> <label className="toggle">
<input <input
type="checkbox" type="checkbox"
@ -401,6 +436,11 @@ export function CharacterView({
never been measured, so it contributes nothing to this score. never been measured, so it contributes nothing to this score.
</p> </p>
)} )}
{build.omittedTiers.length > 0 && (
<p className="warning">
Not modelled: {build.omittedTiers.map((entry) => `tier ${entry.tier}: ${entry.why}`).join("; ")}
</p>
)}
{report && ( {report && (
<table className="items"> <table className="items">
<tbody> <tbody>

View file

@ -306,6 +306,11 @@ function AssignedBuild({
contributes nothing to this score. contributes nothing to this score.
</p> </p>
)} )}
{build.omittedTiers.length > 0 && (
<p className="warning">
Not modelled: {build.omittedTiers.map((tier) => `tier ${tier.tier}: ${tier.why}`).join("; ")}
</p>
)}
<BuildLegend <BuildLegend
cells={build.tiling.cells} cells={build.tiling.cells}

View file

@ -4,9 +4,9 @@
--panel: #1c1f26; --panel: #1c1f26;
--line: #2c313b; --line: #2c313b;
--text: #e6e8ec; --text: #e6e8ec;
--dim: #9aa2b1; --dim: #cacdd5;
--accent: #7aa2f7; --accent: #bbd0ff;
--warn: #e0af68; --warn: #f1cc8d;
--bad: #f7768e; --bad: #f7768e;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
} }
@ -59,7 +59,13 @@ select, input, button {
font-size: 0.85rem; font-size: 0.85rem;
} }
button { cursor: pointer; } button { cursor: pointer; }
button:disabled { opacity: 0.45; cursor: default; } button:hover:not(:disabled), select:hover:not(:disabled), input:hover:not(:disabled) { border-color: var(--accent); }
button:active:not(:disabled) { color: var(--accent); }
button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button:disabled, select:disabled, input:disabled { opacity: 0.45; cursor: default; }
table.items { width: 100%; border-collapse: collapse; font-size: 0.85rem; } table.items { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
table.items th { table.items th {

View file

@ -5,6 +5,7 @@ import { boardFromSlots, fillers, placements, tile } from "../src/domain/board.t
import { REQUIRED_PIECES, SET_IDS, SET_NAMES, activeTiers } from "../src/domain/cartridges.ts"; import { REQUIRED_PIECES, SET_IDS, SET_NAMES, activeTiers } from "../src/domain/cartridges.ts";
import { incompleteSets, setBonus, type SetBonusTable } from "../src/domain/setbonus.ts"; import { incompleteSets, setBonus, type SetBonusTable } from "../src/domain/setbonus.ts";
import { ICON_ORDER, SHAPES, renderShape, sizeOf } from "../src/domain/shapes.ts"; import { ICON_ORDER, SHAPES, renderShape, sizeOf } from "../src/domain/shapes.ts";
import { slotOf } from "../src/domain/statvec.ts";
import { pieceColour, renderBoard } from "../src/domain/render.ts"; import { pieceColour, renderBoard } from "../src/domain/render.ts";
import { cellsOfPiece, loadTilings, multisetOf, type RawTilings } from "../src/domain/tilings.ts"; import { cellsOfPiece, loadTilings, multisetOf, type RawTilings } from "../src/domain/tilings.ts";
@ -182,22 +183,47 @@ describe("set bonuses", () => {
expect(Object.keys(SET_IDS)).toHaveLength(12); expect(Object.keys(SET_IDS)).toHaveLength(12);
}); });
it("contributes nothing rather than a guess while values are unmeasured", () => { it("classifies every tier and preserves the three honest model gaps", () => {
expect(table.format_version).toBe(2);
expect(Object.values(table.sets).flatMap((tiers) => Object.values(tiers))).toHaveLength(24);
expect(
Object.entries(table.sets)
.filter(([, tiers]) => tiers["4"].mode === "unmodellable")
.map(([set]) => set)
.sort(),
).toEqual(["Diabolos", "Kingdom's Guard", "Speedy Hedgehog"]);
expect(incompleteSets(table)).toEqual([]);
});
it("uses the user-decided Mental DMG mapping", () => {
expect(table.sets["Quiet Manor"]!["2"].stats).toEqual([
{ stat: "DamageUpPsychicallyBase", value: 0.1 },
]);
expect(table.sets["Quiet Manor"]!["2"].why).toContain("circumstantial");
});
it("scores only selected tiers and reports timed tiers it omits", () => {
const required = REQUIRED_PIECES["Shadow Creed"]; const required = REQUIRED_PIECES["Shadow Creed"];
const result = setBonus(table, "Shadow Creed", required); const twoPiece = setBonus(table, "Shadow Creed", required, 2);
expect(result.tiers).toEqual([2, 4]); expect(twoPiece.vector[slotOf("AtkUp")]).toBeCloseTo(0.1);
expect(result.unknownTiers).toEqual([2, 4]); expect(twoPiece.omittedTiers).toEqual([]);
expect([...result.vector].every((value) => value === 0)).toBe(true);
const full = setBonus(table, "Shadow Creed", required, 4);
expect(full.vector[slotOf("AtkUp")]).toBeCloseTo(0.1);
expect(full.omittedTiers).toEqual([
{ tier: 4, mode: "duration", why: table.sets["Shadow Creed"]!["4"].why },
]);
}); });
it("reports no active tier when the board misses the shapes", () => { it("uses stated maximum stacks when aiming for 2+4", () => {
const result = setBonus(table, "Shadow Creed", ["cell3_style1"]); const result = setBonus(
expect(result.tiers).toEqual([]); table,
expect(result.unknownTiers).toEqual([]); "Crimson: Twin Butterflies",
}); REQUIRED_PIECES["Crimson: Twin Butterflies"],
4,
it("flags every set as incomplete until the values are filled in", () => { );
expect(incompleteSets(table)).toHaveLength(12); expect(result.vector[slotOf("DamageUpIncantationBase")]).toBeCloseTo(0.1);
expect(result.vector[slotOf("AtkUp")]).toBeCloseTo(0.36);
}); });
}); });

View file

@ -9,6 +9,7 @@ import {
weightForRank, weightForRank,
type GuideTable, type GuideTable,
} from "../src/domain/guides.ts"; } from "../src/domain/guides.ts";
import { isKnownStat } from "../src/domain/statvec.ts";
const table = JSON.parse(readFileSync("data-src/guides.json", "utf8")) as GuideTable; const table = JSON.parse(readFileSync("data-src/guides.json", "utf8")) as GuideTable;
@ -74,7 +75,12 @@ describe("guide to targets", () => {
describe("guide lookup", () => { describe("guide lookup", () => {
it("returns null for a character with no published guide", () => { it("returns null for a character with no published guide", () => {
expect(guideFor(table, "Zankou")).toBeNull(); // Linko (`Radio072`) has a Prydwen page, but its Build tab still says the
// stats "aren't available yet", so the character is deliberately absent
// rather than filled in with something a player could not tell apart from
// real guidance.
expect(guideFor(table, "Radio072")).toBeNull();
expect(guideFor(table, "NotACharacter")).toBeNull();
expect(variantFor(null, null)).toBeNull(); expect(variantFor(null, null)).toBeNull();
}); });
@ -91,10 +97,45 @@ describe("guide lookup", () => {
expect(variantFor(guide, null)!.name).toBe("Main DPS"); expect(variantFor(guide, null)!.name).toBe("Main DPS");
}); });
it("ships an empty table rather than invented guidance", () => { it("carries the published guides, and only stats the model has slots for", () => {
// Prydwen's data could not be obtained; a plausible-looking guess would be
// worse than none, because the player could not tell it apart from real.
expect(table.characters).toEqual([]);
expect(table.format).toBe("nte-guides"); expect(table.format).toBe("nte-guides");
expect(table.characters.length).toBeGreaterThan(0);
for (const guide of table.characters) {
expect(guide.variants.length).toBeGreaterThan(0);
for (const entry of guide.variants) {
// A variant with no targets would score nothing: `targetsFromGuide`
// reads the ranking only to weight the targets it already has.
expect(entry.targets.length).toBeGreaterThan(0);
expect(entry.priority.length).toBeGreaterThan(0);
expect(new Set(entry.priority).size).toBe(entry.priority.length);
for (const stat of entry.priority) expect(isKnownStat(stat)).toBe(true);
for (const target of entry.targets) {
expect(isKnownStat(target.stat)).toBe(true);
expect(target.target).toBeGreaterThan(0);
// Percentages are fractions here, never "70".
if (target.stat.startsWith("DamageUp") || target.stat.endsWith("Up")) {
expect(target.target).toBeLessThanOrEqual(5);
}
}
}
}
});
it("weights a real entry by its published ranking", () => {
const guide = guideFor(table, "Zankou")!;
expect(guide).not.toBeNull();
// Prydwen ranks Crimson: Twin Butterflies first for Zankou.
expect(guide.sets?.[0]?.name).toBe("Crimson: Twin Butterflies");
const targets = targetsFromGuide(guide.variants[0]!);
const byStat = new Map(targets.map((entry) => [entry.stat, entry]));
// Their substat ranking opens "Crit DMG % > Cycle Intensity = Crit Rate %".
expect(byStat.get("CritDamageBase")!.weight).toBe(TOP_WEIGHT);
expect(byStat.get("CritDamageBase")!.weight).toBeGreaterThan(
byStat.get("MagBase")!.weight,
);
expect(byStat.get("MagBase")!.weight).toBeGreaterThan(byStat.get("CritBase")!.weight);
}); });
}); });

View file

@ -101,6 +101,22 @@ describe("runJob", () => {
expect(progress.some((p) => p.phase === "team")).toBe(true); expect(progress.some((p) => p.phase === "team")).toBe(true);
}); });
it("carries selected set tiers through the job boundary", () => {
const twoPiece = job(["Zankou"]);
twoPiece.characters[0]!.targetTier = 2;
const runTwo = runJob(twoPiece);
let step = runTwo.next();
while (!step.done) step = runTwo.next();
expect(step.value.assignment[0]!.build.omittedTiers).toEqual([]);
const full = job(["Zankou"]);
full.characters[0]!.targetTier = 4;
const runFull = runJob(full);
let next = runFull.next();
while (!next.done) next = runFull.next();
expect(next.value.assignment[0]!.build.omittedTiers[0]).toMatchObject({ tier: 4, mode: "duration" });
});
it("gives two characters disjoint items", () => { it("gives two characters disjoint items", () => {
const run = runJob(job(["Zankou", "Haniel"])); const run = runJob(job(["Zankou", "Haniel"]));
let step = run.next(); let step = run.next();

View file

@ -8,12 +8,17 @@ import type { Tiling } from "../src/domain/tilings.ts";
import { solveSingle } from "../src/solver/single.ts"; import { solveSingle } from "../src/solver/single.ts";
import type { SolveProgress, SolveResult } from "../src/solver/protocol.ts"; import type { SolveProgress, SolveResult } from "../src/solver/protocol.ts";
const unknown = {
description: "unmeasured",
mode: "always",
stats: [],
why: "test fixture",
unknown: true,
} as const;
const bonuses: SetBonusTable = { const bonuses: SetBonusTable = {
format: "nte-set-bonuses", format: "nte-set-bonuses",
format_version: 1, format_version: 2,
sets: { sets: { "Shadow Creed": { "2": unknown, "4": unknown } },
"Shadow Creed": { "2": { unknown: true, stats: [] }, "4": { unknown: true, stats: [] } },
},
}; };
const scoring = compile({ const scoring = compile({

View file

@ -19,6 +19,7 @@ function build(score: number, cartridge: number, modules: number[]): Build {
vector: new Float32Array(0), vector: new Float32Array(0),
proven: true, proven: true,
unknownTiers: [], unknownTiers: [],
omittedTiers: [],
}; };
} }

View file

@ -1,16 +1,16 @@
/** /**
* Validates `data-src/guides.json`. * Validates `data-src/guides.json`.
* *
* There is deliberately **no scraper**. Prydwen's character pages are * There is deliberately **no scraper**. Prydwen sits behind a Cloudflare bot
* client-rendered, so a plain fetch returns navigation chrome, and their Gatsby * challenge, and their Gatsby data endpoint answers `410 Gone`. Their
* data endpoint answers `410 Gone`. Their robots.txt does allow content pages * robots.txt does allow content pages with a ten second crawl-delay, so reading
* with a ten second crawl-delay, so a headless render would be within policy - * them is within policy - but through an attended browser session, never as an
* but it is not something an unattended build step should be doing, and the plan * unattended build step, and the plan always called for this file to be
* always called for this file to be human-reviewed and committed. * human-reviewed and committed.
* *
* So the tool checks what a person typed instead: that every stat id has a slot, * So the tool checks the committed file instead: that every stat id has a slot,
* that targets look like the units the model expects, and that keys join to real * that targets look like the units the model expects, that keys join to real
* characters. * characters, and that a recommended set is one the game actually has.
* *
* node --experimental-strip-types tools/check-guides.ts * node --experimental-strip-types tools/check-guides.ts
*/ */
@ -29,6 +29,8 @@ const gamedata = JSON.parse(readFileSync(join(root, "src/generated/gamedata.json
const keys = new Set<string>( const keys = new Set<string>(
gamedata.espers.map((esper: { abilityKey: string }) => esper.abilityKey), gamedata.espers.map((esper: { abilityKey: string }) => esper.abilityKey),
); );
const setBonuses = JSON.parse(readFileSync(join(root, "data-src/set-bonuses.json"), "utf8"));
const setNames = new Set<string>(Object.keys(setBonuses.sets));
const problems: string[] = []; const problems: string[] = [];
@ -43,6 +45,14 @@ for (const guide of guides.characters ?? []) {
} }
if (!guide.variants?.length) problems.push(`${where}: no variants`); if (!guide.variants?.length) problems.push(`${where}: no variants`);
// A recommended set has to be one of the twelve, or the name is a typo and
// would never join to `set-bonuses.json`.
for (const set of guide.sets ?? []) {
if (!setNames.has(set.name)) problems.push(`${where}: not a cartridge set: ${set.name}`);
}
const ranks = (guide.sets ?? []).map((set) => set.rank);
if (new Set(ranks).size !== ranks.length) problems.push(`${where}: duplicate set rank`);
for (const variant of guide.variants ?? []) { for (const variant of guide.variants ?? []) {
const scope = `${where}/${variant.name}`; const scope = `${where}/${variant.name}`;
if (!variant.name) problems.push(`${scope}: variant has no name`); if (!variant.name) problems.push(`${scope}: variant has no name`);