Files
healer-man/src/game/romNativeNpcEquipment.ts
T
2026-10-04 13:13:35 -04:00

267 lines
11 KiB
TypeScript

import catalogJson from "./generated/romNativeNpcEquipment.generated.json";
export interface RomNativeNpcEquipmentItem {
readonly sourceItemId: number;
readonly sourceCatalog: "armorobject.db" | "weaponobject.db";
readonly sourceRecordIndex: number;
readonly objectKind: "equipment-item";
readonly objectType: number;
readonly quality: number;
readonly itemLevel: number;
readonly gearType: number;
readonly gearPosition: number;
readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[];
/** GameObjectItemStruct::DropItemAbility rows; these are drop metadata, not equipment affixes. */
readonly dropAbilityIdRatePairs: readonly { readonly slot: number; readonly attributeId: number; readonly rate: number }[];
readonly durable: number;
readonly refineTableId: number;
readonly attackRange: number | null;
readonly attackSpeed: number | null;
readonly refineLevel: number;
readonly addPowerQuality: number;
readonly collectionNpcId: number;
}
export interface RomNativeNpcEquipmentCandidate {
readonly candidateIndex: number;
readonly sourceItemId: number;
readonly selectionWeightPercent: number;
}
export interface RomNativeNpcEquipmentRecord {
readonly npcId: number;
readonly sourceRecordIndex: number;
readonly mainHandCandidates: readonly RomNativeNpcEquipmentCandidate[];
readonly offHandCandidates: readonly RomNativeNpcEquipmentCandidate[];
readonly bowEquipmentId: number;
readonly ornamentEquipmentId: number;
}
export interface RomNativeNpcEquipmentSelection {
readonly mainHandCandidateIndex?: number;
readonly offHandCandidateIndex?: number;
/** Stable seed for the simulator's weighted candidate choice; defaults to npcId. */
readonly selectionSeed?: string | number;
}
export interface RomNativeNpcRefineAbility {
readonly refineObjectId: number;
readonly sourceRecordIndex: number;
readonly refineType: number;
readonly refineLevel: number;
readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[];
readonly exEqPowerRate: number;
}
export interface RomNativeNpcWornItem {
readonly slot: "mainHand" | "offHand" | "bow" | "ornament";
readonly item: RomNativeNpcEquipmentItem;
readonly fieldQuality: number;
readonly fieldDurability: number;
readonly fieldPowerQuality: number;
readonly fieldRare: number;
readonly effectiveRefineLevel: number;
readonly durabilityPowerRate: number;
readonly wearPower: number;
readonly refineAbilities: readonly RomNativeNpcRefineAbility[];
readonly inherentAttributeIds: readonly number[];
readonly runeIds: readonly number[];
}
export interface RomNativeResolvedNpcEquipment {
readonly mainHand: RomNativeNpcEquipmentItem | null;
readonly offHand: RomNativeNpcEquipmentItem | null;
readonly bow: RomNativeNpcEquipmentItem | null;
readonly ornament: RomNativeNpcEquipmentItem | null;
readonly wornItems: readonly RomNativeNpcWornItem[];
readonly selectedCandidateIndices: {
readonly mainHand: number | null;
readonly offHand: number | null;
};
}
export type RomNativeNpcEquipmentResult =
| { readonly status: "supported"; readonly value: RomNativeResolvedNpcEquipment }
| { readonly status: "unsupported"; readonly reason: string };
interface GeneratedCatalog {
readonly npcs: readonly RomNativeNpcEquipmentRecord[];
readonly itemsById: Readonly<Record<string, RomNativeNpcEquipmentItem | null>>;
readonly refineItemsById: Readonly<Record<string, RomNativeNpcRefineAbility | null>>;
readonly source: {
readonly npcWearDefaults: {
readonly fieldDurable: number;
readonly fieldQuality: number;
readonly fieldPowerQuality: number;
readonly fieldRare: number;
readonly inherentIds: readonly number[];
readonly runeIds: readonly number[];
};
};
}
const catalog = catalogJson as unknown as GeneratedCatalog;
const npcById = new Map(catalog.npcs.map((npc) => [npc.npcId, npc]));
export function romNativeNpcEquipmentRecord(npcId: number): RomNativeNpcEquipmentRecord | undefined {
return npcById.get(npcId);
}
/** All exported source NPC equipment rows, in generated source order. */
export function romNativeNpcEquipmentRecords(): readonly RomNativeNpcEquipmentRecord[] {
return catalog.npcs;
}
function deterministicBucket(seed: string | number, npcId: number, slot: "mainHand" | "offHand"): number {
const key = `rom-native-npc-equipment/v1:${npcId}:${slot}:${String(seed)}`;
let hash = 0x811c9dc5;
for (let index = 0; index < key.length; index += 1) {
hash ^= key.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return Math.floor((hash >>> 0) * 100 / 0x1_0000_0000);
}
function deterministicCandidate(
npcId: number,
slot: "mainHand" | "offHand",
candidates: readonly RomNativeNpcEquipmentCandidate[],
seed: string | number,
): RomNativeNpcEquipmentCandidate | undefined {
const weights = candidates.map((candidate) => candidate.selectionWeightPercent);
if (weights.some((weight) => !Number.isFinite(weight) || weight < 0)
|| Math.abs(weights.reduce((sum, weight) => sum + weight, 0) - 100) > 0.00001) return undefined;
const bucket = deterministicBucket(seed, npcId, slot);
let cumulative = 0;
for (const candidate of candidates) {
cumulative += candidate.selectionWeightPercent;
if (bucket < cumulative) return candidate;
}
return undefined;
}
function resolveCandidate(
npcId: number,
slot: "mainHand" | "offHand",
candidates: readonly RomNativeNpcEquipmentCandidate[],
requestedIndex: number | undefined,
seed: string | number,
): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null; readonly index: number }
| { readonly status: "unsupported"; readonly reason: string } {
if (!candidates.length) return { status: "unsupported", reason: `missing-${slot}-candidates` };
let candidate: RomNativeNpcEquipmentCandidate | undefined;
if (requestedIndex !== undefined) {
if (!Number.isInteger(requestedIndex) || requestedIndex < 0) {
return { status: "unsupported", reason: `invalid-${slot}-candidate-index` };
}
candidate = candidates.find((entry) => entry.candidateIndex === requestedIndex);
if (!candidate) return { status: "unsupported", reason: `missing-${slot}-candidate` };
} else {
candidate = deterministicCandidate(npcId, slot, candidates, seed);
if (!candidate) return { status: "unsupported", reason: `invalid-${slot}-candidate-weights` };
}
if (candidate.sourceItemId === 0) return { status: "supported", item: null, index: candidate.candidateIndex };
const item = catalog.itemsById[String(candidate.sourceItemId)];
if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${candidate.sourceItemId}` };
return { status: "supported", item, index: candidate.candidateIndex };
}
function resolveFixedItem(
slot: "bow" | "ornament",
itemId: number,
): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null }
| { readonly status: "unsupported"; readonly reason: string } {
if (itemId === 0) return { status: "supported", item: null };
const item = catalog.itemsById[String(itemId)];
if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${itemId}` };
return { status: "supported", item };
}
function sourceDurabilityPowerRate(item: RomNativeNpcEquipmentItem, fieldQuality: number, fieldDurability: number): number {
const orgDurability = Math.trunc(item.durable * fieldQuality / 100);
const fieldDurabilityHundreds = Math.trunc(fieldDurability / 100);
if (fieldDurabilityHundreds > 100 || fieldDurabilityHundreds > orgDurability) return 1.2;
if (fieldDurabilityHundreds > Math.trunc(orgDurability / 2)) return 1;
if (fieldDurabilityHundreds > Math.trunc(orgDurability / 5)) return 0.8;
return 0.2;
}
function wornItem(
slot: RomNativeNpcWornItem["slot"],
item: RomNativeNpcEquipmentItem,
): RomNativeNpcWornItem | { readonly reason: string } {
const defaults = catalog.source.npcWearDefaults;
const refineLevel = Math.min(item.refineLevel, 20);
const refineAbilities: RomNativeNpcRefineAbility[] = [];
if (refineLevel > 0) {
if (!Number.isSafeInteger(item.refineTableId) || item.refineTableId <= 0) {
return { reason: `missing-${slot}-refine-table-id-${item.sourceItemId}` };
}
for (let level = 1; level <= refineLevel; level += 1) {
const refineId = item.refineTableId + level - 1;
const refine = catalog.refineItemsById[String(refineId)];
if (!refine) return { reason: `missing-${slot}-refine-ability-${refineId}` };
refineAbilities.push(refine);
}
}
const durabilityPowerRate = sourceDurabilityPowerRate(item, defaults.fieldQuality, defaults.fieldDurable);
return Object.freeze({
slot,
item,
fieldQuality: defaults.fieldQuality,
fieldDurability: defaults.fieldDurable,
fieldPowerQuality: defaults.fieldPowerQuality,
fieldRare: defaults.fieldRare,
effectiveRefineLevel: refineLevel,
durabilityPowerRate,
wearPower: defaults.fieldPowerQuality / 10 * durabilityPowerRate,
refineAbilities: Object.freeze(refineAbilities),
inherentAttributeIds: defaults.inherentIds,
runeIds: defaults.runeIds,
});
}
/**
* Resolves one stable weighted NPC equipment outcome. The source's five
* candidate slots and 20-percent weights stay intact; a caller seed selects
* a reproducible outcome without averaging candidate stats.
*/
export function resolveRomNativeNpcEquipment(
npcId: number,
selection: RomNativeNpcEquipmentSelection = {},
): RomNativeNpcEquipmentResult {
const npc = npcById.get(npcId);
if (!npc) return { status: "unsupported", reason: "missing-source-npc" };
const seed = selection.selectionSeed ?? npcId;
const mainHand = resolveCandidate(npcId, "mainHand", npc.mainHandCandidates, selection.mainHandCandidateIndex, seed);
if (mainHand.status !== "supported") return mainHand;
const offHand = resolveCandidate(npcId, "offHand", npc.offHandCandidates, selection.offHandCandidateIndex, seed);
if (offHand.status !== "supported") return offHand;
const bow = resolveFixedItem("bow", npc.bowEquipmentId);
if (bow.status !== "supported") return bow;
const ornament = resolveFixedItem("ornament", npc.ornamentEquipmentId);
if (ornament.status !== "supported") return ornament;
const items = [
["mainHand", mainHand.item], ["offHand", offHand.item], ["bow", bow.item], ["ornament", ornament.item],
] as const;
const wornItems: RomNativeNpcWornItem[] = [];
for (const [slot, item] of items) {
if (!item) continue;
const worn = wornItem(slot, item);
if ("reason" in worn) return { status: "unsupported", reason: worn.reason };
wornItems.push(worn);
}
return {
status: "supported",
value: {
mainHand: mainHand.item,
offHand: offHand.item,
bow: bow.item,
ornament: ornament.item,
wornItems: Object.freeze(wornItems),
selectedCandidateIndices: { mainHand: mainHand.index, offHand: offHand.index },
},
};
}