482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
/**
|
|
* Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard (SOR)
|
|
* Missile Physics, 64-Entry Circle Emitter, Falling Altitude & 1.13c Parity Suite
|
|
*
|
|
* Requirements (Issue #400 / Blizzard Parity):
|
|
* 1. Missiles.txt ground truth: parses and verifies blizzardcenter (ID 158), blizzard1..4 (IDs 159..162),
|
|
* and blizzardexplode1..3 (IDs 163..165).
|
|
* 2. Visual assets & atlas metadata: BLIZZARD1..4_META (icestormfallvar01/02 and Blizzard.dcc),
|
|
* BLIZZARDEXPLODE1..3_META (icestormimpactvar01/02.dcc), baked PNG/JSON files exist.
|
|
* 3. Authoritative 64-entry circle table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60),
|
|
* 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), step 13 coprime rotation.
|
|
* 4. Controller missile blizzardcenter (pSrvDoFunc = 10): lives 100 ticks, emits 25 shards total
|
|
* (every 4 ticks), harmless emitter (no direct contact damage).
|
|
* 5. Falling shard vertical descent: altitude drops from 120 px to 0 over 9 ticks (120/9 px/tick).
|
|
* 6. Ground impact & wall detonation: spawns blizzardexplode1..3 upon expiry or obstacle collision.
|
|
* 7. NextDelay = 0 sweet-spot burst: multiple overlapping shards deal full damage without immunity windows.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import {
|
|
parseMissilesTxt,
|
|
getMissileTxtData,
|
|
CANONICAL_113C_MISSILES,
|
|
tickProjectiles,
|
|
ISO_GROUND_ASPECT_RATIO,
|
|
calculateBlizzardDamage,
|
|
calculateBlizzardSynergyMultiplier,
|
|
type Projectile,
|
|
} from '../src/game/skills.ts'
|
|
import {
|
|
BLIZZARD_CIRCLE_X,
|
|
BLIZZARD_CIRCLE_Y,
|
|
getBlizzardDropOffset,
|
|
} from '../src/game/engine/blizzard-table.ts'
|
|
import {
|
|
BLIZZARD1_META,
|
|
BLIZZARD2_META,
|
|
BLIZZARD3_META,
|
|
BLIZZARD4_META,
|
|
BLIZZARDEXPLODE1_META,
|
|
BLIZZARDEXPLODE2_META,
|
|
BLIZZARDEXPLODE3_META,
|
|
MISSILE_METAS,
|
|
} from '../src/render/missiles-meta.ts'
|
|
|
|
describe('[Skill #059] Blizzard — 1.13c Missile Physics & Runtime Parity (Issue #400)', () => {
|
|
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
|
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
|
|
|
it('1. Parses authentic 1.13c Missiles.txt and verifies Blizzard missile suite (IDs 158..165)', () => {
|
|
const map = parseMissilesTxt(missilesTxtContent)
|
|
|
|
// blizzardcenter (ID 158)
|
|
const center = map.get('blizzardcenter')
|
|
expect(center).toBeDefined()
|
|
expect(center!.id).toBe(158)
|
|
expect(center!.vel).toBe(0)
|
|
expect(center!.range).toBe(100)
|
|
expect(center!.animLen).toBe(2)
|
|
expect(center!.red).toBe(81)
|
|
expect(center!.green).toBe(81)
|
|
expect(center!.blue).toBe(255)
|
|
|
|
// blizzard1 (ID 159)
|
|
const b1 = map.get('blizzard1')
|
|
expect(b1).toBeDefined()
|
|
expect(b1!.id).toBe(159)
|
|
expect(b1!.vel).toBe(0)
|
|
expect(b1!.range).toBe(9)
|
|
expect(b1!.celFile.toLowerCase()).toBe('icestormfallvar01')
|
|
expect(b1!.animLen).toBe(6)
|
|
|
|
// blizzard2 (ID 160)
|
|
const b2 = map.get('blizzard2')
|
|
expect(b2).toBeDefined()
|
|
expect(b2!.id).toBe(160)
|
|
expect(b2!.vel).toBe(0)
|
|
expect(b2!.range).toBe(9)
|
|
expect(b2!.celFile.toLowerCase()).toBe('icestormfallvar02')
|
|
expect(b2!.animLen).toBe(6)
|
|
|
|
// blizzard3 (ID 161)
|
|
const b3 = map.get('blizzard3')
|
|
expect(b3).toBeDefined()
|
|
expect(b3!.id).toBe(161)
|
|
expect(b3!.vel).toBe(0)
|
|
expect(b3!.range).toBe(9)
|
|
expect(b3!.celFile.toLowerCase()).toBe('blizzard')
|
|
expect(b3!.animLen).toBe(8)
|
|
|
|
// blizzard4 (ID 162)
|
|
const b4 = map.get('blizzard4')
|
|
expect(b4).toBeDefined()
|
|
expect(b4!.id).toBe(162)
|
|
expect(b4!.vel).toBe(0)
|
|
expect(b4!.range).toBe(9)
|
|
expect(b4!.celFile.toLowerCase()).toBe('blizzard')
|
|
expect(b4!.animLen).toBe(8)
|
|
|
|
// blizzardexplode1 (ID 163)
|
|
const exp1 = map.get('blizzardexplode1')
|
|
expect(exp1).toBeDefined()
|
|
expect(exp1!.id).toBe(163)
|
|
expect(exp1!.vel).toBe(0)
|
|
expect(exp1!.range).toBe(6)
|
|
expect(exp1!.celFile.toLowerCase()).toBe('icestormimpactvar01')
|
|
expect(exp1!.animLen).toBe(6)
|
|
|
|
// blizzardexplode2 (ID 164)
|
|
const exp2 = map.get('blizzardexplode2')
|
|
expect(exp2).toBeDefined()
|
|
expect(exp2!.id).toBe(164)
|
|
expect(exp2!.vel).toBe(0)
|
|
expect(exp2!.range).toBe(6)
|
|
expect(exp2!.celFile.toLowerCase()).toBe('icestormimpactvar02')
|
|
expect(exp2!.animLen).toBe(6)
|
|
|
|
// blizzardexplode3 (ID 165)
|
|
const exp3 = map.get('blizzardexplode3')
|
|
expect(exp3).toBeDefined()
|
|
expect(exp3!.id).toBe(165)
|
|
expect(exp3!.vel).toBe(0)
|
|
expect(exp3!.range).toBe(6)
|
|
expect(exp3!.celFile.toLowerCase()).toBe('icestormimpactvar02')
|
|
expect(exp3!.animLen).toBe(6)
|
|
})
|
|
|
|
it('2. CANONICAL_113C_MISSILES export contains Blizzard suite with exact 1.13c values', () => {
|
|
const center = getMissileTxtData('blizzardcenter')
|
|
expect(center.id).toBe(158)
|
|
expect(center.vel).toBe(0)
|
|
expect(center.range).toBe(100)
|
|
|
|
const b1 = getMissileTxtData('blizzard1')
|
|
expect(b1.id).toBe(159)
|
|
expect(b1.vel).toBe(0)
|
|
expect(b1.range).toBe(9)
|
|
expect(b1.animLen).toBe(6)
|
|
expect(b1.explosionMissile).toBe('blizzardexplode1')
|
|
|
|
const b2 = getMissileTxtData('blizzard2')
|
|
expect(b2.id).toBe(160)
|
|
expect(b2.range).toBe(9)
|
|
expect(b2.explosionMissile).toBe('blizzardexplode2')
|
|
|
|
const b3 = getMissileTxtData('blizzard3')
|
|
expect(b3.id).toBe(161)
|
|
expect(b3.range).toBe(9)
|
|
expect(b3.animLen).toBe(8)
|
|
expect(b3.explosionMissile).toBe('blizzardexplode3')
|
|
|
|
const b4 = getMissileTxtData('blizzard4')
|
|
expect(b4.id).toBe(162)
|
|
expect(b4.range).toBe(9)
|
|
expect(b4.animLen).toBe(8)
|
|
expect(b4.explosionMissile).toBe('blizzardexplode1')
|
|
|
|
const exp1 = getMissileTxtData('blizzardexplode1')
|
|
expect(exp1.id).toBe(163)
|
|
expect(exp1.range).toBe(6)
|
|
|
|
const exp2 = getMissileTxtData('blizzardexplode2')
|
|
expect(exp2.id).toBe(164)
|
|
expect(exp2.range).toBe(6)
|
|
|
|
const exp3 = getMissileTxtData('blizzardexplode3')
|
|
expect(exp3.id).toBe(165)
|
|
expect(exp3.range).toBe(6)
|
|
})
|
|
|
|
it('3. Authentic baked DCC atlas metadata and files exist on disk with non-zero byte size', () => {
|
|
expect(BLIZZARD1_META.name).toBe('blizzard1')
|
|
expect(BLIZZARD1_META.celFile).toBe('icestormfallvar01')
|
|
expect(BLIZZARD1_META.framesPerDirection).toBe(6)
|
|
|
|
expect(BLIZZARD2_META.name).toBe('blizzard2')
|
|
expect(BLIZZARD2_META.celFile).toBe('icestormfallvar02')
|
|
expect(BLIZZARD2_META.framesPerDirection).toBe(6)
|
|
|
|
expect(BLIZZARD3_META.name).toBe('blizzard3')
|
|
expect(BLIZZARD3_META.celFile).toBe('Blizzard')
|
|
expect(BLIZZARD3_META.framesPerDirection).toBe(8)
|
|
|
|
expect(BLIZZARD4_META.name).toBe('blizzard4')
|
|
expect(BLIZZARD4_META.celFile).toBe('Blizzard')
|
|
expect(BLIZZARD4_META.framesPerDirection).toBe(8)
|
|
|
|
expect(BLIZZARDEXPLODE1_META.name).toBe('blizzardexplode1')
|
|
expect(BLIZZARDEXPLODE1_META.celFile).toBe('icestormimpactvar01')
|
|
expect(BLIZZARDEXPLODE1_META.framesPerDirection).toBe(6)
|
|
|
|
expect(BLIZZARDEXPLODE2_META.name).toBe('blizzardexplode2')
|
|
expect(BLIZZARDEXPLODE2_META.celFile).toBe('icestormimpactvar02')
|
|
expect(BLIZZARDEXPLODE2_META.framesPerDirection).toBe(6)
|
|
|
|
expect(BLIZZARDEXPLODE3_META.name).toBe('blizzardexplode3')
|
|
expect(BLIZZARDEXPLODE3_META.celFile).toBe('icestormimpactvar02')
|
|
expect(BLIZZARDEXPLODE3_META.framesPerDirection).toBe(6)
|
|
|
|
expect(MISSILE_METAS['blizzard1']).toBe(BLIZZARD1_META)
|
|
expect(MISSILE_METAS['blizzard2']).toBe(BLIZZARD2_META)
|
|
expect(MISSILE_METAS['blizzard3']).toBe(BLIZZARD3_META)
|
|
expect(MISSILE_METAS['blizzard4']).toBe(BLIZZARD4_META)
|
|
expect(MISSILE_METAS['blizzardexplode1']).toBe(BLIZZARDEXPLODE1_META)
|
|
expect(MISSILE_METAS['blizzardexplode2']).toBe(BLIZZARDEXPLODE2_META)
|
|
expect(MISSILE_METAS['blizzardexplode3']).toBe(BLIZZARDEXPLODE3_META)
|
|
|
|
const missileFiles = [
|
|
'blizzard1',
|
|
'blizzard2',
|
|
'blizzard3',
|
|
'blizzard4',
|
|
'blizzardexplode1',
|
|
'blizzardexplode2',
|
|
'blizzardexplode3',
|
|
]
|
|
|
|
for (const name of missileFiles) {
|
|
const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`)
|
|
const jsonPath = path.resolve(__dirname, `../public/missiles/${name}.json`)
|
|
expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true)
|
|
expect(fs.statSync(pngPath).size).toBeGreaterThan(1000)
|
|
expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
|
expect(fs.statSync(jsonPath).size).toBeGreaterThan(100)
|
|
}
|
|
})
|
|
|
|
it('4. Authoritative 64-entry circle lookup table matches 1.13c binary assembly (radius 30, Y*0.5)', () => {
|
|
expect(BLIZZARD_CIRCLE_X.length).toBe(64)
|
|
expect(BLIZZARD_CIRCLE_Y.length).toBe(64)
|
|
|
|
// Primary cardinal points
|
|
expect(BLIZZARD_CIRCLE_X[0]).toBe(0)
|
|
expect(BLIZZARD_CIRCLE_Y[0]).toBe(30)
|
|
expect(BLIZZARD_CIRCLE_X[16]).toBe(30)
|
|
expect(BLIZZARD_CIRCLE_Y[16]).toBe(0)
|
|
expect(BLIZZARD_CIRCLE_X[32]).toBe(0)
|
|
expect(BLIZZARD_CIRCLE_Y[32]).toBe(-30)
|
|
expect(BLIZZARD_CIRCLE_X[48]).toBe(-30)
|
|
expect(BLIZZARD_CIRCLE_Y[48]).toBe(0)
|
|
|
|
// getBlizzardDropOffset checks
|
|
// At index 16 (X=30, Y=0), radius 7 subtiles: dx = 30 * (112/30) = 112, dy = 0
|
|
const offEast = getBlizzardDropOffset(16, 7, 16)
|
|
expect(offEast.dx).toBe(112)
|
|
expect(offEast.dy).toBe(0)
|
|
|
|
// At index 0 (X=0, Y=30), radius 7 subtiles: dx = 0, dy = 30 * (112/30) * 0.5 = 56
|
|
const offSouth = getBlizzardDropOffset(0, 7, 16)
|
|
expect(offSouth.dx).toBe(0)
|
|
expect(offSouth.dy).toBe(56)
|
|
|
|
// 2:1 isometric aspect ratio verification
|
|
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
|
|
})
|
|
|
|
it('5. tickProjectiles: blizzardcenter lives 100 ticks and emits exactly 25 shards (cadence = 4 ticks, step = 13)', () => {
|
|
let center: Projectile = {
|
|
skillId: '59',
|
|
x: 300,
|
|
y: 300,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 150,
|
|
ttl: 100, // 100 frames = 4.0s
|
|
fromPlayer: true,
|
|
missileType: 'blizzardcenter',
|
|
subMissileIdx: 0,
|
|
}
|
|
|
|
const allEmittedShards: Projectile[] = []
|
|
const openTerrain = { overlap: () => 0 }
|
|
|
|
for (let tick = 1; tick <= 100; tick += 1) {
|
|
const outcome = tickProjectiles([center], [], openTerrain)
|
|
if (tick % 4 === 0) {
|
|
expect(outcome.spawnedProjectiles).toBeDefined()
|
|
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
|
const shard = outcome.spawnedProjectiles![0]!
|
|
expect(['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4']).toContain(shard.missileType)
|
|
expect(shard.damage).toBe(150)
|
|
expect(shard.statusEffect).toBe('chill')
|
|
expect(shard.statusDuration).toBe(100)
|
|
expect(shard.ttl).toBe(9)
|
|
expect(shard.altitude).toBe(120)
|
|
allEmittedShards.push(shard)
|
|
} else {
|
|
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
|
|
}
|
|
|
|
// Center must remain alive until tick 100
|
|
center = outcome.alive.find(p => p.missileType === 'blizzardcenter')!
|
|
if (tick < 100) {
|
|
expect(center).toBeDefined()
|
|
expect(center.ttl).toBe(100 - tick)
|
|
}
|
|
}
|
|
|
|
// Exactly 25 shards produced over 100 ticks (100 / 4)
|
|
expect(allEmittedShards.length).toBe(25)
|
|
|
|
// At tick 101, center expires
|
|
const expiredOutcome = tickProjectiles([center], [], openTerrain)
|
|
expect(expiredOutcome.expired).toBe(1)
|
|
expect(expiredOutcome.alive.length).toBe(0)
|
|
})
|
|
|
|
it('6. Main blizzardcenter is a harmless emitter and pierces monsters without dealing direct contact damage', () => {
|
|
const center: Projectile = {
|
|
skillId: '59',
|
|
x: 200,
|
|
y: 200,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 100,
|
|
ttl: 20,
|
|
fromPlayer: true,
|
|
missileType: 'blizzardcenter',
|
|
subMissileIdx: 0,
|
|
}
|
|
|
|
const monster = [{ index: 0, x: 200, y: 200, radius: 25, alive: true }]
|
|
const outcome = tickProjectiles([center], monster, { overlap: () => 0 })
|
|
|
|
// Center must not trigger hits or take damage on the monster
|
|
expect(outcome.hits.length).toBe(0)
|
|
expect(outcome.alive.some(p => p.missileType === 'blizzardcenter')).toBe(true)
|
|
})
|
|
|
|
it('7. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on ground impact', () => {
|
|
let shard: Projectile = {
|
|
skillId: '59',
|
|
x: 150,
|
|
y: 150,
|
|
vx: 0,
|
|
vy: 0,
|
|
altitude: 120,
|
|
damage: 80,
|
|
ttl: 9,
|
|
fromPlayer: true,
|
|
missileType: 'blizzard1',
|
|
}
|
|
|
|
const openTerrain = { overlap: () => 0 }
|
|
|
|
for (let tick = 1; tick <= 9; tick += 1) {
|
|
const outcome = tickProjectiles([shard], [], openTerrain)
|
|
shard = outcome.alive.find(p => p.missileType === 'blizzard1')!
|
|
if (tick < 9) {
|
|
expect(shard).toBeDefined()
|
|
expect(shard.ttl).toBe(9 - tick)
|
|
// Decrements by 120 / 9 px per tick
|
|
const expectedAltitude = 120 - tick * (120 / 9)
|
|
expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.1)
|
|
} else {
|
|
// At tick 9, ttl reached 0
|
|
expect(shard.ttl).toBe(0)
|
|
}
|
|
}
|
|
|
|
// Next tick: shard with ttl 0 expires and detonates ground impact explosion
|
|
const impactOutcome = tickProjectiles([shard], [], openTerrain)
|
|
expect(impactOutcome.expired).toBe(1)
|
|
expect(impactOutcome.spawnedProjectiles).toBeDefined()
|
|
expect(impactOutcome.spawnedProjectiles!.length).toBe(1)
|
|
|
|
const exp = impactOutcome.spawnedProjectiles![0]!
|
|
expect(exp.missileType).toBe('blizzardexplode1')
|
|
expect(exp.damage).toBe(80)
|
|
expect(exp.ttl).toBe(6) // 1.13c Range = 6
|
|
expect(exp.statusEffect).toBe('chill')
|
|
expect(exp.statusDuration).toBe(100) // 100 ticks chill length
|
|
})
|
|
|
|
it('8. Wall collision halts descending shard and spawns impact explosion', () => {
|
|
const shard: Projectile = {
|
|
skillId: '59',
|
|
x: 100,
|
|
y: 100,
|
|
vx: 0,
|
|
vy: 0,
|
|
altitude: 80,
|
|
damage: 90,
|
|
ttl: 7,
|
|
fromPlayer: true,
|
|
missileType: 'blizzard2',
|
|
}
|
|
|
|
// Solid wall at (100, 100)
|
|
const wallTerrain = { overlap: () => 1 }
|
|
const outcome = tickProjectiles([shard], [], wallTerrain)
|
|
|
|
expect(outcome.wallHits).toBe(1)
|
|
expect(outcome.alive.some(p => p.missileType === 'blizzard2')).toBe(false)
|
|
expect(outcome.spawnedProjectiles).toBeDefined()
|
|
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
|
|
|
const exp = outcome.spawnedProjectiles![0]!
|
|
expect(exp.missileType).toBe('blizzardexplode2')
|
|
expect(exp.ttl).toBe(6)
|
|
})
|
|
|
|
it('9. Direct monster hit while falling deals cold damage and chill without immunity window (NextDelay = 0)', () => {
|
|
const shard1: Projectile = {
|
|
skillId: '59',
|
|
x: 100,
|
|
y: 100,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 100,
|
|
ttl: 5,
|
|
fromPlayer: true,
|
|
missileType: 'blizzard1',
|
|
}
|
|
const shard2: Projectile = {
|
|
skillId: '59',
|
|
x: 102,
|
|
y: 100,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: 100,
|
|
ttl: 5,
|
|
fromPlayer: true,
|
|
missileType: 'blizzard2',
|
|
}
|
|
|
|
const dummyMonster = [{ index: 1, x: 101, y: 100, radius: 12, alive: true }]
|
|
const outcome = tickProjectiles([shard1, shard2], dummyMonster, { overlap: () => 0 })
|
|
|
|
// NextDelay = 0 allows both shards to hit the monster in the same frame
|
|
expect(outcome.hits.length).toBe(2)
|
|
for (const hit of outcome.hits) {
|
|
expect(hit.targetIndex).toBe(1)
|
|
expect(hit.damage).toBe(100)
|
|
expect(hit.statusEffect).toBe('chill')
|
|
expect(hit.statusDuration).toBe(100)
|
|
}
|
|
|
|
// Both shards must trigger explosion
|
|
expect(outcome.spawnedProjectiles!.length).toBe(2)
|
|
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode1')).toBe(true)
|
|
expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode2')).toBe(true)
|
|
})
|
|
|
|
it('10. calculateBlizzardDamage and calculateBlizzardSynergyMultiplier evaluate 5-band formulas & synergies', () => {
|
|
// slvl 1: 45 - 75
|
|
const d1 = calculateBlizzardDamage(1)
|
|
expect(d1.min).toBe(45)
|
|
expect(d1.max).toBe(75)
|
|
|
|
// slvl 10: 210 - 249
|
|
const d10 = calculateBlizzardDamage(10)
|
|
expect(d10.min).toBe(210)
|
|
expect(d10.max).toBe(249)
|
|
|
|
// slvl 20: 570 - 619
|
|
const d20 = calculateBlizzardDamage(20)
|
|
expect(d20.min).toBe(570)
|
|
expect(d20.max).toBe(619)
|
|
|
|
// slvl 30: 1120 - 1179
|
|
const d30 = calculateBlizzardDamage(30)
|
|
expect(d30.min).toBe(1120)
|
|
expect(d30.max).toBe(1179)
|
|
|
|
// Synergy: 20 points in Ice Bolt (+100% = 2.0x)
|
|
const syn20 = calculateBlizzardSynergyMultiplier({ iceBolt: 20 })
|
|
expect(syn20).toBe(2.0)
|
|
const d20Syn = calculateBlizzardDamage(20, { iceBolt: 20 })
|
|
expect(d20Syn.min).toBe(1140) // 570 * 2.0
|
|
expect(d20Syn.max).toBe(1238) // 619 * 2.0
|
|
|
|
// Full synergies: 20 in Ice Bolt, 20 in Ice Blast, 20 in Glacial Spike (+300% = 4.0x)
|
|
const syn60 = calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
|
|
expect(syn60).toBe(4.0)
|
|
const d20Full = calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 })
|
|
expect(d20Full.min).toBe(2280) // 570 * 4.0
|
|
expect(d20Full.max).toBe(2476) // 619 * 4.0
|
|
})
|
|
})
|