435 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|