diablo2-web/tests/challenger-m2-adversarial.t...

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)
})
})
})