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

View file

@ -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_<key>_*` 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[];
}

View file

@ -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<string, Record<"2" | "4", TierBonus>>;
}
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();
}

View file

@ -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<JobProgress, JobResult, void>
base: baseVector(character),
trait: character.trait,
setBonuses: job.setBonuses,
targetTier: character.targetTier ?? 4,
scoring: compile(character.scoring),
excluded,
beamWidth: options.beamWidth,

View file

@ -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 {

View file

@ -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<number>;
@ -151,6 +153,7 @@ export function* solveSingle(
base,
trait,
setBonuses,
targetTier = 4,
scoring,
excluded = new Set<number>(),
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;

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 { 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<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 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({
<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">
<input
type="checkbox"
@ -401,6 +436,11 @@ export function CharacterView({
never been measured, so it contributes nothing to this score.
</p>
)}
{build.omittedTiers.length > 0 && (
<p className="warning">
Not modelled: {build.omittedTiers.map((entry) => `tier ${entry.tier}: ${entry.why}`).join("; ")}
</p>
)}
{report && (
<table className="items">
<tbody>

View file

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

View file

@ -4,9 +4,9 @@
--panel: #1c1f26;
--line: #2c313b;
--text: #e6e8ec;
--dim: #9aa2b1;
--accent: #7aa2f7;
--warn: #e0af68;
--dim: #cacdd5;
--accent: #bbd0ff;
--warn: #f1cc8d;
--bad: #f7768e;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@ -59,7 +59,13 @@ select, input, button {
font-size: 0.85rem;
}
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 th {