180 lines
7.0 KiB
TypeScript
180 lines
7.0 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { EMPTY_ITEM_STATS, type ItemStats } from "./itemStats";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { useCombatStore } from "./combatStore";
|
|
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
|
|
|
function stableUnit(seed: string): number {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < seed.length; index += 1) {
|
|
hash ^= seed.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0) / 0x1_0000_0000;
|
|
}
|
|
|
|
function timeForRoll(seed: (now: number) => string, minimum: number, maximum: number): number {
|
|
for (let now = 1_000; now < 200_000; now += 1) {
|
|
const roll = stableUnit(`${seed(now)}:outcome`);
|
|
if (roll >= minimum && roll < maximum) return now;
|
|
}
|
|
throw new Error(`No deterministic roll in [${minimum}, ${maximum})`);
|
|
}
|
|
|
|
function registerTarget(
|
|
id = "target",
|
|
options: { level?: number; armor?: number; fireResistance?: number; fireImmune?: boolean } = {},
|
|
): void {
|
|
useCombatStore.getState().registerMob(id, {
|
|
name: id,
|
|
level: options.level,
|
|
maxHealth: 10_000,
|
|
xpReward: 0,
|
|
resistances: { fire: options.fireResistance ?? 0 },
|
|
schoolImmunities: options.fireImmune ? ["fire"] : [],
|
|
});
|
|
if (options.armor !== undefined) {
|
|
useCombatStore.setState((state) => ({
|
|
mobs: { ...state.mobs, [id]: { ...state.mobs[id], armor: options.armor! } },
|
|
}));
|
|
}
|
|
setMobPosition(id, [2, 0, 0]);
|
|
useCombatStore.getState().selectMob(id);
|
|
}
|
|
|
|
function setGear(patch: Partial<ItemStats>): void {
|
|
useCombatStore.setState({ gearStats: { ...EMPTY_ITEM_STATS, ...patch } });
|
|
}
|
|
|
|
const ENEMY_MELEE: EnemyAttackDefinition = {
|
|
id: "test-swing",
|
|
name: "Test Swing",
|
|
delivery: "melee",
|
|
target: "primary",
|
|
school: "physical",
|
|
animation: "attack",
|
|
castTimeMs: 0,
|
|
range: 5,
|
|
cooldownMs: 10_000,
|
|
initialCooldownMs: [0, 0],
|
|
repeatCooldownMs: [10_000, 10_000],
|
|
damageMultiplier: 1,
|
|
};
|
|
|
|
describe("combat store discrete combat-result integration", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
});
|
|
|
|
it("produces real spell misses and critical strikes", () => {
|
|
registerTarget("high-level", { level: 255 });
|
|
expect(useCombatStore.getState().castAbility("mage-fire-blast", undefined, 1_000).ok).toBe(true);
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
outcome: "miss",
|
|
finalDamage: 0,
|
|
missed: true,
|
|
});
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
|
|
setGear({ criticalStrikeRating: 1_000_000, hitRating: 1_000_000 });
|
|
registerTarget("crit-target", { level: 20 });
|
|
expect(useCombatStore.getState().castAbility("mage-fire-blast", undefined, 2_000).ok).toBe(true);
|
|
const critical = useCombatStore.getState().lastCombatResult!;
|
|
expect(critical.outcome).toBe("crit");
|
|
expect(critical.damageBeforeMitigation).toBe(critical.rawDamage * 2);
|
|
});
|
|
|
|
it.each([
|
|
["dodge", 0.05, 0.10],
|
|
["parry", 0.10, 0.15],
|
|
] as const)("resolves player melee attacks as %s from the NPC attack table", (outcome, low, high) => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
|
registerTarget(outcome, { level: 20 });
|
|
const now = timeForRoll(
|
|
(candidate) => `__player__:rogue-sinister-strike:${outcome}:${candidate}:direct`,
|
|
low,
|
|
high,
|
|
);
|
|
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", undefined, now).ok).toBe(true);
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({ outcome, finalDamage: 0 });
|
|
});
|
|
|
|
it("uses mob armor and armor penetration without expected-value offense multipliers", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
|
registerTarget("armored", { level: 20, armor: 2_000 });
|
|
const now = timeForRoll(
|
|
(candidate) => `__player__:rogue-sinister-strike:armored:${candidate}:direct`,
|
|
0.25,
|
|
0.95,
|
|
);
|
|
useCombatStore.getState().castAbility("rogue-sinister-strike", undefined, now);
|
|
const withoutPenetration = useCombatStore.getState().lastCombatResult!;
|
|
expect(withoutPenetration.armorMitigatedAmount).toBeGreaterThan(0);
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
|
setGear({
|
|
armorPenetrationRating: 1_000_000,
|
|
criticalStrikeRating: 1_000_000,
|
|
hitRating: 1_000_000,
|
|
expertiseRating: 1_000_000,
|
|
});
|
|
registerTarget("armored", { level: 20, armor: 2_000 });
|
|
useCombatStore.getState().castAbility("rogue-sinister-strike", undefined, now);
|
|
const withRatings = useCombatStore.getState().lastCombatResult!;
|
|
expect(withRatings.rawDamage).toBe(withoutPenetration.rawDamage);
|
|
expect(withRatings.effectiveArmor).toBe(0);
|
|
expect(withRatings.finalDamage).toBeGreaterThan(withoutPenetration.finalDamage);
|
|
});
|
|
|
|
it("resolves partial resistance and school immunity through the live store", () => {
|
|
setGear({ hitRating: 1_000_000 });
|
|
registerTarget("resistant", { level: 20, fireResistance: 500 });
|
|
useCombatStore.getState().castAbility("mage-fire-blast", undefined, 3_000);
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
landed: true,
|
|
partiallyResisted: true,
|
|
});
|
|
expect(useCombatStore.getState().lastCombatResult!.resistedAmount).toBeGreaterThan(0);
|
|
|
|
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
|
|
registerTarget("immune", { level: 20, fireImmune: true });
|
|
useCombatStore.getState().castAbility("mage-fire-blast", undefined, 4_000);
|
|
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
|
outcome: "resist",
|
|
fullyResisted: true,
|
|
finalDamage: 0,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["dodge", { dodgeRating: 1_000_000 }],
|
|
["parry", { parryRating: 1_000_000 }],
|
|
["block", { blockRating: 1_000_000, blockValue: 25 }],
|
|
] as const)("resolves incoming %s once without party-style double mitigation", (outcome, gear) => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
|
|
setGear(gear);
|
|
useCombatStore.getState().registerMob(`enemy-${outcome}`, {
|
|
name: `enemy-${outcome}`,
|
|
level: 80,
|
|
maxHealth: 1_000,
|
|
xpReward: 0,
|
|
attacks: [ENEMY_MELEE],
|
|
});
|
|
setMobPosition(`enemy-${outcome}`, [2, 0, 0]);
|
|
const now = timeForRoll(
|
|
(candidate) => `enemy-${outcome}:test-swing:__player__:0:${candidate}`,
|
|
0.25,
|
|
0.75,
|
|
);
|
|
useCombatStore.getState().damageMob(`enemy-${outcome}`, 1, now - 1);
|
|
const healthBefore = useCombatStore.getState().health;
|
|
const applied = useCombatStore.getState().advanceMobCombat(now);
|
|
const result = useCombatStore.getState().lastCombatResult!;
|
|
expect(result.outcome).toBe(outcome);
|
|
expect(healthBefore - useCombatStore.getState().health).toBe(applied);
|
|
expect(applied).toBe(result.finalDamage);
|
|
});
|
|
});
|