feat: model cartridge set bonuses
This commit is contained in:
parent
bddc0817ad
commit
ad2d9b229f
16 changed files with 1486 additions and 187 deletions
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue