feat(items): 实现 auto-TC 运行时生成 (Issue #100)
This commit is contained in:
parent
ca3512f195
commit
ce07af1b9a
|
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* Diablo II runtime auto-TreasureClass generation (`armo3..87`, `weap3..87`, `bow3..87`, `mele3..39`).
|
||||
*
|
||||
* Gold standard reference:
|
||||
* - 1.13c MPQ ItemTypes.txt, Armor.txt, Weapons.txt, and D2Mods KB #368.
|
||||
*
|
||||
* In Diablo II 1.10+, item types with `TreasureClass === 1` in `ItemTypes.txt` define auto-TC seeds:
|
||||
* - `bow` (Bow, rarity multiplier 3)
|
||||
* - `weap` (Weapon, rarity multiplier 3)
|
||||
* - `mele` (Melee Weapon, rarity multiplier 3)
|
||||
* - `armo` (Any Armor, rarity multiplier 3)
|
||||
* - `abow` (Amazon Bow, rarity multiplier 1)
|
||||
*
|
||||
* In 1.13c, `abow` is not referenced in TreasureClassEx rows. The referenced auto-TCs are:
|
||||
* - `weap3, weap6, ... weap87` (29 tiers, step 3)
|
||||
* - `armo3, armo6, ... armo87` (29 tiers, step 3)
|
||||
* - `bow3, bow6, ... bow87` (29 tiers, step 3)
|
||||
* - `mele3, mele6, ... mele39` (13 tiers, step 3)
|
||||
* Total: 29 + 29 + 29 + 13 = exactly 100 virtual auto-TC nodes.
|
||||
*
|
||||
* Bucket placement rule:
|
||||
* - For tier N, an item of matching type (using `isA` along Equiv chain from `item-types.ts`) belongs to bucket N if its `qlvl` (level in Armor/Weapons.txt) satisfies:
|
||||
* `(N - 3) < qlvl <= N`. For tier 3, items have `1 <= qlvl <= 3`.
|
||||
* - ONLY items with `rarity > 0` are placed into auto-TC buckets!
|
||||
* - Item probability in the auto-TC bucket = `item.rarity`.
|
||||
*/
|
||||
|
||||
import { isA, type ItemTypeTable } from './item-types.ts'
|
||||
import type { ArmorTable } from './armor.ts'
|
||||
import type { WeaponTable } from './weapons.ts'
|
||||
import {
|
||||
isClassicValidItem,
|
||||
type TreasureClassItem,
|
||||
type TreasureClassNode,
|
||||
type TreasureClassTable,
|
||||
} from './treasure-class.ts'
|
||||
|
||||
/** Supported auto-TC item type codes referenced in Diablo II 1.13c TreasureClassEx.txt. */
|
||||
export type AutoTcTypeCode = 'weap' | 'armo' | 'bow' | 'mele'
|
||||
|
||||
/** Specification for an auto-TC series. */
|
||||
export interface AutoTcSeriesSpec {
|
||||
/** Item type code seed. */
|
||||
readonly typeCode: AutoTcTypeCode
|
||||
/** Minimum tier level (always 3). */
|
||||
readonly minTier: number
|
||||
/** Maximum tier level (87 for weap/armo/bow, 39 for mele). */
|
||||
readonly maxTier: number
|
||||
/** Step increment between tiers (always 3). */
|
||||
readonly step: number
|
||||
/** Number of tiers in this series. */
|
||||
readonly count: number
|
||||
}
|
||||
|
||||
/** The 4 auto-TC series referenced in Diablo II 1.13c TreasureClassEx.txt. */
|
||||
export const AUTO_TC_SERIES: readonly AutoTcSeriesSpec[] = [
|
||||
{ typeCode: 'weap', minTier: 3, maxTier: 87, step: 3, count: 29 },
|
||||
{ typeCode: 'armo', minTier: 3, maxTier: 87, step: 3, count: 29 },
|
||||
{ typeCode: 'bow', minTier: 3, maxTier: 87, step: 3, count: 29 },
|
||||
{ typeCode: 'mele', minTier: 3, maxTier: 39, step: 3, count: 13 },
|
||||
]
|
||||
|
||||
/** Total number of virtual auto-TreasureClass nodes in 1.13c (29 + 29 + 29 + 13 = 100). */
|
||||
export const AUTO_TC_TOTAL_NODES = 100
|
||||
|
||||
/**
|
||||
* Precomputed set of all 100 canonical auto-TC node names.
|
||||
*/
|
||||
export const AUTO_TC_NAMES: ReadonlySet<string> = new Set(
|
||||
AUTO_TC_SERIES.flatMap(series => {
|
||||
const names: string[] = []
|
||||
for (let tier = series.minTier; tier <= series.maxTier; tier += series.step) {
|
||||
names.push(`${series.typeCode}${tier}`)
|
||||
}
|
||||
return names
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Case-insensitive, whitespace-tolerant Map for auto-TC lookups.
|
||||
*/
|
||||
class CaseInsensitiveMap<V> extends Map<string, V> {
|
||||
private readonly lowerMap = new Map<string, string>()
|
||||
|
||||
override set(key: string, value: V): this {
|
||||
super.set(key, value)
|
||||
this.lowerMap.set(key.trim().toLowerCase(), key)
|
||||
return this
|
||||
}
|
||||
|
||||
override get(key: string): V | undefined {
|
||||
const direct = super.get(key)
|
||||
if (direct !== undefined) return direct
|
||||
const orig = this.lowerMap.get(typeof key === 'string' ? key.trim().toLowerCase() : String(key))
|
||||
return orig !== undefined ? super.get(orig) : undefined
|
||||
}
|
||||
|
||||
override has(key: string): boolean {
|
||||
return super.has(key) || this.lowerMap.has(typeof key === 'string' ? key.trim().toLowerCase() : String(key))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An item candidate entry inside an auto-TreasureClass node.
|
||||
*/
|
||||
export interface AutoTcEntry {
|
||||
/** 3-4 character base item code (e.g. 'cap', 'hax'). */
|
||||
readonly itemCode: string
|
||||
/** Probability weight in the auto-TC bucket, equal to item.rarity. */
|
||||
readonly prob: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A virtual auto-TreasureClass node.
|
||||
*/
|
||||
export interface AutoTcNode {
|
||||
/** The auto-TC name (e.g. 'armo3', 'weap87'). */
|
||||
readonly name: string
|
||||
/** Group index, always 0 for auto-TC nodes. */
|
||||
readonly group: 0
|
||||
/** Level / tier requirement (e.g. 3, 6, ..., 87). */
|
||||
readonly level: number
|
||||
/** Number of item picks, always 1 for auto-TC nodes. */
|
||||
readonly picks: 1
|
||||
/** All eligible item entries in this tier bucket. */
|
||||
readonly entries: readonly AutoTcEntry[]
|
||||
/** Sum of all entry probabilities (weights). */
|
||||
readonly totalProb: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an auto-TC name into its type code and tier.
|
||||
* Returns undefined if name is not one of the 100 valid auto-TC virtual node names.
|
||||
* Case-insensitive and trimmed.
|
||||
*
|
||||
* @param name - Auto-TC candidate name (e.g. 'armo3', 'weap87', 'mele39').
|
||||
* @returns Parsed { typeCode, tier } or undefined.
|
||||
*/
|
||||
export function parseAutoTcName(name: string): { typeCode: string; tier: number } | undefined {
|
||||
if (typeof name !== 'string') return undefined
|
||||
const trimmed = name.trim().toLowerCase()
|
||||
const match = trimmed.match(/^(weap|armo|bow|mele)(\d+)$/)
|
||||
if (!match) return undefined
|
||||
const typeCode = match[1]!
|
||||
const tier = parseInt(match[2]!, 10)
|
||||
const canonicalName = `${typeCode}${tier}`
|
||||
if (!AUTO_TC_NAMES.has(canonicalName)) {
|
||||
return undefined
|
||||
}
|
||||
return { typeCode, tier }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given identifier is a valid auto-TreasureClass virtual node name
|
||||
* among the 100 canonical auto-TCs in Diablo II 1.13c.
|
||||
*
|
||||
* @param name - Name to test.
|
||||
*/
|
||||
export function isAutoTcName(name: string): boolean {
|
||||
return parseAutoTcName(name) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates all 100 virtual auto-TreasureClass nodes from ItemTypes, Armor, and Weapons tables.
|
||||
*
|
||||
* Rules:
|
||||
* - 4 types: weap (3..87), armo (3..87), bow (3..87), mele (3..39) in steps of 3 = 100 nodes.
|
||||
* - An item matches typeCode if `isA(item.type, typeCode, types)` or `(item.type2 && isA(item.type2, typeCode, types))`.
|
||||
* - For tier N: items must have `(N - 3) < qlvl <= N` (for tier 3, `1 <= qlvl <= 3`).
|
||||
* - Only items with `rarity > 0` are placed in the bucket.
|
||||
* - Each item's probability = `item.rarity`.
|
||||
* - Returned Map is keyed by auto-TC name and supports case-insensitive lookup.
|
||||
*
|
||||
* @param types - Parsed item types table (`ItemTypes.txt`).
|
||||
* @param armor - Parsed armor table (`Armor.txt`).
|
||||
* @param weapons - Parsed weapons table (`Weapons.txt`).
|
||||
* @returns Map of all 100 auto-TC nodes keyed by name.
|
||||
*/
|
||||
export function generateAutoTreasureClasses(
|
||||
types: ItemTypeTable,
|
||||
armor: ArmorTable,
|
||||
weapons: WeaponTable,
|
||||
): Map<string, AutoTcNode> {
|
||||
const result = new CaseInsensitiveMap<AutoTcNode>()
|
||||
|
||||
// Combine items in canonical table order: armor first, then weapons
|
||||
const allItems = [...armor.all, ...weapons.all]
|
||||
|
||||
for (const series of AUTO_TC_SERIES) {
|
||||
for (let tier = series.minTier; tier <= series.maxTier; tier += series.step) {
|
||||
const name = `${series.typeCode}${tier}`
|
||||
const entries: AutoTcEntry[] = []
|
||||
let totalProb = 0
|
||||
|
||||
for (const item of allItems) {
|
||||
if (item.rarity <= 0) continue
|
||||
const qlvl = item.level
|
||||
if (qlvl <= tier - 3 || qlvl > tier) continue
|
||||
|
||||
const matches =
|
||||
isA(item.type, series.typeCode, types) ||
|
||||
(item.type2 ? isA(item.type2, series.typeCode, types) : false)
|
||||
|
||||
if (matches) {
|
||||
entries.push({
|
||||
itemCode: item.code,
|
||||
prob: item.rarity,
|
||||
})
|
||||
totalProb += item.rarity
|
||||
}
|
||||
}
|
||||
|
||||
const node: AutoTcNode = {
|
||||
name,
|
||||
group: 0,
|
||||
level: tier,
|
||||
picks: 1,
|
||||
entries,
|
||||
totalProb,
|
||||
}
|
||||
|
||||
result.set(name, node)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an `AutoTcNode` into a full `TreasureClassNode` conforming to `TreasureClassEx.txt` structure.
|
||||
*
|
||||
* @param autoTc - The auto-TC virtual node to convert.
|
||||
* @returns Fully constructed `TreasureClassNode`.
|
||||
*/
|
||||
export function autoTcToTreasureClassNode(autoTc: AutoTcNode): TreasureClassNode {
|
||||
const items: TreasureClassItem[] = []
|
||||
let totalProbClassic = 0
|
||||
let totalProbExpansion = 0
|
||||
const cumulativeProbExpansion: number[] = []
|
||||
|
||||
for (const entry of autoTc.entries) {
|
||||
const isClassic = isClassicValidItem(entry.itemCode)
|
||||
if (isClassic) {
|
||||
totalProbClassic += entry.prob
|
||||
}
|
||||
totalProbExpansion += entry.prob
|
||||
cumulativeProbExpansion.push(totalProbExpansion)
|
||||
|
||||
items.push({
|
||||
item: entry.itemCode,
|
||||
prob: entry.prob,
|
||||
kind: 'base-item',
|
||||
isDangling: false,
|
||||
isClassic,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: autoTc.name,
|
||||
group: undefined,
|
||||
level: autoTc.level,
|
||||
picks: autoTc.picks,
|
||||
unique: 0,
|
||||
set: 0,
|
||||
rare: 0,
|
||||
magic: 0,
|
||||
noDrop: 0,
|
||||
items,
|
||||
totalProbClassic,
|
||||
totalProbExpansion,
|
||||
cumulativeProbExpansion,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges generated auto-TC virtual nodes into a `TreasureClassTable`, allowing seamless
|
||||
* resolution of auto-TC references via `table.get(name)` and `table.byName.get(name)`.
|
||||
*
|
||||
* @param tcTable - Target `TreasureClassTable` to enrich.
|
||||
* @param autoTcs - Map of generated `AutoTcNode`s.
|
||||
* @returns The mutated and enriched `TreasureClassTable`.
|
||||
*/
|
||||
export function mergeAutoTreasureClasses(
|
||||
tcTable: TreasureClassTable,
|
||||
autoTcs: Map<string, AutoTcNode>,
|
||||
): TreasureClassTable {
|
||||
for (const autoNode of autoTcs.values()) {
|
||||
const tcNode = autoTcToTreasureClassNode(autoNode)
|
||||
const existing = tcTable.byName.get(tcNode.name)
|
||||
if (existing) {
|
||||
const idx = tcTable.all.indexOf(existing)
|
||||
if (idx !== -1) {
|
||||
tcTable.all[idx] = tcNode
|
||||
} else {
|
||||
tcTable.all.push(tcNode)
|
||||
}
|
||||
} else {
|
||||
tcTable.all.push(tcNode)
|
||||
}
|
||||
tcTable.byName.set(tcNode.name, tcNode)
|
||||
}
|
||||
return tcTable
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries a treasure class by name, checking regular TCs first and falling back
|
||||
* to auto-TC virtual nodes (converted to `TreasureClassNode`).
|
||||
*
|
||||
* @param tcTable - Base `TreasureClassTable`.
|
||||
* @param autoTcs - Map of generated `AutoTcNode`s.
|
||||
* @param name - Treasure class name to query.
|
||||
* @returns Found `TreasureClassNode` or undefined.
|
||||
*/
|
||||
export function queryTreasureClass(
|
||||
tcTable: TreasureClassTable,
|
||||
autoTcs: Map<string, AutoTcNode>,
|
||||
name: string,
|
||||
): TreasureClassNode | undefined {
|
||||
const direct = tcTable.get(name)
|
||||
if (direct) return direct
|
||||
|
||||
const autoNode = autoTcs.get(name)
|
||||
if (autoNode) {
|
||||
return autoTcToTreasureClassNode(autoNode)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries an auto-TC node directly from the auto-TC map.
|
||||
* Case-insensitive and trimmed.
|
||||
*
|
||||
* @param autoTcs - Map of generated `AutoTcNode`s.
|
||||
* @param name - Auto-TC name (e.g. 'armo3').
|
||||
* @returns Found `AutoTcNode` or undefined.
|
||||
*/
|
||||
export function queryAutoTreasureClass(
|
||||
autoTcs: Map<string, AutoTcNode>,
|
||||
name: string,
|
||||
): AutoTcNode | undefined {
|
||||
return autoTcs.get(name)
|
||||
}
|
||||
|
|
@ -0,0 +1,737 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { loadItemTypes, type ItemTypeTable, type ItemTypeDefinition } from '../src/game/item-types.ts'
|
||||
import { loadArmor, ArmorTable, type ArmorBase } from '../src/game/armor.ts'
|
||||
import { loadWeapons, type WeaponTable, type WeaponBase } from '../src/game/weapons.ts'
|
||||
import {
|
||||
loadTreasureClasses,
|
||||
type TreasureClassTable,
|
||||
} from '../src/game/treasure-class.ts'
|
||||
import {
|
||||
AUTO_TC_NAMES,
|
||||
AUTO_TC_SERIES,
|
||||
AUTO_TC_TOTAL_NODES,
|
||||
isAutoTcName,
|
||||
parseAutoTcName,
|
||||
generateAutoTreasureClasses,
|
||||
autoTcToTreasureClassNode,
|
||||
mergeAutoTreasureClasses,
|
||||
queryTreasureClass,
|
||||
queryAutoTreasureClass,
|
||||
type AutoTcNode,
|
||||
} from '../src/game/auto-tc.ts'
|
||||
|
||||
const hasD2 = fs.existsSync('samples/d2/d2data.mpq')
|
||||
|
||||
describe('Auto-TC Runtime Generation (Issue #100)', () => {
|
||||
describe('Constants and Specifications', () => {
|
||||
it('defines exactly 100 auto-TC nodes across 4 series', () => {
|
||||
expect(AUTO_TC_TOTAL_NODES).toBe(100)
|
||||
expect(AUTO_TC_NAMES.size).toBe(100)
|
||||
|
||||
const weapSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'weap')!
|
||||
expect(weapSeries).toBeDefined()
|
||||
expect(weapSeries.count).toBe(29)
|
||||
expect(weapSeries.minTier).toBe(3)
|
||||
expect(weapSeries.maxTier).toBe(87)
|
||||
|
||||
const armoSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'armo')!
|
||||
expect(armoSeries).toBeDefined()
|
||||
expect(armoSeries.count).toBe(29)
|
||||
expect(armoSeries.minTier).toBe(3)
|
||||
expect(armoSeries.maxTier).toBe(87)
|
||||
|
||||
const bowSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'bow')!
|
||||
expect(bowSeries).toBeDefined()
|
||||
expect(bowSeries.count).toBe(29)
|
||||
expect(bowSeries.minTier).toBe(3)
|
||||
expect(bowSeries.maxTier).toBe(87)
|
||||
|
||||
const meleSeries = AUTO_TC_SERIES.find(s => s.typeCode === 'mele')!
|
||||
expect(meleSeries).toBeDefined()
|
||||
expect(meleSeries.count).toBe(13)
|
||||
expect(meleSeries.minTier).toBe(3)
|
||||
expect(meleSeries.maxTier).toBe(39)
|
||||
|
||||
// Total count across all series: 29 + 29 + 29 + 13 = 100
|
||||
const totalCount = AUTO_TC_SERIES.reduce((sum, s) => sum + s.count, 0)
|
||||
expect(totalCount).toBe(100)
|
||||
})
|
||||
|
||||
it('contains all expected 100 canonical names', () => {
|
||||
// Boundaries
|
||||
expect(AUTO_TC_NAMES.has('weap3')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('weap87')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('armo3')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('armo87')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('bow3')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('bow87')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('mele3')).toBe(true)
|
||||
expect(AUTO_TC_NAMES.has('mele39')).toBe(true)
|
||||
|
||||
// Excluded / Out of bounds
|
||||
expect(AUTO_TC_NAMES.has('mele42')).toBe(false)
|
||||
expect(AUTO_TC_NAMES.has('armo90')).toBe(false)
|
||||
expect(AUTO_TC_NAMES.has('weap90')).toBe(false)
|
||||
expect(AUTO_TC_NAMES.has('abow3')).toBe(false)
|
||||
expect(AUTO_TC_NAMES.has('weap0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAutoTcName & parseAutoTcName APIs', () => {
|
||||
it('correctly parses valid auto-TC names', () => {
|
||||
expect(parseAutoTcName('weap3')).toEqual({ typeCode: 'weap', tier: 3 })
|
||||
expect(parseAutoTcName('weap87')).toEqual({ typeCode: 'weap', tier: 87 })
|
||||
expect(parseAutoTcName('armo3')).toEqual({ typeCode: 'armo', tier: 3 })
|
||||
expect(parseAutoTcName('armo87')).toEqual({ typeCode: 'armo', tier: 87 })
|
||||
expect(parseAutoTcName('bow15')).toEqual({ typeCode: 'bow', tier: 15 })
|
||||
expect(parseAutoTcName('bow87')).toEqual({ typeCode: 'bow', tier: 87 })
|
||||
expect(parseAutoTcName('mele3')).toEqual({ typeCode: 'mele', tier: 3 })
|
||||
expect(parseAutoTcName('mele39')).toEqual({ typeCode: 'mele', tier: 39 })
|
||||
})
|
||||
|
||||
it('supports case-insensitivity and whitespace padding', () => {
|
||||
expect(parseAutoTcName('ARMO3')).toEqual({ typeCode: 'armo', tier: 3 })
|
||||
expect(parseAutoTcName(' weap6 ')).toEqual({ typeCode: 'weap', tier: 6 })
|
||||
expect(parseAutoTcName('MeLe39')).toEqual({ typeCode: 'mele', tier: 39 })
|
||||
expect(isAutoTcName(' ARMO87 ')).toBe(true)
|
||||
expect(isAutoTcName('BoW3')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid or non-auto-TC names', () => {
|
||||
// Non-existent prefixes or item codes
|
||||
expect(parseAutoTcName('abow3')).toBeUndefined()
|
||||
expect(parseAutoTcName('cap')).toBeUndefined()
|
||||
expect(parseAutoTcName('hax')).toBeUndefined()
|
||||
expect(parseAutoTcName('Gold')).toBeUndefined()
|
||||
expect(parseAutoTcName('')).toBeUndefined()
|
||||
|
||||
// Invalid steps (not multiple of 3)
|
||||
expect(parseAutoTcName('armo4')).toBeUndefined()
|
||||
expect(parseAutoTcName('weap1')).toBeUndefined()
|
||||
expect(parseAutoTcName('bow2')).toBeUndefined()
|
||||
|
||||
// Tiers out of 1.13c range
|
||||
expect(parseAutoTcName('mele42')).toBeUndefined()
|
||||
expect(parseAutoTcName('armo90')).toBeUndefined()
|
||||
expect(parseAutoTcName('weap90')).toBeUndefined()
|
||||
expect(parseAutoTcName('weap0')).toBeUndefined()
|
||||
|
||||
expect(isAutoTcName('abow3')).toBe(false)
|
||||
expect(isAutoTcName('mele42')).toBe(false)
|
||||
expect(isAutoTcName('Gold')).toBe(false)
|
||||
expect(isAutoTcName('armo4')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with 1.13c MPQ data (samples/d2)', () => {
|
||||
let types: ItemTypeTable
|
||||
let armor: ArmorTable
|
||||
let weapons: WeaponTable
|
||||
let autoTcs: Map<string, AutoTcNode>
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!hasD2) {
|
||||
throw new Error('samples/d2 MPQ files required for Auto-TC test')
|
||||
}
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
types = await loadItemTypes(archives)
|
||||
armor = await loadArmor(archives)
|
||||
weapons = await loadWeapons(archives)
|
||||
autoTcs = generateAutoTreasureClasses(types, armor, weapons)
|
||||
})
|
||||
|
||||
it('generates exactly 100 auto-TC nodes', () => {
|
||||
expect(autoTcs.size).toBe(100)
|
||||
|
||||
for (const name of AUTO_TC_NAMES) {
|
||||
expect(autoTcs.has(name)).toBe(true)
|
||||
const node = autoTcs.get(name)
|
||||
expect(node).toBeDefined()
|
||||
expect(node?.name).toBe(name)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies AutoTcNode structure invariants across all 100 nodes', () => {
|
||||
for (const [name, node] of autoTcs) {
|
||||
const parsed = parseAutoTcName(name)!
|
||||
expect(parsed).toBeDefined()
|
||||
|
||||
expect(node.name).toBe(name)
|
||||
expect(node.group).toBe(0)
|
||||
expect(node.level).toBe(parsed.tier)
|
||||
expect(node.picks).toBe(1)
|
||||
expect(Array.isArray(node.entries)).toBe(true)
|
||||
|
||||
// totalProb must strictly equal sum of entry probabilities
|
||||
const calculatedProb = node.entries.reduce((sum, e) => sum + e.prob, 0)
|
||||
expect(node.totalProb).toBe(calculatedProb)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies all items in each bucket have rarity > 0', () => {
|
||||
let totalItemEntries = 0
|
||||
for (const [, node] of autoTcs) {
|
||||
for (const entry of node.entries) {
|
||||
totalItemEntries++
|
||||
expect(entry.prob).toBeGreaterThan(0)
|
||||
|
||||
const base = armor.get(entry.itemCode) ?? weapons.get(entry.itemCode)
|
||||
expect(base, `Item ${entry.itemCode} should exist in armor or weapons`).toBeDefined()
|
||||
expect(base!.rarity).toBeGreaterThan(0)
|
||||
expect(entry.prob).toBe(base!.rarity)
|
||||
}
|
||||
}
|
||||
expect(totalItemEntries).toBe(637)
|
||||
})
|
||||
|
||||
it('verifies tier N items strictly satisfy (N - 3) < qlvl <= N', () => {
|
||||
for (const [name, node] of autoTcs) {
|
||||
const tier = node.level
|
||||
for (const entry of node.entries) {
|
||||
const base = armor.get(entry.itemCode) ?? weapons.get(entry.itemCode)
|
||||
expect(base).toBeDefined()
|
||||
const qlvl = base!.level
|
||||
|
||||
expect(
|
||||
qlvl,
|
||||
`In node ${name}, item ${base!.code} with qlvl ${qlvl} must be > ${tier - 3}`,
|
||||
).toBeGreaterThan(tier - 3)
|
||||
expect(
|
||||
qlvl,
|
||||
`In node ${name}, item ${base!.code} with qlvl ${qlvl} must be <= ${tier}`,
|
||||
).toBeLessThanOrEqual(tier)
|
||||
|
||||
if (tier === 3) {
|
||||
expect(qlvl).toBeGreaterThanOrEqual(1)
|
||||
expect(qlvl).toBeLessThanOrEqual(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sample checks: armo3 contains cap (qlvl 1, rarity 1); weap3 contains hax (qlvl 3, rarity 3)', () => {
|
||||
// 1. armo3 check
|
||||
const armo3 = autoTcs.get('armo3')!
|
||||
expect(armo3).toBeDefined()
|
||||
expect(armo3.name).toBe('armo3')
|
||||
expect(armo3.level).toBe(3)
|
||||
expect(armo3.group).toBe(0)
|
||||
expect(armo3.picks).toBe(1)
|
||||
|
||||
const capEntry = armo3.entries.find(e => e.itemCode === 'cap')
|
||||
expect(capEntry).toBeDefined()
|
||||
expect(capEntry?.itemCode).toBe('cap')
|
||||
expect(capEntry?.prob).toBe(1)
|
||||
|
||||
const capBase = armor.get('cap')!
|
||||
expect(capBase).toBeDefined()
|
||||
expect(capBase.level).toBe(1)
|
||||
expect(capBase.rarity).toBe(1)
|
||||
|
||||
// Verify all items in armo3: cap, qui, lea, buc, lgl, lbt, lbl
|
||||
const armo3Codes = armo3.entries.map(e => e.itemCode)
|
||||
expect(armo3Codes).toEqual(['cap', 'qui', 'lea', 'buc', 'lgl', 'lbt', 'lbl'])
|
||||
expect(armo3.totalProb).toBe(9)
|
||||
|
||||
// 2. weap3 check
|
||||
const weap3 = autoTcs.get('weap3')!
|
||||
expect(weap3).toBeDefined()
|
||||
expect(weap3.name).toBe('weap3')
|
||||
expect(weap3.level).toBe(3)
|
||||
|
||||
const haxEntry = weap3.entries.find(e => e.itemCode === 'hax')
|
||||
expect(haxEntry).toBeDefined()
|
||||
expect(haxEntry?.itemCode).toBe('hax')
|
||||
|
||||
const haxBase = weapons.get('hax')!
|
||||
expect(haxBase).toBeDefined()
|
||||
expect(haxBase.level).toBe(3)
|
||||
expect(haxBase.rarity).toBe(3)
|
||||
expect(haxEntry?.prob).toBe(haxBase.rarity)
|
||||
|
||||
// Also verify wand and club in weap3 have rarity 1
|
||||
const wndEntry = weap3.entries.find(e => e.itemCode === 'wnd')!
|
||||
expect(wndEntry).toBeDefined()
|
||||
expect(wndEntry.prob).toBe(1)
|
||||
|
||||
const clbEntry = weap3.entries.find(e => e.itemCode === 'clb')!
|
||||
expect(clbEntry).toBeDefined()
|
||||
expect(clbEntry.prob).toBe(1)
|
||||
|
||||
expect(weap3.entries.length).toBe(12)
|
||||
expect(weap3.totalProb).toBe(26)
|
||||
})
|
||||
|
||||
it('sample checks: mele3 and bow3 buckets', () => {
|
||||
// mele3 contains melee weapons (excludes short bow sbw and eagle orb ob1)
|
||||
const mele3 = autoTcs.get('mele3')!
|
||||
expect(mele3).toBeDefined()
|
||||
expect(mele3.level).toBe(3)
|
||||
expect(mele3.entries.length).toBe(10)
|
||||
expect(mele3.entries.some(e => e.itemCode === 'hax')).toBe(true)
|
||||
expect(mele3.entries.some(e => e.itemCode === 'sbw')).toBe(false)
|
||||
expect(mele3.entries.some(e => e.itemCode === 'ob1')).toBe(false)
|
||||
expect(mele3.totalProb).toBe(22)
|
||||
|
||||
// bow3 contains Short Bow
|
||||
const bow3 = autoTcs.get('bow3')!
|
||||
expect(bow3).toBeDefined()
|
||||
expect(bow3.level).toBe(3)
|
||||
expect(bow3.entries.length).toBe(1)
|
||||
expect(bow3.entries[0]?.itemCode).toBe('sbw')
|
||||
expect(bow3.entries[0]?.prob).toBe(2)
|
||||
expect(bow3.totalProb).toBe(2)
|
||||
})
|
||||
|
||||
it('correctly handles sparse/empty buckets (e.g. bow15)', () => {
|
||||
const bow15 = autoTcs.get('bow15')!
|
||||
expect(bow15).toBeDefined()
|
||||
expect(bow15.name).toBe('bow15')
|
||||
expect(bow15.level).toBe(15)
|
||||
expect(bow15.entries.length).toBe(0)
|
||||
expect(bow15.totalProb).toBe(0)
|
||||
|
||||
// Converted to TreasureClassNode
|
||||
const tcNode = autoTcToTreasureClassNode(bow15)
|
||||
expect(tcNode.name).toBe('bow15')
|
||||
expect(tcNode.items.length).toBe(0)
|
||||
expect(tcNode.totalProbExpansion).toBe(0)
|
||||
expect(tcNode.totalProbClassic).toBe(0)
|
||||
expect(tcNode.cumulativeProbExpansion).toEqual([])
|
||||
})
|
||||
|
||||
it('verifies 1:1 match with TreasureClassEx.txt autoTCDanglingReferences', async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
const tcTable = await loadTreasureClasses(archives)
|
||||
|
||||
// TreasureClassEx.txt references exactly 100 auto-TCs
|
||||
expect(tcTable.autoTCDanglingReferences.size).toBe(100)
|
||||
|
||||
// Every referenced auto-TC must exist in the generated map
|
||||
for (const ref of tcTable.autoTCDanglingReferences) {
|
||||
expect(autoTcs.has(ref), `Auto-TC ${ref} should be generated`).toBe(true)
|
||||
}
|
||||
|
||||
// Every generated auto-TC must be in tcTable.autoTCDanglingReferences
|
||||
for (const key of autoTcs.keys()) {
|
||||
expect(tcTable.autoTCDanglingReferences.has(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('supports autoTcToTreasureClassNode conversion', () => {
|
||||
const armo3 = autoTcs.get('armo3')!
|
||||
const tcNode = autoTcToTreasureClassNode(armo3)
|
||||
|
||||
expect(tcNode.name).toBe('armo3')
|
||||
expect(tcNode.level).toBe(3)
|
||||
expect(tcNode.picks).toBe(1)
|
||||
expect(tcNode.unique).toBe(0)
|
||||
expect(tcNode.set).toBe(0)
|
||||
expect(tcNode.rare).toBe(0)
|
||||
expect(tcNode.magic).toBe(0)
|
||||
expect(tcNode.noDrop).toBe(0)
|
||||
expect(tcNode.items.length).toBe(armo3.entries.length)
|
||||
expect(tcNode.totalProbExpansion).toBe(armo3.totalProb)
|
||||
expect(tcNode.totalProbClassic).toBe(armo3.totalProb) // all armo3 items are classic
|
||||
expect(tcNode.cumulativeProbExpansion.length).toBe(armo3.entries.length)
|
||||
|
||||
const capItem = tcNode.items.find(it => it.item === 'cap')
|
||||
expect(capItem).toBeDefined()
|
||||
expect(capItem?.kind).toBe('base-item')
|
||||
expect(capItem?.prob).toBe(1)
|
||||
expect(capItem?.isDangling).toBe(false)
|
||||
expect(capItem?.isClassic).toBe(true)
|
||||
})
|
||||
|
||||
it('supports mergeAutoTreasureClasses into TreasureClassTable', async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
const tcTable = await loadTreasureClasses(archives)
|
||||
|
||||
// Before merge: auto-TCs return undefined
|
||||
expect(tcTable.get('armo3')).toBeUndefined()
|
||||
expect(tcTable.get('weap87')).toBeUndefined()
|
||||
|
||||
const initialTotal = tcTable.all.length
|
||||
expect(initialTotal).toBe(852)
|
||||
|
||||
// Merge
|
||||
const merged = mergeAutoTreasureClasses(tcTable, autoTcs)
|
||||
expect(merged).toBe(tcTable)
|
||||
expect(tcTable.all.length).toBe(852 + 100)
|
||||
|
||||
// After merge: auto-TCs are accessible via .get() and .byName.get()
|
||||
const armo3 = tcTable.get('armo3')
|
||||
expect(armo3).toBeDefined()
|
||||
expect(armo3?.name).toBe('armo3')
|
||||
expect(armo3?.level).toBe(3)
|
||||
expect(armo3?.items.length).toBe(7)
|
||||
|
||||
const armo3Upper = tcTable.get('ARMO3')
|
||||
expect(armo3Upper).toBe(armo3)
|
||||
|
||||
const weap87 = tcTable.get('weap87')
|
||||
expect(weap87).toBeDefined()
|
||||
expect(weap87?.level).toBe(87)
|
||||
|
||||
const mele39 = tcTable.get('mele39')
|
||||
expect(mele39).toBeDefined()
|
||||
expect(mele39?.level).toBe(39)
|
||||
|
||||
// Merging a second time is idempotent (doesn't duplicate nodes in all)
|
||||
mergeAutoTreasureClasses(tcTable, autoTcs)
|
||||
expect(tcTable.all.length).toBe(852 + 100)
|
||||
})
|
||||
|
||||
it('supports queryTreasureClass and queryAutoTreasureClass helpers', async () => {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`)))
|
||||
}
|
||||
const tcTable = await loadTreasureClasses(archives)
|
||||
|
||||
// Direct TC query
|
||||
const gold = queryTreasureClass(tcTable, autoTcs, 'Gold')
|
||||
expect(gold).toBeDefined()
|
||||
expect(gold?.name).toBe('Gold')
|
||||
|
||||
// Auto TC fallback query
|
||||
const armo3 = queryTreasureClass(tcTable, autoTcs, 'armo3')
|
||||
expect(armo3).toBeDefined()
|
||||
expect(armo3?.name).toBe('armo3')
|
||||
expect(armo3?.items.length).toBe(7)
|
||||
|
||||
// Non-existent query
|
||||
expect(queryTreasureClass(tcTable, autoTcs, 'NonExistent')).toBeUndefined()
|
||||
|
||||
// queryAutoTreasureClass
|
||||
expect(queryAutoTreasureClass(autoTcs, 'armo3')?.name).toBe('armo3')
|
||||
expect(queryAutoTreasureClass(autoTcs, 'ARMO3')?.name).toBe('armo3')
|
||||
expect(queryAutoTreasureClass(autoTcs, 'Gold')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Synthetic Hermetic Unit Tests (no MPQs required)', () => {
|
||||
function createMockTypes(): ItemTypeTable {
|
||||
const defs: ItemTypeDefinition[] = [
|
||||
{
|
||||
code: 'armo',
|
||||
name: 'Any Armor',
|
||||
equiv1: '',
|
||||
equiv2: '',
|
||||
treasureClass: 1,
|
||||
rarity: 3,
|
||||
maxSock1: 0,
|
||||
maxSock25: 0,
|
||||
maxSock40: 0,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'tors',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'helm',
|
||||
name: 'Helm',
|
||||
equiv1: 'armo',
|
||||
equiv2: '',
|
||||
treasureClass: 0,
|
||||
rarity: 3,
|
||||
maxSock1: 2,
|
||||
maxSock25: 3,
|
||||
maxSock40: 3,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'head',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'weap',
|
||||
name: 'Weapon',
|
||||
equiv1: '',
|
||||
equiv2: '',
|
||||
treasureClass: 1,
|
||||
rarity: 3,
|
||||
maxSock1: 0,
|
||||
maxSock25: 0,
|
||||
maxSock40: 0,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'axe',
|
||||
name: 'Axe',
|
||||
equiv1: 'mele',
|
||||
equiv2: '',
|
||||
treasureClass: 0,
|
||||
rarity: 3,
|
||||
maxSock1: 2,
|
||||
maxSock25: 4,
|
||||
maxSock40: 6,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'mele',
|
||||
name: 'Melee Weapon',
|
||||
equiv1: 'weap',
|
||||
equiv2: '',
|
||||
treasureClass: 1,
|
||||
rarity: 3,
|
||||
maxSock1: 0,
|
||||
maxSock25: 0,
|
||||
maxSock40: 0,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'bow',
|
||||
name: 'Bow',
|
||||
equiv1: 'miss',
|
||||
equiv2: '',
|
||||
treasureClass: 1,
|
||||
rarity: 3,
|
||||
maxSock1: 3,
|
||||
maxSock25: 4,
|
||||
maxSock40: 6,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: 'larm',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
{
|
||||
code: 'miss',
|
||||
name: 'Missile Weapon',
|
||||
equiv1: 'weap',
|
||||
equiv2: '',
|
||||
treasureClass: 0,
|
||||
rarity: 3,
|
||||
maxSock1: 0,
|
||||
maxSock25: 0,
|
||||
maxSock40: 0,
|
||||
magic: false,
|
||||
rare: true,
|
||||
normal: false,
|
||||
charm: false,
|
||||
gem: false,
|
||||
staffMods: '',
|
||||
body: true,
|
||||
bodyLoc1: 'rarm',
|
||||
bodyLoc2: '',
|
||||
beltable: false,
|
||||
class: '',
|
||||
},
|
||||
]
|
||||
|
||||
const byCode = new Map<string, ItemTypeDefinition>()
|
||||
for (const d of defs) {
|
||||
byCode.set(d.code, d)
|
||||
}
|
||||
return { types: defs, byCode, _isACache: new Map() }
|
||||
}
|
||||
|
||||
function createMockArmor(): ArmorTable {
|
||||
const items: ArmorBase[] = [
|
||||
{
|
||||
id: 'cap',
|
||||
code: 'cap',
|
||||
name: 'Cap',
|
||||
namestr: 'cap',
|
||||
version: 0,
|
||||
kind: 'armor',
|
||||
spawnable: true,
|
||||
rarity: 1,
|
||||
level: 1,
|
||||
levelreq: 0,
|
||||
minac: 3,
|
||||
maxac: 5,
|
||||
reqstr: 0,
|
||||
durability: 12,
|
||||
cost: 12,
|
||||
gambleCost: 100,
|
||||
type: 'helm',
|
||||
gemsockets: 2,
|
||||
gemapplytype: 0,
|
||||
normcode: 'cap',
|
||||
ubercode: 'xap',
|
||||
ultracode: 'uap',
|
||||
invWidth: 1,
|
||||
invHeight: 2,
|
||||
tags: ['helm'],
|
||||
defense: 5,
|
||||
maxStack: 1,
|
||||
value: 12,
|
||||
damage: 0,
|
||||
},
|
||||
// Excluded: rarity 0
|
||||
{
|
||||
id: 'zer',
|
||||
code: 'zer',
|
||||
name: 'Zero Armor',
|
||||
namestr: 'zer',
|
||||
version: 0,
|
||||
kind: 'armor',
|
||||
spawnable: true,
|
||||
rarity: 0,
|
||||
level: 2,
|
||||
minac: 0,
|
||||
maxac: 0,
|
||||
reqstr: 0,
|
||||
durability: 0,
|
||||
cost: 0,
|
||||
gambleCost: 0,
|
||||
type: 'helm',
|
||||
gemsockets: 0,
|
||||
gemapplytype: 0,
|
||||
normcode: '',
|
||||
ubercode: '',
|
||||
ultracode: '',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
tags: ['helm'],
|
||||
defense: 0,
|
||||
maxStack: 1,
|
||||
value: 0,
|
||||
damage: 0,
|
||||
levelreq: 0,
|
||||
},
|
||||
]
|
||||
const byCode = new Map<string, ArmorBase>()
|
||||
for (const it of items) byCode.set(it.code, it)
|
||||
return new ArmorTable(items, byCode)
|
||||
}
|
||||
|
||||
function createMockWeapons(): WeaponTable {
|
||||
const items: WeaponBase[] = [
|
||||
{
|
||||
id: 'hax',
|
||||
code: 'hax',
|
||||
name: 'Hand Axe',
|
||||
namestr: 'hax',
|
||||
version: 0,
|
||||
kind: 'weapon',
|
||||
spawnable: true,
|
||||
rarity: 3,
|
||||
level: 3,
|
||||
levelreq: 0,
|
||||
mindam: 3,
|
||||
maxdam: 6,
|
||||
twoHanded: false,
|
||||
speed: 0,
|
||||
reqstr: 0,
|
||||
reqdex: 0,
|
||||
durability: 28,
|
||||
cost: 170,
|
||||
gambleCost: 4510,
|
||||
type: 'axe',
|
||||
wclass: '1hs',
|
||||
stackable: false,
|
||||
rangeadder: 0,
|
||||
normcode: 'hax',
|
||||
ubercode: '9ha',
|
||||
ultracode: '7ha',
|
||||
gemsockets: 2,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
tags: ['axe'],
|
||||
damage: 6,
|
||||
value: 170,
|
||||
defense: 0,
|
||||
maxStack: 1,
|
||||
},
|
||||
]
|
||||
const byCode = new Map<string, WeaponBase>()
|
||||
for (const it of items) byCode.set(it.code, it)
|
||||
return {
|
||||
all: items,
|
||||
byCode,
|
||||
get: (c: string) => byCode.get(c.trim().toLowerCase()),
|
||||
length: items.length,
|
||||
} as unknown as WeaponTable
|
||||
}
|
||||
|
||||
it('filters out rarity === 0 items and populates armo3 and weap3', () => {
|
||||
const mockTypes = createMockTypes()
|
||||
const mockArmor = createMockArmor()
|
||||
const mockWeapons = createMockWeapons()
|
||||
|
||||
const result = generateAutoTreasureClasses(mockTypes, mockArmor, mockWeapons)
|
||||
expect(result.size).toBe(100)
|
||||
|
||||
const armo3 = result.get('armo3')!
|
||||
expect(armo3.entries).toEqual([{ itemCode: 'cap', prob: 1 }])
|
||||
expect(armo3.totalProb).toBe(1)
|
||||
|
||||
const weap3 = result.get('weap3')!
|
||||
expect(weap3.entries).toEqual([{ itemCode: 'hax', prob: 3 }])
|
||||
expect(weap3.totalProb).toBe(3)
|
||||
|
||||
const mele3 = result.get('mele3')!
|
||||
expect(mele3.entries).toEqual([{ itemCode: 'hax', prob: 3 }])
|
||||
expect(mele3.totalProb).toBe(3)
|
||||
|
||||
const bow3 = result.get('bow3')!
|
||||
expect(bow3.entries).toEqual([])
|
||||
expect(bow3.totalProb).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue