feat: model cartridge set bonuses
This commit is contained in:
parent
24999a30ba
commit
e38d5baf6a
16 changed files with 1486 additions and 187 deletions
|
|
@ -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 { incompleteSets, setBonus, type SetBonusTable } from "../src/domain/setbonus.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 { cellsOfPiece, loadTilings, multisetOf, type RawTilings } from "../src/domain/tilings.ts";
|
||||
|
||||
|
|
@ -182,22 +183,47 @@ describe("set bonuses", () => {
|
|||
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 result = setBonus(table, "Shadow Creed", required);
|
||||
expect(result.tiers).toEqual([2, 4]);
|
||||
expect(result.unknownTiers).toEqual([2, 4]);
|
||||
expect([...result.vector].every((value) => value === 0)).toBe(true);
|
||||
const twoPiece = setBonus(table, "Shadow Creed", required, 2);
|
||||
expect(twoPiece.vector[slotOf("AtkUp")]).toBeCloseTo(0.1);
|
||||
expect(twoPiece.omittedTiers).toEqual([]);
|
||||
|
||||
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", () => {
|
||||
const result = setBonus(table, "Shadow Creed", ["cell3_style1"]);
|
||||
expect(result.tiers).toEqual([]);
|
||||
expect(result.unknownTiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags every set as incomplete until the values are filled in", () => {
|
||||
expect(incompleteSets(table)).toHaveLength(12);
|
||||
it("uses stated maximum stacks when aiming for 2+4", () => {
|
||||
const result = setBonus(
|
||||
table,
|
||||
"Crimson: Twin Butterflies",
|
||||
REQUIRED_PIECES["Crimson: Twin Butterflies"],
|
||||
4,
|
||||
);
|
||||
expect(result.vector[slotOf("DamageUpIncantationBase")]).toBeCloseTo(0.1);
|
||||
expect(result.vector[slotOf("AtkUp")]).toBeCloseTo(0.36);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
weightForRank,
|
||||
type GuideTable,
|
||||
} from "../src/domain/guides.ts";
|
||||
import { isKnownStat } from "../src/domain/statvec.ts";
|
||||
|
||||
const table = JSON.parse(readFileSync("data-src/guides.json", "utf8")) as GuideTable;
|
||||
|
||||
|
|
@ -74,7 +75,12 @@ describe("guide to targets", () => {
|
|||
|
||||
describe("guide lookup", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
|
@ -91,10 +97,45 @@ describe("guide lookup", () => {
|
|||
expect(variantFor(guide, null)!.name).toBe("Main DPS");
|
||||
});
|
||||
|
||||
it("ships an empty table rather than invented guidance", () => {
|
||||
// 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([]);
|
||||
it("carries the published guides, and only stats the model has slots for", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const run = runJob(job(["Zankou", "Haniel"]));
|
||||
let step = run.next();
|
||||
|
|
|
|||
|
|
@ -8,12 +8,17 @@ import type { Tiling } from "../src/domain/tilings.ts";
|
|||
import { solveSingle } from "../src/solver/single.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 = {
|
||||
format: "nte-set-bonuses",
|
||||
format_version: 1,
|
||||
sets: {
|
||||
"Shadow Creed": { "2": { unknown: true, stats: [] }, "4": { unknown: true, stats: [] } },
|
||||
},
|
||||
format_version: 2,
|
||||
sets: { "Shadow Creed": { "2": unknown, "4": unknown } },
|
||||
};
|
||||
|
||||
const scoring = compile({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function build(score: number, cartridge: number, modules: number[]): Build {
|
|||
vector: new Float32Array(0),
|
||||
proven: true,
|
||||
unknownTiers: [],
|
||||
omittedTiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue