516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
/**
|
|
* Tier 4 — Real-World Gameplay Scenarios (`tests/e2e-parity/tier4-real-world-scenarios.test.ts`).
|
|
*
|
|
* Verifies 5 complete full-lifecycle gameplay scenarios under Diablo II v1.13c parity:
|
|
* 1. Blood Moor Clearing: Town departure, obstacle navigation, monster combat, belt potion usage.
|
|
* 2. Den of Evil Completion: Cave exploration, room visibility, monster clearing count (5 -> 0), quest completion.
|
|
* 3. Countess Tower Run: Multi-floor cellar descent (21 -> 25), superunique fight, rune drop to ground, pickup into inventory.
|
|
* 4. Tristram Rescue: Red portal transition (4 -> 38), Griswold combat & movement kiting, Deckard Cain cage interaction & quest update.
|
|
* 5. Act Boss Kill (Andariel): Catacombs L4 chamber entry, poison spray mechanics & vulnerability, Andariel defeat, Act 1 quest complete, Act 2 Lut Gholein transition.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
COLLIDE_BLANK,
|
|
COLLIDE_DOOR,
|
|
COLLIDE_MISSILE_BARRIER,
|
|
COLLIDE_NONE,
|
|
COLLIDE_OBJECT,
|
|
COLLIDE_WALL,
|
|
D2_TICK_MS,
|
|
calculateHitChance,
|
|
calculatePlayerBlockChance,
|
|
computeEffectiveFcr,
|
|
computeOneShotDurationMs,
|
|
computeStepSpeed256,
|
|
createCollisionGrid,
|
|
createParitySessionFixture,
|
|
getMissileTxtData,
|
|
getOverlayTxtData,
|
|
isTownLevel,
|
|
makeParityItem,
|
|
onlineItemDataToUiInventoryItem,
|
|
} from './helpers.ts'
|
|
|
|
describe('Tier 4 — Real-World Gameplay Scenarios (1.13c Ground Truth)', () => {
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scenario 1: Blood Moor Clearing
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('Scenario 1: Blood Moor Clearing', () => {
|
|
it('executes full town departure, terrain navigation, combat against Fallen, and potion recovery', () => {
|
|
const fixture = createParitySessionFixture({ selfId: 101, charClass: 3 }) // Paladin
|
|
|
|
// 1. Initial State: Inside Rogue Encampment (Act 1 Town, Area 1)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 42109,
|
|
areaId: 1,
|
|
})
|
|
expect(fixture.world.areaId).toBe(1)
|
|
expect(isTownLevel(fixture.world.areaId)).toBe(true)
|
|
|
|
// Player in town has town neutral posture mode 5
|
|
expect(fixture.selfUnit?.mode).toBe(5)
|
|
|
|
// 2. Departure: Player moves across boundary into Blood Moor (Area 2)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 42109,
|
|
areaId: 2,
|
|
})
|
|
expect(fixture.world.areaId).toBe(2)
|
|
expect(isTownLevel(fixture.world.areaId)).toBe(false)
|
|
// Wilderness mode is combat neutral mode 1
|
|
expect(fixture.selfUnit?.mode).toBe(1)
|
|
|
|
// 3. Terrain Navigation: Setup 40x40 collision grid with rocks/trees
|
|
const moorGrid = createCollisionGrid(40, 40, COLLIDE_NONE)
|
|
// Place tree clump at x: 15..17, y: 15..17
|
|
for (let x = 15; x <= 17; x++) {
|
|
for (let y = 15; y <= 17; y++) {
|
|
moorGrid.setCollision(x, y, COLLIDE_WALL | COLLIDE_OBJECT)
|
|
}
|
|
}
|
|
expect(moorGrid.isWalkable(10, 10)).toBe(true)
|
|
expect(moorGrid.isWalkable(16, 16)).toBe(false) // Obstructed by tree
|
|
|
|
// 4. Monster Encounter: Spawn Fallen (classId 1) at (25, 20)
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 201,
|
|
classId: 1, // Fallen
|
|
x: 25,
|
|
y: 20,
|
|
mode: 1,
|
|
},
|
|
})
|
|
const fallen = fixture.world.getUnit('monster', 201)
|
|
expect(fallen).toBeDefined()
|
|
expect(fallen?.classId).toBe(1)
|
|
|
|
// Hit chance calculation: Level 3 Paladin (AR: 180) vs Level 1 Fallen (Def: 12)
|
|
const hitChance = calculateHitChance(180, 12, 3, 1)
|
|
expect(hitChance).toBeGreaterThanOrEqual(75)
|
|
expect(hitChance).toBeLessThanOrEqual(95)
|
|
|
|
// Paladin strikes Fallen, Fallen transitions to death mode 12
|
|
fixture.applyServerEvents({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 201,
|
|
mode: 12,
|
|
})
|
|
expect(fixture.world.getUnit('monster', 201)?.mode).toBe(12)
|
|
|
|
// 5. Damage & Belt Potion Recovery
|
|
// Zombie ambush hits player, reducing life to 35
|
|
fixture.applyServerEvents({
|
|
type: 'LifeMana',
|
|
life: 35,
|
|
mana: 100,
|
|
stamina: 100,
|
|
})
|
|
expect(fixture.world.self.life).toBe(35)
|
|
|
|
// Equip Minor Healing Potion (hp1) into belt slot 0
|
|
fixture.applyServerEvents({
|
|
type: 'ItemEvent',
|
|
action: 0,
|
|
container: 'belt',
|
|
item: makeParityItem({
|
|
id: 701,
|
|
code: 'hp1',
|
|
container: 'belt',
|
|
gridX: 0,
|
|
gridY: 0,
|
|
}),
|
|
})
|
|
expect(fixture.world.containers.belt.has(701)).toBe(true)
|
|
|
|
// Player drinks belt potion from slot 0
|
|
fixture.clearCommands()
|
|
const used = fixture.hudModel.useBeltSlot(0)
|
|
expect(used).toBe(true)
|
|
expect(fixture.sentCommands).toContainEqual({
|
|
type: 'BeltUse',
|
|
itemId: 701,
|
|
shift: false,
|
|
})
|
|
|
|
// Potion heals player over time to 85
|
|
fixture.applyServerEvents({
|
|
type: 'LifeMana',
|
|
life: 85,
|
|
mana: 100,
|
|
stamina: 100,
|
|
})
|
|
expect(fixture.world.self.life).toBe(85)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scenario 2: Den of Evil Completion
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('Scenario 2: Den of Evil Completion', () => {
|
|
it('tracks cave entrance, room reveal, monster counter decrease, and quest completion flag', () => {
|
|
const fixture = createParitySessionFixture({ selfId: 102, charClass: 1 }) // Sorceress
|
|
|
|
// 1. Transition into Cave: Area 8 (Den of Evil)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 88310,
|
|
areaId: 8,
|
|
})
|
|
expect(fixture.world.areaId).toBe(8)
|
|
expect(isTownLevel(8)).toBe(false)
|
|
|
|
// 2. Room Discovery: Revealing two adjacent cavern rooms
|
|
fixture.applyServerEvents(
|
|
{ type: 'RoomReveal', areaId: 8, tileX: 20, tileY: 15 },
|
|
{ type: 'RoomReveal', areaId: 8, tileX: 21, tileY: 15 },
|
|
)
|
|
expect(fixture.world.getRevealedAreaIds()).toContain(8)
|
|
|
|
// 3. Line of sight within cavern corridors
|
|
const caveGrid = createCollisionGrid(30, 30, COLLIDE_NONE)
|
|
// Carve solid cave wall along x = 12
|
|
for (let y = 5; y <= 25; y++) {
|
|
caveGrid.setCollision(12, y, COLLIDE_WALL | COLLIDE_MISSILE_BARRIER)
|
|
}
|
|
// Line of sight blocked through cave wall
|
|
expect(caveGrid.hasLineOfSight(8, 15, 18, 15)).toBe(false)
|
|
// Line of sight open along same corridor
|
|
expect(caveGrid.hasLineOfSight(8, 10, 8, 20)).toBe(true)
|
|
|
|
// 4. Den of Evil Monster Counter Tracking (starts with 5 remaining monsters)
|
|
let denMonstersRemaining = 5
|
|
const monsterIds = [301, 302, 303, 304, 305]
|
|
|
|
for (const mId of monsterIds) {
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: mId,
|
|
classId: mId === 305 ? 683 : 1, // 305 is Corpsefire (Superunique Gargantuan Beast), others Fallens
|
|
x: 20 + (mId % 5),
|
|
y: 15 + (mId % 3),
|
|
mode: 1,
|
|
},
|
|
})
|
|
}
|
|
expect(fixture.world.units.size).toBe(6) // 1 player + 5 monsters
|
|
|
|
// Player casts Fire Bolt and clears all monsters one by one
|
|
for (const mId of monsterIds) {
|
|
fixture.applyServerEvents({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: mId,
|
|
mode: 12,
|
|
})
|
|
denMonstersRemaining--
|
|
}
|
|
expect(denMonstersRemaining).toBe(0)
|
|
|
|
// 5. Quest Completion: Server sends QuestState marking Den of Evil (Quest 0) complete
|
|
// Bit 0 = quest completed (0x1001)
|
|
fixture.applyServerEvents({
|
|
type: 'QuestState',
|
|
quests: new Uint16Array([0x1001]),
|
|
})
|
|
expect((fixture.world.quests[0]! & 0x0001) !== 0).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scenario 3: Countess Tower Run
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('Scenario 3: Countess Tower Run', () => {
|
|
it('navigates multi-floor cellar sequence, executes Countess battle, and picks up dropped Ral rune', () => {
|
|
const fixture = createParitySessionFixture({ selfId: 103, charClass: 1 }) // Sorceress
|
|
|
|
// 1. Descend through Forgotten Tower Cellars: 21 -> 22 -> 23 -> 24 -> 25
|
|
const cellarLevels = [21, 22, 23, 24, 25]
|
|
for (const lvl of cellarLevels) {
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 91919,
|
|
areaId: lvl,
|
|
})
|
|
expect(fixture.world.areaId).toBe(lvl)
|
|
}
|
|
expect(fixture.world.areaId).toBe(25) // Tower Cellar Level 5
|
|
|
|
// 2. Boss Fight — The Countess (SuperUnique classId 45)
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 401,
|
|
classId: 45, // Countess
|
|
x: 50,
|
|
y: 60,
|
|
mode: 1,
|
|
},
|
|
})
|
|
const countess = fixture.world.getUnit('monster', 401)
|
|
expect(countess).toBeDefined()
|
|
expect(countess?.classId).toBe(45)
|
|
|
|
// Sorceress equipped with +65% FCR gear casts Static Field
|
|
const fcrDur = computeOneShotDurationMs(14, computeStepSpeed256(256, computeEffectiveFcr(65)))
|
|
expect(fcrDur).toBeLessThan(computeOneShotDurationMs(14, computeStepSpeed256(256, computeEffectiveFcr(0))))
|
|
|
|
// Countess dies
|
|
fixture.applyServerEvents({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 401,
|
|
mode: 12,
|
|
})
|
|
expect(fixture.world.getUnit('monster', 401)?.mode).toBe(12)
|
|
|
|
// 3. Guaranteed Rune Drop: Countess drops Ral Rune (code 'r08') on ground at (52, 61)
|
|
fixture.applyServerEvents({
|
|
type: 'ItemEvent',
|
|
action: 0,
|
|
container: 'ground',
|
|
item: makeParityItem({
|
|
id: 888,
|
|
code: 'r08',
|
|
container: 'ground',
|
|
x: 52,
|
|
y: 61,
|
|
}),
|
|
})
|
|
const groundItemUnit = fixture.world.getUnit('item', 888)
|
|
expect(groundItemUnit).toBeDefined()
|
|
expect(groundItemUnit?.itemCode).toBe('r08')
|
|
|
|
// 4. Pickup Ral Rune from ground to inventory slot (2, 3)
|
|
fixture.applyServerEvents({
|
|
type: 'ItemEvent',
|
|
action: 0,
|
|
container: 'inventory',
|
|
item: makeParityItem({
|
|
id: 888,
|
|
code: 'r08',
|
|
container: 'inventory',
|
|
gridX: 2,
|
|
gridY: 3,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
}),
|
|
})
|
|
expect(fixture.world.containers.inventory.has(888)).toBe(true)
|
|
const runeItem = fixture.world.containers.inventory.get(888)
|
|
expect(runeItem?.gridX).toBe(2)
|
|
expect(runeItem?.gridY).toBe(3)
|
|
|
|
// Bridge to UI inventory model
|
|
const uiRune = onlineItemDataToUiInventoryItem(runeItem!)
|
|
expect(uiRune.id).toBe('888')
|
|
expect(uiRune.code).toBe('r08')
|
|
expect(uiRune.invWidth).toBe(1)
|
|
expect(uiRune.invHeight).toBe(1)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scenario 4: Tristram Rescue
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('Scenario 4: Tristram Rescue', () => {
|
|
it('traverses Red Portal to Tristram, kites Griswold, frees Deckard Cain, and advances quest', () => {
|
|
const fixture = createParitySessionFixture({ selfId: 104, charClass: 4 }) // Barbarian
|
|
|
|
// 1. In Stony Field (Area 4), player activates Cairn Stones Red Portal to Tristram (Area 38)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 55112,
|
|
areaId: 38,
|
|
})
|
|
expect(fixture.world.areaId).toBe(38)
|
|
// Tristram is in ruins and counts as active wilderness, not safe town
|
|
expect(isTownLevel(38)).toBe(false)
|
|
expect(fixture.selfUnit?.mode).toBe(1)
|
|
|
|
// 2. Encounter Griswold (SuperUnique classId 156)
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 502,
|
|
classId: 156, // Griswold
|
|
x: 45,
|
|
y: 45,
|
|
mode: 1,
|
|
},
|
|
})
|
|
const griswold = fixture.world.getUnit('monster', 502)
|
|
expect(griswold).toBeDefined()
|
|
expect(griswold?.classId).toBe(156)
|
|
|
|
// Player kites Griswold by running away across 10 simulation frames
|
|
fixture.applyServerEvents({
|
|
type: 'UnitMove',
|
|
unitType: 'player',
|
|
id: 104,
|
|
to: { x: 75, y: 75 },
|
|
mode: 'run',
|
|
})
|
|
expect(fixture.selfUnit?.mode).toBe(3) // Run mode
|
|
|
|
for (let t = 0; t < 10; t++) {
|
|
fixture.tick(1)
|
|
}
|
|
expect(fixture.world.frameCount).toBe(10)
|
|
|
|
// 3. Freeing Deckard Cain from Gibbet Cage (Object 503)
|
|
fixture.clearCommands()
|
|
// Player interacts with Cain's cage via QuestMessage
|
|
fixture.hudModel.questMessage(503, 0)
|
|
expect(fixture.sentCommands).toContainEqual({
|
|
type: 'QuestMessage',
|
|
npcId: 503,
|
|
messageId: 0,
|
|
})
|
|
|
|
// Deckard Cain spawns and enters portal back to Rogue Encampment
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 504,
|
|
classId: 146, // Deckard Cain
|
|
x: 40,
|
|
y: 40,
|
|
mode: 1,
|
|
},
|
|
})
|
|
expect(fixture.world.getUnit('monster', 504)).toBeDefined()
|
|
|
|
// Quest 2 (The Search for Cain) state updates to completed
|
|
// Quest 2 bitmask set to 0x0001 (complete)
|
|
fixture.applyServerEvents({
|
|
type: 'QuestState',
|
|
quests: new Uint16Array([0x1001, 0, 0x0001]),
|
|
})
|
|
expect((fixture.world.quests[2]! & 0x0001) !== 0).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Scenario 5: Act Boss Kill: Andariel
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
describe('Scenario 5: Act Boss Kill: Andariel', () => {
|
|
it('infiltrates Catacombs L4, survives Poison Spray, slays Andariel, and transitions to Act 2 Lut Gholein', () => {
|
|
const fixture = createParitySessionFixture({ selfId: 105, charClass: 1 }) // Sorceress
|
|
|
|
// 1. Enter Catacombs Level 4 (Area 39)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 0,
|
|
mapSeed: 66330,
|
|
areaId: 39,
|
|
})
|
|
expect(fixture.world.areaId).toBe(39)
|
|
expect(isTownLevel(39)).toBe(false)
|
|
|
|
// Reveal Andariel's blood pool chamber
|
|
fixture.applyServerEvents({
|
|
type: 'RoomReveal',
|
|
areaId: 39,
|
|
tileX: 30,
|
|
tileY: 30,
|
|
})
|
|
expect(fixture.world.getRevealedAreaIds()).toContain(39)
|
|
|
|
// 2. Andariel Spawns (classId 156 / Andariel boss)
|
|
fixture.applyServerEvents({
|
|
type: 'UnitAssign',
|
|
isSelf: false,
|
|
unit: {
|
|
unitType: 'monster',
|
|
id: 601,
|
|
classId: 156, // Andariel
|
|
x: 100,
|
|
y: 100,
|
|
mode: 1,
|
|
},
|
|
})
|
|
const andariel = fixture.world.getUnit('monster', 601)
|
|
expect(andariel).toBeDefined()
|
|
|
|
// 3. Poison Spray Missile Trajectory
|
|
// Andariel fires poison spray / nova
|
|
const poisonMissile = getMissileTxtData('poisonnova')
|
|
expect(poisonMissile).toBeDefined()
|
|
expect(poisonMissile?.vel).toBeGreaterThan(0)
|
|
expect(poisonMissile?.celFile).toBe('poisonNova')
|
|
|
|
// Poison hits player: life drains to 80
|
|
fixture.applyServerEvents({
|
|
type: 'LifeMana',
|
|
life: 80,
|
|
mana: 100,
|
|
stamina: 100,
|
|
})
|
|
expect(fixture.world.self.life).toBe(80)
|
|
|
|
// 4. Andariel Weakness: In 1.13c Normal difficulty, Andariel has -50% Fire Resistance
|
|
// Sorceress casts Fire Ball: effective damage is 150% of base damage
|
|
const baseFireDamage = 100
|
|
const andarielFireRes = -50
|
|
const effectiveDamage = Math.round(baseFireDamage * (1 - andarielFireRes / 100))
|
|
expect(effectiveDamage).toBe(150)
|
|
|
|
// 5. Killing Blow & Quest Completion: Andariel Dies
|
|
fixture.applyServerEvents({
|
|
type: 'UnitMode',
|
|
unitType: 'monster',
|
|
id: 601,
|
|
mode: 12,
|
|
})
|
|
expect(fixture.world.getUnit('monster', 601)?.mode).toBe(12)
|
|
|
|
// Flame death overlay triggers
|
|
const deathOverlay = getOverlayTxtData('fire_cast_1')
|
|
expect(deathOverlay).toBeDefined()
|
|
expect(deathOverlay?.frames).toBeGreaterThan(0)
|
|
|
|
// Sisters to the Slaughter (Quest 5) completes
|
|
fixture.applyServerEvents({
|
|
type: 'QuestState',
|
|
quests: new Uint16Array([0x1001, 0, 0x0001, 0, 0, 0x0001]),
|
|
})
|
|
expect((fixture.world.quests[5]! & 0x0001) !== 0).toBe(true)
|
|
|
|
// 6. Act 2 Transition: Warriv caravan takes hero East to Lut Gholein (Act 1 in 0-index, Area 40)
|
|
fixture.applyServerEvents({
|
|
type: 'LoadAct',
|
|
act: 1, // Act 2
|
|
mapSeed: 66330,
|
|
areaId: 40, // Lut Gholein
|
|
})
|
|
expect(fixture.world.act).toBe(1)
|
|
expect(fixture.world.areaId).toBe(40)
|
|
expect(isTownLevel(40)).toBe(true) // Lut Gholein is Town!
|
|
// In town, player returns to safe town posture mode 5
|
|
expect(fixture.selfUnit?.mode).toBe(5)
|
|
})
|
|
})
|
|
})
|