267 lines
11 KiB
TypeScript
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 },
|
|
},
|
|
};
|
|
}
|