1
This commit is contained in:
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '服务端故事',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user