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 = {}, ) { 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); }); });