feat: model cartridge set bonuses
This commit is contained in:
parent
bddc0817ad
commit
ad2d9b229f
16 changed files with 1486 additions and 187 deletions
1194
data-src/guides.json
1194
data-src/guides.json
File diff suppressed because it is too large
Load diff
|
|
@ -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": []
|
},
|
||||||
},
|
"Devil's Blood: Curse": {
|
||||||
"4": {
|
"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": []
|
},
|
||||||
}
|
"Diabolos": {
|
||||||
},
|
"2": {"description": "Chaos DMG +10%", "mode": "always", "stats": [{"stat": "DamageUpChaosBase", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||||
"Devil's Blood: Curse": {
|
"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."}
|
||||||
"2": {
|
},
|
||||||
"unknown": true,
|
"Fireflies and the Forest": {
|
||||||
"stats": []
|
"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."}
|
||||||
"4": {
|
},
|
||||||
"unknown": true,
|
"Kingdom's Guard": {
|
||||||
"stats": []
|
"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."}
|
||||||
},
|
},
|
||||||
"Diabolos": {
|
"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": []
|
},
|
||||||
},
|
"Quiet Manor": {
|
||||||
"4": {
|
"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": []
|
},
|
||||||
}
|
"Shadow Creed": {
|
||||||
},
|
"2": {"description": "ATK +10%", "mode": "always", "stats": [{"stat": "AtkUp", "value": 0.1}], "why": "Unconditional wearer stat."},
|
||||||
"Fireflies and the Forest": {
|
"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."}
|
||||||
"2": {
|
},
|
||||||
"unknown": true,
|
"Speedy Hedgehog": {
|
||||||
"stats": []
|
"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."}
|
||||||
"4": {
|
},
|
||||||
"unknown": true,
|
"Street Boxer": {
|
||||||
"stats": []
|
"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."}
|
||||||
},
|
},
|
||||||
"Kingdom's Guard": {
|
"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": []
|
},
|
||||||
},
|
"Tiny Big Adventure": {
|
||||||
"4": {
|
"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": []
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. */
|
/** 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue