Compare commits

...
28 changed files with 1591 additions and 66 deletions

No files matched your search

+21
View File
@@ -5744,3 +5744,24 @@ unchanged; no commit, deployment or readiness promotion was performed.
build; the actual prior dist has a separate exclusive backup.
- Evidence: artifacts/playtest/rom-residual-review-20261006/; scope/limits and
source/promotion/build proofs: docs/ROM_RESIDUAL_RENDERING_REVIEW_2026-10-06.md.
## Gear namespace checkpoint — native #292 / shared #9 (2026-10-10)
Character/class source category now governs item normalization, grants, equip
and active stats independently from dungeon source. Shared profile schema 8
migrates missing-category WoW records only for explicit WoW owners and retains
foreign/unidentified/invalid RoM records plus assignments in recovery. Rejected
updates leave stored bytes unchanged. Native ordinary loot stays disabled;
boss cards and simulator reward policy retain their existing behavior.
253 unique focused source tests, full-scope app typecheck and release hygiene
pass. The composed native bundle builds, 21 selected native cases pass, and five
public Ready WoW level-80 snapshots match the pre-change bundle with controlled
clock/RNG. This does not certify overall balance or equipped Ready gear.
No dungeon was entered for this checkpoint, so no new Cellblock Rush boss,
GPU screenshot, browser roster/dungeon inspection, manual fight or listening
result is claimed. The source browser build stops at missing ignored Manastorm
assets; browser release/visual verification remains open. Native offline gear
persistence, recovery presentation and authenticated RoM progression remain
under #124. See docs/GAME_GEAR_NAMESPACES.md and gear-namespace-validation.json.
+11
View File
@@ -0,0 +1,11 @@
# Account Boss Card checkpoints
`PATCH /api/cloud-save` adds an account-scoped checkpoint for Boss Cards without sending a replacement cloud document. The server derives the owner from the authenticated bearer session; request fields cannot choose an account. The body is `{ "bossCards": <raw collection>, "expectedRevision": <nonnegative safe integer> }`.
The server serializes the compare and write in a SQLite `BEGIN IMMEDIATE` transaction. A missing save has revision `0`. A matching request merges the submitted collection with that account’s saved cards, changes only `data.bossCards`, increments the cloud revision, and returns `{ revision, updatedAt, data }`. A stale revision returns HTTP `409` with `code: "revision_conflict"` and writes nothing. The client should reload the current save before retrying. Missing collections and invalid revisions return a client error; malformed collection values normalize to empty under the source policy without removing existing cards; an unauthenticated request is rejected before the body is used.
Normalization and merge behavior match `src/game/bossCards.ts`: the allowed rarities are Normal, Rare, Epic, and Legendary; positive finite counts are floored and bounded by `Number.MAX_SAFE_INTEGER`; invalid/unknown entries are dropped; repeated account copies keep maximum counts. Numeric `time:revision` claim IDs keep the later time and then higher revision; other claim strings use lexical maximum. Map-like output uses null-prototype count/claim records so names such as `__proto__` remain ordinary data keys.
Legacy `PUT /api/cloud-save` remains a full replacement for non-card fields. When either the stored or incoming document has a Boss Cards field, the server max-merges those collections into the incoming document. This keeps old clients that omit or resend stale card snapshots from deleting earned account cards, while their other fields retain the existing replacement behavior. PUT does not gain optimistic concurrency; PATCH is the revision-checked card-only route.
The focused loopback test uses the pinned source server factory, authenticated SQLite accounts, and a temporary database. It covers account isolation, cross-origin PATCH preflight, authorization, normalization, conflict/no-write and retry, concurrent revision checks, stale and omitted-card PUTs, deduplication, and server restart persistence. It does not contact the hosted service or exercise native UI integration.
+100
View File
@@ -0,0 +1,100 @@
# Gear namespaces and recoverable legacy records
The source runtime distinguishes an item's game namespace from the dungeon that
produced its source row. `InventoryItem.contentCategory` is `wow`, `rom`, or
`coa`; it stays optional in the TypeScript contract for legacy callers, while
new WoW and Runewaker factories explicitly write their own source category.
Saved character/class identity chooses the expected owner category. A dungeon
selection cannot change it or relabel another game's gear.
## Item and equipment boundaries
`lootTypes.ts` exposes an explicit category match and a usability check.
RoM usability additionally requires a nonempty payload accepted by the existing
`normalizeRomNativeGearStats` normalizer. This is structural validation, not
proof of an authenticated item association or authoritative stat ranges.
Unidentified and foreign records cannot be equipped or contribute active gear.
Contradictory owner-category hints cannot override the source class category.
Owner-aware loot generation and combat grants filter foreign/unusable records.
An unavailable same-game item pool yields no borrowed gear. Boss-card identity
continues to use the boss/dungeon source. Native Godot still remaps both loot
catalogs to its cards-only adapter; this change does not enable ordinary loot.
The generic Runewaker item catalog has source-tagged records without native
gear payloads and is not promoted into a playable equipment progression pool.
Synthetic equipment scoring and internal training gear remain simulation
scaffolding. They are not imported live loot. A private training-item identity
can preserve existing simulator fallback calculations without authorizing a
persisted or copied record as native RoM gear.
## Profile migration and recovery
`accountRepository.ts` reads local database versions 1–8 at the existing
`healer-man.profile-database.v1` key and writes schema 8. A missing item category
may migrate to `wow` only for an explicit WoW owner; the existing normalized
numeric item fields and equipment references retain their prior values.
Missing categories on RoM/CoA records are not guessed from names, IDs or dungeons.
Normalized active inventories and assignments contain usable same-game items.
Rejected legacy records remain in character-local `gearRecovery.items` with
original item data and a reason (`missing-category`, `foreign-category`, or
`invalid-native-payload`). `gearRecovery.equipment` retains former owner/slot/
instance references. Repeated load/cloud normalization merges recovery without
multiplying it, and ordinary gear updates preserve the existing recovery.
This schema retains data for a later explicit recovery operation; it does not
provide a recovery UI or invent a replacement item.
Inventory/equipment mutations reject foreign or unusable incoming gear before
writing. Rejected mutations leave stored bytes unchanged. Other profiles and
owners retain separate inventories and recovery. Cloud serialization carries
full profile rows, and hydration queues a save when namespace/recovery migration
changes the roster; existing boss-card checkpoint and session-generation behavior
remain separate.
## Native integration and remaining scope
Shared source issue [#9](https://git.whoagland.com/phenom/healer-man/issues/9)
owns the source guards and profile schema. Native checkpoint
[#292](https://git.whoagland.com/Codex/healer-man-godot/issues/292) owns reviewed
source pins, bridge regressions and installation. Parent native
[#124](https://git.whoagland.com/Codex/healer-man-godot/issues/124) remains open:
native offline profiles currently store identity/appearance and do not persist
runtime item inventory/loadout or expose recovery. RoM entry retains empty gear.
Source-backed RoM progression and equipment presentation are not established by
the source association catalog. CoA is Ascension Conquest of Azeroth, distinct
from supplementary Chronicles of Arcadia references; no native CoA mode is added.
RoM stat formulas, weights and allocations remain under the separate native
[#123](https://git.whoagland.com/Codex/healer-man-godot/issues/123) claim. The
existing possibility of non-finite native gear aggregates is tracked separately
in shared [#10](https://git.whoagland.com/phenom/healer-man/issues/10); namespace
metadata does not establish numeric bounds or source authenticity.
## Verification
Validation results and exact inputs are recorded with the owning issues. The
focused item/equipment suites pass 39 tests; account/migration/cloud suites pass
35; combat/stat regressions pass 120; selected training/slot checks pass three;
and the avatar suite passes 56 with verified copied inputs and explicit
AppleDouble exclusions. The native source-boundary suite passes seven tests,
and the candidate native bundle builds from the composed, pinned source.
The required browser production build currently stops at the missing
`public/assets/game/manastorm` asset-audit input in the isolated review checkout.
This is retained as an unmet browser build/visual verification limit; a passing
native bundle does not certify the browser release. Source TypeScript and
combined native/card/simulator checks are recorded separately. The repository
release hygiene check passes. No new GPU capture, live browser roster/dungeon
check, manual playthrough or listening is claimed.
The full source app TypeScript check passes with the original `src` scope and
task-local exclusions for AppleDouble companion files. All 21 selected native
cases pass across the unchanged 20-case run and the exact missing-catalog case
rerun. A serial public-runtime comparison matches all five Ready WoW profiles
at level 80 with identical clock and random inputs. Those profiles have empty
gear; equipped-item and migration behavior is covered by the focused source
suites. Earlier varying mana results came from run-ID-seeded party identities,
not a gear formula change; the superseded controls remain in the archive.
See `gear-namespace-validation.json` for exact source and evidence identities.
+85
View File
@@ -0,0 +1,85 @@
{
"schemaVersion": 1,
"date": "2026-10-10",
"coordinator": "/root",
"session": "01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6",
"issues": {
"native": 292,
"nativeParent": 124,
"shared": 9
},
"status": "tested-native-checkpoint-browser-verification-incomplete",
"sourceFiles": {
"src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa",
"src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92",
"src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5",
"src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a",
"src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d",
"src/game/combatStore.ts": "f49b7af337af0fb6e69f6df68ec5ec9ab558cc88fe819ae30c4cf9ad823cd4e5",
"src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8",
"src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3",
"src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628",
"src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe"
},
"composedNativeSourceFiles": {
"src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa",
"src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92",
"src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5",
"src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a",
"src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d",
"src/game/combatStore.ts": "5afefb58b9a02b564cf07140c19388163a8423d22695f3619d196a9d603eeb7d",
"src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8",
"src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3",
"src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628",
"src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe"
},
"nativeEngineSha256": "6f9a06c8fb46dbc25b5ff7aa1fee77fcc950a8481e2beb8eb9ed69e33d51e15d",
"checks": {
"sourceFocusedUniqueTests": 253,
"sourceGroups": {
"itemEquipment": 39,
"profileCloud": 35,
"combatStats": 120,
"targetedTraining": 3,
"avatar": 56
},
"sourceAppTypecheck": "pass: full original src scope; task-local AppleDouble exclusions only",
"sourceReleaseHygiene": "pass",
"sourceBrowserBuild": "blocked: missing public/assets/game/manastorm in isolated review checkout",
"nativeBundleBuild": "pass",
"nativeFocusedCases": 21,
"nativeCoverageMethod": "20 unchanged cases pass in attempt-03; the one missing-catalog case passes in the exact-case rerun after copying and pinning the installed boss-card catalog",
"readyWowParity": "5/5 level-80 public Ready profiles match with fixed clock/RNG and one fresh process per profile; empty gear only",
"liveBrowserRosterAndDungeon": "not performed",
"newGodotGpuCapture": "not performed"
},
"artifacts": {
"diagnostics/source-worker/attempt-08/source-worker-receipt.json": "8bcae5d6e47e7e3547ac72d5f3d650fa2f7280993f96a77a3a597fd32e696a48",
"diagnostics/persistence-worker/final-receipt-attempt-04.json": "241b8423795b3596b3f77fcc93c0d50bf093f0dcfa6043ec9974c1c8050d1b2a",
"diagnostics/required-source-checks/combat-regressions-attempt-01/result.json": "8def574f70b9b19ff57f4413eb4382ecd1e29df126d1036173dd092974021e2d",
"diagnostics/required-source-checks/avatar-attempt-03/result.json": "74b511dc10bc233f4aaf49c3bf5a5eb6a626e5639b85332b1444a8d50ec08157",
"diagnostics/required-source-checks/typecheck-attempt-04/result.json": "cb2f6f49e3f8f2f3c3f44ce02e0a0f644bcb9d69dfe656ba04b78cfb2af517ca",
"diagnostics/required-source-checks/release-attempt-01/result.json": "1c0cd604233dfc3ed01ddca47b34770392e917cd5a589efee15a46d1ceb4c230",
"diagnostics/required-source-checks/build-attempt-02/result.json": "d068f38485c7a38af27586d1bbd1ad96db9630bf976092da0572fe0c3a7d5255",
"diagnostics/native-build/baseline-attempt-02/result.json": "92bf94d5b26fa99c502575d2fb9bd624518f9e5e21b362b697324efd6a6e6d63",
"diagnostics/native-build/candidate-attempt-01/result.json": "05bf46066a9429edb2405d4a60ca7937446f1cb978e71e504a1bf0a051b3dbfb",
"diagnostics/native-regressions/attempt-03/result.json": "7e31c04b48f9b01e1419c718023ad929a65cc0efdc3c4759e00a3b72e5d6775e",
"diagnostics/native-regressions/cards-catalog-rerun-attempt-01/result.json": "23b8d1b0216de53c462ca25ada0443aa9bdd62336dd554b10bc5f0b9dfa19fcf",
"diagnostics/persistence-worker/wow-preservation/attempt-10/acceptance-receipt.json": "8533744d44af288149572f7147c69961b50315101e3c7f86e90ee4d0bafc99ea",
"diagnostics/native-candidate-integration/attempt-02/result.json": "44d4672f6f1c174563e2524fffe38ab18f1d96b74486b7a0c198bad764990fbe",
"diagnostics/native-candidate-integration/attempt-02/hunk-preservation.json": "b2f6c6e236dc6484fd14ee9bada39fd60933b33320b927e721370feff8445e44",
"diagnostics/required-source-checks/release-attempt-02/result.json": "daf8d3a2bc4dd0d103127687d6fd44ba5ee30f7e2ff51f518fb88fe80004caa6"
},
"preservedScopes": [
"WoW rigs/animations/models/stats and installed short-GCD work",
"#123 RoM formulas/weights/allocation",
"native cards-only loot and simulator reward policy",
"character category independent from dungeon category"
],
"remaining": [
"Full browser build and live roster/in-dungeon screenshots",
"#124 native offline item/loadout persistence and recovery UI",
"#124 authenticated RoM item progression and equipment presentation",
"Shared #10 non-finite aggregate follow-up"
]
}
+245
View File
@@ -0,0 +1,245 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomBytes } from "node:crypto";
import test from "node:test";
import { createGameServer } from "./server.mjs";
function fixtureUsername() {
return `card${process.pid.toString(36)}${randomBytes(4).toString("hex")}`;
}
async function startServer(dataDir) {
const root = path.join(dataDir, "http");
const staticDir = path.join(root, "static");
const contentDir = path.join(root, "content");
mkdirSync(staticDir, { recursive: true });
mkdirSync(contentDir, { recursive: true });
const runtime = createGameServer({
staticDir, contentDir, dataDir, enableShelvedModes: true, corsOrigins: "https://native-test.invalid",
});
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
return { runtime, origin: `http://127.0.0.1:${runtime.server.address().port}` };
}
async function stopServer(runtime) {
if (!runtime?.server.listening) return;
await new Promise((resolve, reject) => {
runtime.server.close((error) => error ? reject(error) : resolve());
});
}
async function request(origin, route, { method = "GET", token, body } = {}) {
const response = await fetch(`${origin}${route}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, body: await response.json() };
}
async function register(origin, username, password) {
const result = await request(origin, "/api/auth/register", {
method: "POST", body: { username, password },
});
assert.equal(result.status, 201);
return result.body;
}
test("cloud card PATCH checkpoints atomically per authenticated account and legacy PUT cannot erase cards", async () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "healer-boss-card-checkpoint-"));
const firstPassword = `A${randomBytes(18).toString("base64url")}`;
const secondPassword = `B${randomBytes(18).toString("base64url")}`;
const firstUsername = fixtureUsername();
const secondUsername = fixtureUsername();
let runtime;
try {
let service = await startServer(tempDir);
runtime = service.runtime;
const unauthenticated = await request(service.origin, "/api/cloud-save", {
method: "PATCH", body: { expectedRevision: 0, bossCards: {} },
});
assert.equal(unauthenticated.status, 401);
const preflight = await fetch(`${service.origin}/api/cloud-save`, {
method: "OPTIONS",
headers: {
Origin: "https://native-test.invalid",
"Access-Control-Request-Method": "PATCH",
"Access-Control-Request-Headers": "authorization,content-type",
},
});
assert.equal(preflight.status, 204);
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://native-test.invalid");
assert.match(preflight.headers.get("access-control-allow-methods"), /\bPATCH\b/);
const unauthorizedMalformed = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: "not-a-valid-session-token",
body: { expectedRevision: 0 },
});
assert.equal(unauthorizedMalformed.status, 401);
const firstAccount = await register(service.origin, firstUsername, firstPassword);
const secondAccount = await register(service.origin, secondUsername, secondPassword);
assert.notEqual(firstAccount.account.id, secondAccount.account.id);
assert.ok(firstAccount.token);
assert.ok(secondAccount.token);
const missingCards = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token, body: { expectedRevision: 0 },
});
assert.equal(missingCards.status, 400);
assert.equal(missingCards.body.code, "invalid_boss_cards");
const initialFirst = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
const initialSecond = await request(service.origin, "/api/cloud-save", { token: secondAccount.token });
assert.equal(initialFirst.body.revision, 0);
assert.deepEqual(initialFirst.body.data, { version: 1, characters: [] });
assert.equal(initialSecond.body.revision, 0);
const rawCards = JSON.parse(
'{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":2.9,"rare":0,"epic":1.7,"legendary":9007199254740992,"unknown":50},"":{"normal":7}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","ignored":42}}',
);
const firstCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH",
token: firstAccount.token,
body: { accountId: secondAccount.account.id, expectedRevision: 0, bossCards: rawCards },
});
assert.equal(firstCheckpoint.status, 200);
assert.equal(firstCheckpoint.body.revision, 1);
assert.deepEqual(firstCheckpoint.body.data.version, 1);
assert.deepEqual(firstCheckpoint.body.data.characters, []);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].normal, 2);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].epic, 1);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].legendary, Number.MAX_SAFE_INTEGER);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].rare, undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].unknown, undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts[""], undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts["__proto__"].normal, 9);
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["__proto__"], "200:1");
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["spawn:a"], "100:2");
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims.ignored, undefined);
const conflict = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: { expectedRevision: 0, bossCards: { counts: { "wow:55": { normal: 50 } } } },
});
assert.equal(conflict.status, 409);
assert.equal(conflict.body.code, "revision_conflict");
const unchangedAfterConflict = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
assert.deepEqual(unchangedAfterConflict.body, firstCheckpoint.body);
const invalidRevision = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: { expectedRevision: 1.5, bossCards: {} },
});
assert.equal(invalidRevision.status, 400);
assert.equal(invalidRevision.body.code, "invalid_revision");
const retried = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: {
expectedRevision: 1,
bossCards: {
counts: { "wow:55": { normal: 5, rare: 1 }, "wow:new": { epic: 1 } },
lastClaims: { "spawn:a": "100:1", "spawn:b": "alpha" },
},
},
});
assert.equal(retried.status, 200);
assert.equal(retried.body.revision, 2);
assert.equal(retried.body.data.bossCards.counts["wow:55"].normal, 5);
assert.equal(retried.body.data.bossCards.counts["wow:55"].rare, 1);
assert.equal(retried.body.data.bossCards.counts["wow:new"].epic, 1);
assert.equal(retried.body.data.bossCards.lastClaims["spawn:a"], "100:2");
const otherCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: secondAccount.token,
body: { expectedRevision: 0, bossCards: { counts: { "rom:9": { rare: 3 } } } },
});
assert.equal(otherCheckpoint.status, 200);
assert.equal(otherCheckpoint.body.revision, 1);
assert.deepEqual(Object.keys(otherCheckpoint.body.data.bossCards.counts), ["rom:9"]);
const firstStillIsolated = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
assert.equal(firstStillIsolated.body.revision, 2);
assert.equal(firstStillIsolated.body.data.bossCards.counts["rom:9"], undefined);
const stalePut = await request(service.origin, "/api/cloud-save", {
method: "PUT", token: firstAccount.token,
body: {
data: {
version: 2,
characters: [{ id: "character-a" }],
uiPreferences: { theme: "night" },
futureField: { preservedDuringThisWrite: true },
bossCards: {
counts: { "wow:55": { normal: 1 }, "wow:legacy": { legendary: 1 } },
lastClaims: { "spawn:a": "99:9", "spawn:c": "claim-c" },
},
},
},
});
assert.equal(stalePut.status, 200);
assert.equal(stalePut.body.revision, 3);
const stalePutExpectedData = JSON.parse(
'{"version":2,"characters":[{"id":"character-a"}],"uiPreferences":{"theme":"night"},"futureField":{"preservedDuringThisWrite":true},"bossCards":{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":5,"rare":1,"epic":1,"legendary":9007199254740991},"wow:new":{"epic":1},"wow:legacy":{"legendary":1}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","spawn:b":"alpha","spawn:c":"claim-c"}}}',
);
assert.deepEqual(stalePut.body.data, stalePutExpectedData);
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].normal, 5);
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].rare, 1);
assert.equal(stalePut.body.data.bossCards.counts["wow:legacy"].legendary, 1);
assert.equal(stalePut.body.data.bossCards.lastClaims["spawn:a"], "100:2");
const omittedCardPut = await request(service.origin, "/api/cloud-save", {
method: "PUT", token: firstAccount.token,
body: { data: { version: 3, characters: [], custom: "legacy replacement" } },
});
assert.equal(omittedCardPut.status, 200);
assert.equal(omittedCardPut.body.revision, 4);
assert.deepEqual(omittedCardPut.body.data, {
version: 3,
characters: [],
custom: "legacy replacement",
bossCards: stalePut.body.data.bossCards,
});
await stopServer(runtime);
runtime = null;
service = await startServer(tempDir);
runtime = service.runtime;
const firstLogin = await request(service.origin, "/api/auth/login", {
method: "POST", body: { username: firstUsername, password: firstPassword },
});
const secondLogin = await request(service.origin, "/api/auth/login", {
method: "POST", body: { username: secondUsername, password: secondPassword },
});
assert.equal(firstLogin.status, 200);
assert.equal(secondLogin.status, 200);
const reopenedFirst = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
const reopenedSecond = await request(service.origin, "/api/cloud-save", { token: secondLogin.body.token });
assert.deepEqual(reopenedFirst.body.data, omittedCardPut.body.data);
assert.deepEqual(reopenedSecond.body.data.bossCards.counts, { "rom:9": { rare: 3 } });
const concurrent = await Promise.all([0, 1].map(() => request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstLogin.body.token,
body: { expectedRevision: 4, bossCards: stalePut.body.data.bossCards },
})));
assert.deepEqual(concurrent.map((result) => result.status).sort(), [200, 409]);
assert.equal(concurrent.find((result) => result.status === 409).body.code, "revision_conflict");
const afterConcurrentCheckpoint = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
assert.equal(afterConcurrentCheckpoint.body.revision, 5);
assert.deepEqual(afterConcurrentCheckpoint.body.data, omittedCardPut.body.data);
const duplicateCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstLogin.body.token,
body: { expectedRevision: 5, bossCards: stalePut.body.data.bossCards },
});
assert.equal(duplicateCheckpoint.status, 200);
assert.equal(duplicateCheckpoint.body.revision, 6);
assert.deepEqual(duplicateCheckpoint.body.data.bossCards, omittedCardPut.body.data.bossCards);
} finally {
await stopServer(runtime);
rmSync(tempDir, { recursive: true, force: true });
}
});
+56
View File
@@ -0,0 +1,56 @@
const BOSS_CARD_RARITIES = ["normal", "rare", "epic", "legendary"];
export function emptyBossCardCollection() {
return { counts: {}, lastClaims: {} };
}
/** Keep this normalization in step with src/game/bossCards.ts. */
export function normalizeBossCardCollection(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return emptyBossCardCollection();
const raw = value;
const counts = Object.create(null);
if (raw.counts && typeof raw.counts === "object" && !Array.isArray(raw.counts)) {
for (const [key, candidate] of Object.entries(raw.counts)) {
if (!key || !candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
const entry = {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = candidate[rarity];
if (typeof count === "number" && Number.isFinite(count) && count >= 1) {
entry[rarity] = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(count));
}
}
if (Object.keys(entry).length) counts[key] = entry;
}
}
const lastClaims = raw.lastClaims && typeof raw.lastClaims === "object" && !Array.isArray(raw.lastClaims)
? Object.fromEntries(Object.entries(raw.lastClaims).filter(([key, claim]) => key && typeof claim === "string"))
: {};
return { counts, lastClaims };
}
/** Max-reconcile copies of one account collection, matching source mergeBossCardCollections(..., "max"). */
export function mergeBossCardCollections(collections) {
const counts = Object.create(null);
const lastClaims = Object.create(null);
for (const value of collections) {
const collection = normalizeBossCardCollection(value);
for (const [bossKey, entry] of Object.entries(collection.counts)) {
const merged = counts[bossKey] ??= {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = entry[rarity];
if (count === undefined) continue;
merged[rarity] = Math.max(merged[rarity] ?? 0, count);
}
}
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
const previous = lastClaims[spawnId];
if (!previous) { lastClaims[spawnId] = claim; continue; }
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
const [previousTime, previousRevision] = previous.split(":").map(Number);
const [time, revision] = claim.split(":").map(Number);
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
} else if (claim > previous) lastClaims[spawnId] = claim;
}
}
return { counts, lastClaims };
}
+70 -3
View File
@@ -26,6 +26,7 @@ import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import { mergeBossCardCollections } from "./boss-card-collections.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const scrypt = promisify(scryptCallback);
@@ -402,13 +403,78 @@ export function openGameDatabase(options = {}) {
}
}
function cloudSaveDataForWrite(row) {
if (!row) return null;
try {
const data = JSON.parse(row.data_json);
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Cloud save data is not an object.");
return data;
} catch {
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
}
}
function previousCloudSaveForPut(row) {
try {
return cloudSaveDataForWrite(row);
} catch (error) {
// Keep legacy PUT's ability to replace an unreadable save document.
if (error instanceof GameDatabaseError && error.code === "cloud_save_corrupt") return null;
throw error;
}
}
function saveCloudData(accountId, data) {
const now = Date.now();
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
}
function transactCloudSave(operation) {
database.exec("BEGIN IMMEDIATE");
try {
const result = operation();
database.exec("COMMIT");
return result;
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
function putCloudSave(accountId, data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new GameDatabaseError("Cloud save data must be an object.");
}
const now = Date.now();
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
return transactCloudSave(() => {
const previous = previousCloudSaveForPut(statements.cloudSave.get(accountId));
const nextData = { ...data };
const hasPreviousCards = previous !== null && Object.hasOwn(previous, "bossCards");
const hasIncomingCards = Object.hasOwn(data, "bossCards");
if (hasPreviousCards || hasIncomingCards) {
nextData.bossCards = mergeBossCardCollections([previous?.bossCards, data.bossCards]);
}
return saveCloudData(accountId, nextData);
});
}
function patchCloudSaveBossCards(accountId, bossCards, expectedRevision) {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("Expected cloud save revision must be a nonnegative integer.", 400, "invalid_revision");
}
return transactCloudSave(() => {
const row = statements.cloudSave.get(accountId);
const currentRevision = row ? Number(row.revision) : 0;
if (currentRevision !== expectedRevision) {
throw new GameDatabaseError("Cloud save revision changed. Reload before retrying.", 409, "revision_conflict");
}
const previous = cloudSaveDataForWrite(row) ?? { version: 1, characters: [] };
const nextData = {
...previous,
bossCards: mergeBossCardCollections([previous.bossCards, bossCards]),
};
return saveCloudData(accountId, nextData);
});
}
function isGameMaster(account) {
@@ -1213,6 +1279,7 @@ export function openGameDatabase(options = {}) {
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
getCloudSave,
putCloudSave,
patchCloudSaveBossCards,
isGameMaster,
getManastormConfig,
putManastormConfig,
+10 -1
View File
@@ -209,7 +209,7 @@ export function createGameServer(options = {}) {
response.writeHead(204, {
...cors,
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Max-Age": "86400",
});
response.end();
@@ -445,6 +445,15 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
return;
}
if (url.pathname === "/api/cloud-save" && request.method === "PATCH") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, bodyLimit);
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "bossCards")) {
throw new GameDatabaseError("Boss Card collection is required.", 400, "invalid_boss_cards");
}
json(response, 200, gameDatabase.patchCloudSaveBossCards(account.id, body.bossCards, body.expectedRevision));
return;
}
if (url.pathname === "/api/online-group" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.onlineGroupState(account.id));
+166 -5
View File
@@ -164,6 +164,7 @@ describe("local profile repository", () => {
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const item = {
contentCategory: "rom" as const,
instanceId: "kept-item",
itemId: 10412,
itemKey: "belt-of-the-fang",
@@ -174,7 +175,7 @@ describe("local profile repository", () => {
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15, romNative: { strength: 1 } },
quantity: 1,
sourceBossKey: "lady-anacondra",
sourceBossName: "Lady Anacondra",
@@ -483,7 +484,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
}
const legacyV3Storage = new MemoryStorage();
@@ -642,16 +643,176 @@ describe("local profile repository", () => {
acquiredAt: 5_000,
};
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([item]);
const normalizedItem = { ...item, contentCategory: "wow" as const };
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([normalizedItem]);
expect(updateCharacterEquipment(offline.ownerId, character.id, {
player: { waist: item.instanceId },
}, storage)?.equipment).toEqual({ player: { waist: item.instanceId } });
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([item]);
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([normalizedItem]);
expect(listCharacters(offline.ownerId, storage)[0]?.equipment).toEqual({
player: { waist: item.instanceId },
});
});
it("migrates foreign and unresolved gear into character-scoped recovery without changing owner identity", () => {
const storage = new MemoryStorage();
const makeItem = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
...(contentCategory ? { contentCategory } : {}),
instanceId,
itemId: 10412,
itemKey: `gear:${instanceId}`,
name: `Gear ${instanceId}`,
quality: "rare" as const,
inventorySlot: "waist",
armorType: "leather",
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: {
...EMPTY_ITEM_STATS,
agility: 4,
...(romNative === undefined ? {} : { romNative }),
},
quantity: 1,
sourceBossKey: "source-boss",
sourceBossName: "Source Boss",
acquiredAt: 5_000,
});
const legacyProfile = (
id: string,
ownerId: string,
classId: string,
raceId: string,
inventory: unknown[],
equipment: unknown = {},
) => ({
id, ownerId, name: id, classId, raceId, gender: "female", appearance: {},
inventory, equipment, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 2,
});
const wowItem = makeItem("wow-legacy");
const romMissingCategory = makeItem("rom-missing-category");
const romForeign = makeItem("rom-foreign", "wow");
const romInvalidNative = makeItem("rom-invalid-native", "rom", {});
const coaMissingCategory = makeItem("coa-missing-category");
const untouchedWow = makeItem("other-owner-item", "wow");
const wowProfile = legacyProfile("wow-profile", "owner-one", "priest", "human", [wowItem], {
player: { waist: wowItem.instanceId },
});
const romProfile = legacyProfile("rom-profile", "owner-one", "rom-priest", "rom-human", [
romMissingCategory, romForeign, romInvalidNative,
], {
player: { waist: romMissingCategory.instanceId },
companion: { waist: romForeign.instanceId },
reserve: { waist: romInvalidNative.instanceId },
});
const coaProfile = legacyProfile("coa-profile", "owner-one", "barbarian", "human", [coaMissingCategory]);
const otherOwnerProfile = legacyProfile("other-owner-profile", "owner-two", "priest", "human", [untouchedWow]);
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 7,
accounts: [],
rosters: { "owner-one": [wowProfile, romProfile, coaProfile], "owner-two": [otherOwnerProfile] },
bossCardsByOwner: {},
presentationSettingsByOwner: {},
}));
const ownerOne = listCharacters("owner-one", storage);
const wow = ownerOne.find((character) => character.id === "wow-profile")!;
const rom = ownerOne.find((character) => character.id === "rom-profile")!;
const coa = ownerOne.find((character) => character.id === "coa-profile")!;
expect(wow.inventory).toMatchObject([{ instanceId: "wow-legacy", contentCategory: "wow", stats: { agility: 4 } }]);
expect(wow.equipment).toEqual({ player: { waist: "wow-legacy" } });
expect(rom.inventory).toEqual([]);
expect(rom.equipment).toEqual({});
expect(rom.gearRecovery?.items).toEqual([
{ item: romMissingCategory, reason: "missing-category" },
{ item: romForeign, reason: "foreign-category" },
{ item: romInvalidNative, reason: "invalid-native-payload" },
]);
expect(rom.gearRecovery?.equipment).toEqual([
{ ownerId: "player", slot: "waist", instanceId: "rom-missing-category", reason: "missing-category" },
{ ownerId: "companion", slot: "waist", instanceId: "rom-foreign", reason: "foreign-category" },
{ ownerId: "reserve", slot: "waist", instanceId: "rom-invalid-native", reason: "invalid-native-payload" },
]);
expect(coa.inventory).toEqual([]);
expect(coa.gearRecovery?.items).toEqual([{ item: coaMissingCategory, reason: "missing-category" }]);
expect(listCharacters("owner-two", storage)[0]?.inventory).toMatchObject([
{ instanceId: "other-owner-item", contentCategory: "wow" },
]);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
const validRomReplacement = makeItem("valid-rom-replacement", "rom", { strength: 2 });
const ordinarySave = updateCharacterInventory("owner-one", rom.id, [validRomReplacement], storage)!;
expect(ordinarySave.inventory).toMatchObject([{ instanceId: "valid-rom-replacement", contentCategory: "rom" }]);
expect(ordinarySave.gearRecovery).toEqual(rom.gearRecovery);
const migratedBytes = storage.getItem(PROFILE_STORAGE_KEYS.database);
expect(listCharacters("owner-one", storage).find((character) => character.id === "rom-profile")?.gearRecovery)
.toEqual(rom.gearRecovery);
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(migratedBytes);
const withoutRemoteRecovery = { ...rom, gearRecovery: undefined };
const replaced = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
expect(replaced[0]?.gearRecovery).toEqual(rom.gearRecovery);
const repeated = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
expect(repeated[0]?.gearRecovery).toEqual(rom.gearRecovery);
});
it("rejects foreign, unresolved, and unusable RoM inventory updates without writing partial state", () => {
const storage = new MemoryStorage();
const rom = createCharacter({
ownerId: "offline-rom",
name: "Runesworn",
categoryId: "rom",
raceId: "rom-human",
classId: "rom-priest",
gender: "female",
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
}, storage).character!;
const item = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
...(contentCategory ? { contentCategory } : {}),
instanceId,
itemId: 10412,
itemKey: `gear:${instanceId}`,
name: `Gear ${instanceId}`,
quality: "rare" as const,
inventorySlot: "waist",
armorType: "leather",
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: {
...EMPTY_ITEM_STATS,
agility: 4,
...(romNative === undefined ? {} : { romNative }),
},
quantity: 1,
sourceBossKey: "source-boss",
sourceBossName: "Source Boss",
acquiredAt: 5_000,
});
const foreign = item("foreign", "wow");
const unresolved = item("unresolved");
const invalidNative = item("invalid-native", "rom", {});
for (const candidate of [foreign, unresolved, invalidNative]) {
const before = storage.getItem(PROFILE_STORAGE_KEYS.database);
expect(updateCharacterInventory(rom.ownerId, rom.id, [candidate], storage)).toBeNull();
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(before);
}
const valid = item("valid-rom", "rom", { strength: 3 });
const updated = updateCharacterInventory(rom.ownerId, rom.id, [valid], storage)!;
expect(updated.inventory).toMatchObject([{ instanceId: "valid-rom", contentCategory: "rom" }]);
const beforeForeignEquipment = storage.getItem(PROFILE_STORAGE_KEYS.database);
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
player: { waist: "foreign" },
}, storage)).toBeNull();
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(beforeForeignEquipment);
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
player: { waist: "valid-rom" },
}, storage)?.equipment).toEqual({ player: { waist: "valid-rom" } });
});
it("persists account card copies independently of character creation and deletion", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
@@ -691,7 +852,7 @@ describe("local profile repository", () => {
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
expect(database.version).toBe(7);
expect(database.version).toBe(8);
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
// An old cloud roster can be downloaded again without repeating the migration.
replaceCharactersForOwner("one", legacyCharacters, storage);
+250 -15
View File
@@ -1,4 +1,11 @@
import type { CharacterProfile, PlayerSession } from "./types";
import type {
CharacterGearRecovery,
CharacterProfile,
GearRecoveryEquipmentReference,
GearRecoveryItem,
GearRecoveryReason,
PlayerSession,
} from "./types";
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import {
@@ -11,7 +18,12 @@ import {
orderedClassLoadoutKey,
ROM_SECONDARY_UNLOCK_LEVEL,
} from "./romDualClass";
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
import {
inventoryItemMatchesCategory,
inventoryItemUsableForCategory,
normalizeInventory,
type InventoryItem,
} from "../game/lootTypes";
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
@@ -70,7 +82,7 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 7;
version: 8;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
@@ -114,7 +126,196 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
return { version: 8, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
}
const CONTENT_CATEGORY_IDS = new Set(["wow", "rom", "coa"]);
function isGearRecoveryReason(value: unknown): value is GearRecoveryReason {
return value === "missing-category" || value === "foreign-category" || value === "invalid-native-payload";
}
function normalizeGearRecovery(value: unknown): CharacterGearRecovery {
if (!value || typeof value !== "object" || Array.isArray(value)) return { items: [], equipment: [] };
const record = value as { items?: unknown; equipment?: unknown };
const items: GearRecoveryItem[] = Array.isArray(record.items)
? record.items.flatMap((entry): GearRecoveryItem[] => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|| !Object.prototype.hasOwnProperty.call(entry, "item")) return [];
const row = entry as { item: unknown; reason?: unknown };
return [{
item: row.item,
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
}];
})
: [];
const equipment: GearRecoveryEquipmentReference[] = Array.isArray(record.equipment)
? record.equipment.flatMap((entry): GearRecoveryEquipmentReference[] => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
const row = entry as Partial<GearRecoveryEquipmentReference>;
if (typeof row.ownerId !== "string" || typeof row.slot !== "string" || typeof row.instanceId !== "string") return [];
return [{
ownerId: row.ownerId,
slot: row.slot,
instanceId: row.instanceId,
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
}];
})
: [];
return { items, equipment };
}
function jsonIdentity(value: unknown): string {
try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; }
}
/** Merges repeat cloud/profile normalization without duplicating the same recovered source row. */
function mergeGearRecovery(
...values: readonly unknown[]
): CharacterGearRecovery | undefined {
const mergedItems: GearRecoveryItem[] = [];
const mergedEquipment: GearRecoveryEquipmentReference[] = [];
const itemCounts = new Map<string, number>();
const equipmentCounts = new Map<string, number>();
for (const value of values) {
const recovery = normalizeGearRecovery(value);
const currentItemCounts = new Map<string, number>();
for (const entry of recovery.items) {
const key = `${entry.reason}\n${jsonIdentity(entry.item)}`;
const count = (currentItemCounts.get(key) ?? 0) + 1;
currentItemCounts.set(key, count);
if (count > (itemCounts.get(key) ?? 0)) mergedItems.push(entry);
}
for (const [key, count] of currentItemCounts) {
itemCounts.set(key, Math.max(itemCounts.get(key) ?? 0, count));
}
for (const entry of recovery.equipment) {
const key = jsonIdentity(entry);
if (equipmentCounts.has(key)) continue;
equipmentCounts.set(key, 1);
mergedEquipment.push(entry);
}
}
return mergedItems.length || mergedEquipment.length
? { items: mergedItems, equipment: mergedEquipment }
: undefined;
}
function recoveryReasonForCategory(
category: unknown,
ownerCategory: CharacterProfile["categoryId"],
normalizedItem?: InventoryItem,
): GearRecoveryReason {
if (CONTENT_CATEGORY_IDS.has(String(category)) && category !== ownerCategory) return "foreign-category";
if (category === "rom" && ownerCategory === "rom" && normalizedItem
&& !inventoryItemUsableForCategory(normalizedItem, ownerCategory)) return "invalid-native-payload";
return "missing-category";
}
function incomingInventoryMatchesCategory(
inventory: readonly InventoryItem[],
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
): boolean {
for (const candidate of inventory as readonly unknown[]) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
const item = candidate as Record<string, unknown>;
const category = item.contentCategory;
if (!(category === undefined && ownerCategory === "wow")
&& !inventoryItemMatchesCategory(item as Pick<InventoryItem, "contentCategory">, ownerCategory)) return false;
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
if (!normalized || !inventoryItemUsableForCategory(normalized, ownerCategory)) return false;
}
return true;
}
function equipmentReferencesRecoveredItem(
assignments: EquipmentAssignments,
recovery: CharacterGearRecovery | undefined,
): boolean {
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
if (!recovery?.items.length && !recovery?.equipment.length) return false;
const recoveredIds = new Set<string>();
for (const entry of recovery.items) {
if (entry.item && typeof entry.item === "object" && !Array.isArray(entry.item)) {
const instanceId = (entry.item as { instanceId?: unknown }).instanceId;
if (typeof instanceId === "string") recoveredIds.add(instanceId);
}
}
for (const entry of recovery.equipment) recoveredIds.add(entry.instanceId);
return Object.values(assignments).some((loadout) => (
loadout && typeof loadout === "object"
&& Object.values(loadout).some((instanceId) => typeof instanceId === "string" && recoveredIds.has(instanceId))
));
}
function equipmentReferencesUsableInventory(
assignments: EquipmentAssignments,
profile: CharacterProfile,
): boolean {
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
const itemById = new Map(profile.inventory.map((item) => [item.instanceId, item]));
for (const loadout of Object.values(assignments as Record<string, unknown>)) {
if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false;
for (const rawId of Object.values(loadout as Record<string, unknown>)) {
if (typeof rawId !== "string") continue;
const item = itemById.get(rawId);
if (!item || !inventoryItemUsableForCategory(item, profile.categoryId)) return false;
}
}
return true;
}
interface ProfileGearNormalization {
inventory: InventoryItem[];
equipment: EquipmentAssignments;
gearRecovery?: CharacterGearRecovery;
}
function normalizeProfileGear(
rawInventory: unknown,
rawEquipment: unknown,
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
existingRecovery: unknown,
): ProfileGearNormalization {
const usableInventory: InventoryItem[] = [];
const recoveredItems: GearRecoveryItem[] = [];
const rejectedInstanceIds = new Map<string, GearRecoveryReason>();
const candidates = Array.isArray(rawInventory) ? rawInventory : [];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
if (normalized && inventoryItemUsableForCategory(normalized, ownerCategory)) {
usableInventory.push(normalized);
continue;
}
const raw = candidate as Record<string, unknown>;
// Keep the old invalid-row behavior for an item that already claims this
// profile's namespace but cannot be normalized as an InventoryItem.
if (!normalized && raw.contentCategory === ownerCategory) continue;
const reason = recoveryReasonForCategory(raw.contentCategory, ownerCategory, normalized);
recoveredItems.push({ item: candidate, reason });
if (typeof raw.instanceId === "string" && !rejectedInstanceIds.has(raw.instanceId)) {
rejectedInstanceIds.set(raw.instanceId, reason);
}
}
const recoveredEquipment: GearRecoveryEquipmentReference[] = [];
if (rawEquipment && typeof rawEquipment === "object" && !Array.isArray(rawEquipment)) {
for (const [ownerId, rawLoadout] of Object.entries(rawEquipment as Record<string, unknown>)) {
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
if (typeof rawInstanceId !== "string") continue;
const reason = rejectedInstanceIds.get(rawInstanceId);
if (reason) recoveredEquipment.push({ ownerId, slot, instanceId: rawInstanceId, reason });
}
}
}
const equipment = normalizeEquipment(rawEquipment, usableInventory, ownerCategory);
return {
inventory: usableInventory,
equipment,
gearRecovery: mergeGearRecovery(existingRecovery, { items: recoveredItems, equipment: recoveredEquipment }),
};
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -147,6 +348,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
level?: unknown;
inventory?: unknown;
equipment?: unknown;
gearRecovery?: unknown;
manastormProgress?: unknown;
settings?: unknown;
secondaryClassId?: unknown;
@@ -196,14 +398,19 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
})) as Record<string, Record<string, string | null>>;
const activeLoadoutKey = orderedClassLoadoutKey(value.classId, secondaryClassId);
actionLoadouts[activeLoadoutKey] = { ...(actionLoadouts[activeLoadoutKey] ?? actionBindings) };
const inventory = normalizeInventory(legacy.inventory);
const equipment = normalizeEquipment(legacy.equipment, inventory);
const normalizedGear = normalizeProfileGear(
legacy.inventory,
legacy.equipment,
categoryId,
legacy.gearRecovery,
);
const { inventory, equipment, gearRecovery } = normalizedGear;
const manastormProgress = normalizeManastormProgress(legacy.manastormProgress);
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
? { ...(legacy.settings as Partial<GameplaySettings>) }
: undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return {
const normalizedProfile: CharacterProfile = {
...profile,
level,
experience,
@@ -220,9 +427,15 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
dungeonRunCheckpoint,
settings,
};
if (gearRecovery) normalizedProfile.gearRecovery = gearRecovery;
else delete normalizedProfile.gearRecovery;
return normalizedProfile;
}
function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase {
function readDatabase(
storage: StorageLike | null = browserLocalStorage(),
persistMigrations = true,
): ProfileDatabase {
if (!storage) return emptyDatabase();
try {
const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({
@@ -233,7 +446,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5, 6, 7, 8].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -296,8 +509,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
);
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
const database: ProfileDatabase = { version: 8, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (persistMigrations && (parsed.version !== 8 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated)) {
// A full or read-only device must still expose its existing roster.
// Retry migration on the next read when persistence is available.
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
@@ -534,10 +747,20 @@ export function replaceCharactersForOwner(
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
: normalizeBossCardCollection(accountBossCards);
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
const existingRecoveryByCharacterId = new Map(
(database.rosters[ownerId] ?? []).map((character) => [character.id, character.gearRecovery]),
);
database.rosters[ownerId] = roster.map((character) => {
const gearRecovery = mergeGearRecovery(
existingRecoveryByCharacterId.get(character.id),
character.gearRecovery,
);
return normalizeCharacterProfile({
...character,
ownerId,
}));
...(gearRecovery ? { gearRecovery } : {}),
});
});
writeDatabase(database, storage);
return listCharacters(ownerId, storage);
}
@@ -800,7 +1023,11 @@ export function updateCharacterInventory(
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
const database = readDatabase(storage);
// Do not persist a migration if this mutation is rejected. Existing profile
// bytes (including legacy gear and its quarantine state) remain untouched.
const database = readDatabase(storage, false);
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
if (!current?.categoryId || !incomingInventoryMatchesCategory(inventory, current.categoryId)) return null;
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
@@ -821,7 +1048,15 @@ export function updateCharacterEquipment(
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
const database = readDatabase(storage);
// Equipment updates are all-or-nothing: never silently erase assignments to
// foreign or quarantined inventory during normalization.
const database = readDatabase(storage, false);
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
if (
!current
|| equipmentReferencesRecoveredItem(equipment, current.gearRecovery)
|| !equipmentReferencesUsableInventory(equipment, current)
) return null;
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
+62
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PlayerSession } from "./types";
import { EMPTY_ITEM_STATS } from "../game/itemStats";
function storage() {
const values = new Map<string, string>();
@@ -101,4 +102,65 @@ describe("account boss card integration", () => {
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await client.logoutOnlineSession(session);
});
it("uploads cloud gear namespace quarantine once without changing card synchronization", async () => {
const device = storage();
vi.stubGlobal("window", { localStorage: device });
const repository = await import("./accountRepository");
const character = repository.createCharacter(draft).character!;
const foreignItem = {
contentCategory: "rom",
instanceId: "foreign-rom-item",
itemId: 10412,
itemKey: "rom:foreign-item",
name: "Foreign RoM item",
quality: "rare",
inventorySlot: "waist",
armorType: "leather",
weaponType: null,
itemLevel: 17,
sourceItemLevel: 21,
stats: { ...EMPTY_ITEM_STATS, agility: 4 },
quantity: 1,
sourceBossKey: "rom:source-boss",
sourceBossName: "Source Boss",
acquiredAt: 5_000,
};
let cloudData: unknown = {
version: 2,
characters: [{
...character,
inventory: [foreignItem],
equipment: { player: { waist: foreignItem.instanceId } },
}],
uiPreferences: {},
bossCards: { counts: {}, lastClaims: {} },
};
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const client = await import("./onlineAccountClient");
const hydrated = await client.hydrateOnlineSession(session);
expect(hydrated[0]?.inventory).toEqual([]);
expect(hydrated[0]?.equipment).toEqual({});
expect(hydrated[0]?.gearRecovery).toMatchObject({
items: [{ item: foreignItem, reason: "foreign-category" }],
equipment: [{ ownerId: "player", slot: "waist", instanceId: foreignItem.instanceId, reason: "foreign-category" }],
});
await vi.advanceTimersByTimeAsync(501);
const firstPut = fetchMock.mock.calls.filter(([, init]) => init.method === "PUT");
expect(firstPut).toHaveLength(1);
expect(cloudData).toMatchObject({
bossCards: { counts: {}, lastClaims: {} },
characters: [{ inventory: [], gearRecovery: { items: [{ reason: "foreign-category" }] } }],
});
await client.hydrateOnlineSession(session);
await vi.advanceTimersByTimeAsync(501);
expect(fetchMock.mock.calls.filter(([, init]) => init.method === "PUT")).toHaveLength(1);
await client.logoutOnlineSession(session);
});
});
+32 -1
View File
@@ -150,6 +150,32 @@ export function retryCloudSave(): void {
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
}
function gearNamespaceSnapshot(value: unknown): string {
if (!Array.isArray(value)) return "[]";
return JSON.stringify(value.map((candidate) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
const character = candidate as Record<string, unknown>;
const rawInventory = character.inventory;
const inventory = Array.isArray(rawInventory)
? rawInventory.map((rawItem) => {
if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) return rawItem;
const item = rawItem as Record<string, unknown>;
return {
instanceId: item.instanceId,
contentCategory: Object.prototype.hasOwnProperty.call(item, "contentCategory")
? item.contentCategory
: "[missing-content-category]",
};
})
: rawInventory == null ? [] : rawInventory;
return {
id: character.id,
inventory,
gearRecovery: character.gearRecovery ?? null,
};
}));
}
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
if (!session.accessToken) return listCharacters(session.ownerId);
activateOnlineSession(session);
@@ -163,7 +189,12 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
suppressUpload = false;
}
// Publish migrated logs even if the player has not earned another card yet.
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
const gearNamespaceMigrated = gearNamespaceSnapshot(cloud.data?.characters)
!== gearNamespaceSnapshot(characters);
if (
gearNamespaceMigrated
|| JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))
) {
queueCloudSave(session.ownerId, characters);
}
return characters;
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+24
View File
@@ -22,6 +22,28 @@ export interface PlayerSession {
accessToken?: string;
}
export type GearRecoveryReason = "missing-category" | "foreign-category" | "invalid-native-payload";
/** Original rejected gear data kept with its character so migrations never erase it. */
export interface GearRecoveryItem {
readonly item: unknown;
readonly reason: GearRecoveryReason;
}
/** A former loadout reference for an item that is quarantined from active gear. */
export interface GearRecoveryEquipmentReference {
readonly ownerId: string;
readonly slot: string;
readonly instanceId: string;
readonly reason: GearRecoveryReason;
}
/** Character-scoped recovery for legacy or foreign-namespace gear. */
export interface CharacterGearRecovery {
readonly items: readonly GearRecoveryItem[];
readonly equipment: readonly GearRecoveryEquipmentReference[];
}
export interface CharacterProfile {
id: string;
ownerId: string;
@@ -50,6 +72,8 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[];
equipment: EquipmentAssignments;
/** Rejected gear remains recoverable here and is excluded from active inventory/loadouts. */
gearRecovery?: CharacterGearRecovery;
/** Legacy character log, read only during migration to the account collection. */
bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */
+73
View File
@@ -30,6 +30,8 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { talentModifiersForRanks } from "./talentModifiers";
import type { InventoryItem } from "./lootTypes";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
@@ -63,6 +65,31 @@ function stableUnit(seed: string): number {
return (hash >>> 0) / 0x1_0000_0000;
}
function categoryGearFixture(
instanceId: string,
contentCategory: InventoryItem["contentCategory"],
romNative?: InventoryItem["stats"]["romNative"],
): InventoryItem {
return {
contentCategory,
instanceId,
itemId: 900001,
itemKey: instanceId,
name: instanceId,
quality: "rare",
inventorySlot: "back",
armorType: null,
weaponType: null,
itemLevel: 40,
sourceItemLevel: 40,
stats: { ...EMPTY_ITEM_STATS, stamina: 100, ...(romNative ? { romNative } : {}) },
quantity: 1,
sourceBossKey: "test-source",
sourceBossName: "Test Source",
acquiredAt: 1,
};
}
function paladinVindicationProcTime(
sourceActorId: string,
abilityId: string,
@@ -1215,6 +1242,52 @@ describe("combat store", () => {
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(1);
});
it("derives the owner namespace from the source class and accepts only validated native gear rewards", () => {
const foreignWow = categoryGearFixture("foreign-wow", "wow");
const missingNative = categoryGearFixture("rom-without-native", "rom");
const missingCategory = categoryGearFixture("legacy-without-category", undefined);
const nullCategory = categoryGearFixture("explicit-null-category", null);
const nativeRom = categoryGearFixture("rom-native", "rom", { stamina: 4 });
const sourceProfile = {
classId: "rom-priest",
categoryId: "wow",
level: 40,
inventory: [foreignWow, missingNative, missingCategory, nullCategory, nativeRom],
equipment: { player: { back: foreignWow.instanceId, chest: missingNative.instanceId } },
} as const;
useCombatStore.getState().initializeCharacter(sourceProfile);
expect(useCombatStore.getState().contentCategoryId).toBe("rom");
expect(useCombatStore.getState().inventory.map((item) => item.instanceId)).toEqual([nativeRom.instanceId]);
expect(useCombatStore.getState().equipment).toEqual({});
expect(sourceProfile.inventory.map((item) => item.contentCategory)).toEqual(["wow", "rom", undefined, null, "rom"]);
const expectedTrainingOnlyStats = resolvedEquipmentStats({}, [], {
ownerId: "player",
ownerKind: "player",
classId: "rom-priest",
secondaryClassId: null,
specializationId: null,
role: "healer",
level: 40,
});
expect(useCombatStore.getState().gearStats).toEqual(expectedTrainingOnlyStats);
useCombatStore.getState().registerMob("gear-boundary", { name: "Gear Boundary", maxHealth: 1, hasLoot: true });
const nativeLoot = categoryGearFixture("rom-loot", "rom", { agility: 2 });
useCombatStore.setState((state) => {
const mob = state.mobs["gear-boundary"]!;
return { mobs: { ...state.mobs, "gear-boundary": {
...mob,
dead: true,
health: 0,
pendingLoot: [foreignWow, missingNative, missingCategory, nullCategory, nativeLoot],
} } };
});
expect(useCombatStore.getState().lootMob("gear-boundary", 10_000)).toBe(true);
expect(useCombatStore.getState().inventory.map((item) => item.instanceId))
.toEqual([nativeRom.instanceId, nativeLoot.instanceId]);
});
it("aggros every surviving packmate even when the pulled mob dies to the opening hit", () => {
for (const [id, maxHealth] of [["one-shot:a", 1], ["one-shot:b", 100], ["outsider", 100]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
+49 -18
View File
@@ -64,11 +64,13 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
import {
baseClassFor,
contentCategoryForClass,
isRomClassId,
type ClassId,
type GenderId,
type RaceId,
} from "../app/characterCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
import { applyRomNativeStatModifiers, type RomNativeAdditiveChannel, type RomNativeAttributeKey,
type RomNativeDerivedStats, type RomNativeRateChannel, type RomNativeStatModifiers } from "./romNativeStats";
@@ -455,7 +457,7 @@ import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
@@ -1405,6 +1407,7 @@ export interface ActorHealingRequest {
export interface CharacterCombatSeed {
readonly classId: ClassId;
readonly categoryId?: ContentCategoryId;
readonly statRuleset?: CharacterStatRuleset;
readonly hunterPetFamily?: Wow335HunterPetFamily;
readonly specializationId?: string | null;
@@ -1570,6 +1573,7 @@ export interface EnemyPeriodicCombatEffect {
export interface CombatState {
classId: ClassId;
statRuleset: CharacterStatRuleset;
contentCategoryId: ContentCategoryId;
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
hunterPetFamily: Wow335HunterPetFamily;
specializationId: string | null;
@@ -7799,6 +7803,7 @@ function damageMobInWork(
const testing = useGameStore.getState().gmTestMode;
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const ownerCategory = useCombatStore.getState().contentCategoryId;
const hasTreasureHunter = work.auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& (aura.expiresAt === null || aura.expiresAt > now)
@@ -7807,9 +7812,10 @@ function damageMobInWork(
const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
ownerCategory,
bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter),
})
: mob.pendingLoot;
: mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null;
@@ -11786,6 +11792,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
function initialState() {
const classId: ClassId = "priest";
const statRuleset: CharacterStatRuleset = "wow";
const contentCategoryId: ContentCategoryId = "wow";
const raceId: RaceId = "human";
const gender: GenderId = "male";
const level = 1;
@@ -11805,6 +11812,7 @@ function initialState() {
return {
classId,
statRuleset,
contentCategoryId,
hunterPetFamily: "wolf" as const,
specializationId: null,
gearRole: "healer" as PartyRole,
@@ -11893,9 +11901,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [];
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
const equipment = normalizeEquipment(profile.equipment, inventory);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
// The selected source class owns the character namespace; dungeon and
// untrusted seed metadata cannot change which game's items are usable.
const contentCategoryId = contentCategoryForClass(classId);
const inventory = normalizeInventory(profile.inventory, { ownerCategory: contentCategoryId });
const categoryInventory = inventory.filter((item) => inventoryItemUsableForCategory(item, contentCategoryId));
const equipment = normalizeEquipment(profile.equipment, inventory, contentCategoryId);
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
@@ -11903,7 +11915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId,
role: gearRole,
level: progression.level,
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId, contentCategoryId);
const maxHealth = talentAdjustedMaxHealth(
classId,
progression.level,
@@ -11952,6 +11964,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId,
gender,
statRuleset,
contentCategoryId,
romNativeRecoveryClock: undefined,
level: progression.level,
xp: progression.xp,
@@ -11978,7 +11991,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
abilities,
talentRanks,
),
inventory,
inventory: categoryInventory,
bossCards: normalizeBossCardCollection(profile.bossCards),
equipment,
gearStats,
@@ -11988,6 +12001,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
refreshResolvedGear: () => set((state) => {
const categoryInventory = state.inventory.filter((item) => inventoryItemUsableForCategory(item, state.contentCategoryId));
const context: HeirloomContext = {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
@@ -11998,8 +12012,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
level: state.level,
};
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
? resolvedEquipmentStats(state.equipment, state.inventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
? resolvedEquipmentStats(state.equipment, categoryInventory, context)
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
@@ -12259,9 +12273,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
lootMob: (id, now = combatNow()) => {
const mob = get().mobs[id];
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
const ownerCategory = get().contentCategoryId;
const acceptedLoot = mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
const lootMessage = mob.pendingLoot.length
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
const lootMessage = acceptedLoot.length
? `Looted ${acceptedLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
: `Looted ${mob.name}.`;
set((state) => {
const bossReward = mob.boss
@@ -12269,13 +12285,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
id: ++bossLootRewardSequence,
bossId: id,
bossName: mob.name,
items: mob.pendingLoot,
items: acceptedLoot,
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
acquiredAt: now,
} satisfies BossLootReward
: null;
return {
inventory: [...state.inventory, ...mob.pendingLoot],
inventory: [...state.inventory, ...acceptedLoot],
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
mobs: {
...state.mobs,
@@ -12332,6 +12348,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
{
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
ownerCategory: ownerId === PLAYER_EQUIPMENT_OWNER_ID
? state.contentCategoryId
: usePartyStore.getState().categoryId ?? contentCategoryForClass(ownerClassId),
},
);
if (!change.ok) {
@@ -12342,7 +12361,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: ownerMember?.name ?? "Companion";
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
candidate,
state.contentCategoryId,
));
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
@@ -12350,7 +12373,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
@@ -12403,7 +12426,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
),
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
});
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
const partyCategory = usePartyStore.getState().categoryId;
usePartyStore.getState().syncEquipment(
change.assignments,
state.inventory.filter((candidate) => inventoryItemUsableForCategory(candidate, partyCategory)),
);
return true;
},
@@ -12411,7 +12438,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
const next = unequipInventorySlot(state.equipment, ownerId, slot);
if (next === state.equipment) return false;
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
candidate,
state.contentCategoryId,
));
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, categoryInventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
@@ -12419,7 +12450,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
state.level,
+69
View File
@@ -6,6 +6,7 @@ import {
equipInventoryItem,
equipmentItemLevel,
equipmentStats,
activeEquipmentStatsForClass,
normalizeEquipment,
unequipInventorySlot,
} from "./equipment";
@@ -26,6 +27,7 @@ function item(
itemId: 1,
itemKey: instanceId,
name: instanceId,
contentCategory: "wow",
quality: "uncommon",
inventorySlot,
armorType: null,
@@ -66,6 +68,56 @@ describe("equipment assignments", () => {
expect(classCanEquipItem("warlock", bow)).toBe(false);
});
it("rejects foreign, unresolved, and non-native RoM gear before changing assignments", () => {
const wowDagger = item("wow-dagger", "one-hand", { weaponType: "dagger" });
const nativeRomDagger = item("rom-dagger", "one-hand", {
contentCategory: "rom",
weaponType: "dagger",
stats: stats({ strength: 12, romNative: { strength: 12 } }),
});
const unresolvedRomDagger = item("unresolved-rom-dagger", "one-hand", {
contentCategory: null,
weaponType: "dagger",
stats: stats({ strength: 12 }),
});
const foreign = equipInventoryItem({ player: { back: "kept" } }, [wowDagger], "player", "rom-priest", wowDagger.instanceId);
expect(foreign).toEqual({
ok: false,
assignments: { player: { back: "kept" } },
reason: "That item belongs to a different game category.",
});
expect(equipInventoryItem({}, [nativeRomDagger], "player", "rom-priest", nativeRomDagger.instanceId).ok).toBe(true);
expect(equipInventoryItem({}, [unresolvedRomDagger], "player", "rom-priest", unresolvedRomDagger.instanceId).ok).toBe(false);
expect(normalizeEquipment({ player: { "main-hand": wowDagger.instanceId } }, [wowDagger], "rom")).toEqual({});
const forgedCategory = equipInventoryItem(
{ player: { back: "kept" } },
[wowDagger],
"player",
"rom-priest",
wowDagger.instanceId,
{ ownerCategory: "wow" },
);
expect(forgedCategory.ok).toBe(false);
expect(forgedCategory.assignments).toEqual({ player: { back: "kept" } });
expect(classCanEquipItem("rom-priest", wowDagger, { ownerCategory: "wow" })).toBe(false);
});
it("does not apply foreign or unresolved items in active equipment stat reads", () => {
const romItem = item("rom-native", "back", {
contentCategory: "rom",
stats: stats({ stamina: 20, romNative: { stamina: 20 } }),
});
const wowItem = item("wow-foreign", "head", { stats: stats({ stamina: 90 }) });
const assignments = { player: { back: romItem.instanceId, head: wowItem.instanceId } };
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "rom"))
.toMatchObject({ stamina: 20 });
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "coa"))
.toMatchObject({ stamina: 0 });
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "wow"))
.toEqual(EMPTY_ITEM_STATS);
});
it("handles two-handed conflicts, unequipping, normalization, and item level", () => {
const staff = item("staff", "two-hand", { weaponType: "staff", itemLevel: 18 });
const shield = item("shield", "off-hand", { armorType: "shield", itemLevel: 14 });
@@ -126,4 +178,21 @@ describe("equipment assignments", () => {
expect(deriveRomNativeStats({ classId: "rom-warrior", level: 1, gear: unequipped.romNative }).health.max)
.toBe(withoutGear.health.max);
});
it("keeps CoA-tagged direct stats out of the RoM native projection", () => {
const coaGear = item("coa-gear", "chest", {
contentCategory: "coa",
armorType: "plate",
stats: stats({ stamina: 7, strength: 3 }),
});
const active = activeEquipmentStatsForClass(
{ player: { chest: coaGear.instanceId } },
[coaGear],
"player",
"guardian",
"coa",
);
expect(active).toMatchObject({ stamina: 7, strength: 3 });
expect(active.romNative).toBeUndefined();
});
});
+42 -4
View File
@@ -1,9 +1,11 @@
import {
baseClassFor,
classById,
contentCategoryForClass,
type BaseClassId,
type ClassId,
} from "../app/characterCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
import {
EMPTY_ITEM_STATS,
ITEM_STAT_KEYS,
@@ -11,7 +13,7 @@ import {
type NumericItemStatKey,
type WeaponStats,
} from "./itemStats";
import type { InventoryItem } from "./lootTypes";
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
export const PLAYER_EQUIPMENT_OWNER_ID = "player";
@@ -48,6 +50,8 @@ export interface EquipmentChange {
export interface EquipmentClassContext {
readonly secondaryClassId?: ClassId | null;
readonly level?: number;
/** Saved owner category; falls back to the owner's class category when absent. */
readonly ownerCategory?: ContentCategoryId;
}
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
@@ -82,7 +86,15 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
/**
* Internal shape-only predicate for synthetic catalog scoring. Callers must
* still use classCanEquipItem before assigning or applying an item.
*/
export function classItemShapeFitsEquipmentRules(
classId: ClassId,
item: InventoryItem,
context: EquipmentClassContext = {},
): boolean {
const slot = equipmentSlotForItem(item);
if (!slot) return false;
const level = context.level ?? Number.POSITIVE_INFINITY;
@@ -111,6 +123,13 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem, context
return true;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
const classCategory = contentCategoryForClass(classId);
if (context.ownerCategory !== undefined && context.ownerCategory !== classCategory) return false;
return inventoryItemUsableForCategory(item, classCategory)
&& classItemShapeFitsEquipmentRules(classId, item, context);
}
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
return Object.fromEntries(
Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]),
@@ -141,6 +160,14 @@ export function equipInventoryItem(
): EquipmentChange {
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
const ownerCategory = contentCategoryForClass(ownerClassId);
if ((classContext.ownerCategory !== undefined && classContext.ownerCategory !== ownerCategory)
|| !inventoryItemUsableForCategory(item, ownerCategory)) {
const reason = ownerCategory === "rom" && item.contentCategory === "rom"
? "That item has no validated RuneWaker-native gear values."
: "That item belongs to a different game category.";
return { ok: false, assignments, reason };
}
const slot = equipmentSlotForItem(item);
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
if (!classCanEquipItem(ownerClassId, item, classContext)) {
@@ -206,6 +233,7 @@ export function equipmentItemsForOwner(
export function normalizeEquipment(
value: unknown,
inventory: readonly InventoryItem[],
ownerCategory?: ContentCategoryId,
): EquipmentAssignments {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
@@ -219,6 +247,7 @@ export function normalizeEquipment(
if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue;
const item = itemById.get(rawInstanceId);
if (!item || equipmentSlotForItem(item) !== rawSlot) continue;
if (ownerCategory !== undefined && !inventoryItemUsableForCategory(item, ownerCategory)) continue;
loadout[rawSlot as EquipmentSlot] = rawInstanceId;
assigned.add(rawInstanceId);
}
@@ -260,7 +289,11 @@ export function equipmentStats(
const stats = instanceId ? itemById.get(instanceId)?.stats : null;
if (!stats) continue;
for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key];
const nativePayload = stats.romNative ?? romNativeGearFromSharedDirectStats(stats);
const contentCategory = itemById.get(instanceId)?.contentCategory;
const nativePayload = stats.romNative
?? (contentCategory === "rom" || contentCategory === "coa"
? undefined
: romNativeGearFromSharedDirectStats(stats));
if (nativePayload) nativePayloads.push(nativePayload);
if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon;
if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon;
@@ -275,14 +308,19 @@ export function activeEquipmentStatsForClass(
inventory: readonly InventoryItem[],
ownerId: string,
classId: ClassId,
ownerCategory?: ContentCategoryId,
): ItemStats {
const classCategory = contentCategoryForClass(classId);
if (ownerCategory !== undefined && ownerCategory !== classCategory) return EMPTY_ITEM_STATS;
const loadout = assignments[ownerId];
if (!loadout) return EMPTY_ITEM_STATS;
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
const activeAssignments: EquipmentAssignments = {
[ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => {
const item = instanceId ? itemById.get(instanceId) : null;
return Boolean(item && classCanEquipItem(classId, item));
return Boolean(item
&& inventoryItemUsableForCategory(item, classCategory)
&& classCanEquipItem(classId, item));
})),
};
return equipmentStats(activeAssignments, inventory, ownerId);
+65 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ClassId } from "../app/characterCatalog";
import { contentCategoryForClass, type ClassId } from "../app/characterCatalog";
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
import {
compareEquipmentUpgrade,
@@ -7,6 +7,7 @@ import {
resolvedAverageItemLevel,
resolvedEquipmentItems,
resolvedEquipmentLoadout,
resolvedEquipmentStats,
type HeirloomContext,
} from "./heirloomEquipment";
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
@@ -33,8 +34,14 @@ function context(profile: SpecializationDefinition, level: number, ownerKind: "p
};
}
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
function catalogItem(
template: LootCatalogItem,
itemLevel: number,
suffix = "test",
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
contentCategory: "wow",
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -50,6 +57,7 @@ function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "tes
sourceBossKey: "test-boss",
sourceBossName: "Test Boss",
acquiredAt: 1,
...overrides,
};
}
@@ -77,6 +85,59 @@ describe("specialization-aware heirlooms", () => {
expect(loadout["off-hand"]?.armorType).toBe("shield");
});
it("keeps RoM training defaults when foreign, unresolved, or non-native assigned items are present", () => {
const romContext: HeirloomContext = {
ownerId: "rom-owner",
ownerKind: "player",
classId: "rom-priest",
specializationId: null,
role: "healer",
level: 40,
};
const chestTemplate = templates.find((template) => template.inventorySlot === "chest" && template.armorType === "cloth")!;
const training = resolvedEquipmentLoadout({}, [], romContext).chest!;
expect(training).toMatchObject({
contentCategory: "rom",
quality: "heirloom",
sourceBossKey: "heirloom",
sourceBossName: "Training Armory",
});
expect(training.stats.romNative).toBeDefined();
const trainingSlotWithoutReference = resolvedEquipmentLoadout({}, [], romContext)["off-hand"]!;
expect(trainingSlotWithoutReference.stats.romNative).toBeUndefined();
expect(equipmentStatsForItems([trainingSlotWithoutReference]).romNative).toBeDefined();
const copiedTraining = JSON.parse(JSON.stringify(trainingSlotWithoutReference)) as InventoryItem;
expect(copiedTraining).toMatchObject({
contentCategory: "rom",
instanceId: trainingSlotWithoutReference.instanceId,
sourceBossName: "Training Armory",
});
expect(equipmentStatsForItems([copiedTraining]).romNative).toBeUndefined();
expect(classCanEquipItem("rom-priest", copiedTraining)).toBe(false);
const badAssignedItems = [
catalogItem(chestTemplate, 40, "foreign-wow"),
catalogItem(chestTemplate, 40, "unresolved", { contentCategory: null }),
catalogItem(chestTemplate, 40, "rom-without-native", { contentCategory: "rom" }),
];
for (const item of badAssignedItems) {
expect(classCanEquipItem("rom-priest", item)).toBe(false);
const loadout = resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext);
expect(loadout.chest).toMatchObject({ sourceBossName: "Training Armory", contentCategory: "rom" });
expect(resolvedEquipmentStats({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext))
.toEqual(resolvedEquipmentStats({}, [], romContext));
}
const nativeItem = catalogItem(chestTemplate, 40, "rom-native", {
contentCategory: "rom",
stats: itemStatsFor({ ...chestTemplate, itemLevel: 40, romNative: { stamina: 3 } }),
});
expect(classCanEquipItem("rom-priest", nativeItem)).toBe(true);
expect(resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: nativeItem.instanceId } }, [nativeItem], romContext).chest)
.toBe(nativeItem);
});
it("allocates only useful archetype stats", () => {
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
@@ -118,10 +179,10 @@ describe("specialization-aware heirlooms", () => {
}
});
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
it("opens an item-level-85 catalog upgrade path for every WoW gear profile at level 80", () => {
expect(scaledLootItemLevel(79)).toBe(79);
expect(scaledLootItemLevel(80)).toBe(85);
for (const profile of SPECIALIZATION_DEFINITIONS) {
for (const profile of SPECIALIZATION_DEFINITIONS.filter((candidate) => contentCategoryForClass(candidate.classId) === "wow")) {
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
const upgrades = compatibleItems(profile.classId, 85)
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
+19 -2
View File
@@ -1,10 +1,12 @@
import {
contentCategoryForClass,
classById,
isRomClassId,
type ClassId,
} from "../app/characterCatalog";
import {
classCanEquipItem,
classItemShapeFitsEquipmentRules,
EQUIPMENT_SLOTS,
equipmentSlotForItem,
type EquipmentAssignments,
@@ -94,6 +96,11 @@ const ARMOR_SLOTS = new Set<EquipmentSlot>([
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
const itemCache = new Map<string, InventoryItem>();
// Training placeholders are generated internally and are never serialized as
// earned inventory. Keep their legacy simulator projection private so it can
// preserve the existing RoM training profile without making direct WoW stats
// valid on a caller-supplied RoM item.
const romTrainingItems = new WeakSet<InventoryItem>();
function effectiveLevel(context: HeirloomContext): number {
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
@@ -181,6 +188,7 @@ function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: str
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
return {
contentCategory: "wow",
instanceId: `catalog:${template.itemId}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -203,7 +211,9 @@ function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: numbe
let highest = 0;
for (const template of CATALOG_ITEMS) {
const candidate = syntheticCatalogItem(template, level);
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
// Catalog comparison is an internal shape-only specialization baseline.
// These WoW templates never enter inventory or an equipment assignment.
if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue;
highest = Math.max(highest, specializationScore(candidate.stats, profile));
}
return highest;
@@ -286,6 +296,7 @@ function heirloomItem(
// internal quality so this label change cannot alter saved builds or budgets.
const romTrainingGear = isRomClassId(context.classId);
const result: InventoryItem = Object.freeze({
contentCategory: contentCategoryForClass(context.classId),
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
itemId: 9_000_000 + stableNumber % 900_000,
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
@@ -302,6 +313,7 @@ function heirloomItem(
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
acquiredAt: 0,
});
if (romTrainingGear) romTrainingItems.add(result);
itemCache.set(cacheKey, result);
return result;
}
@@ -348,7 +360,12 @@ export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemSta
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
for (const item of items) {
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
const nativePayload = item.stats.romNative ?? romNativeGearFromSharedDirectStats(item.stats);
const nativePayload = item.stats.romNative
?? (romTrainingItems.has(item)
|| item.contentCategory === undefined
|| item.contentCategory === "wow"
? romNativeGearFromSharedDirectStats(item.stats)
: undefined);
if (nativePayload) nativePayloads.push(nativePayload);
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
+8
View File
@@ -35,6 +35,7 @@ describe("SQL-generated boss loot catalog", () => {
expect(first).toHaveLength(1);
expect(first[0]).toMatchObject({
contentCategory: "wow",
itemId: 5404,
name: "Serpent's Shoulders",
itemLevel: 85,
@@ -61,6 +62,13 @@ describe("SQL-generated boss loot catalog", () => {
expect(rollBossLoot("not-a-boss", 20)).toEqual([]);
});
it("selects source-tagged WoW drops only for a WoW-owned character", () => {
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "wow" })[0].contentCategory)
.toBe("wow");
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "rom" })).toEqual([]);
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "coa" })).toEqual([]);
});
it("adds at most one bonus weighted item without changing normal rolls", () => {
const normal = rollBossLoot("lady-anacondra", 80, {
random: () => 0,
+6 -1
View File
@@ -1,4 +1,5 @@
import generatedLootCatalog from "./generated/lootCatalog.json";
import type { ContentCategoryId } from "../app/contentCategories";
import {
itemStatsFor,
type SourceItemStats,
@@ -48,6 +49,8 @@ export interface LootRollOptions {
readonly now?: number;
/** A single additional weighted item roll after the authored boss rolls. */
readonly bonusItemChance?: number;
/** Saved character/party category; source identity is never inferred from dungeon. */
readonly ownerCategory?: ContentCategoryId;
}
export const LOOT_CATALOG = generatedLootCatalog as LootCatalog;
@@ -104,6 +107,7 @@ export function rollBossLoot(
if (!table?.items.length) {
return rollRunewakerBossLoot(bossKey, playerLevel, options);
}
if (options.ownerCategory !== undefined && options.ownerCategory !== "wow") return [];
const random = options.random ?? Math.random;
const acquiredAt = options.now ?? Date.now();
const rolls = randomInteger(table.rollsMin, table.rollsMax, random);
@@ -117,6 +121,7 @@ export function rollBossLoot(
itemInstanceSequence += 1;
const scaledItemLevel = template.scalable ? itemLevel : template.sourceItemLevel;
drops.push({
contentCategory: "wow",
instanceId: `${bossKey}:${acquiredAt}:${itemInstanceSequence}`,
itemId: template.itemId,
itemKey: template.itemKey,
@@ -154,5 +159,5 @@ export function rollBossLoot(
addWeightedDrop();
}
return allowedLootDrops(drops, playerLevel);
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
}
+10 -4
View File
@@ -1,8 +1,14 @@
import type { InventoryItem } from "./lootTypes";
import type { ContentCategoryId } from "../app/contentCategories";
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
/** Leveling dungeon bosses award cards through the separate boss-card path. */
export function allowedLootDrops(drops: readonly InventoryItem[], playerLevel: number): readonly InventoryItem[] {
return Number.isFinite(playerLevel) && playerLevel >= 80
export function allowedLootDrops(
drops: readonly InventoryItem[],
playerLevel: number,
ownerCategory?: ContentCategoryId,
): readonly InventoryItem[] {
if (!Number.isFinite(playerLevel) || playerLevel < 80) return [];
return ownerCategory === undefined
? drops
: [];
: drops.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { inventoryItemMatchesCategory, inventoryItemUsableForCategory, normalizeInventory } from "./lootTypes";
function savedItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
instanceId: "gear:1",
itemId: 1,
itemKey: "gear-1",
name: "Saved Gear",
quality: "rare",
inventorySlot: "back",
armorType: null,
weaponType: null,
itemLevel: 20,
sourceItemLevel: 20,
stats: EMPTY_ITEM_STATS,
quantity: 1,
sourceBossKey: "source-boss",
sourceBossName: "Source Boss",
acquiredAt: 1,
...overrides,
};
}
describe("inventory item game-category metadata", () => {
it("migrates only category-less WoW-owned legacy records", () => {
expect(normalizeInventory([savedItem()], { ownerCategory: "wow" })[0].contentCategory).toBe("wow");
expect(normalizeInventory([savedItem()], { ownerCategory: "rom" })[0].contentCategory).toBeNull();
expect(normalizeInventory([savedItem()], { ownerCategory: "coa" })[0].contentCategory).toBeNull();
expect(normalizeInventory([savedItem()])[0].contentCategory).toBeNull();
});
it("preserves explicit category identity, including mismatches and unresolved null", () => {
expect(normalizeInventory([savedItem({ contentCategory: "rom" })], { ownerCategory: "wow" })[0].contentCategory)
.toBe("rom");
expect(normalizeInventory([savedItem({ contentCategory: "coa" })], { ownerCategory: "rom" })[0].contentCategory)
.toBe("coa");
expect(normalizeInventory([savedItem({ contentCategory: null })], { ownerCategory: "wow" })[0].contentCategory)
.toBeNull();
});
it("requires an explicit category and validated native payload for usable RoM gear", () => {
const romNative = normalizeInventory([savedItem({
contentCategory: "rom",
stats: { ...EMPTY_ITEM_STATS, romNative: { strength: 7 } },
})])[0];
const romWithoutNative = normalizeInventory([savedItem({ contentCategory: "rom" })])[0];
expect(inventoryItemMatchesCategory(romNative, "rom")).toBe(true);
expect(inventoryItemMatchesCategory(romNative, undefined)).toBe(false);
expect(inventoryItemUsableForCategory(romNative, "rom")).toBe(true);
expect(inventoryItemUsableForCategory(romWithoutNative, "rom")).toBe(false);
expect(inventoryItemUsableForCategory(romNative, "wow")).toBe(false);
});
});
+40 -1
View File
@@ -4,11 +4,15 @@ import {
type ItemQuality,
type ItemStats,
} from "./itemStats";
import { normalizeRomNativeGearStats } from "./romNativeStatsTypes";
import type { ContentCategoryId } from "../app/contentCategories";
export type { ItemQuality, ItemStats } from "./itemStats";
/** A concrete item owned by a character, distinct from its SQL item template. */
export interface InventoryItem {
/** Source-game namespace. Missing legacy metadata is normalized with the owning profile. */
readonly contentCategory?: ContentCategoryId | null;
readonly instanceId: string;
readonly itemId: number;
readonly itemKey: string;
@@ -31,8 +35,37 @@ export interface InventoryItem {
const ITEM_QUALITIES = new Set<ItemQuality>(["common", "uncommon", "rare", "epic", "legendary"]);
export interface NormalizeInventoryOptions {
/** Saved character/party owner; dungeon and loot-source identities are not owner categories. */
readonly ownerCategory?: ContentCategoryId;
}
const CONTENT_CATEGORIES = new Set<ContentCategoryId>(["wow", "rom", "coa"]);
/** Returns true only for an explicit item/owner namespace match. */
export function inventoryItemMatchesCategory(
item: Pick<InventoryItem, "contentCategory">,
expectedCategory: ContentCategoryId | undefined,
): boolean {
return expectedCategory !== undefined && item.contentCategory === expectedCategory;
}
/** A RoM item is usable only when it carries nonempty validated native values. */
export function inventoryItemUsableForCategory(
item: Pick<InventoryItem, "contentCategory" | "stats">,
expectedCategory: ContentCategoryId | undefined,
): boolean {
if (!inventoryItemMatchesCategory(item, expectedCategory)) return false;
if (expectedCategory !== "rom") return true;
const native = normalizeRomNativeGearStats(item.stats?.romNative);
return Boolean(native && Object.keys(native).length > 0);
}
/** Safely migrates persisted profiles created before inventory existed. */
export function normalizeInventory(value: unknown): InventoryItem[] {
export function normalizeInventory(
value: unknown,
options: NormalizeInventoryOptions = {},
): InventoryItem[] {
if (!Array.isArray(value)) return [];
const normalized: InventoryItem[] = [];
for (const candidate of value) {
@@ -53,7 +86,13 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|| !Number.isFinite(item.acquiredAt)
) continue;
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
const contentCategory = CONTENT_CATEGORIES.has(item.contentCategory as ContentCategoryId)
? item.contentCategory as ContentCategoryId
: item.contentCategory === undefined && options.ownerCategory === "wow"
? "wow"
: null;
normalized.push({
contentCategory,
instanceId: item.instanceId,
itemId: Math.max(1, Math.trunc(item.itemId!)),
itemKey: item.itemKey,
+9 -5
View File
@@ -145,7 +145,7 @@ describe("RoM five class validation scope", () => {
}
});
it("equips the role defining weapons in the manual cohort", () => {
it("keeps role-defined training slots separate from earned RoM gear", () => {
for (const { classId, role, layout } of CASES) {
expect(romGearProfile(classId, role).weaponLayout).toBe(layout);
const loadout = resolvedEquipmentLoadout({}, [], {
@@ -184,14 +184,18 @@ describe("RoM five class validation scope", () => {
expect(loadout.ranged).toBeUndefined();
}
expect(loadout.wrist).toBeUndefined();
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
for (const item of Object.values(loadout)) {
expect(item!.contentCategory).toBe("rom");
const hasNativePayload = Boolean(item!.stats.romNative && Object.keys(item!.stats.romNative).length > 0);
expect(classCanEquipItem(classId, item!, {
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
level: 60,
})).toBe(true);
})).toBe(hasNativePayload);
}
}
});
it("opens the independent ranged and off-hand positions at the source training levels", () => {
it("opens training weapon slots without making placeholders equippable inventory", () => {
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
resolvedEquipmentLoadout({}, [], {
ownerId: `rom-slots:${classId}`,
@@ -213,7 +217,7 @@ describe("RoM five class validation scope", () => {
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
});
+8
View File
@@ -20,6 +20,7 @@ describe("RuneWaker source loot catalog", () => {
now: 12_345,
});
expect(drops[0]).toMatchObject({
contentCategory: "rom",
itemId: 200004,
name: "Healing Sigil",
quantity: 1,
@@ -28,11 +29,18 @@ describe("RuneWaker source loot catalog", () => {
inventorySlot: "misc",
});
expect(drops[0].stats).toMatchObject({ stamina: 0, armor: 0, meleeWeapon: null });
expect(drops[0].stats.romNative).toBeUndefined();
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
random: () => 0,
})).toEqual([]);
});
it("keeps source-tagged generic outcomes out of gear when an owner category is supplied", () => {
const options = { random: () => 0, now: 12_346, ownerCategory: "rom" as const };
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, options)).toEqual([]);
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, { ...options, ownerCategory: "wow" })).toEqual([]);
});
it("keeps no-source tables and unknown bosses fail closed", () => {
const noDrops = RUNEWAKER_LOOT_CATALOG.bosses.find((boss) => boss.status === "no-source-drops")!;
expect(runewakerBossHasLoot(noDrops.bossKey)).toBe(false);
+5 -1
View File
@@ -1,4 +1,5 @@
import generatedCatalog from "./generated/runewakerLootCatalog.json";
import type { ContentCategoryId } from "../app/contentCategories";
import { allowedLootDrops } from "./lootPolicy";
import { EMPTY_ITEM_STATS } from "./itemStats";
import type { InventoryItem } from "./lootTypes";
@@ -51,6 +52,8 @@ export interface RunewakerLootRollOptions {
readonly levelDelta?: number;
/** HealerMan gameplay modifier: one extra authored slot roll may be attempted. */
readonly bonusItemChance?: number;
/** Saved character/party category; dungeon category is not used as owner identity. */
readonly ownerCategory?: ContentCategoryId;
}
export const RUNEWAKER_LOOT_CATALOG = generatedCatalog as RunewakerLootCatalog;
@@ -101,6 +104,7 @@ function rollSourceSlot(
const itemLevel = Math.max(1, Math.min(255, Math.trunc(playerLevel)));
itemInstanceSequence += 1;
return Object.freeze({
contentCategory: "rom",
instanceId: `${boss.bossKey}:rw:${acquiredAt}:${itemInstanceSequence}`,
itemId: outcome.objectId,
itemKey: `runewaker-${outcome.objectId}`,
@@ -143,5 +147,5 @@ export function rollRunewakerBossLoot(
const item = rollSourceSlot(boss, bonusRoll, playerLevel, levelDelta, acquiredAt, random);
if (item) drops.push(item);
}
return allowedLootDrops(drops, playerLevel);
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
}