This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -15,6 +15,7 @@ vi.mock('../../services/rpg-runtime', () => ({
}));
import { generateNextStep } from '../../services/aiService';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
@@ -138,6 +139,60 @@ function createFallbackStory(text = 'fallback'): StoryMoment {
};
}
function createCustomWorldProfileForSceneAct(sceneId: string) {
return {
id: 'custom-world-test',
name: '场景幕重置测试',
summary: '用于验证战败后回到首幕。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: `${sceneId}-chapter`,
sceneId,
title: '测试章节',
summary: '测试章节摘要',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: `${sceneId}-act-1`,
sceneId,
title: '第一幕',
summary: '开场第一幕',
stageCoverage: ['opening'],
backgroundImageSrc: '/act-1.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第一幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第一幕目标',
transitionHook: '第一幕过渡',
},
{
id: `${sceneId}-act-2`,
sceneId,
title: '第二幕',
summary: '推进第二幕',
stageCoverage: ['expansion'],
backgroundImageSrc: '/act-2.png',
encounterNpcIds: [],
primaryNpcId: null,
oppositeNpcId: null,
eventDescription: '第二幕事件',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '完成第二幕目标',
transitionHook: '第二幕过渡',
},
],
},
],
} as NonNullable<GameState['customWorldProfile']>;
}
const neverNpcEncounter = (
encounter: GameState['currentEncounter'],
): encounter is Encounter => false;
@@ -634,6 +689,144 @@ describe('createStoryChoiceActions', () => {
);
});
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
vi.useFakeTimers();
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
const customWorldProfile = createCustomWorldProfileForSceneAct(firstScene.id);
const state = {
...createBaseState(),
customWorldProfile,
currentScenePreset: firstScene,
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: firstScene.id,
chapterId: `${firstScene.id}-chapter`,
currentActId: `${firstScene.id}-act-2`,
currentActIndex: 1,
completedActIds: [`${firstScene.id}-act-1`],
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
},
},
currentEncounter: {
id: 'npc-opponent',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
},
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
playerHp: 0,
inBattle: false,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_defeat' as const,
};
const finalizeNpcBattleResult = vi.fn(() => ({
nextState: afterSequence,
resultText: '不应该进入胜利结算',
}));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 0,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult,
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
const choicePromise = handleChoice(option);
await vi.advanceTimersByTimeAsync(3000);
await choicePromise;
vi.useRealTimers();
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
expect(setGameState).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
playerHp: 0,
inBattle: false,
currentNpcBattleOutcome: 'fight_defeat',
animationState: AnimationState.DIE,
}),
);
expect(setGameState).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: firstScene.id,
}),
playerHp: 100,
inBattle: false,
currentNpcBattleOutcome: null,
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
sceneId: firstScene.id,
currentActId: `${firstScene.id}-act-1`,
currentActIndex: 0,
}),
}),
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
});
it('settles escape locally without ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);

View File

@@ -683,6 +683,44 @@ describe('npcEncounterActions', () => {
});
});
it('does not turn fight_defeat into a local npc victory settlement', () => {
const actions = createNpcEncounterActions({
gameState: createState({
inBattle: false,
playerHp: 0,
currentBattleNpcId: 'npc-rival',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: 'fight_defeat',
sceneHostileNpcs: [
{
id: 'npc-rival',
name: '断桥客',
action: '逼近',
description: '拦路旧敌',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.4,
speed: 7,
hp: 12,
maxHp: 12,
renderKind: 'npc',
},
],
}),
});
const result = actions.finalizeNpcBattleResult(
actions.gameState,
actions.gameState.playerCharacter!,
'fight',
'fight_defeat',
);
expect(result).toBeNull();
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -1,3 +1,8 @@
import { createNpcBattleMonster } from '../../data/npcInteractions';
import {
buildNpcBattleFormationFromEncounter,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getForwardScenePreset } from '../../data/scenePresets';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -11,9 +16,93 @@ import {
type RuntimeStoryResponse,
type RuntimeStorySnapshotRequest,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
import { buildMapTravelResolution } from './storyGenerationState';
function isNpcBattleAlignmentDebugEnabled() {
if (typeof window === 'undefined') {
return false;
}
return (
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
window.location.search.includes('npcBattleAlignmentDebug=1')
);
}
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
if (!isNpcBattleAlignmentDebugEnabled()) {
return;
}
console.info(
`[npc-battle-alignment] ${label}`,
monsters.map((monster) => ({
id: monster.id,
encounterId: monster.encounter?.id ?? null,
encounterName: monster.encounter?.npcName ?? null,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
facing: monster.facing,
animation: monster.animation,
})),
);
}
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
return monsters.map(
(monster) =>
({
...monster,
encounter: monster.encounter
? {
...monster.encounter,
}
: monster.encounter,
}) satisfies SceneHostileNpc,
);
}
function alignBattleFormationToVisibleFormation(params: {
visibleFormation: GameState['sceneHostileNpcs'];
battleFormation: GameState['sceneHostileNpcs'];
}) {
const { visibleFormation, battleFormation } = params;
if (visibleFormation.length === 0 || battleFormation.length === 0) {
return battleFormation;
}
const visibleFormationByEncounterId = new Map(
visibleFormation.map((monster) => [
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
monster,
]),
);
return battleFormation.map((monster) => {
const encounterKey =
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
if (!visibleMonster) {
return monster;
}
return {
...monster,
xMeters: visibleMonster.xMeters,
yOffset: visibleMonster.yOffset,
facing: visibleMonster.facing,
encounter: monster.encounter
? {
...monster.encounter,
xMeters:
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
}
: monster.encounter,
} satisfies SceneHostileNpc;
});
}
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
? response.viewModel.availableOptions
@@ -120,6 +209,102 @@ function bridgeServerSceneTravelSnapshot(params: {
} satisfies HydratedSavedGameSnapshot;
}
function bridgeServerNpcBattleSnapshot(params: {
previousState: GameState;
hydratedSnapshot: HydratedSavedGameSnapshot;
functionId: string;
}) {
const { previousState, hydratedSnapshot, functionId } = params;
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
return hydratedSnapshot;
}
const snapshotState = hydratedSnapshot.gameState;
const isNpcBattleActive =
snapshotState.inBattle &&
Boolean(snapshotState.currentBattleNpcId) &&
Boolean(snapshotState.currentNpcBattleMode);
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
const sourceEncounter =
previousState.currentEncounter?.kind === 'npc'
? previousState.currentEncounter
: null;
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
if (!isNpcBattleActive || !sourceEncounter) {
return hydratedSnapshot;
}
const fallbackNpcState =
snapshotState.npcStates[
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
] ??
previousState.npcStates[
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
] ?? {
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
};
const battleMode =
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
state: previousState,
encounter: {
...sourceEncounter,
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
},
mode: battleMode,
});
const fallbackFormation =
previousState.sceneHostileNpcs.length > 0
? cloneBattleFormation(previousState.sceneHostileNpcs)
: fallbackFormationFromSceneAct.length > 0
? fallbackFormationFromSceneAct
: [
createNpcBattleMonster(
sourceEncounter,
fallbackNpcState,
battleMode,
{
worldType: snapshotState.worldType,
customWorldProfile: snapshotState.customWorldProfile,
},
),
];
const resolvedBattleFormation = hasResolvedBattleMonster
? alignBattleFormationToVisibleFormation({
visibleFormation: previousState.sceneHostileNpcs,
battleFormation: snapshotState.sceneHostileNpcs,
})
: fallbackFormation;
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
return {
...hydratedSnapshot,
gameState: {
...snapshotState,
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
// 若上一帧还没有 battle combatants则从幕预览/当前遭遇恢复完整 NPC 编队,
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
sceneHostileNpcs: resolvedBattleFormation,
currentEncounter: null,
npcInteractionActive: false,
},
} satisfies HydratedSavedGameSnapshot;
}
/**
* 前端访问服务端 runtime story 的统一网关。
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
@@ -204,7 +389,11 @@ export async function resolveServerRuntimeChoice(params: {
});
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
previousState: params.gameState,
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
previousState: params.gameState,
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
functionId: params.option.functionId,
}),
functionId: params.option.functionId,
});

View File

@@ -653,6 +653,515 @@ describe('runtimeStoryCoordinator', () => {
);
});
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
npcDescription: '拦路的刀客',
npcAvatar: '/npc-bandit.png',
context: '断桥口',
hostile: true,
initialAffinity: -12,
},
npcInteractionActive: true,
} as GameState;
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-bandit',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
options: [],
},
patches: [],
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
npcDescription: '拦路的刀客',
context: '断桥口',
hostile: true,
} as GameState['currentEncounter'],
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: true,
currentBattleNpcId: 'npc-bandit',
currentNpcBattleMode: 'fight',
}),
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
encounter: expect.objectContaining({
id: 'npc-bandit',
npcName: '断桥匪首',
}),
renderKind: 'npc',
}),
);
expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull();
expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false);
});
it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
initialAffinity: -20,
},
npcInteractionActive: true,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 3.2,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 4.28,
yOffset: 62,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 4.28,
},
},
] as GameState['sceneHostileNpcs'],
} as GameState;
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-front',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
hostile: true,
affinity: -20,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '正面对手带着同伴压了上来。',
options: [],
},
patches: [],
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
id: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手',
context: '桥口',
hostile: true,
} as GameState['currentEncounter'],
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: true,
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
}),
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
encounterId: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{
encounterId: 'npc-front',
xMeters: 3.2,
yOffset: 0,
},
{
encounterId: 'npc-back-1',
xMeters: 4.28,
yOffset: 62,
},
]);
});
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
const gameState = {
...createTravelGameState(),
currentEncounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
initialAffinity: -20,
},
npcInteractionActive: true,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 3.2,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 4.28,
yOffset: 62,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 4.28,
},
},
] as GameState['sceneHostileNpcs'],
} as GameState;
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-front',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
hostile: true,
affinity: -20,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_attack_basic',
actionText: '普通攻击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '正面对手带着同伴压了上来。',
options: [],
},
patches: [],
snapshot: createRuntimeNpcBattleSnapshot({
currentEncounter: {
kind: 'npc',
id: 'npc-front',
npcName: '正面对手',
npcDescription: '正面对手',
context: '桥口',
hostile: true,
} as GameState['currentEncounter'],
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc-opponent-npc-front',
name: '正面对手',
action: '摆开架势,随时准备出手',
description: '正面对手',
animation: 'idle',
xMeters: 1.4,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 8,
hp: 88,
maxHp: 88,
renderKind: 'npc',
encounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '/npc-front.png',
context: '桥口',
hostile: true,
xMeters: 1.4,
},
},
{
id: 'npc-opponent-npc-back-1',
name: '后排甲',
action: '摆开架势,随时准备出手',
description: '后排甲',
animation: 'idle',
xMeters: 2.1,
yOffset: 16,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 76,
maxHp: 76,
renderKind: 'npc',
encounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '后排甲',
npcDescription: '后排甲',
npcAvatar: '/npc-back-1.png',
context: '桥口',
hostile: true,
xMeters: 2.1,
},
},
] as GameState['sceneHostileNpcs'],
inBattle: true,
currentBattleNpcId: 'npc-front',
currentNpcBattleMode: 'fight',
}),
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
encounterId: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{
encounterId: 'npc-front',
xMeters: 3.2,
yOffset: 0,
},
{
encounterId: 'npc-back-1',
xMeters: 4.28,
yOffset: 62,
},
]);
});
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
const gameState = createTravelGameState();
const currentStory = createStory('桥口这一段已经收束。');

View File

@@ -151,6 +151,14 @@ function buildDeterministicStoryForState(params: {
} satisfies StoryMoment;
}
function isLocalNpcBattleVictoryOutcome(
battleOutcome: GameState['currentNpcBattleOutcome'],
) {
return (
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
);
}
export async function runLocalStoryChoiceContinuation(params: {
gameState: GameState;
currentStory: StoryMoment | null;
@@ -239,9 +247,7 @@ export async function runLocalStoryChoiceContinuation(params: {
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
(projectedState.currentNpcBattleOutcome ||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
!projectedState.inBattle)),
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
@@ -447,7 +453,11 @@ export async function runLocalStoryChoiceContinuation(params: {
if (
resolvedChoice.optionKind === 'battle' &&
(!nextState.inBattle || nextState.currentNpcBattleOutcome === 'spar_complete')
(
nextState.currentNpcBattleOutcome === 'fight_victory' ||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
)
) {
const postBattleState = buildPostBattleVictoryState(nextState);
const postBattle = buildPostBattleVictoryStory(

View File

@@ -452,6 +452,104 @@ describe('storyChoiceRuntime', () => {
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
const gameState = createState({
worldType: 'WUXIA',
inBattle: true,
playerHp: 6,
playerMaxHp: 30,
playerMana: 10,
playerMaxMana: 10,
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
connectedSceneIds: [],
connections: [],
forwardSceneId: null,
treasureHints: [],
npcs: [],
},
sceneHostileNpcs: [
{
id: 'wolf',
name: '山狼',
action: '逼近',
description: '山狼',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 4,
maxHp: 18,
},
],
});
const finalState = createState({
...gameState,
inBattle: false,
playerHp: 0,
currentEncounter: null,
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_defeat',
});
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
response: {
presentation: {
battle: {
targetId: 'wolf',
damageDealt: 22,
damageTaken: 8,
outcome: 'defeat',
},
resultText: '你在山狼的反扑下倒地。',
},
},
hydratedSnapshot: {
gameState: finalState,
},
nextStory: createStory('不会进入胜利文本'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory: createStory('当前故事'),
option: createOption('battle_all_in_crush'),
character: createCharacter(),
setBattleReward: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
turnVisualMs: 1,
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
playerHp: 0,
animationState: 'die',
inBattle: false,
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('重新醒来'),
}),
);
expect(setCurrentStory).not.toHaveBeenCalledWith(
expect.objectContaining({
text: '不会进入胜利文本',
}),
);
});
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
const gameState = createState({
currentScenePreset: {

View File

@@ -458,7 +458,7 @@ async function playServerBattlePresentation(params: {
const targetDefeated =
battle.outcome === 'victory' ||
battle.outcome === 'spar_complete' ||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
params.setGameState({
...actingState,
playerHp: params.finalState.playerHp,

View File

@@ -424,6 +424,12 @@ export function createStoryNpcEncounterActions({
if (!npcState) return null;
const activeBattleHostiles = state.sceneHostileNpcs;
// 中文注释:只有正式胜利或切磋完成才允许进入 NPC 战后收束;
// 若当前是 fight_defeat则必须交回死亡复活链不能继续发奖励或推进剧情幕。
if (battleMode === 'fight' && battleOutcome !== 'fight_victory') {
return null;
}
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
const restoredEncounter = state.sparReturnEncounter;