This commit is contained in:
434
src/data/buildDamage.test.ts
Normal file
434
src/data/buildDamage.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type EquipmentLoadout,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
|
||||
import {
|
||||
getBuildContributionAttributeRows,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from './buildDamage';
|
||||
import { getCharacterCombatTags } from './buildTags';
|
||||
import { getCharacterById } from './characterPresets';
|
||||
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function requireCharacter(characterId: string) {
|
||||
const character = getCharacterById(characterId);
|
||||
expect(character).toBeTruthy();
|
||||
return character!;
|
||||
}
|
||||
|
||||
function cloneCharacter(
|
||||
character: Character,
|
||||
overrides: Partial<Character> = {},
|
||||
) {
|
||||
const nextCharacter = {
|
||||
...character,
|
||||
...overrides,
|
||||
attributes: {
|
||||
...character.attributes,
|
||||
...(overrides.attributes ?? {}),
|
||||
},
|
||||
} satisfies Character;
|
||||
|
||||
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(
|
||||
nextCharacter,
|
||||
wuxiaSchema,
|
||||
);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(
|
||||
nextCharacter,
|
||||
xianxiaSchema,
|
||||
);
|
||||
|
||||
return {
|
||||
...nextCharacter,
|
||||
attributeProfile: wuxiaProfile,
|
||||
attributeProfiles: {
|
||||
...nextCharacter.attributeProfiles,
|
||||
[WorldType.WUXIA]: wuxiaProfile,
|
||||
[WorldType.XIANXIA]: xianxiaProfile,
|
||||
},
|
||||
} satisfies Character;
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: 'weapon' | 'armor' | 'relic';
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId?: string;
|
||||
setName?: string;
|
||||
}): InventoryItem {
|
||||
return {
|
||||
id: params.id,
|
||||
category:
|
||||
params.slot === 'weapon'
|
||||
? 'weapon'
|
||||
: params.slot === 'armor'
|
||||
? 'armor'
|
||||
: 'relic',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: params.tags,
|
||||
equipmentSlotId: params.slot,
|
||||
buildProfile: {
|
||||
role: params.role,
|
||||
tags: params.tags,
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.slot,
|
||||
synergy: params.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGameState(
|
||||
loadout: EquipmentLoadout,
|
||||
activeBuildBuffs: GameState['activeBuildBuffs'] = [],
|
||||
) {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'melee',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 100,
|
||||
playerMaxMana: 100,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs,
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: loadout,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
describe('buildDamage', () => {
|
||||
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const breakdown = getCompanionBuildDamageBreakdown(character);
|
||||
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
|
||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||
|
||||
breakdown.rows.forEach((row) => {
|
||||
const contributionSum = Object.values(row.attributeContributions).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const modifierSum = Object.values(row.attributeModifierDeltas).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
const attributeRows = getBuildContributionAttributeRows(row, schema);
|
||||
const activeSlots = Object.entries(row.attributeModifierDeltas).filter(
|
||||
([, value]) => value > 0.0001,
|
||||
);
|
||||
|
||||
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
|
||||
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
|
||||
expect(attributeRows.length).toBeGreaterThan(0);
|
||||
expect(activeSlots.length).toBeLessThanOrEqual(2);
|
||||
attributeRows.forEach((attributeRow) => {
|
||||
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removing one tag only removes that tag row and does not recalculate shared rows', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const combatTags = getCharacterCombatTags(baseCharacter);
|
||||
expect(combatTags.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const fullBreakdown = getCompanionBuildDamageBreakdown(
|
||||
cloneCharacter(baseCharacter, {
|
||||
combatTags,
|
||||
}),
|
||||
);
|
||||
const trimmedBreakdown = getCompanionBuildDamageBreakdown(
|
||||
cloneCharacter(baseCharacter, {
|
||||
combatTags: combatTags.slice(0, 2),
|
||||
}),
|
||||
);
|
||||
|
||||
const sharedLabels = combatTags.slice(0, 2);
|
||||
sharedLabels.forEach((label) => {
|
||||
const fullRow = fullBreakdown.rows.find((row) => row.label === label);
|
||||
const trimmedRow = trimmedBreakdown.rows.find(
|
||||
(row) => row.label === label,
|
||||
);
|
||||
|
||||
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
|
||||
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
|
||||
});
|
||||
expect(
|
||||
trimmedBreakdown.rows.find((row) => row.label === combatTags[2]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the same build multiplier for different attribute profiles when tags are unchanged', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
|
||||
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'test-weapon',
|
||||
name: 'Test Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'test-armor',
|
||||
name: 'Test Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const agileCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 7,
|
||||
agility: 11,
|
||||
intelligence: 3,
|
||||
spirit: 3,
|
||||
},
|
||||
});
|
||||
const mageCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 3,
|
||||
agility: 4,
|
||||
intelligence: 10,
|
||||
spirit: 9,
|
||||
},
|
||||
});
|
||||
|
||||
const agileBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout),
|
||||
agileCharacter,
|
||||
);
|
||||
const mageBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout),
|
||||
mageCharacter,
|
||||
);
|
||||
|
||||
expect(agileBreakdown.buildDamageMultiplier).toBe(
|
||||
mageBreakdown.buildDamageMultiplier,
|
||||
);
|
||||
expect(agileBreakdown.buildDamageBonus).toBe(
|
||||
mageBreakdown.buildDamageBonus,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes both buff tags and set tags in the final additive build bonus', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(character);
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'set-weapon',
|
||||
name: 'Set Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'set-armor',
|
||||
name: 'Set Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [secondaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const breakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState(loadout, [
|
||||
{
|
||||
id: 'buff-1',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'test-skill',
|
||||
name: 'Test Buff',
|
||||
tags: [primaryTag],
|
||||
durationTurns: 2,
|
||||
},
|
||||
]),
|
||||
character,
|
||||
);
|
||||
|
||||
expect(breakdown.rows.some((row) => row.source === 'buff')).toBe(true);
|
||||
expect(breakdown.rows.some((row) => row.source === 'set')).toBe(true);
|
||||
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses different source coefficients for weapon, armor, and relic tags', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const equipmentOnlyTag = 'balanced';
|
||||
|
||||
const weaponBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'weapon-only',
|
||||
name: 'Weapon Only',
|
||||
slot: 'weapon',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
const armorBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: null,
|
||||
armor: buildEquipmentItem({
|
||||
id: 'armor-only',
|
||||
name: 'Armor Only',
|
||||
slot: 'armor',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
const relicBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: buildEquipmentItem({
|
||||
id: 'relic-only',
|
||||
name: 'Relic Only',
|
||||
slot: 'relic',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
}),
|
||||
character,
|
||||
);
|
||||
|
||||
const weaponRow = weaponBreakdown.rows.find(
|
||||
(row) => row.source === 'weapon',
|
||||
);
|
||||
const armorRow = armorBreakdown.rows.find((row) => row.source === 'armor');
|
||||
const relicRow = relicBreakdown.rows.find((row) => row.source === 'relic');
|
||||
|
||||
expect(weaponRow?.sourceCoefficient).toBe(0.85);
|
||||
expect(armorRow?.sourceCoefficient).toBe(0.75);
|
||||
expect(relicRow?.sourceCoefficient).toBe(0.8);
|
||||
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(
|
||||
relicRow?.bonusDelta ?? 0,
|
||||
);
|
||||
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(
|
||||
armorRow?.bonusDelta ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not allow resource attributes to enter tag bonus rows', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
const mpBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'mana-weapon',
|
||||
name: 'Mana Weapon',
|
||||
slot: 'weapon',
|
||||
role: 'mana',
|
||||
tags: ['mana'],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
const hpBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'fortress-weapon',
|
||||
name: 'Fortress Weapon',
|
||||
slot: 'weapon',
|
||||
role: 'fortress',
|
||||
tags: ['fortress'],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}),
|
||||
character,
|
||||
);
|
||||
|
||||
const mpRow = mpBreakdown.rows.find((row) => row.source === 'weapon');
|
||||
const hpRow = hpBreakdown.rows.find((row) => row.source === 'weapon');
|
||||
const mpAttributeRows = mpRow
|
||||
? getBuildContributionAttributeRows(mpRow, schema)
|
||||
: [];
|
||||
const hpAttributeRows = hpRow
|
||||
? getBuildContributionAttributeRows(hpRow, schema)
|
||||
: [];
|
||||
|
||||
expect(
|
||||
mpAttributeRows.every(
|
||||
(attribute) => !attribute.slotId.startsWith('resource_'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hpAttributeRows.every(
|
||||
(attribute) => !attribute.slotId.startsWith('resource_'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user