Files
Genarrative/src/data/buildDamage.test.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

435 lines
12 KiB
TypeScript

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);
});
});