1
This commit is contained in:
@@ -378,46 +378,48 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
await withTestServer('combat-finisher', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
|
||||
|
||||
await putSnapshot(baseUrl, entry.token, {
|
||||
worldType: 'WUXIA',
|
||||
storyHistory: [],
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 12,
|
||||
maxHp: 28,
|
||||
description: '桥口劫匪',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 12,
|
||||
maxHp: 28,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
companions: [],
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
});
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
@@ -486,6 +488,313 @@ test('runtime story actions resolve combat finishers on the server and collapse
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => {
|
||||
await withTestServer('combat-state-options', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123');
|
||||
const playerCharacter = {
|
||||
...requirePlayerCharacter(),
|
||||
skills: [
|
||||
{
|
||||
id: 'slash',
|
||||
name: '试锋斩',
|
||||
animation: 'attack',
|
||||
damage: 18,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
{
|
||||
id: 'wind-step',
|
||||
name: '断风步',
|
||||
animation: 'attack',
|
||||
damage: 12,
|
||||
manaCost: 2,
|
||||
cooldownTurns: 0,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
playerCharacter,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 36,
|
||||
maxHp: 36,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerMana: 6,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {
|
||||
slash: 2,
|
||||
'wind-step': 0,
|
||||
},
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'focus-tonic',
|
||||
category: '消耗品',
|
||||
name: '凝神灵液',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['mana'],
|
||||
useProfile: {
|
||||
manaRestore: 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/state/runtime-main`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${entry.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
viewModel: {
|
||||
status: {
|
||||
inBattle: boolean;
|
||||
};
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: {
|
||||
skillId?: string;
|
||||
itemId?: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.viewModel.status.inBattle, true);
|
||||
assert.deepEqual(
|
||||
payload.viewModel.availableOptions.map((option) => option.functionId),
|
||||
[
|
||||
'battle_attack_basic',
|
||||
'battle_recover_breath',
|
||||
'inventory_use',
|
||||
'battle_use_skill',
|
||||
'battle_use_skill',
|
||||
'battle_escape_breakout',
|
||||
],
|
||||
);
|
||||
|
||||
const itemOption = payload.viewModel.availableOptions[2];
|
||||
assert.equal(itemOption?.functionId, 'inventory_use');
|
||||
assert.equal(itemOption?.payload?.itemId, 'focus-tonic');
|
||||
assert.equal(itemOption?.disabled, undefined);
|
||||
|
||||
const slashOption = payload.viewModel.availableOptions[3];
|
||||
assert.equal(slashOption?.actionText, '试锋斩');
|
||||
assert.equal(slashOption?.payload?.skillId, 'slash');
|
||||
assert.equal(slashOption?.disabled, true);
|
||||
assert.match(slashOption?.reason ?? '', /冷却中/u);
|
||||
|
||||
const windStepOption = payload.viewModel.availableOptions[4];
|
||||
assert.equal(windStepOption?.actionText, '断风步');
|
||||
assert.equal(windStepOption?.payload?.skillId, 'wind-step');
|
||||
assert.equal(windStepOption?.disabled, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => {
|
||||
await withTestServer('combat-use-skill', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123');
|
||||
const playerCharacter = {
|
||||
...requirePlayerCharacter(),
|
||||
skills: [
|
||||
{
|
||||
id: 'slash',
|
||||
name: '试锋斩',
|
||||
animation: 'attack',
|
||||
damage: 18,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
buildBuffs: [
|
||||
{
|
||||
id: 'slash:buff',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'slash',
|
||||
name: '试锋余势',
|
||||
tags: ['快剑'],
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
playerCharacter,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 32,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 9,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'battle_use_skill',
|
||||
payload: {
|
||||
skillId: 'slash',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
serverVersion: number;
|
||||
viewModel: {
|
||||
player: {
|
||||
mana: number;
|
||||
};
|
||||
status: {
|
||||
inBattle: boolean;
|
||||
};
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: {
|
||||
skillId?: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
presentation: {
|
||||
resultText: string;
|
||||
storyText: string;
|
||||
battle: {
|
||||
outcome: string;
|
||||
damageDealt: number;
|
||||
} | null;
|
||||
};
|
||||
snapshot: {
|
||||
gameState: {
|
||||
playerMana: number;
|
||||
playerSkillCooldowns: Record<string, number>;
|
||||
activeBuildBuffs: Array<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
patches: Array<{
|
||||
type: string;
|
||||
functionId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.serverVersion, 1);
|
||||
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
|
||||
assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0);
|
||||
assert.equal(payload.presentation.storyText, payload.presentation.resultText);
|
||||
assert.match(payload.presentation.storyText, /试锋斩/u);
|
||||
assert.equal(payload.viewModel.status.inBattle, true);
|
||||
assert.equal(payload.viewModel.player.mana, 5);
|
||||
assert.equal(payload.snapshot.gameState.playerMana, 5);
|
||||
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2);
|
||||
assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff');
|
||||
assert.ok(
|
||||
payload.patches.some(
|
||||
(patch) =>
|
||||
patch.type === 'battle_resolved' &&
|
||||
patch.functionId === 'battle_use_skill',
|
||||
),
|
||||
);
|
||||
|
||||
const skillOption = payload.viewModel.availableOptions.find(
|
||||
(option) =>
|
||||
option.functionId === 'battle_use_skill' &&
|
||||
option.payload?.skillId === 'slash',
|
||||
);
|
||||
assert.ok(skillOption);
|
||||
assert.equal(skillOption.actionText, '试锋斩');
|
||||
assert.equal(skillOption.disabled, true);
|
||||
assert.match(skillOption.reason ?? '', /冷却中/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve inventory_use and persist updated resources', async () => {
|
||||
await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123');
|
||||
|
||||
Reference in New Issue
Block a user