213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { ICON_ORDER } from "../src/domain/shapes.ts";
|
|
import type { SetBonusTable } from "../src/domain/setbonus.ts";
|
|
import { consoleTrait } from "../src/domain/stats.ts";
|
|
import { SLOT_COUNT } from "../src/domain/statvec.ts";
|
|
import type { RawTilings } from "../src/domain/tilings.ts";
|
|
import type { ExportedItem } from "../src/domain/items.ts";
|
|
import { runJob, type JobProgress, type SolveJob } from "../src/solver/job.ts";
|
|
import { CancelledError, createInlineHost, createSolverHost } from "../src/solver/host.ts";
|
|
|
|
const gamedata = JSON.parse(readFileSync("src/generated/gamedata.json", "utf8"));
|
|
const tilings = JSON.parse(readFileSync("src/generated/tilings.json", "utf8")) as RawTilings;
|
|
const setBonuses = JSON.parse(readFileSync("data-src/set-bonuses.json", "utf8")) as SetBonusTable;
|
|
|
|
/**
|
|
* A synthetic account: plenty of every shape, and cartridges of one set. Real
|
|
* game data for the boards and packings, invented inventory - the fixture policy
|
|
* keeps real captures out of the repo.
|
|
*/
|
|
function account(): ExportedItem[] {
|
|
const items: ExportedItem[] = [];
|
|
let n = 0;
|
|
for (const shape of ICON_ORDER) {
|
|
for (let i = 0; i < 8; i += 1) {
|
|
n += 1;
|
|
items.push({
|
|
instance: `m${n}`,
|
|
kind: "module",
|
|
item_id: `${shape}_1_Orange`,
|
|
shape,
|
|
level: 20,
|
|
rarity: "orange",
|
|
main_stats: [],
|
|
substats: [
|
|
{ stat: "CritBase", value: 0.01 * (i + 1) },
|
|
{ stat: "CritDamageBase", value: 0.02 * (i + 1) },
|
|
],
|
|
owner_group: null,
|
|
});
|
|
}
|
|
}
|
|
for (let i = 0; i < 6; i += 1) {
|
|
n += 1;
|
|
items.push({
|
|
instance: `c${n}`,
|
|
kind: "cartridge",
|
|
item_id: "Attack_orange",
|
|
set: "Shadow Creed",
|
|
level: 20,
|
|
rarity: "orange",
|
|
main_stats: [{ stat: "CritDamageBase", value: 0.6 }],
|
|
substats: [{ stat: "CritBase", value: 0.01 * (i + 1) }],
|
|
owner_group: null,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function job(keys: string[]): SolveJob {
|
|
return {
|
|
items: account(),
|
|
setBonuses,
|
|
tilings,
|
|
characters: keys.map((name) => {
|
|
const esper = gamedata.espers.find((e: { name: string }) => e.name === name);
|
|
return {
|
|
key: name,
|
|
slots: esper.slots,
|
|
trait: consoleTrait(esper),
|
|
base: new Array(SLOT_COUNT).fill(0),
|
|
scoring: {
|
|
targets: [
|
|
{ stat: "CritBase" as const, target: 0.7, weight: 3 },
|
|
{ stat: "CritDamageBase" as const, target: 2.0, weight: 3 },
|
|
],
|
|
},
|
|
sets: ["Shadow Creed" as const],
|
|
};
|
|
}),
|
|
options: { rounds: 1 },
|
|
};
|
|
}
|
|
|
|
describe("runJob", () => {
|
|
it("solves one character end to end from a serialisable job", () => {
|
|
const run = runJob(job(["Zankou"]));
|
|
const progress: JobProgress[] = [];
|
|
let step = run.next();
|
|
while (!step.done) {
|
|
progress.push(step.value);
|
|
step = run.next();
|
|
}
|
|
const result = step.value;
|
|
expect(result.infeasible).toBe(false);
|
|
expect(result.assignment).toHaveLength(1);
|
|
expect(result.unbuildable).toEqual([]);
|
|
expect(result.unknownShapes).toEqual([]);
|
|
expect(progress.some((p) => p.phase === "portfolio")).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", () => {
|
|
const run = runJob(job(["Zankou", "Haniel"]));
|
|
let step = run.next();
|
|
while (!step.done) step = run.next();
|
|
const used = step.value.assignment.flatMap((entry) => [
|
|
entry.build.cartridge,
|
|
...entry.build.modules,
|
|
]);
|
|
expect(step.value.assignment).toHaveLength(2);
|
|
expect(new Set(used).size).toBe(used.length);
|
|
});
|
|
|
|
|
|
it("honours a character's excluded instances", () => {
|
|
// R2 turned off: whatever another character wears is off limits, and the
|
|
// solve must actually avoid it rather than merely be told about it.
|
|
const plain = runJob(job(["Zankou"]));
|
|
let step = plain.next();
|
|
while (!step.done) step = plain.next();
|
|
const chosen = step.value.assignment[0]!.build;
|
|
const pool = job(["Zankou"]).items;
|
|
const used = [chosen.cartridge, ...chosen.modules].map((index) => pool[index]!.instance);
|
|
expect(used.length).toBeGreaterThan(0);
|
|
|
|
const restricted = job(["Zankou"]);
|
|
restricted.characters[0]!.excludedInstances = used;
|
|
const run = runJob(restricted);
|
|
let next = run.next();
|
|
while (!next.done) next = run.next();
|
|
const after = next.value.assignment[0]!.build;
|
|
const afterInstances = [after.cartridge, ...after.modules].map(
|
|
(index) => restricted.items[index]!.instance,
|
|
);
|
|
for (const instance of used) expect(afterInstances).not.toContain(instance);
|
|
});
|
|
|
|
it("carries a job with no packings through as unbuildable", () => {
|
|
const empty = job(["Zankou"]);
|
|
empty.characters[0]!.sets = ["Kingdom's Guard"]; // owned by nobody here
|
|
const run = runJob(empty);
|
|
let step = run.next();
|
|
while (!step.done) step = run.next();
|
|
expect(step.value.unbuildable).toEqual(["Zankou"]);
|
|
expect(step.value.assignment).toEqual([]);
|
|
});
|
|
|
|
it("reports item ids it has no shape for instead of dropping them", () => {
|
|
const odd = job(["Zankou"]);
|
|
odd.items.push({
|
|
instance: "x1",
|
|
kind: "module",
|
|
item_id: "cell9_style9_1_Orange",
|
|
shape: "cell9_style9",
|
|
level: 0,
|
|
rarity: "orange",
|
|
main_stats: [],
|
|
substats: [],
|
|
owner_group: null,
|
|
});
|
|
const run = runJob(odd);
|
|
let step = run.next();
|
|
while (!step.done) step = run.next();
|
|
expect(step.value.unknownShapes).toEqual(["cell9_style9_1_Orange"]);
|
|
});
|
|
});
|
|
|
|
describe("solver host", () => {
|
|
it("falls back to inline where there is no Worker", () => {
|
|
// Node has none, which is also the file:// build's situation.
|
|
expect(createSolverHost().kind).toBe("inline");
|
|
});
|
|
|
|
it("resolves with the same answer the generator gives", async () => {
|
|
const host = createInlineHost();
|
|
const direct = runJob(job(["Zankou"]));
|
|
let step = direct.next();
|
|
while (!step.done) step = direct.next();
|
|
|
|
const seen: JobProgress[] = [];
|
|
const handle = host.solve(job(["Zankou"]), (progress) => seen.push(progress));
|
|
const result = await handle.promise;
|
|
expect(result.sorted).toEqual(step.value.sorted);
|
|
expect(seen.length).toBeGreaterThan(0);
|
|
host.dispose();
|
|
});
|
|
|
|
it("cancels cooperatively", async () => {
|
|
const host = createInlineHost();
|
|
const handle = host.solve(job(["Zankou", "Haniel"]));
|
|
handle.cancel();
|
|
await expect(handle.promise).rejects.toBeInstanceOf(CancelledError);
|
|
host.dispose();
|
|
});
|
|
});
|