Files
Genarrative/server-node/src/modules/inventory/inventoryMutationService.test.ts
2026-04-10 15:37:02 +08:00

231 lines
5.8 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
import {
craftForgeRecipe,
equipInventoryItem,
useInventoryItem,
type RuntimeGameState,
type RuntimeInventoryItem,
} from './inventoryMutationService.js';
const TEST_WORLD = 'WUXIA' as RuntimeGameState['worldType'];
const TEST_IDLE_ANIMATION = 'idle' as RuntimeGameState['animationState'];
function requireCharacter() {
return createTestPlayerCharacter<
NonNullable<RuntimeGameState['playerCharacter']>
>();
}
function buildItem(
overrides: Partial<RuntimeInventoryItem> &
Pick<RuntimeInventoryItem, 'id' | 'category' | 'name'>,
): RuntimeInventoryItem {
return {
quantity: 1,
rarity: 'common',
tags: [],
...overrides,
};
}
function createState(overrides: Partial<RuntimeGameState> = {}): RuntimeGameState {
return {
worldType: TEST_WORLD,
customWorldProfile: null,
playerCharacter: requireCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'test-scene',
storyHistory: [],
characterChats: {},
animationState: TEST_IDLE_ANIMATION,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'melee',
scrollWorld: false,
inBattle: false,
playerHp: 64,
playerMaxHp: 100,
playerMana: 18,
playerMaxMana: 60,
playerSkillCooldowns: {
slash: 2,
},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 120,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} satisfies RuntimeGameState;
}
test('useInventoryItem applies recovery, cooldown推进 and buff mutation', () => {
const state = createState({
playerInventory: [
buildItem({
id: 'focus-tonic',
category: '消耗品',
name: '凝神灵液',
rarity: 'rare',
tags: ['healing', 'mana'],
useProfile: {
hpRestore: 22,
manaRestore: 16,
cooldownReduction: 1,
buildBuffs: [
{
id: 'focus-tonic:buff',
sourceType: 'item',
sourceId: 'focus-tonic',
name: '凝神增益',
tags: ['快剑'],
durationTurns: 2,
},
],
},
}),
],
});
const result = useInventoryItem(state, 'focus-tonic');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'use');
assert.equal(result.nextState.playerHp, 86);
assert.equal(result.nextState.playerMana, 34);
assert.equal(result.nextState.playerSkillCooldowns.slash, 1);
assert.equal(result.nextState.playerInventory.length, 0);
assert.equal(result.nextState.runtimeStats.itemsUsed, 1);
assert.equal(result.nextState.activeBuildBuffs[0]?.id, 'focus-tonic:buff');
});
test('equipInventoryItem swaps loadout and returns replaced gear to inventory', () => {
const oldWeapon = buildItem({
id: 'starter-blade',
category: '武器',
name: '旧佩剑',
rarity: 'common',
tags: ['weapon', '快剑'],
equipmentSlotId: 'weapon',
statProfile: {
outgoingDamageBonus: 0.04,
},
buildProfile: {
role: '快剑',
tags: ['快剑'],
synergy: ['快剑'],
forgeRank: 0,
},
});
const nextWeapon = buildItem({
id: 'storm-blade',
category: '武器',
name: '逐风短剑',
rarity: 'rare',
tags: ['weapon', '快剑', '突进'],
equipmentSlotId: 'weapon',
statProfile: {
outgoingDamageBonus: 0.12,
},
buildProfile: {
role: '快剑',
tags: ['快剑', '突进'],
synergy: ['快剑', '突进'],
forgeRank: 0,
},
});
const state = createState({
playerInventory: [nextWeapon],
playerEquipment: {
weapon: oldWeapon,
armor: null,
relic: null,
},
});
const result = equipInventoryItem(state, 'storm-blade');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'equip');
assert.equal(result.slot, 'weapon');
assert.equal(result.nextState.playerEquipment.weapon?.name, '逐风短剑');
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'starter-blade'),
true,
);
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'storm-blade'),
false,
);
});
test('craftForgeRecipe consumes materials and produces forged output on the server side', () => {
const state = createState({
playerCurrency: 40,
playerInventory: [
buildItem({
id: 'scrap-iron',
category: '材料',
name: '残铁碎片',
quantity: 3,
rarity: 'common',
tags: ['material'],
}),
],
});
const result = craftForgeRecipe(state, 'synthesis-refined-ingot');
assert.equal(result.ok, true);
if (!result.ok) {
return;
}
assert.equal(result.mutation, 'craft');
assert.equal(result.nextState.playerCurrency, 22);
assert.equal(result.createdItem?.name, '精炼锭材');
assert.equal(
result.nextState.playerInventory.some((item) => item.name === '精炼锭材'),
true,
);
assert.equal(
result.nextState.playerInventory.some((item) => item.id === 'scrap-iron'),
false,
);
});