diablo2-web/tests/engine.test.ts

139 lines
5.3 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import { goldItem } from "../src/game/items"
import { GameEngine } from '../src/game/engine'
import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine'
describe('GameEngine', () => {
const dummyTerrain: WorldMapProvider = {
widthPx: 1000,
heightPx: 1000,
overlap: (x, y) => {
// Small blocked area around 500,500
if (x >= 400 && x <= 600 && y >= 400 && y <= 600) return 1
return 0
}
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 100, y: 100 },
stats: [
{ id: 'mon1', name: 'Goblin', hp: 20, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 50, xp: 5 },
{ id: 'mon2', name: 'Orc', hp: 50, damage: 5, cooldownTicks: 20, reach: 30, aggroRadius: 150, speed: 40, xp: 10 }
],
xpTable: [0, 0, 10, 30, 60],
itemBases: [
{ id: 'sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 10, damage: 5, defense: 0, tags: ['weap'], level: 1 }
],
prefixAffixes: [],
suffixAffixes: [],
skills: [
{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 3, baseMaxDamage: 4, damagePerLevel: 1, radius: 20 },
{ id: 'fire', name: 'Fire', manaCost: 5, cooldownTicks: 15, range: 200, projectile: true, speed: 200, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 2, radius: 20 }
],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 10,
playerDamage: 5,
playerManaPerAttack: 0,
respawnTicks: 100
},
lootSeed: 12345,
talkRadius: 50,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4
}
test('Determinism: same seed + same inputs yields identical hash', () => {
const runFrames = (engine: GameEngine) => {
// 50 ticks of walking right, attacking, then picking up
for (let i = 0; i < 50; i++) {
engine.tick({
movement: { x: 1, y: 0 },
attacking: i % 10 === 0,
pickingUp: i === 45,
talking: false,
digits: [2],
saving: false,
loading: false
})
}
return engine.saveSnapshot()
}
const engine1 = new GameEngine(dummyTerrain, dummyOpts)
const hash1 = runFrames(engine1)
const engine2 = new GameEngine(dummyTerrain, dummyOpts)
const hash2 = runFrames(engine2)
expect(hash1).toEqual(hash2)
})
test('Collision and sliding against stub WorldMapProvider', () => {
const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 390, y: 450 } })
// Move strictly right, towards the blocked 400,400 region
for (let i = 0; i < 20; i++) {
engine.tick({ movement: { x: 1, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
}
// Player should be stopped by the wall at x=400 (if their reach/body allows, else close.
// Given movement logic, they shouldn't pass x=400.
expect(engine.world.player.x).toBeLessThan(400)
})
test('Skill cast, cooldown gating, and cast refusal on insufficient mana', () => {
const engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.mana = 3 // Not enough for Fire (cost 5)
// Select skill first tick
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false })
// Attack next tick
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
// Since we don't have mana, castRefusals goes up
expect(engine.metrics.castRefusals).toBe(1)
expect(engine.metrics.casts).toBe(0)
// Give mana
engine.world.player.mana = 10
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
expect(engine.metrics.casts).toBe(1)
expect(engine.metrics.castHits).toBeGreaterThanOrEqual(0)
})
test('Save round-trip: save() -> load() -> state equivalent', () => {
const engine1 = new GameEngine(dummyTerrain, dummyOpts)
for (let i = 0; i < 10; i++) {
engine1.tick({ movement: { x: 1, y: 1 }, attacking: true, pickingUp: true, talking: false, digits: [2], saving: false, loading: false })
}
const stateStr = engine1.saveSnapshot()
const engine2 = new GameEngine(dummyTerrain, dummyOpts)
engine2.loadSnapshot(stateStr)
expect(engine2.saveSnapshot()).toEqual(stateStr)
expect(engine2.world.player.x).toEqual(engine1.world.player.x)
expect(engine2.projectiles.length).toEqual(engine1.projectiles.length)
})
test('Loot drop and pickup; inventory add/remove and capacity limits', () => {
const engine = new GameEngine(dummyTerrain, dummyOpts)
// Add an item to ground
engine.ground.push({ x: engine.world.player.x, y: engine.world.player.y, item: goldItem(10) })
// Tick to pick it up
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: true, talking: false, digits: [], saving: false, loading: false })
expect(engine.bag.contents.length).toBe(1)
expect(engine.ground.length).toBe(0)
expect(engine.metrics.pickups).toBe(1)
})
})