This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -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');