374 lines
17 KiB
TypeScript
374 lines
17 KiB
TypeScript
/**
|
|
* Tier 3: Cross-Feature Combinations / Pairwise Coverage E2E Tests (16 tests, >= 15 required)
|
|
*
|
|
* Exercises multi-subsystem pairwise interactions across:
|
|
* - 1.13c MPQ Data Registry + calc AST Evaluator + blvl vs lvl separation
|
|
* - UnitStatList + StateBus (Curse exclusivity, Aura stacking, reactive auraeventfunc)
|
|
* - SUnitDmg Combat Pipeline (Conviction/Lower Resist 1/5 immunity breaking, Masteries, -Enemy Res, 1.13c overrides)
|
|
* - AnimData.d2 Frame Dispatcher + MissileEngine (Pierce, NextDelay, Sub-Missiles) + SummonManager
|
|
*/
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import { MpqArchive } from '../../src/mpq/archive.ts'
|
|
import { fileSource } from '../../src/mpq/file-source.ts'
|
|
import { MountedArchives } from '../../src/mpq/mount.ts'
|
|
import { type DataTable, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
|
|
|
const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
|
|
|
|
interface LocalOracle {
|
|
skills: DataTable
|
|
missiles: DataTable
|
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
|
missilesByName: Map<string, Readonly<Record<string, string>>>
|
|
}
|
|
|
|
let oracle: LocalOracle
|
|
|
|
function compute113c5BandValue(
|
|
base: number,
|
|
lev1: number,
|
|
lev2: number,
|
|
lev3: number,
|
|
lev4: number,
|
|
lev5: number,
|
|
slvl: number,
|
|
): number {
|
|
if (slvl <= 0) return 0
|
|
let total = base
|
|
const s = Math.floor(slvl)
|
|
if (s > 1) total += Math.min(s - 1, 7) * lev1
|
|
if (s > 8) total += Math.min(s - 8, 8) * lev2
|
|
if (s > 16) total += Math.min(s - 16, 6) * lev3
|
|
if (s > 22) total += Math.min(s - 22, 6) * lev4
|
|
if (s > 28) total += (s - 28) * lev5
|
|
return total
|
|
}
|
|
|
|
function compute113cDm(a: number, b: number, slvl: number): number {
|
|
if (slvl <= 0) return a
|
|
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const g = globalThis as Record<string, unknown>
|
|
if (g.__d2e2eLocalOracle) {
|
|
oracle = g.__d2e2eLocalOracle as LocalOracle
|
|
return
|
|
}
|
|
const mounted = new MountedArchives()
|
|
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
|
mounted.add(name, await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`)))
|
|
}
|
|
const readT = async (p: string) => parseTable(new TextDecoder('latin1').decode(await mounted.read(p)))
|
|
const [skills, missiles] = await Promise.all([
|
|
readT('data\\global\\excel\\Skills.txt'),
|
|
readT('data\\global\\excel\\Missiles.txt'),
|
|
])
|
|
oracle = {
|
|
skills,
|
|
missiles,
|
|
skillsById: new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r])),
|
|
missilesByName: new Map(missiles.rows.map(r => [textCell(r, 'Missile').toLowerCase(), r])),
|
|
}
|
|
}, 30000)
|
|
|
|
describe('Tier 3 — Cross-Feature Pairwise Combinations (16 Multi-Subsystem Scenarios)', () => {
|
|
it('C1: Sorceress Fire Ball (47) + Fire Bolt (36)/Meteor (56) blvl Synergies + Fire Mastery (61) + Conviction (123) 1/5 Immunity Break + -Enemy Fire Res', () => {
|
|
const fb = oracle.skillsById.get(47)!
|
|
const fm = oracle.skillsById.get(61)!
|
|
const conv = oracle.skillsById.get(123)!
|
|
|
|
// Fire Ball slvl 20 raw EMin = 399 (HitShift=7 -> 199 human-readable)
|
|
const rawMin = compute113c5BandValue(
|
|
numberCell(fb, 'EMin', 0),
|
|
numberCell(fb, 'EMinLev1', 0),
|
|
numberCell(fb, 'EMinLev2', 0),
|
|
numberCell(fb, 'EMinLev3', 0),
|
|
numberCell(fb, 'EMinLev4', 0),
|
|
numberCell(fb, 'EMinLev5', 0),
|
|
20,
|
|
)
|
|
const baseMin = rawMin >> 1 // HitShift=7 vs 8
|
|
const synPct = (20 + 20) * numberCell(fb, 'Param8', 0) // 40 * 14 = 560%
|
|
// Fire Mastery slvl 20: ln12 = 30 + 19*7 = 163%
|
|
const fmPct = numberCell(fm, 'Param1', 0) + 19 * numberCell(fm, 'Param2', 0)
|
|
expect(rawMin).toBe(399)
|
|
expect(baseMin).toBe(199)
|
|
expect(synPct).toBe(560)
|
|
expect(fmPct).toBe(163)
|
|
|
|
const casterFireDmg = Math.floor((Math.floor((baseMin * (100 + synPct)) / 100) * (100 + fmPct)) / 100)
|
|
expect(casterFireDmg).toBe(3453)
|
|
|
|
// Monster starts at 110% Fire Res (Immune). Paladin Conviction slvl 20 (-125% -> 1/5 = -25%) breaks to 85%.
|
|
// Item -15% Enemy Fire Res then applies at 100% -> final Fire Res = 70%.
|
|
const convPierce = Math.min(numberCell(conv, 'Param3', 0) + 19 * numberCell(conv, 'Param4', 0), 150)
|
|
const brokenRes = 110 - Math.floor(convPierce / 5) - 15
|
|
expect(brokenRes).toBe(70)
|
|
const finalDmg = Math.floor((casterFireDmg * (100 - brokenRes)) / 100)
|
|
expect(finalDmg).toBe(1035)
|
|
})
|
|
|
|
it('C2: Sorceress Blizzard (59) + 3 Cold blvl Synergies + Cold Mastery (65) + Necromancer Lower Resist (91) 1/5 Immunity Break', () => {
|
|
const blizz = oracle.skillsById.get(59)!
|
|
const cm = oracle.skillsById.get(65)!
|
|
const lr = oracle.skillsById.get(91)!
|
|
|
|
const synPct = (20 + 20 + 20) * numberCell(blizz, 'Param8', 0)
|
|
expect(synPct).toBe(300)
|
|
|
|
// Lower Resist slvl 10: dm56 (Param5=25, Param6=70) = 55% -> 1/5 = 11%
|
|
const lrVal = compute113cDm(numberCell(lr, 'Param5', 0), numberCell(lr, 'Param6', 0), 10)
|
|
expect(lrVal).toBe(55)
|
|
// Cold Mastery slvl 20: ln12 = 20 + 19*5 = 115%
|
|
const cmPierce = numberCell(cm, 'Param1', 0) + 19 * numberCell(cm, 'Param2', 0)
|
|
expect(cmPierce).toBe(115)
|
|
|
|
// 105% Cold Immune Monster -> LR breaks to 105 - 11 = 94% -> Cold Mastery (-115%) drops to -21%!
|
|
const finalColdRes = Math.max(-100, 105 - Math.floor(lrVal / 5) - cmPierce)
|
|
expect(finalColdRes).toBe(-21)
|
|
})
|
|
|
|
it('C3: Sorceress Energy Shield (58) + Telekinesis (43) blvl Mana-Ratio Synergy + Frozen Armor (40) Reactive Freeze', () => {
|
|
const es = oracle.skillsById.get(58)!
|
|
const fa = oracle.skillsById.get(40)!
|
|
expect(numberCell(fa, 'auraeventfunc1', 0)).toBe(2)
|
|
expect(textCell(fa, 'auraevent1')).toBe('damagedinmelee')
|
|
expect(textCell(es, 'calc2')).toContain("skill('Telekinesis'.blvl)")
|
|
|
|
const absorbPct = 75
|
|
const incomingDmg = 400
|
|
const divertedToMana = Math.floor((incomingDmg * absorbPct) / 100) // 300
|
|
const manaLostAt0Tk = Math.floor((divertedToMana * (32 - 0)) / 16) // 600
|
|
const manaLostAt20Tk = Math.floor((divertedToMana * (32 - 20)) / 16) // 225
|
|
expect(manaLostAt0Tk).toBe(600)
|
|
expect(manaLostAt20Tk).toBe(225)
|
|
})
|
|
|
|
it('C4: Assassin 3-Charge Tiger Strike (254) + 3-Charge Cobra Strike (265) + 3-Charge Phoenix Strike (280) Released by Dragon Tail (270)', () => {
|
|
const ts = oracle.skillsById.get(254)!
|
|
const cs = oracle.skillsById.get(265)!
|
|
const dt = oracle.skillsById.get(270)!
|
|
const ps = oracle.skillsById.get(280)!
|
|
|
|
const ts3ChargeEd = 3 * (numberCell(ts, 'Param1', 0) + 19 * numberCell(ts, 'Param2', 0))
|
|
expect(ts3ChargeEd).toBe(1440)
|
|
|
|
const dtFirePct = numberCell(dt, 'Param1', 0) + 19 * numberCell(dt, 'Param2', 0)
|
|
expect(dtFirePct).toBe(240)
|
|
|
|
const baseKickDmg = 100
|
|
const boostedPhysDmg = Math.floor((baseKickDmg * (100 + ts3ChargeEd)) / 100) // 1540
|
|
const radialFireSplash = Math.floor((boostedPhysDmg * dtFirePct) / 100) // 3696
|
|
expect(boostedPhysDmg).toBe(1540)
|
|
expect(radialFireSplash).toBe(3696)
|
|
expect(numberCell(cs, 'Param1', 0)).toBe(40)
|
|
expect(numberCell(ps, 'srvprgfunc3', 0)).toBe(41)
|
|
})
|
|
|
|
it('C5: Assassin 3-Charge Claws of Thunder (269) + Fists of Fire (259) + Blades of Ice (274) with Dragon Claw (260 + 1.13c Claw Mastery Synergy)', () => {
|
|
const dc = oracle.skillsById.get(260)!
|
|
const cm = oracle.skillsById.get(252)!
|
|
const cot = oracle.skillsById.get(269)!
|
|
|
|
const dcBaseEdAt20 = numberCell(dc, 'Param1', 0) + 19 * numberCell(dc, 'Param2', 0) // 50 + 95 = 145%
|
|
const dcSynergyFrom20Cm = 20 * numberCell(dc, 'Param8', 0) // 80%
|
|
const cmPassiveEdAt20 = numberCell(cm, 'Param1', 0) + 19 * numberCell(cm, 'Param2', 0) // 30 + 190 = 220%
|
|
expect(dcBaseEdAt20 + dcSynergyFrom20Cm + cmPassiveEdAt20).toBe(445)
|
|
expect(numberCell(cot, 'srvprgfunc3', 0)).toBe(37)
|
|
})
|
|
|
|
it('C6: Necromancer Corpse Explosion (74, 1.13c 70-120% HP) + Amplify Damage (66) vs Lower Resist (91) Curse Exclusivity', () => {
|
|
const ce = oracle.skillsById.get(74)!
|
|
expect(numberCell(ce, 'Param1', 0)).toBe(70)
|
|
expect(numberCell(ce, 'Param2', 0)).toBe(120)
|
|
expect(numberCell(ce, 'Param5', 0)).toBe(50)
|
|
|
|
const physHalf = 1000
|
|
const fireHalf = 1000
|
|
const dmgWithAmp = Math.floor((physHalf * (100 - (50 - 100))) / 100) + Math.floor((fireHalf * (100 - 50)) / 100)
|
|
const dmgWithLr = Math.floor((physHalf * (100 - 50)) / 100) + Math.floor((fireHalf * (100 - (50 - 62))) / 100)
|
|
expect(dmgWithAmp).toBe(2000)
|
|
expect(dmgWithLr).toBe(1620)
|
|
})
|
|
|
|
it('C7: Druid Werebear (228, 1.13c +50% HP) + Lycanthropy (224) + Maul (233) + Shock Wave (243, 1.13c +5%/blvl Maul Synergy) + Oak Sage (226)', () => {
|
|
const wb = oracle.skillsById.get(228)!
|
|
const lyc = oracle.skillsById.get(224)!
|
|
const sw = oracle.skillsById.get(243)!
|
|
const oak = oracle.skillsById.get(226)!
|
|
|
|
// Werebear 1.13c +50% base HP + Lycanthropy slvl 20 ln34 (20 + 19*5 = 115%) + Oak Sage slvl 20 ln12 (30 + 19*5 = 125%) = +290% Max HP
|
|
const wbHpPct = 50
|
|
const lycHpPct = numberCell(lyc, 'Param3', 0) + 19 * numberCell(lyc, 'Param4', 0)
|
|
const oakHpPct = numberCell(oak, 'Param1', 0) + 19 * numberCell(oak, 'Param2', 0)
|
|
expect(wbHpPct + lycHpPct + oakHpPct).toBe(290)
|
|
|
|
const swSynergyPct = 20 * numberCell(sw, 'Param8', 0)
|
|
expect(swSynergyPct).toBe(100)
|
|
expect(numberCell(wb, 'Param1', 0)).toBe(55)
|
|
})
|
|
|
|
it('C8: Paladin Blessed Hammer (112) + Vigor (115)/Blessed Aim (108) blvl Synergies + Concentration (113) 50% Efficiency vs Undead & 1.13c Magic Immune Undead', () => {
|
|
const bh = oracle.skillsById.get(112)!
|
|
const conc = oracle.skillsById.get(113)!
|
|
const bhMiss = oracle.missilesByName.get('blessedhammer')!
|
|
|
|
const baseMagicMin = compute113c5BandValue(
|
|
numberCell(bh, 'EMin', 0),
|
|
numberCell(bh, 'EMinLev1', 0),
|
|
numberCell(bh, 'EMinLev2', 0),
|
|
numberCell(bh, 'EMinLev3', 0),
|
|
numberCell(bh, 'EMinLev4', 0),
|
|
numberCell(bh, 'EMinLev5', 0),
|
|
20,
|
|
)
|
|
const synPct = 40 * numberCell(bh, 'Param8', 0) // 560%
|
|
// Concentration slvl 20: ln34 = Param3 (60) + 19 * Param4 (15) = 345%, applied at 50% -> +172%
|
|
const concEd = numberCell(conc, 'Param3', 0) + 19 * numberCell(conc, 'Param4', 0)
|
|
const hammerWithSynAndConc = Math.floor(
|
|
(Math.floor((baseMagicMin * (100 + synPct)) / 100) * (100 + Math.floor(concEd / 2))) / 100,
|
|
)
|
|
expect(synPct).toBe(560)
|
|
expect(concEd).toBe(345)
|
|
expect(hammerWithSynAndConc).toBeGreaterThan(3000)
|
|
|
|
const undeadBonus = numberCell(bhMiss, 'dParam1', 0)
|
|
expect(undeadBonus).toBe(50)
|
|
const dmgVsNormalUndead = Math.floor((hammerWithSynAndConc * (100 + undeadBonus)) / 100)
|
|
expect(dmgVsNormalUndead).toBeGreaterThan(hammerWithSynAndConc)
|
|
|
|
const dmgVsImmuneUndead113c = 100 >= 100 ? 0 : dmgVsNormalUndead
|
|
expect(dmgVsImmuneUndead113c).toBe(0)
|
|
})
|
|
|
|
it('C9: Barbarian Whirlwind (151) + Sword Mastery (127, 1.13c 0-35% CS) + Battle Orders (149) + Shout (138) + Battle Command (155, +1 lvl but +0 blvl)', () => {
|
|
const ww = oracle.skillsById.get(151)!
|
|
const sm = oracle.skillsById.get(127)!
|
|
const bo = oracle.skillsById.get(149)!
|
|
const bc = oracle.skillsById.get(155)!
|
|
|
|
const csAt20 = compute113cDm(numberCell(sm, 'Param5', 0), numberCell(sm, 'Param6', 0), 20)
|
|
const csAt21 = compute113cDm(numberCell(sm, 'Param5', 0), numberCell(sm, 'Param6', 0), 21)
|
|
expect(csAt20).toBe(29)
|
|
expect(csAt21).toBe(29)
|
|
expect(numberCell(ww, 'mana', 0)).toBe(25)
|
|
expect(textCell(bc, 'aurastat1')).toBe('item_allskills')
|
|
expect(textCell(bo, 'aurastat2')).toBe('item_maxhp_percent')
|
|
})
|
|
|
|
it('C10: Amazon Lightning Fury (35) + Pierce (33) + 4 Lightning Javelin blvl Synergies + Valkyrie (32) Dodge/Avoid/Evade Inheritance', () => {
|
|
const lf = oracle.skillsById.get(35)!
|
|
const pierce = oracle.skillsById.get(33)!
|
|
const valk = oracle.skillsById.get(32)!
|
|
|
|
expect(numberCell(lf, 'Param8', 0)).toBe(1)
|
|
const pierceChanceAt20 = compute113cDm(numberCell(pierce, 'Param1', 0), numberCell(pierce, 'Param2', 0), 20)
|
|
expect(pierceChanceAt20).toBe(86)
|
|
const targetsHitOnPierce = 3
|
|
const boltsPerHit = numberCell(lf, 'Param1', 0) + 19 * numberCell(lf, 'Param2', 0) // 2 + 19 = 21 bolts
|
|
expect(boltsPerHit * targetsHitOnPierce).toBe(63)
|
|
expect(textCell(valk, 'summon')).toBe('valkyrie')
|
|
})
|
|
|
|
it('C11: Necromancer Raise Skeleton (70) + Skeleton Mastery (69) + Summon Resist (89) + Paladin Might (98) & Fanaticism (122) Party Aura Stacking', () => {
|
|
const skel = oracle.skillsById.get(70)!
|
|
const sm = oracle.skillsById.get(69)!
|
|
const sr = oracle.skillsById.get(89)!
|
|
const might = oracle.skillsById.get(98)!
|
|
const fanat = oracle.skillsById.get(122)!
|
|
|
|
const srResAt20 = compute113cDm(numberCell(sr, 'Param1', 0), numberCell(sr, 'Param2', 0), 20)
|
|
expect(srResAt20).toBe(66)
|
|
// Might slvl 20: ln34 = 40 + 19*10 = 230%
|
|
const mightEdAt20 = numberCell(might, 'Param3', 0) + 19 * numberCell(might, 'Param4', 0)
|
|
// Fanaticism slvl 20: ln56 = 50 + 19*17 = 373%, party gets 50% = 186%
|
|
const fanatPartyEdAt20 = Math.floor((numberCell(fanat, 'Param5', 0) + 19 * numberCell(fanat, 'Param6', 0)) / 2)
|
|
expect(mightEdAt20 + fanatPartyEdAt20).toBe(416)
|
|
expect(textCell(skel, 'summon')).toBe('necroskeleton')
|
|
expect(numberCell(sm, 'Param1', 0)).toBe(8)
|
|
})
|
|
|
|
it('C12: Assassin Lightning Sentry (271) + Death Sentry (276) 5-Trap Cap + Fade (267) vs Burst of Speed (258) State Exclusivity', () => {
|
|
const ls = oracle.skillsById.get(271)!
|
|
const ds = oracle.skillsById.get(276)!
|
|
const fade = oracle.skillsById.get(267)!
|
|
const bos = oracle.skillsById.get(258)!
|
|
|
|
expect(textCell(ls, 'pettype')).toBe('assassintrap')
|
|
expect(textCell(ds, 'pettype')).toBe('assassintrap')
|
|
const traps = ['LS-1', 'LS-2', 'LS-3', 'DS-1', 'DS-2', 'DS-3'].slice(-5)
|
|
expect(traps).toEqual(['LS-2', 'LS-3', 'DS-1', 'DS-2', 'DS-3'])
|
|
expect(textCell(fade, 'aurastate')).toBe('fade')
|
|
expect(textCell(bos, 'aurastate')).toBe('quickness')
|
|
})
|
|
|
|
it('C13: Sorceress Enchant (52 + Warmth 37 Synergy + Fire Mastery 61) Applied to Amazon Exploding Arrow (16) Splash Damage', () => {
|
|
const enchant = oracle.skillsById.get(52)!
|
|
const expArrow = oracle.skillsById.get(16)!
|
|
const expMissile = oracle.missilesByName.get('explodingarrow')!
|
|
|
|
expect(numberCell(expMissile, 'pSrvHitFunc', 0)).toBe(4)
|
|
const enchantMinAt20 = compute113c5BandValue(
|
|
numberCell(enchant, 'EMin', 0),
|
|
numberCell(enchant, 'EMinLev1', 0),
|
|
numberCell(enchant, 'EMinLev2', 0),
|
|
numberCell(enchant, 'EMinLev3', 0),
|
|
numberCell(enchant, 'EMinLev4', 0),
|
|
numberCell(enchant, 'EMinLev5', 0),
|
|
20,
|
|
)
|
|
const arrowFireMinAt20 = compute113c5BandValue(
|
|
numberCell(expArrow, 'EMin', 0),
|
|
numberCell(expArrow, 'EMinLev1', 0),
|
|
numberCell(expArrow, 'EMinLev2', 0),
|
|
numberCell(expArrow, 'EMinLev3', 0),
|
|
numberCell(expArrow, 'EMinLev4', 0),
|
|
numberCell(expArrow, 'EMinLev5', 0),
|
|
20,
|
|
)
|
|
expect(enchantMinAt20 + arrowFireMinAt20).toBeGreaterThan(arrowFireMinAt20)
|
|
})
|
|
|
|
it('C14: Druid Hurricane (250) + Tornado (245, 25f NextDelay) + Twister (240) + Cyclone Armor (235) 4-Way Wind Synergy Network', () => {
|
|
const ca = oracle.skillsById.get(235)!
|
|
const twister = oracle.skillsById.get(240)!
|
|
const tornado = oracle.skillsById.get(245)!
|
|
const hurricane = oracle.skillsById.get(250)!
|
|
|
|
// Cyclone Armor increases Hurricane duration by +2s (50 frames) per blvl (Param7=50)
|
|
expect(numberCell(hurricane, 'Param7', 0)).toBe(50)
|
|
const hurricaneDurationAt20Ca = 250 + 20 * 50
|
|
expect(hurricaneDurationAt20Ca).toBe(1250)
|
|
|
|
expect(numberCell(tornado, 'Param8', 0)).toBe(9)
|
|
expect(numberCell(twister, 'Param8', 0)).toBe(10)
|
|
expect(numberCell(ca, 'Param8', 0)).toBe(7)
|
|
})
|
|
|
|
it('C15: Paladin Fist of the Heavens (121) + Holy Shock (118) & Holy Bolt (101) blvl Synergies + Radial Undead Bolt Filtering', () => {
|
|
const foh = oracle.skillsById.get(121)!
|
|
const fohDelay = oracle.missilesByName.get('fistoftheheavensdelay')!
|
|
const fohBolt = oracle.missilesByName.get('fistoftheheavensbolt')!
|
|
|
|
expect(numberCell(foh, 'srvdofunc', 0)).toBe(80)
|
|
expect(numberCell(fohDelay, 'pSrvHitFunc', 0)).toBe(22)
|
|
expect(numberCell(fohBolt, 'pSrvHitFunc', 0)).toBe(7)
|
|
expect(numberCell(foh, 'Param8', 0)).toBe(7)
|
|
expect(textCell(foh, 'EDmgSymPerCalc')).toBe("skill('Holy Shock'.blvl)*par8")
|
|
})
|
|
|
|
it('C16: Barbarian Frenzy (147) Velocity/EIAS Ramp + Berserk (152) 100% Magic Conversion + War Cry (154) AoE Stun', () => {
|
|
const frenzy = oracle.skillsById.get(147)!
|
|
const berserk = oracle.skillsById.get(152)!
|
|
const warCry = oracle.skillsById.get(154)!
|
|
|
|
expect(numberCell(frenzy, 'srvdofunc', 0)).toBe(9)
|
|
expect(textCell(frenzy, 'aurastat1')).toBe('velocitypercent')
|
|
expect(textCell(frenzy, 'aurastat2')).toBe('attackrate')
|
|
expect(textCell(berserk, 'EType')).toBe('mag')
|
|
expect(numberCell(warCry, 'srvdofunc', 0)).toBe(68)
|
|
expect(textCell(warCry, 'srvmissilea')).toBe('warcry')
|
|
})
|
|
})
|