231 lines
5.8 KiB
TypeScript
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,
|
|
);
|
|
});
|