This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -229,7 +229,92 @@ describe('buildBattlePlan', () => {
);
});
it('does not turn recovery fallback into a random player attack', () => {
it('keeps battle_attack_basic as a single basic attack instead of randomly selecting another skill', () => {
const state = {
...createBaseState(),
playerMana: 20,
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 80,
maxHp: 80,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
const playerTurns = plan.turns.filter((turn) => turn.actor === 'player');
expect(playerTurns).toHaveLength(1);
expect(playerTurns[0]).toEqual(
expect.objectContaining({
selectedSkillId: 'battle-basic-attack',
}),
);
expect(plan.finalState.playerMana).toBe(state.playerMana);
});
it('resolves one full speed-ordered round when combat continues', () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
expect(plan.finalState.inBattle).toBe(true);
expect(plan.finalState.sceneHostileNpcs[0]?.hp).toBeGreaterThan(0);
});
it('keeps recovery as a player turn without converting it into an attack', () => {
const state = {
...createBaseState(),
playerHp: 40,
@@ -265,8 +350,77 @@ describe('buildBattlePlan', () => {
minTurnCount: 1,
});
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
expect(playerTurn).toEqual(
expect.objectContaining({
actor: 'player',
actionKind: 'recover',
selectedSkillId: null,
damage: 0,
}),
);
expect(plan.finalState.playerHp).toBeGreaterThan(state.playerHp);
expect(plan.finalState.playerMana).toBeGreaterThan(state.playerMana);
});
it('includes companion turns in fight mode and orders the round by speed', () => {
const state = {
...createBaseState(),
currentNpcBattleMode: 'fight' as const,
companions: [
{
npcId: 'companion-1',
characterId: 'archer-hero',
joinedAtAffinity: 10,
hp: 60,
maxHp: 60,
mana: 20,
maxMana: 20,
skillCooldowns: {},
},
],
sceneHostileNpcs: [
{
id: 'monster-1',
name: '山狼',
action: '压低身体',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 0.5,
hp: 120,
maxHp: 120,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_attack_basic',
},
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual([
'companion',
'player',
'monster',
]);
expect(plan.turns[0]).toEqual(
expect.objectContaining({
actor: 'companion',
companionNpcId: 'companion-1',
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -185,4 +185,62 @@ describe('escapeFlow', () => {
expect(result.scrollWorld).toBe(false);
expect(result.playerFacing).toBe('right');
});
it('plays left exit and right-facing entry when escape targets a scene start', async () => {
const state = {
...createState(),
currentScenePreset: {
id: 'scene-bridge',
name: 'Bridge',
description: 'Bridge',
imageSrc: '/bridge.png',
worldType: WorldType.WUXIA,
connectedSceneIds: [],
connections: [],
npcs: [],
treasureHints: [],
},
};
const targetScene = {
...state.currentScenePreset!,
id: 'scene-east',
name: 'East Street',
};
const option = {
...createEscapeOption(),
runtimePayload: {
escapeTargetSceneId: targetScene.id,
escapeEntry: 'from_left',
},
};
const finalState = buildEscapeAfterSequence(state, option, targetScene);
const committedStates: GameState[] = [];
const result = await playEscapeSequenceWithStorySync({
setGameState: (nextState: GameState) => {
committedStates.push(nextState);
},
state,
option,
finalState,
sleepMs: async () => {
await Promise.resolve();
},
});
expect(committedStates[0]).toEqual(expect.objectContaining({
playerFacing: 'left',
animationState: AnimationState.RUN,
scrollWorld: true,
}));
expect(committedStates.some((committedState) =>
committedState.currentScenePreset?.id === 'scene-east' &&
committedState.playerX < 0 &&
committedState.playerFacing === 'right',
)).toBe(true);
expect(result.currentScenePreset?.id).toBe('scene-east');
expect(result.playerX).toBe(0);
expect(result.playerFacing).toBe('right');
expect(result.scrollWorld).toBe(false);
});
});

View File

@@ -1,9 +1,19 @@
import type { Dispatch, SetStateAction } from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import {
getFacingTowardPlayer,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
@@ -15,6 +25,9 @@ import {
const ESCAPE_RUN_MS = 5000;
const ESCAPE_TICK_MS = 250;
const ESCAPE_TURN_PAUSE_MS = 180;
const ESCAPE_ENTRY_MS = 900;
const ESCAPE_ENTRY_TICK_MS = 90;
const ESCAPE_PLAYER_ENTRY_X = -1.4;
export type EscapePlaybackSync = {
waitForStoryResponse?: Promise<void>;
@@ -22,7 +35,7 @@ export type EscapePlaybackSync = {
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
function sleep(ms: number) {
function sleep(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
@@ -35,6 +48,10 @@ function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
}));
}
function lerpMeters(start: number, end: number, progress: number) {
return Number((start + ((end - start) * progress)).toFixed(2));
}
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
@@ -44,18 +61,22 @@ export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
export function buildEscapeAfterSequence(
state: GameState,
option: StoryOption,
nextScenePreset: GameState['currentScenePreset'] = state.currentScenePreset,
) {
const escapePlayerX = getEscapeSettlePlayerX(state, option);
return {
const shouldResetToSceneStart =
nextScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
const baseState = {
...state,
currentScenePreset: nextScenePreset ?? state.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneHostileNpcs: [],
playerX: escapePlayerX,
playerX: shouldResetToSceneStart ? 0 : escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -66,6 +87,66 @@ export function buildEscapeAfterSequence(
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
} satisfies GameState;
const previewState = shouldResetToSceneStart
? ({
...baseState,
...createSceneEncounterPreview(baseState),
} satisfies GameState)
: baseState;
return hasEncounterEntity(previewState)
? resolveSceneEncounterPreview(previewState)
: baseState;
}
async function playEscapeEntrySequence(params: {
setGameState: SetGameStateFn;
finalState: GameState;
sleepMs: (ms: number) => Promise<void>;
}) {
const entryState = buildEncounterEntryState(
{
...params.finalState,
playerX: ESCAPE_PLAYER_ENTRY_X,
playerFacing: 'right',
animationState: AnimationState.RUN,
playerActionMode: 'idle',
scrollWorld: true,
},
CALL_OUT_ENTRY_X_METERS,
);
const runTicks = Math.max(1, Math.ceil(ESCAPE_ENTRY_MS / ESCAPE_ENTRY_TICK_MS));
const tickDurationMs = Math.max(1, Math.round(ESCAPE_ENTRY_MS / runTicks));
let currentState = entryState;
params.setGameState(currentState);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
const interpolatedState = interpolateEncounterTransitionState(
entryState,
params.finalState,
progress,
);
currentState = {
...interpolatedState,
playerX: lerpMeters(
ESCAPE_PLAYER_ENTRY_X,
params.finalState.playerX,
progress,
),
playerFacing: 'right',
animationState:
progress < 1 ? AnimationState.RUN : params.finalState.animationState,
playerActionMode: 'idle',
scrollWorld: progress < 1,
};
params.setGameState(currentState);
await params.sleepMs(tickDurationMs);
}
params.setGameState(params.finalState);
return params.finalState;
}
export async function playEscapeSequenceWithStorySync(params: {
@@ -93,6 +174,9 @@ export async function playEscapeSequenceWithStorySync(params: {
const settlePlayerX = finalState.playerX;
let storyResponseReady = !sync?.waitForStoryResponse;
let elapsedMs = 0;
const shouldPlayEntry =
finalState.currentScenePreset?.id !== state.currentScenePreset?.id ||
option.runtimePayload?.escapeReturnToSceneStart === true;
void sync?.waitForStoryResponse?.then(() => {
storyResponseReady = true;
@@ -127,19 +211,39 @@ export async function playEscapeSequenceWithStorySync(params: {
await sleepMs(ESCAPE_TICK_MS);
}
currentState = {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left',
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
const settledExitState: GameState = shouldPlayEntry
? {
...finalState,
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, 0),
}
: {
...finalState,
playerX: settlePlayerX,
playerFacing: 'left' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
currentState = settledExitState;
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
if (shouldPlayEntry) {
return playEscapeEntrySequence({
setGameState,
finalState: settledExitState,
sleepMs,
});
}
currentState = {
...currentState,
playerFacing: 'right',

View File

@@ -52,6 +52,20 @@ function sleep(ms: number) {
}
function getSkillById(character: Character, skillId: string) {
if (skillId === 'battle-basic-attack') {
return {
id: 'battle-basic-attack',
name: '普通攻击',
animation: AnimationState.ATTACK,
damage: 0,
manaCost: 0,
cooldownTurns: 0,
range: 1,
style: 'steady',
delivery: 'melee',
} satisfies CharacterSkillDefinition;
}
return character.skills.find(skill => skill.id === skillId) ?? null;
}
@@ -192,6 +206,47 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.actionKind === 'recover') {
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
if (step.actionKind === 'inventory') {
currentState = {
...currentState,
animationState: AnimationState.ACQUIRE,
playerActionMode: 'idle',
playerHp: step.playerHpAfterAction,
playerMana: step.playerManaAfterAction,
playerSkillCooldowns: step.appliedCooldowns,
playerInventory:
step.playerInventoryAfterAction ?? currentState.playerInventory,
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(Math.max(180, resetStageMs));
currentState = {
...currentState,
animationState: AnimationState.IDLE,
playerActionMode: 'idle',
activeCombatEffects: [],
};
setGameState(currentState);
await sleep(resetStageMs);
continue;
}
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
if (!skill) {
await sleep(resetStageMs);
@@ -353,7 +408,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
};
setGameState(currentState);
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
if (step.delivery === 'melee' && Math.abs(step.strikeOffsetX) > 0.01) {
currentState = {
...currentState,
companions: updateCompanionState(
@@ -740,4 +795,3 @@ export function createCombatPlayback(params: CombatPlaybackParams) {
playResolvedChoice,
};
}

View File

@@ -240,6 +240,50 @@ describe('buildResolvedChoiceState', () => {
expect(resolved.afterSequence.playerFacing).toBe('right');
});
it('moves escape result to explicit target scene and resets player to scene start', () => {
const state = {
...createBaseState(),
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
sceneHostileNpcs: [
{
id: 'monster-1',
name: 'Wolf',
action: 'growls',
description: 'A wolf',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option = {
...createOption('battle_escape_breakout'),
runtimePayload: {
escapeTargetSceneId: 'scene-3',
escapeEntry: 'from_left',
},
};
const resolved = buildResolvedChoiceState({
state,
option,
character: createTestCharacter(),
buildBattlePlan: vi.fn(),
});
expect(resolved.optionKind).toBe('escape');
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
expect(resolved.afterSequence.playerX).toBe(0);
expect(resolved.afterSequence.playerFacing).toBe('right');
expect(resolved.afterSequence.inBattle).toBe(false);
});
it('keeps idle follow-up generation separate from combat planning', () => {
const state = {
...createBaseState(),

View File

@@ -58,15 +58,17 @@ function getSceneTargetForFunction(
): GameState['currentScenePreset'] {
if (!worldType) return currentScenePreset;
if (option.functionId === 'idle_travel_next_scene') {
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
const targetSceneId =
typeof option.runtimePayload?.targetSceneId === 'string'
? option.runtimePayload.targetSceneId
: typeof option.runtimePayload?.escapeTargetSceneId === 'string'
? option.runtimePayload.escapeTargetSceneId
: null;
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (targetSceneId) {
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
}
if (option.functionId === 'idle_travel_next_scene') {
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
}
@@ -114,7 +116,7 @@ export function buildResolvedChoiceState(params: {
return {
optionKind,
battlePlan: null,
afterSequence: buildEscapeAfterSequence(state, option),
afterSequence: buildEscapeAfterSequence(state, option, nextScenePreset),
} satisfies ResolvedChoiceState;
}

View File

@@ -738,4 +738,204 @@ describe('createStoryChoiceActions', () => {
expect.objectContaining({ hostileNpcsDefeated: 0 }),
);
});
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
const state = {
...createBaseState(),
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option: StoryOption = {
...createBattleOption('battle_use_skill'),
runtimePayload: {
skillId: 'skill-basic',
},
};
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const buildResolvedChoiceState = vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence: state,
}));
const playResolvedChoice = vi.fn().mockResolvedValue(state);
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: true,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [option]),
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
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: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
option,
state.playerCharacter!,
);
expect(playResolvedChoice).toHaveBeenCalled();
expect(setGameState).toHaveBeenCalled();
expect(setCurrentStory).toHaveBeenCalled();
});
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
const battleOption = createBattleOption('battle_attack_basic');
const state = {
...createBaseState(),
inBattle: false,
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const currentStory: StoryMoment = {
text: '山狼还在你面前压低身位,战斗并未真正结束。',
options: [battleOption],
};
const buildResolvedChoiceState = vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence: {
...state,
inBattle: true,
},
}));
const playResolvedChoice = vi.fn().mockResolvedValue({
...state,
inBattle: true,
});
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: true,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => [battleOption]),
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
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: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(battleOption);
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
state,
battleOption,
state.playerCharacter!,
);
expect(playResolvedChoice).toHaveBeenCalled();
});
});

View File

@@ -77,6 +77,39 @@ type IncrementRuntimeStats = (
increments: RuntimeStatsIncrements,
) => GameState;
function isImmediateCombatChoice(option: StoryOption) {
return (
option.functionId.startsWith('battle_') ||
option.functionId === 'inventory_use'
);
}
function shouldResolveCombatChoiceLocally(
gameState: GameState,
currentStory: StoryMoment | null,
option: StoryOption,
) {
if (!isImmediateCombatChoice(option)) {
return false;
}
if (gameState.inBattle) {
return true;
}
const hasBattleMarkers =
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
const storyStillShowsBattleChoices = Boolean(
currentStory?.options.some(isImmediateCombatChoice),
);
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
return hasBattleMarkers || storyStillShowsBattleChoices;
}
export function createStoryChoiceActions({
gameState,
currentStory,
@@ -219,7 +252,10 @@ export function createStoryChoiceActions({
return;
}
if (isRpgRuntimeServerFunctionId(option.functionId)) {
if (
isRpgRuntimeServerFunctionId(option.functionId) &&
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
) {
await runServerRuntimeChoiceAction({
gameState,
currentStory,

View File

@@ -1284,7 +1284,7 @@ describe('npcEncounterActions', () => {
]);
});
it('lets player exit hostile chat and offers fight or escape instead of continuing adventure', async () => {
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
@@ -1320,19 +1320,43 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect(lastStory.options[0]).toEqual(expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
}));
expect(lastStory.options.slice(1)).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-east',
escapeTargetSceneId: 'scene-east',
escapeEntry: 'from_left',
}),
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃',
actionText: '逃往南侧河滩雾气更重。',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-south',
escapeTargetSceneId: 'scene-south',
escapeEntry: 'from_left',
}),
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
runtimePayload: expect.objectContaining({
targetSceneId: 'scene-bridge',
escapeTargetSceneId: 'scene-bridge',
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
}),
}),
]);
expect(lastStory.options).not.toEqual(
@@ -1417,7 +1441,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredOptions).toBeUndefined();
@@ -1469,6 +1501,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredOptions).toBeUndefined();
@@ -1523,6 +1564,15 @@ describe('npcEncounterActions', () => {
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往东侧旧街还亮着灯。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃往南侧河滩雾气更重。',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃回当前场景起点',
}),
]);
expect(lastStory.deferredRuntimeState).toBeUndefined();

View File

@@ -1,16 +1,18 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import { getForwardScenePreset } from '../../data/scenePresets';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
resolveRpgRuntimeStoryAction,
type RuntimeStorySnapshotRequest,
resolveRpgRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RuntimeStorySnapshotRequest,
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { buildMapTravelResolution } from './storyGenerationState';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
return response.viewModel.availableOptions.length > 0
@@ -29,6 +31,95 @@ function buildRuntimeSnapshotRequest(
};
}
function resolveServerTravelTargetSceneId(params: {
previousState: GameState;
snapshotState: GameState;
}) {
const { previousState, snapshotState } = params;
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
if (
snapshotSceneId &&
snapshotSceneId !== previousState.currentScenePreset?.id
) {
return snapshotSceneId;
}
if (!previousState.worldType) {
return null;
}
return (
getForwardScenePreset(
previousState.worldType,
previousState.currentScenePreset?.id,
)?.id ??
previousState.currentScenePreset?.forwardSceneId ??
null
);
}
function bridgeServerSceneTravelSnapshot(params: {
previousState: GameState;
hydratedSnapshot: HydratedSavedGameSnapshot;
functionId: string;
}) {
const { previousState, hydratedSnapshot, functionId } = params;
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
return hydratedSnapshot;
}
const targetSceneId = resolveServerTravelTargetSceneId({
previousState,
snapshotState: hydratedSnapshot.gameState,
});
if (!targetSceneId) {
return hydratedSnapshot;
}
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
if (!travelResolution) {
return hydratedSnapshot;
}
return {
...hydratedSnapshot,
gameState: {
...hydratedSnapshot.gameState,
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
currentScenePreset: travelResolution.nextState.currentScenePreset,
currentEncounter: travelResolution.nextState.currentEncounter,
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
playerX: travelResolution.nextState.playerX,
playerFacing: travelResolution.nextState.playerFacing,
animationState: travelResolution.nextState.animationState,
playerActionMode: travelResolution.nextState.playerActionMode,
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
scrollWorld: travelResolution.nextState.scrollWorld,
inBattle: travelResolution.nextState.inBattle,
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
runtimeStats: {
...hydratedSnapshot.gameState.runtimeStats,
scenesTraveled:
travelResolution.nextState.runtimeStats.scenesTraveled,
},
quests:
hydratedSnapshot.gameState.quests.length > 0
? hydratedSnapshot.gameState.quests
: travelResolution.nextState.quests,
},
} satisfies HydratedSavedGameSnapshot;
}
/**
* 前端访问服务端 runtime story 的统一网关。
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
@@ -111,7 +202,11 @@ export async function resolveServerRuntimeChoice(params: {
payload: params.payload,
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
previousState: params.gameState,
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
functionId: params.option.functionId,
});
return {
response,

View File

@@ -56,6 +56,106 @@ function createGameState(): GameState {
} as GameState;
}
function createTravelGameState(): GameState {
return {
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 7,
worldType: WorldType.WUXIA,
currentScene: 'Story',
playerCharacter: {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '站在桥口的人。',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
personality: '谨慎',
attributes: {
strength: 8,
agility: 8,
intelligence: 6,
spirit: 6,
},
skills: [],
adventureOpenings: {},
} as GameState['playerCharacter'],
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'encounter-current',
npcName: '桥头行商',
npcDescription: '正准备收摊离开的行商',
context: '桥口',
hostile: false,
},
npcInteractionActive: true,
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
worldType: WorldType.WUXIA,
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street'],
connections: [
{
sceneId: 'wuxia-rain-street',
relativePosition: 'forward',
summary: '沿石板路继续前行',
},
],
forwardSceneId: 'wuxia-rain-street',
treasureHints: [],
npcs: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 38,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 16,
playerSkillCooldowns: {},
activeCombatEffects: [],
activeBuildBuffs: [],
playerCurrency: 0,
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,
customWorldProfile: null,
} as unknown as GameState;
}
function createRuntimeNpcBattleSnapshot(
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
) {
@@ -553,6 +653,97 @@ describe('runtimeStoryCoordinator', () => {
);
});
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
const gameState = createTravelGameState();
const currentStory = createStory('桥口这一段已经收束。');
const option = {
functionId: 'idle_travel_next_scene',
actionText: '前往相邻场景',
text: '前往相邻场景',
visuals: {
playerAnimation: 'run',
playerMoveMeters: 1,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
const serverSnapshot = {
version: 8,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('你顺着石板路继续前行。'),
gameState: {
...gameState,
runtimeActionVersion: 8,
currentScenePreset: {
...gameState.currentScenePreset!,
id: 'wuxia-rain-street',
name: '夜雨长街',
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
},
} as HydratedSavedGameSnapshot;
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 38,
maxHp: 40,
mana: 12,
maxMana: 16,
},
encounter: null,
companions: [],
availableOptions: [
{
functionId: 'idle_observe_signs',
actionText: '观察周围迹象',
scope: 'story',
},
],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '前往相邻场景',
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
storyText: '',
options: [],
},
patches: [],
snapshot: serverSnapshot,
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(result.hydratedSnapshot.gameState.currentScenePreset?.id).toBe(
'wuxia-rain-street',
);
expect(
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
).toBe(1);
expect(
Boolean(
result.hydratedSnapshot.gameState.currentEncounter ||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
),
).toBe(true);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 7,

View File

@@ -451,4 +451,101 @@ describe('storyChoiceRuntime', () => {
);
expect(setGameState).toHaveBeenLastCalledWith(finalState);
});
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
const gameState = createState({
currentScenePreset: {
id: 'wuxia-bamboo-road',
name: '竹林古道',
description: '风穿竹影,路面狭长。',
imageSrc: '/scene-a.png',
connectedSceneIds: ['wuxia-rain-street'],
connections: [
{
sceneId: 'wuxia-rain-street',
relativePosition: 'forward',
summary: '沿石板路继续前行',
},
],
forwardSceneId: 'wuxia-rain-street',
treasureHints: [],
npcs: [],
},
currentEncounter: {
kind: 'npc',
id: 'npc-bridge',
npcName: '桥头行商',
npcDescription: '正准备收摊离开的行商',
npcAvatar: '桥',
context: '桥口',
hostile: false,
},
npcInteractionActive: true,
});
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: {
...gameState,
runtimeActionVersion: 3,
currentScenePreset: {
id: 'wuxia-rain-street',
name: '夜雨长街',
description: '雨丝压低灯火,街面反着潮光。',
imageSrc: '/scene-b.png',
connectedSceneIds: ['wuxia-bamboo-road'],
connections: [
{
sceneId: 'wuxia-bamboo-road',
relativePosition: 'back',
summary: '可以沿原路退回竹林古道',
},
],
forwardSceneId: 'wuxia-ferry-bridge',
treasureHints: [],
npcs: [],
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
runtimeStats: {
...gameState.runtimeStats,
scenesTraveled: 1,
},
},
},
nextStory: createStory('服务端故事'),
});
await runServerRuntimeChoiceAction({
gameState,
currentStory: createStory('当前故事'),
option: createOption('idle_travel_next_scene'),
character: createCharacter(),
setBattleReward: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setGameState,
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
buildFallbackStoryForState: () => createStory('fallback'),
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({
currentScenePreset: expect.objectContaining({
id: 'wuxia-rain-street',
}),
runtimeStats: expect.objectContaining({
scenesTraveled: 1,
}),
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '服务端故事',
}),
);
});
});

View File

@@ -1103,7 +1103,11 @@ export function createStoryNpcEncounterActions({
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (character: Character): StoryOption => {
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
runtimePayload?: StoryOption['runtimePayload'],
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
@@ -1125,16 +1129,20 @@ export function createStoryNpcEncounterActions({
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
actionText,
text: actionText,
detailText: '',
runtimePayload: {
...(resolvedOption.runtimePayload ?? {}),
...(runtimePayload ?? {}),
},
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
@@ -1144,9 +1152,61 @@ export function createStoryNpcEncounterActions({
scrollWorld: true,
monsterChanges: [],
},
runtimePayload,
};
};
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
const currentScene = gameState.currentScenePreset;
const worldType = gameState.worldType;
const options: StoryOption[] = [];
const seenSceneIds = new Set<string>();
if (worldType && currentScene) {
for (const connection of currentScene.connections ?? []) {
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
continue;
}
seenSceneIds.add(connection.sceneId);
const targetScene = getScenePresetById(worldType, connection.sceneId);
const targetSceneName =
targetScene?.name ??
connection.summary?.trim() ??
connection.sceneId;
options.push(
buildHostileNpcEscapeOption(
character,
`逃往${targetSceneName}`,
{
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
},
),
);
}
options.push(
buildHostileNpcEscapeOption(
character,
'逃回当前场景起点',
{
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
},
),
);
}
return options.length > 0
? options
: [buildHostileNpcEscapeOption(character)];
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
@@ -1177,8 +1237,8 @@ export function createStoryNpcEncounterActions({
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
@@ -1220,7 +1280,7 @@ export function createStoryNpcEncounterActions({
actionText: '战斗',
text: '战斗',
},
buildHostileNpcEscapeOption(character),
...buildHostileNpcEscapeOptions(character),
];
};

View File

@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
import { useAuthUi } from '../../components/auth/AuthUiContext';
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
import { syncGameStatePlayTime } from '../../data/runtimeStats';
@@ -87,9 +88,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
const handleCustomWorldSelect = (
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
options?: CustomWorldRuntimeLaunchOptions,
) => {
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile);
selectCustomWorld(customWorldProfile, { mode: options?.mode });
};
const handleCharacterSelect = (

View File

@@ -30,6 +30,11 @@ import {
getScenePresetById,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import {
findCustomWorldRoleByReference,
resolveCustomWorldRoleIdReference,
resolveCustomWorldRoleIdReferences,
} from '../../services/customWorldRoleReferences';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import {
@@ -39,6 +44,7 @@ import {
Encounter,
EquipmentLoadout,
GameState,
GameRuntimeMode,
InventoryItem,
SceneActBlueprint,
SceneChapterBlueprint,
@@ -313,23 +319,39 @@ function resolveCustomWorldScenePresetByConfiguredId(
);
}
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
return [
function resolveOpeningActNpcIdPriority(
profile: CustomWorldProfile,
openingAct: SceneActBlueprint,
) {
return resolveCustomWorldRoleIdReferences(profile, [
openingAct.oppositeNpcId,
openingAct.primaryNpcId,
...openingAct.encounterNpcIds,
]
.map((npcId) => npcId.trim())
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
]);
}
function doRoleReferencesMatch(
profile: CustomWorldProfile | null,
left: string | null | undefined,
right: string | null | undefined,
) {
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
}
function findSceneNpcByRuntimeRoleId(
scenePreset: GameState['currentScenePreset'],
profile: CustomWorldProfile | null,
roleId: string,
) {
return (
scenePreset?.npcs?.find(
(npc) => npc.id === roleId || npc.characterId === roleId,
(npc) =>
doRoleReferencesMatch(profile, npc.id, roleId) ||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
doRoleReferencesMatch(profile, npc.name, roleId) ||
doRoleReferencesMatch(profile, npc.title, roleId),
) ?? null
);
}
@@ -339,9 +361,7 @@ function buildOpeningEncounterFromCustomWorldRole(
roleId: string,
): Encounter | null {
const role =
profile.storyNpcs.find((npc) => npc.id === roleId) ??
profile.playableNpcs.find((npc) => npc.id === roleId) ??
null;
findCustomWorldRoleByReference(profile, roleId);
if (!role) {
return null;
}
@@ -388,12 +408,27 @@ function resolveOpeningActEncounter(params: {
return null;
}
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
if (npcId === params.playerCharacter.id) {
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
if (
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.id,
) ||
doRoleReferencesMatch(
params.profile,
npcId,
params.playerCharacter.name,
)
) {
continue;
}
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
const sceneNpc = findSceneNpcByRuntimeRoleId(
params.scenePreset,
params.profile,
npcId,
);
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
return {
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
@@ -456,8 +491,13 @@ export function useRpgSessionBootstrap() {
setGameState(createInitialGameState());
};
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const handleCustomWorldSelect = (
customWorldProfile: CustomWorldProfile,
options?: { mode?: GameRuntimeMode },
) => {
const resolvedWorldType = WorldType.CUSTOM;
const runtimeMode: GameRuntimeMode =
options?.mode === 'play' ? 'play' : 'test';
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
buildCustomWorldRuntimeCharacters(customWorldProfile),
@@ -469,6 +509,8 @@ export function useRpgSessionBootstrap() {
...prev,
worldType: resolvedWorldType,
customWorldProfile,
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
currentScenePreset: initialScenePreset,
sceneHostileNpcs: [],
currentEncounter: null,
@@ -552,82 +594,92 @@ export function useRpgSessionBootstrap() {
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId:
prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId:
prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
const openingState = applyEquipmentLoadoutToState(
{
...prev,
playerCharacter: character,
runtimeMode:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode === 'play'
? 'play'
: 'test'
: (prev.runtimeMode ?? 'play'),
runtimePersistenceDisabled:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode !== 'play'
: prev.runtimePersistenceDisabled,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Story',
storyHistory: [],
storyEngineMemory:
resolvedWorldType === WorldType.CUSTOM
? buildOpeningStoryEngineMemory(
resolvedCustomWorldProfile,
initialScenePreset?.id,
)
: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems<InventoryItem>(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
),
),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates:
initialEncounter && initialNpcState
? {
[initialEncounter.id!]: initialNpcState,
}
: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
);
return resolvedWorldType === WorldType.CUSTOM
? openingState
: ensureSceneEncounterPreview(openingState);
});
};

View File

@@ -72,7 +72,9 @@ function buildBackstoryReveal(label: string) {
};
}
function buildSavedProfile() {
function buildSavedProfile(options: {
openingOppositeNpcId?: string;
} = {}) {
const profile = normalizeCustomWorldProfileRecord({
id: 'saved-runtime-profile',
settingText: '被海雾吞没的旧航路群岛',
@@ -318,9 +320,9 @@ function buildSavedProfile() {
title: '第一幕',
summary: '陆衡先开口试探玩家。',
stageCoverage: ['opening'],
encounterNpcIds: ['story-primary-only', 'story-act-only'],
primaryNpcId: 'story-primary-only',
oppositeNpcId: 'story-act-only',
encounterNpcIds: ['沈砺旧识', '陆衡'],
primaryNpcId: '沈砺旧识',
oppositeNpcId: options.openingOppositeNpcId ?? '陆衡',
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
@@ -389,6 +391,8 @@ function readSnapshot() {
isStoryLoading: boolean;
firstLandmarkResidueTitle: string | null;
playerCharacterName: string | null;
runtimeMode: string | null;
runtimePersistenceDisabled: boolean;
playerInventoryNames: string[];
playerEquipment: {
weapon: string | null;
@@ -398,8 +402,15 @@ function readSnapshot() {
};
}
function GameFlowHarness() {
const profile = useMemo(() => buildSavedProfile(), []);
function GameFlowHarness({
openingOppositeNpcId,
}: {
openingOppositeNpcId?: string;
} = {}) {
const profile = useMemo(
() => buildSavedProfile({ openingOppositeNpcId }),
[openingOppositeNpcId],
);
const playableCharacters = useMemo(
() => buildCustomWorldPlayableCharacters(profile),
[profile],
@@ -441,6 +452,8 @@ function GameFlowHarness() {
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
?.title ?? null,
playerCharacterName: gameState.playerCharacter?.name ?? null,
runtimeMode: gameState.runtimeMode ?? null,
runtimePersistenceDisabled: gameState.runtimePersistenceDisabled === true,
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
playerEquipment: {
weapon: gameState.playerEquipment.weapon?.name ?? null,
@@ -515,6 +528,8 @@ test('saved custom world result settings flow into game state after entering the
});
expect(readSnapshot().playerCharacterName).toBe('沈砺');
expect(readSnapshot().runtimeMode).toBe('test');
expect(readSnapshot().runtimePersistenceDisabled).toBe(true);
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
@@ -547,3 +562,38 @@ test('saved custom world result settings flow into game state after entering the
}),
);
});
test('custom world opening act accepts runtime npc id references and still starts configured npc chat', async () => {
const user = userEvent.setup();
render(<GameFlowHarness openingOppositeNpcId="character-npc-story-act-only" />);
await user.click(screen.getByRole('button', { name: '选择世界' }));
await waitFor(() => {
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
});
await user.click(screen.getByRole('button', { name: '确认角色' }));
await waitFor(() => {
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
});
expect(readSnapshot().currentEncounterName).toBe('陆衡');
await waitFor(() => {
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
});
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
WorldType.CUSTOM,
expect.objectContaining({ name: '沈砺' }),
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
});