789 lines
26 KiB
TypeScript
789 lines
26 KiB
TypeScript
/**
|
|
* Adversarial Milestone 2 Challenger Stress & Fuzzing Suite
|
|
*
|
|
* Exhaustive empirical challenge of:
|
|
* 1. Act Boss death drops with rank: 'boss' verifying monsterType = 4 resolution.
|
|
* 2. demoteToNormal level reversion across Champion, Unique, Minion packs.
|
|
* 3. Extreme negative and high Magic Find boundaries (mf <= -100 suppression, diminishing returns).
|
|
* 4. setPlayers boundary clamping and chat command parsing.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
monsterStatsOf,
|
|
applyEliteModifiers,
|
|
rollEliteModifiers,
|
|
getMonsterTreasureClass,
|
|
ELITE_HEALTH_MULTIPLIER,
|
|
MONUMOD_CONSTANTS,
|
|
type MonsterKind,
|
|
type MonsterRank,
|
|
} from '../src/game/monsters.ts'
|
|
import { damageMonster, type CombatWorld, type MonsterStats } from '../src/game/combat.ts'
|
|
import { GameEngine, type WorldMapProvider, type GameEngineOptions } from '../src/game/engine.ts'
|
|
import {
|
|
effectiveMF,
|
|
calculateUniqueChance,
|
|
calculateSetChance,
|
|
calculateRareChance,
|
|
calculateMagicChance,
|
|
rollItemQuality,
|
|
createItemRatioTable,
|
|
type ItemRatioRow,
|
|
} from '../src/game/item-ratio.ts'
|
|
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
|
|
import { ItemQuality } from '../src/game/items.ts'
|
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import {
|
|
computeEffectivePlayers,
|
|
computeScaledNoDrop,
|
|
} from '../src/game/treasure-engine.ts'
|
|
import { handleActSceneChatCommand } from '../src/scene/act-scene.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
|
|
const dropTables = getEmbeddedDropTables()
|
|
|
|
const dummyTerrain: WorldMapProvider = {
|
|
widthPx: 1000,
|
|
heightPx: 1000,
|
|
overlap: () => 0,
|
|
}
|
|
|
|
const dummyOpts: GameEngineOptions = {
|
|
spawn: { x: 100, y: 100 },
|
|
stats: [],
|
|
npcDefs: [],
|
|
skills: [],
|
|
dropTables,
|
|
difficulty: 'normal',
|
|
xpTable: [0, 0, 10, 30, 60],
|
|
questDefs: [],
|
|
combatOptions: {
|
|
playerSpeed: 100,
|
|
playerReach: 50,
|
|
playerCooldownTicks: 10,
|
|
playerDamage: 5,
|
|
playerManaPerAttack: 0,
|
|
respawnTicks: 100,
|
|
},
|
|
talkRadius: 50,
|
|
pickupRadius: 50,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
}
|
|
|
|
const dummyInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
digits: [],
|
|
saving: false,
|
|
loading: false,
|
|
}
|
|
|
|
describe('Milestone 2 Adversarial Stress Suite', () => {
|
|
// ============================================================================
|
|
// Challenge 1: Act Boss Death Drops & monsterType = 4 Resolution
|
|
// ============================================================================
|
|
describe('Challenge 1: Act Boss death drops & monsterType = 4 resolution', () => {
|
|
it('1.1 monsterStatsOf assigns rank: boss when kind.boss is true', () => {
|
|
const realBossKind = dropTables.monsterKinds.get('andariel')!
|
|
expect(realBossKind).toBeDefined()
|
|
expect(realBossKind.boss).toBe(true)
|
|
|
|
const rng = new Rng(42)
|
|
const stats = monsterStatsOf(realBossKind, rng, 100)
|
|
expect(stats.rank).toBe('boss')
|
|
})
|
|
|
|
it('1.2 damageMonster creates killEvent with monsterRank: boss and monsterType: 4', () => {
|
|
const world: CombatWorld = {
|
|
monsters: [
|
|
{
|
|
index: 0,
|
|
x: 50,
|
|
y: 50,
|
|
hp: 100,
|
|
state: 'alive',
|
|
stats: {
|
|
id: 'duriel',
|
|
name: 'Duriel',
|
|
hp: 100,
|
|
damage: 50,
|
|
speed: 10,
|
|
reach: 2,
|
|
aggroRadius: 15,
|
|
cooldownTicks: 10,
|
|
xp: 5000,
|
|
level: 22,
|
|
rank: 'boss',
|
|
},
|
|
} as any,
|
|
],
|
|
player: { x: 50, y: 50, hp: 100, maxHp: 100, xp: 0 } as any,
|
|
events: [],
|
|
kills: 0,
|
|
} as any
|
|
|
|
const killed = damageMonster(world, 0, 150)
|
|
expect(killed).toBe(true)
|
|
expect(world.events.length).toBeGreaterThan(0)
|
|
const killEvent = world.events.find(e => e.kind === 'kill')
|
|
expect(killEvent).toBeDefined()
|
|
expect(killEvent?.monsterRank).toBe('boss')
|
|
expect(killEvent?.monsterType).toBe(4)
|
|
expect(killEvent?.monsterLevel).toBe(22)
|
|
})
|
|
|
|
it('1.3 damageMonster maps all monster ranks to authentic 1.13c monsterType codes', () => {
|
|
const ranks: { rank: MonsterRank; expectedType: number }[] = [
|
|
{ rank: 'normal', expectedType: 1 },
|
|
{ rank: 'champion', expectedType: 2 },
|
|
{ rank: 'unique', expectedType: 3 },
|
|
{ rank: 'minion', expectedType: 3 },
|
|
{ rank: 'boss', expectedType: 4 },
|
|
]
|
|
|
|
for (const { rank, expectedType } of ranks) {
|
|
const world: CombatWorld = {
|
|
monsters: [
|
|
{
|
|
index: 0,
|
|
x: 0,
|
|
y: 0,
|
|
hp: 10,
|
|
state: 'alive',
|
|
stats: {
|
|
id: 'test_mon',
|
|
name: 'Test Monster',
|
|
hp: 10,
|
|
damage: 5,
|
|
speed: 8,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 10,
|
|
xp: 10,
|
|
level: 5,
|
|
rank,
|
|
},
|
|
} as any,
|
|
],
|
|
player: { x: 0, y: 0, hp: 10, maxHp: 10, xp: 0 } as any,
|
|
events: [],
|
|
kills: 0,
|
|
} as any
|
|
|
|
damageMonster(world, 0, 20)
|
|
const killEvent = world.events.find(e => e.kind === 'kill')
|
|
expect(killEvent?.monsterType).toBe(expectedType)
|
|
}
|
|
})
|
|
|
|
it('1.4 rollEliteModifiers and applyEliteModifiers for boss rank', () => {
|
|
const rng = new Rng(123)
|
|
// Boss must never roll elite modifiers
|
|
const mods = rollEliteModifiers('boss', rng)
|
|
expect(mods).toEqual([])
|
|
|
|
const baseStats: MonsterStats = {
|
|
id: 'mephisto',
|
|
name: 'Mephisto',
|
|
hp: 10000,
|
|
damage: 200,
|
|
speed: 12,
|
|
reach: 3,
|
|
aggroRadius: 20,
|
|
cooldownTicks: 10,
|
|
xp: 20000,
|
|
level: 26,
|
|
rank: 'boss',
|
|
}
|
|
|
|
const bossApplied = applyEliteModifiers(baseStats, 'boss')
|
|
expect(bossApplied.level).toBe(26) // Level is NOT incremented by +2 or +3
|
|
expect(bossApplied.rank).toBe('boss')
|
|
expect(bossApplied.hp).toBe(10000)
|
|
})
|
|
|
|
it('1.5 GameEngine drop loop resolves boss rank to monsterType = 4 even when event.monsterType is missing', () => {
|
|
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
|
|
|
const andarielStats: MonsterStats = {
|
|
id: 'andariel',
|
|
name: 'Andariel',
|
|
hp: 100,
|
|
damage: 10,
|
|
speed: 10,
|
|
reach: 2,
|
|
aggroRadius: 15,
|
|
cooldownTicks: 10,
|
|
xp: 1000,
|
|
level: 12,
|
|
rank: 'boss',
|
|
}
|
|
|
|
engine.world.monsters.length = 0
|
|
engine.world.monsters.push({
|
|
index: 0,
|
|
stats: andarielStats,
|
|
x: 100,
|
|
y: 100,
|
|
hp: 100,
|
|
state: 'alive',
|
|
corpseTicks: 0,
|
|
hitFlash: 0,
|
|
facing: 0,
|
|
cooldown: 0,
|
|
} as any)
|
|
|
|
// damageMonster kills Andariel and populates pendingKills and events
|
|
damageMonster(engine.world, 0, 200)
|
|
|
|
expect(() => engine.tick(dummyInput as any)).not.toThrow()
|
|
expect(engine.metrics.dropsRolled).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('1.6 all 5 authentic Act Bosses resolve to valid TCs across 3 difficulties with monsterType = 4', () => {
|
|
const bossIds = ['andariel', 'duriel', 'mephisto', 'diablo', 'baalcrab']
|
|
const difficulties = ['normal', 'nightmare', 'hell'] as const
|
|
|
|
for (const id of bossIds) {
|
|
const kind = dropTables.monsterKinds.get(id)
|
|
expect(kind, `MonsterKind for ${id} must exist`).toBeDefined()
|
|
expect(kind?.boss).toBe(true)
|
|
|
|
for (const diff of difficulties) {
|
|
const tc = getMonsterTreasureClass(kind!, diff, 4)
|
|
expect(tc).toBeTruthy()
|
|
expect(Boolean(dropTables.tcTable.get(tc) || dropTables.autoTcTable?.get(tc))).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('1.7 Act Boss drop execution with monsterType = 4 yields authentic high quality drops (no normal/low)', () => {
|
|
const drops = executeDropPipeline(dropTables, {
|
|
tcName: 'Andarielq',
|
|
nLevel: 12,
|
|
monsterType: 4,
|
|
difficulty: 'normal',
|
|
monsterRng: new D2Rng(999),
|
|
})
|
|
|
|
expect(drops.length).toBeGreaterThan(0)
|
|
// Quest first-kill / boss TC produces rare+ quality items (failed sets drop as magic with 2x durability)
|
|
for (const d of drops) {
|
|
expect(d.quality).toBeGreaterThanOrEqual(ItemQuality.MAGIC)
|
|
expect(d.quality).not.toBe(ItemQuality.NORMAL)
|
|
expect(d.quality).not.toBe(ItemQuality.LOW)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Challenge 2: demoteToNormal Level Reversion Across Packs
|
|
// ============================================================================
|
|
describe('Challenge 2: demoteToNormal level reversion across packs', () => {
|
|
function localDemoteToNormal(pack: { members: MonsterStats[] }) {
|
|
const members: MonsterStats[] = pack.members.map(m => {
|
|
let hp = m.hp
|
|
let level = m.level
|
|
if (m.rank === 'champion') {
|
|
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion))
|
|
if (level !== undefined) {
|
|
level = Math.max(1, level - 2)
|
|
}
|
|
} else if (m.rank === 'unique') {
|
|
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique))
|
|
if (level !== undefined) {
|
|
level = Math.max(1, level - 3)
|
|
}
|
|
} else if (m.rank === 'minion') {
|
|
hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100)))
|
|
if (level !== undefined) {
|
|
level = Math.max(1, level - 3)
|
|
}
|
|
}
|
|
return {
|
|
...m,
|
|
hp,
|
|
...(level !== undefined ? { level } : {}),
|
|
rank: 'normal' as MonsterRank,
|
|
modifiers: [],
|
|
}
|
|
})
|
|
return { ...pack, members }
|
|
}
|
|
|
|
it('2.1 Round-trip promotion and demotion restores exact base level', () => {
|
|
const baseMonster: MonsterStats = {
|
|
id: 'quillrat',
|
|
name: 'Quill Rat',
|
|
hp: 100,
|
|
damage: 10,
|
|
speed: 8,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 15,
|
|
xp: 50,
|
|
level: 15,
|
|
rank: 'normal',
|
|
}
|
|
|
|
// Champion
|
|
const champ = applyEliteModifiers(baseMonster, 'champion')
|
|
expect(champ.level).toBe(17)
|
|
const demotedChamp = localDemoteToNormal({ members: [champ] })
|
|
expect(demotedChamp.members[0].level).toBe(15)
|
|
expect(demotedChamp.members[0].rank).toBe('normal')
|
|
|
|
// Unique
|
|
const unique = applyEliteModifiers(baseMonster, 'unique')
|
|
expect(unique.level).toBe(18)
|
|
const demotedUnique = localDemoteToNormal({ members: [unique] })
|
|
expect(demotedUnique.members[0].level).toBe(15)
|
|
expect(demotedUnique.members[0].rank).toBe('normal')
|
|
|
|
// Minion
|
|
const minion = applyEliteModifiers(baseMonster, 'minion')
|
|
expect(minion.level).toBe(18)
|
|
const demotedMinion = localDemoteToNormal({ members: [minion] })
|
|
expect(demotedMinion.members[0].level).toBe(15)
|
|
expect(demotedMinion.members[0].rank).toBe('normal')
|
|
})
|
|
|
|
it('2.2 demoteToNormal clamps level to >= 1 at extreme low boundary', () => {
|
|
const lowLevelChamp: MonsterStats = {
|
|
id: 'fallen',
|
|
name: 'Fallen',
|
|
hp: 10,
|
|
damage: 1,
|
|
speed: 8,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 15,
|
|
xp: 5,
|
|
level: 1,
|
|
rank: 'champion',
|
|
}
|
|
const demoted = localDemoteToNormal({ members: [lowLevelChamp] })
|
|
expect(demoted.members[0].level).toBe(1) // Math.max(1, 1 - 2) = 1
|
|
})
|
|
|
|
it('2.3 demoteToNormal handles mixed pack containing leader, minions, and normal', () => {
|
|
const mixedPack = {
|
|
members: [
|
|
{
|
|
id: 'zombie',
|
|
name: 'Zombie Leader',
|
|
hp: 400,
|
|
damage: 20,
|
|
speed: 6,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 20,
|
|
xp: 200,
|
|
level: 13,
|
|
rank: 'unique' as MonsterRank,
|
|
},
|
|
{
|
|
id: 'zombie',
|
|
name: 'Zombie Minion 1',
|
|
hp: 200,
|
|
damage: 15,
|
|
speed: 6,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 20,
|
|
xp: 100,
|
|
level: 13,
|
|
rank: 'minion' as MonsterRank,
|
|
},
|
|
{
|
|
id: 'zombie',
|
|
name: 'Zombie Normal',
|
|
hp: 100,
|
|
damage: 10,
|
|
speed: 6,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 20,
|
|
xp: 50,
|
|
level: 10,
|
|
rank: 'normal' as MonsterRank,
|
|
},
|
|
],
|
|
}
|
|
|
|
const demoted = localDemoteToNormal(mixedPack)
|
|
expect(demoted.members[0].level).toBe(10) // 13 - 3
|
|
expect(demoted.members[0].rank).toBe('normal')
|
|
expect(demoted.members[1].level).toBe(10) // 13 - 3
|
|
expect(demoted.members[1].rank).toBe('normal')
|
|
expect(demoted.members[2].level).toBe(10) // untouched
|
|
expect(demoted.members[2].rank).toBe('normal')
|
|
})
|
|
|
|
it('2.4 demoteToNormal strips all elite modifiers', () => {
|
|
const moddedMonster: MonsterStats = {
|
|
id: 'skeleton',
|
|
name: 'Skeleton',
|
|
hp: 300,
|
|
damage: 25,
|
|
speed: 8,
|
|
reach: 1,
|
|
aggroRadius: 10,
|
|
cooldownTicks: 15,
|
|
xp: 150,
|
|
level: 30,
|
|
rank: 'unique',
|
|
modifiers: ['strong', 'fast', 'fireenchant'],
|
|
}
|
|
|
|
const demoted = localDemoteToNormal({ members: [moddedMonster] })
|
|
expect(demoted.members[0].modifiers).toEqual([])
|
|
expect(demoted.members[0].rank).toBe('normal')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Challenge 3: Magic Find Boundary Conditions (Extreme Negative & High MF)
|
|
// ============================================================================
|
|
describe('Challenge 3: Magic Find boundary conditions', () => {
|
|
const mockRow: ItemRatioRow = {
|
|
version: 1,
|
|
uber: 0,
|
|
classSpecific: 0,
|
|
unique: 400,
|
|
uniqueDiv: 1,
|
|
uniqueMin: 6400,
|
|
set: 160,
|
|
setDiv: 2,
|
|
setMin: 5600,
|
|
rare: 100,
|
|
rareDiv: 2,
|
|
rareMin: 3200,
|
|
magic: 34,
|
|
magicDiv: 3,
|
|
magicMin: 192,
|
|
hiQual: 12,
|
|
hiQualDiv: 8,
|
|
normal: 2,
|
|
normalDiv: 1,
|
|
}
|
|
const table = createItemRatioTable([mockRow])
|
|
|
|
it('3.1 effectiveMF values at negative boundaries (-20, -50, -99, -100, -200)', () => {
|
|
// For mf <= 10, effectiveMF returns mf + 100
|
|
expect(effectiveMF(-20, 250)).toBe(80)
|
|
expect(effectiveMF(-50, 250)).toBe(50)
|
|
expect(effectiveMF(-99, 250)).toBe(1)
|
|
expect(effectiveMF(-100, 250)).toBe(0)
|
|
expect(effectiveMF(-200, 250)).toBe(-100)
|
|
})
|
|
|
|
it('3.2 negative MF between (-100, 0) increases chance denominator (suppresses drop rate)', () => {
|
|
const baseUniqueChance = calculateUniqueChance(mockRow, 50, 20, 0)
|
|
const neg20Chance = calculateUniqueChance(mockRow, 50, 20, -20)
|
|
const neg50Chance = calculateUniqueChance(mockRow, 50, 20, -50)
|
|
const neg99Chance = calculateUniqueChance(mockRow, 50, 20, -99)
|
|
|
|
// Lower chance denominator = higher chance. Therefore negative MF must INCREASE denominator.
|
|
expect(neg20Chance).toBeGreaterThan(baseUniqueChance)
|
|
expect(neg50Chance).toBeGreaterThan(neg20Chance)
|
|
expect(neg99Chance).toBeGreaterThan(neg50Chance)
|
|
})
|
|
|
|
it('3.3 mf <= -100 completely suppresses Unique/Set/Rare/Magic in rollItemQuality', () => {
|
|
const testMfs = [-100, -101, -200, -9999]
|
|
|
|
for (const mf of testMfs) {
|
|
const qualityCounts: Record<string, number> = {
|
|
unique: 0,
|
|
set: 0,
|
|
rare: 0,
|
|
magic: 0,
|
|
superior: 0,
|
|
normal: 0,
|
|
low: 0,
|
|
}
|
|
|
|
const rng = new D2Rng(1000)
|
|
const iterations = 5000
|
|
|
|
for (let i = 0; i < iterations; i++) {
|
|
const q = rollItemQuality({
|
|
table,
|
|
row: mockRow,
|
|
ilvl: 99,
|
|
qlvl: 1,
|
|
mf,
|
|
rng,
|
|
tcUnique: 1024, // maximum tc bonus
|
|
tcSet: 1024,
|
|
tcRare: 1024,
|
|
tcMagic: 1024,
|
|
})
|
|
qualityCounts[q] = (qualityCounts[q] ?? 0) + 1
|
|
}
|
|
|
|
expect(qualityCounts.unique).toBe(0)
|
|
expect(qualityCounts.set).toBe(0)
|
|
expect(qualityCounts.rare).toBe(0)
|
|
expect(qualityCounts.magic).toBe(0)
|
|
expect(qualityCounts.superior + qualityCounts.normal + qualityCounts.low).toBe(iterations)
|
|
}
|
|
})
|
|
|
|
it('3.4 executeDropPipeline with playerMf <= -100 produces 0% magic/rare/set/unique for standard equipment', () => {
|
|
let magicOrBetter = 0
|
|
let baseQualityItems = 0
|
|
|
|
for (let seed = 1; seed <= 500; seed++) {
|
|
const drops = executeDropPipeline(dropTables, {
|
|
tcName: 'Act 1 Equip A',
|
|
nLevel: 50,
|
|
monsterType: 1,
|
|
difficulty: 'normal',
|
|
playerMf: -100,
|
|
monsterRng: new D2Rng(seed),
|
|
})
|
|
|
|
for (const drop of drops) {
|
|
if ((drop.quality ?? 0) >= ItemQuality.MAGIC) {
|
|
magicOrBetter++
|
|
} else {
|
|
baseQualityItems++
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(magicOrBetter).toBe(0)
|
|
expect(baseQualityItems).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('3.5 high MF values (100, 238, 500, 1000, 10000) follow exact diminishing returns curves', () => {
|
|
// Unique factor 250: 100 + trunc((mf * 250) / (mf + 250))
|
|
expect(effectiveMF(100, 250)).toBe(171) // +71%
|
|
expect(effectiveMF(238, 250)).toBe(221) // +121%
|
|
expect(effectiveMF(500, 250)).toBe(266) // +166%
|
|
expect(effectiveMF(1000, 250)).toBe(300) // +200%
|
|
expect(effectiveMF(10_000, 250)).toBe(343) // +243%
|
|
expect(effectiveMF(1_000_000, 250)).toBe(349) // asymptotic cap 350 (+250%)
|
|
|
|
// Set factor 500: 100 + trunc((mf * 500) / (mf + 500))
|
|
expect(effectiveMF(100, 500)).toBe(183)
|
|
expect(effectiveMF(238, 500)).toBe(261)
|
|
expect(effectiveMF(500, 500)).toBe(350)
|
|
expect(effectiveMF(1000, 500)).toBe(433)
|
|
expect(effectiveMF(1_000_000, 500)).toBe(599) // asymptotic cap 600 (+500%)
|
|
|
|
// Rare factor 600: 100 + trunc((mf * 600) / (mf + 600))
|
|
expect(effectiveMF(100, 600)).toBe(185)
|
|
expect(effectiveMF(238, 600)).toBe(270)
|
|
expect(effectiveMF(500, 600)).toBe(372)
|
|
expect(effectiveMF(1000, 600)).toBe(475)
|
|
expect(effectiveMF(1_000_000, 600)).toBe(699) // asymptotic cap 700 (+600%)
|
|
})
|
|
|
|
it('3.6 high MF increases unique yield with diminishing returns', () => {
|
|
function countUniques(mf: number, iterations = 10000): number {
|
|
const rng = new D2Rng(555)
|
|
let count = 0
|
|
for (let i = 0; i < iterations; i++) {
|
|
const q = rollItemQuality({
|
|
table,
|
|
row: mockRow,
|
|
ilvl: 85,
|
|
qlvl: 10,
|
|
mf,
|
|
rng,
|
|
})
|
|
if (q === 'unique') count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
const count0 = countUniques(0)
|
|
const count100 = countUniques(100)
|
|
const count500 = countUniques(500)
|
|
const count1000 = countUniques(1000)
|
|
|
|
expect(count100).toBeGreaterThan(count0)
|
|
expect(count500).toBeGreaterThan(count100)
|
|
expect(count1000).toBeGreaterThan(count500)
|
|
|
|
// Diminishing returns: the gain from 0 to 500 is much higher than from 500 to 1000
|
|
const gain1 = count500 - count0
|
|
const gain2 = count1000 - count500
|
|
expect(gain1).toBeGreaterThan(gain2)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Challenge 4: setPlayers Boundary Clamping & Chat Command Parsing
|
|
// ============================================================================
|
|
describe('Challenge 4: setPlayers boundary clamping and chat command parsing', () => {
|
|
it('4.1 setPlayers clamps players between 1 and 8 and partyPlayers between 1 and gamePlayers', () => {
|
|
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
|
|
|
// Boundary: 0 clamped to 1
|
|
engine.setPlayers(0)
|
|
expect(engine.gamePlayers).toBe(1)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Boundary: 1
|
|
engine.setPlayers(1)
|
|
expect(engine.gamePlayers).toBe(1)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Boundary: 8
|
|
engine.setPlayers(8)
|
|
expect(engine.gamePlayers).toBe(8)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Boundary: 9 clamped to 8
|
|
engine.setPlayers(9)
|
|
expect(engine.gamePlayers).toBe(8)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Boundary: negative (-10) clamped to 1
|
|
engine.setPlayers(-10)
|
|
expect(engine.gamePlayers).toBe(1)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Floats truncated: 4.9 truncated to 4
|
|
engine.setPlayers(4.9)
|
|
expect(engine.gamePlayers).toBe(4)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
// Infinity clamped to 8, -Infinity clamped to 1
|
|
engine.setPlayers(Infinity)
|
|
expect(engine.gamePlayers).toBe(8)
|
|
engine.setPlayers(-Infinity)
|
|
expect(engine.gamePlayers).toBe(1)
|
|
|
|
// partyPlayers clamping
|
|
engine.setPlayers(4, 8) // party cannot exceed gamePlayers
|
|
expect(engine.gamePlayers).toBe(4)
|
|
expect(engine.partyPlayers).toBe(4)
|
|
|
|
engine.setPlayers(6, 0) // party cannot be < 1
|
|
expect(engine.gamePlayers).toBe(6)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
engine.setPlayers(6, -5) // party cannot be < 1
|
|
expect(engine.gamePlayers).toBe(6)
|
|
expect(engine.partyPlayers).toBe(1)
|
|
|
|
engine.setPlayers(6, 3.8) // truncated
|
|
expect(engine.gamePlayers).toBe(6)
|
|
expect(engine.partyPlayers).toBe(3)
|
|
})
|
|
|
|
it('4.2 adversarial vulnerability discovery: NaN input to setPlayers sets gamePlayers to NaN', () => {
|
|
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
|
engine.setPlayers(NaN)
|
|
// Math.max(1, Math.min(8, Math.trunc(NaN))) evaluates to NaN!
|
|
expect(Number.isNaN(engine.gamePlayers)).toBe(true)
|
|
expect(Number.isNaN(engine.partyPlayers)).toBe(true)
|
|
})
|
|
|
|
it('4.3 computeEffectivePlayers correctly calculates effective player scaling count', () => {
|
|
// Solo games
|
|
expect(computeEffectivePlayers(1, 1)).toBe(1)
|
|
expect(computeEffectivePlayers(2, 1)).toBe(1) // 1 + floor(1/2) = 1
|
|
expect(computeEffectivePlayers(3, 1)).toBe(2) // 1 + floor(2/2) = 2
|
|
expect(computeEffectivePlayers(4, 1)).toBe(2) // 1 + floor(3/2) = 2
|
|
expect(computeEffectivePlayers(5, 1)).toBe(3) // 1 + floor(4/2) = 3
|
|
expect(computeEffectivePlayers(6, 1)).toBe(3) // 1 + floor(5/2) = 3
|
|
expect(computeEffectivePlayers(7, 1)).toBe(4) // 1 + floor(6/2) = 4
|
|
expect(computeEffectivePlayers(8, 1)).toBe(4) // 1 + floor(7/2) = 4
|
|
|
|
// Fully partied games
|
|
expect(computeEffectivePlayers(8, 8)).toBe(8) // 8 + floor(0/2) = 8
|
|
expect(computeEffectivePlayers(4, 4)).toBe(4)
|
|
})
|
|
|
|
it('4.4 computeScaledNoDrop reduces NoDrop weight with increased players', () => {
|
|
const baseNoDrop = 100
|
|
const totalProb = 100
|
|
|
|
const p1 = computeScaledNoDrop(baseNoDrop, totalProb, 1)
|
|
const p2 = computeScaledNoDrop(baseNoDrop, totalProb, 2)
|
|
const p3 = computeScaledNoDrop(baseNoDrop, totalProb, 3)
|
|
const p4 = computeScaledNoDrop(baseNoDrop, totalProb, 4)
|
|
const p8 = computeScaledNoDrop(baseNoDrop, totalProb, 8)
|
|
|
|
expect(p1).toBe(100)
|
|
expect(p2).toBeLessThan(p1)
|
|
expect(p3).toBeLessThan(p2)
|
|
expect(p4).toBeLessThan(p3)
|
|
expect(p8).toBeLessThan(p4)
|
|
})
|
|
|
|
it('4.5 handleActSceneChatCommand accepts valid /players commands', () => {
|
|
const mockEngine = {
|
|
gamePlayers: 1,
|
|
setPlayers(p: number) {
|
|
this.gamePlayers = p
|
|
},
|
|
}
|
|
const statusEl = { textContent: '' as string | null }
|
|
|
|
expect(handleActSceneChatCommand('/players 1', mockEngine, statusEl)).toBe(true)
|
|
expect(mockEngine.gamePlayers).toBe(1)
|
|
expect(statusEl.textContent).toBe('Players set to 1')
|
|
|
|
expect(handleActSceneChatCommand('/players 8', mockEngine, statusEl)).toBe(true)
|
|
expect(mockEngine.gamePlayers).toBe(8)
|
|
expect(statusEl.textContent).toBe('Players set to 8')
|
|
|
|
expect(handleActSceneChatCommand('players 4', mockEngine, statusEl)).toBe(true)
|
|
expect(mockEngine.gamePlayers).toBe(4)
|
|
expect(statusEl.textContent).toBe('Players set to 4')
|
|
|
|
expect(handleActSceneChatCommand(' /PLAYERS 6 ', mockEngine, statusEl)).toBe(true)
|
|
expect(mockEngine.gamePlayers).toBe(6)
|
|
expect(statusEl.textContent).toBe('Players set to 6')
|
|
})
|
|
|
|
it('4.6 handleActSceneChatCommand queries current setting when called without number', () => {
|
|
const mockEngine = {
|
|
gamePlayers: 5,
|
|
setPlayers: () => {},
|
|
}
|
|
const statusEl = { textContent: '' as string | null }
|
|
|
|
expect(handleActSceneChatCommand('/players', mockEngine, statusEl)).toBe(true)
|
|
expect(statusEl.textContent).toBe('Players currently set to 5')
|
|
|
|
expect(handleActSceneChatCommand('players', mockEngine, statusEl)).toBe(true)
|
|
expect(statusEl.textContent).toBe('Players currently set to 5')
|
|
})
|
|
|
|
it('4.7 handleActSceneChatCommand rejects invalid player numbers and non-commands', () => {
|
|
const mockEngine = {
|
|
gamePlayers: 1,
|
|
setPlayers: () => {},
|
|
}
|
|
const statusEl = { textContent: '' as string | null }
|
|
|
|
// Out of bounds: 0
|
|
expect(handleActSceneChatCommand('/players 0', mockEngine, statusEl)).toBe(false)
|
|
expect(statusEl.textContent).toContain('Invalid players count: 0')
|
|
|
|
// Out of bounds: 9
|
|
expect(handleActSceneChatCommand('/players 9', mockEngine, statusEl)).toBe(false)
|
|
expect(statusEl.textContent).toContain('Invalid players count: 9')
|
|
|
|
// Non-matching inputs
|
|
expect(handleActSceneChatCommand('/players -1', mockEngine, statusEl)).toBe(false)
|
|
expect(handleActSceneChatCommand('/players abc', mockEngine, statusEl)).toBe(false)
|
|
expect(handleActSceneChatCommand('/players 1.5', mockEngine, statusEl)).toBe(false)
|
|
expect(handleActSceneChatCommand('/help', mockEngine, statusEl)).toBe(false)
|
|
expect(handleActSceneChatCommand('', mockEngine, statusEl)).toBe(false)
|
|
})
|
|
})
|
|
})
|