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

View File

@@ -102,6 +102,8 @@ export default function App() {
kind: 'custom-world',
profile: customWorldProfile,
mode: options?.mode ?? 'play',
disablePersistence: options?.disablePersistence,
exitToResult: options?.returnStage === 'custom-world-result',
});
},
[createRuntimeIntent],

View File

@@ -10,7 +10,9 @@ export type RpgRuntimeAppIntent =
token: number;
kind: 'custom-world';
profile: CustomWorldProfile;
mode?: 'play' | 'test';
mode?: 'play';
disablePersistence?: boolean;
exitToResult?: boolean;
}
| {
token: number;
@@ -37,6 +39,7 @@ export function RpgRuntimeApp({
if (initialIntent.kind === 'custom-world') {
gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile, {
mode: initialIntent.mode ?? 'play',
disablePersistence: initialIntent.disablePersistence,
});
return;
}
@@ -44,7 +47,16 @@ export function RpgRuntimeApp({
gameShellProps.entry.handleContinueGame(initialIntent.snapshot);
}, [gameShellProps.entry, initialIntent]);
return <RpgRuntimeShell {...gameShellProps} onExitTestRuntime={onExitRuntime} />;
return (
<RpgRuntimeShell
{...gameShellProps}
onExitRuntimePreview={onExitRuntime}
showRuntimePreviewExit={
initialIntent?.kind === 'custom-world' &&
initialIntent.exitToResult === true
}
/>
);
}
export default RpgRuntimeApp;

View File

@@ -14,6 +14,7 @@ import {
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getMirroredStageEntityLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
@@ -173,6 +174,59 @@ describe('GameCanvasEntityLayer', () => {
expect(getNpcCombatHpTop(null, null)).toBe(CHARACTER_COMBAT_HP_TOP_PX);
});
it('does not apply scene visual ground offset twice for custom character enemies in battle', () => {
const hostileNpc = createHostileNpc({
encounter: createEncounter({
id: 'npc-shark-elder',
npcName: '珊瑚长老',
characterId: 'hero',
imageSrc: '/generated-custom-world-npc/shark-elder.png',
}),
});
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[hostileNpc]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('bottom:calc(calc(18% + 68px) + -78px)');
expect(html).not.toContain('bottom:calc(calc(18% + 68px - 78px) + -78px)');
});
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
@@ -283,4 +337,160 @@ describe('GameCanvasEntityLayer', () => {
expect(html).toContain('查看后排甲详情');
expect(html).toContain('查看后排乙详情');
});
it('keeps hostile combatant identity stable while attack position changes', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const attackingNpc = createHostileNpc({
id: 'npc-attacker',
xMeters: 0.1,
animation: 'attack',
combatMode: 'melee',
encounter: createEncounter({
id: 'npc-attacker',
npcName: '突进敌人',
}),
});
const renderedLeft = getMonsterWorldLeft(
sideAnchor,
attackingNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
);
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[attackingNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
expect(html).toContain(`left:${renderedLeft}`);
});
it('keeps enemy formation positions when battle starts before any attack dash', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;
const monsterAnchorMeters = 3.2;
const frontNpc = createHostileNpc({
id: 'npc-front',
xMeters: 3.2,
encounter: createEncounter({
id: 'npc-front',
npcName: '前排敌人',
}),
});
const backNpc = createHostileNpc({
id: 'npc-back',
xMeters: 4.28,
yOffset: 62,
encounter: createEncounter({
id: 'npc-back',
npcName: '后排敌人',
}),
});
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={true}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[frontNpc, backNpc]}
monsters={[]}
getHostileNpcOuterLeft={(hostileNpc) =>
getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)
}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={0}
/>,
);
const frontLeft = `left:${getMonsterWorldLeft(
sideAnchor,
frontNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
const backLeft = `left:${getMonsterWorldLeft(
sideAnchor,
backNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
)}`;
expect(html).toContain(frontLeft);
expect(html).toContain(backLeft);
});
});

View File

@@ -445,8 +445,6 @@ export function GameCanvasEntityLayer({
const hostileRenderKey = [
hostileNpc.id,
npcEncounter.id ?? npcEncounter.npcName,
hostileNpc.xMeters,
hostileNpc.yOffset ?? 0,
index,
].join(':');
const config = monsters.find(item => item.id === hostileNpc.id);
@@ -469,18 +467,25 @@ export function GameCanvasEntityLayer({
npcMonsterConfig
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
// 中文注释:带 characterId 的自定义世界角色在 getEncounterCharacterOpponentBottom()
// 里已经按场景立绘脚底锚点完成了一次落地修正。
// 若这里再把 getSceneNpcVisualBottomOffsetPx() 叠加到战斗实体底边,
// 就会在刚进入战斗时整队额外下沉 78px表现成敌方瞬间偏到右下角。
const battleEntityVisualOffsetPx = npcCharacter
? 0
: hostileNpcBottomOffsetPx;
const opponentBottom = npcCharacter
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx}px)`;
const entityBottomOffsetPx = npcCharacter
? getEncounterCharacterBottomOffsetPx(
stageLiftPx,
npcEncounter,
npcCharacter,
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
(hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx,
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
return (
<div

View File

@@ -18,7 +18,6 @@ import {
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getPlayerWorldLeft,
HOSTILE_NPC_SCENE_INSET_PX,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
@@ -119,7 +118,6 @@ export function GameCanvasRuntime({
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerStageLeft = getMirroredStageEntityLeft(sideAnchor, 'player');
const opponentStageLeft = getMirroredStageEntityLeft(sideAnchor, 'opponent');
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
@@ -132,15 +130,16 @@ export function GameCanvasRuntime({
: playerStageLeft;
const monsterAnchorMeters = 3.2;
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
if (!scrollWorld && hostileNpc.animation !== 'attack') {
return opponentStageLeft;
if (hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld) {
return monsterMeleeLeft;
}
const baseLeft =
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
? monsterMeleeLeft
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
return getMonsterWorldLeft(
sideAnchor,
hostileNpc.xMeters,
cameraAnchorX,
monsterAnchorMeters,
);
};
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld

View File

@@ -4,10 +4,11 @@ import type {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
export type CustomWorldRuntimeLaunchMode = 'play';
export type CustomWorldRuntimeLaunchOptions = {
mode?: CustomWorldRuntimeLaunchMode;
disablePersistence?: boolean;
returnStage?: SelectionStage | null;
};

View File

@@ -1,11 +1,16 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
import {
PuzzleRuntimeShell,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
} from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
@@ -247,3 +252,16 @@ test('合并块按实际拼块外轮廓描边', () => {
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});
test('拖拽层级辅助函数只提升当前被拖动对象', () => {
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80);
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-1', false)).toBeUndefined();
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', true)).toBeUndefined();
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81);
expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined();
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', true)).toBeUndefined();
expect(resolveDraggedMergedGroupLayer('group-1', 'group-1')).toBe(90);
expect(resolveDraggedMergedGroupLayer('group-1', 'group-2')).toBeUndefined();
});

View File

@@ -65,6 +65,35 @@ function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
@@ -186,8 +215,13 @@ export function PuzzleRuntimeShell({
} | null>(null);
const dragVisualFrameRef = useRef<number | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dragRenderTarget, setDragRenderTarget] = useState<{
pieceId: string;
groupId: string | null;
} | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
@@ -250,8 +284,18 @@ export function PuzzleRuntimeShell({
[pieces],
);
const resolvePieceCellElement = (pieceId: string) => {
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
const pieceCellElement =
(pieceElement?.parentElement as HTMLDivElement | null) ??
pieceCellElementRefMap.current.get(pieceId) ??
null;
return pieceCellElement;
};
const resetDragVisualTarget = () => {
const dragVisualTarget = dragVisualTargetRef.current;
setDragRenderTarget(null);
if (!dragVisualTarget) {
return;
}
@@ -259,6 +303,10 @@ export function PuzzleRuntimeShell({
const pieceElement = pieceElementRefMap.current.get(
dragVisualTarget.pieceId,
);
const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
@@ -319,6 +367,15 @@ export function PuzzleRuntimeShell({
resetDragVisualTarget();
}
dragVisualTargetRef.current = nextTarget;
setDragRenderTarget((currentTarget) => {
if (
currentTarget?.pieceId === nextTarget.pieceId &&
currentTarget.groupId === nextTarget.groupId
) {
return currentTarget;
}
return nextTarget;
});
const offsetX = dragSession.currentX - dragSession.startX;
const offsetY = dragSession.currentY - dragSession.startY;
@@ -327,11 +384,16 @@ export function PuzzleRuntimeShell({
if (groupId) {
const groupElement = groupElementRefMap.current.get(groupId);
if (groupElement) {
// 合并块拖动时直接提升整个组容器层级,确保完整拼块永远压在单块之上。
groupElement.style.willChange = 'transform';
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
groupElement.style.zIndex = '80';
groupElement.style.zIndex = '90';
groupElement.style.opacity = '0.95';
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.transform = '';
@@ -342,11 +404,16 @@ export function PuzzleRuntimeShell({
return;
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
// 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。
pieceCellElement.style.zIndex = '80';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.willChange = 'transform';
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
pieceElement.style.zIndex = '70';
pieceElement.style.zIndex = '81';
pieceElement.style.opacity = '0.95';
}
};
@@ -559,7 +626,8 @@ export function PuzzleRuntimeShell({
return;
}
// 拖动中的视觉更新直接写入 DOM transform避免 pointermove 触发整盘 React 重渲染导致跟手延迟
// 首帧拖拽反馈立即落到 DOM确保层级提升不会滞后一帧后续仍保留 raf 兜底连续刷新
flushDragVisual();
scheduleDragVisual();
};
@@ -575,6 +643,8 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -643,7 +713,28 @@ export function PuzzleRuntimeShell({
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
<div
key={`${cell.row}:${cell.col}`}
ref={(node) => {
if (!piece) {
return;
}
if (node) {
pieceCellElementRefMap.current.set(piece.pieceId, node);
return;
}
pieceCellElementRefMap.current.delete(piece.pieceId);
}}
data-piece-cell-id={piece?.pieceId ?? undefined}
className="relative p-1"
style={{
zIndex: resolveDraggedPieceCellLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
}}
>
<div
ref={(node) => {
if (!piece) {
@@ -656,7 +747,7 @@ export function PuzzleRuntimeShell({
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
@@ -669,6 +760,13 @@ export function PuzzleRuntimeShell({
? 'transition-colors'
: 'transition-[background-color,border-color,box-shadow,opacity]'
}`}
style={{
zIndex: resolveDraggedPieceLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
}}
onPointerDown={(event) => {
if (!piece || isMerged) {
return;
@@ -734,8 +832,13 @@ export function PuzzleRuntimeShell({
}
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10 p-1"
style={{
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,

View File

@@ -2070,8 +2070,9 @@ function SceneActPreviewRuntime({
...current,
worldType: WorldType.CUSTOM,
customWorldProfile: profile,
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
runtimeMode: 'preview',
// 中文注释:幕预览也统一复用正式 play 运行链,
// 只通过禁持久化控制“不写正式存档”。
runtimeMode: 'play',
runtimePersistenceDisabled: true,
currentScene: 'Story',
currentScenePreset: previewScenePreset,

View File

@@ -2527,7 +2527,8 @@ test('agent draft result test button enters current draft without publish gate',
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
expect.objectContaining({
mode: 'test',
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
}),
);

View File

@@ -114,10 +114,10 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
id: 'session-profile',
name: '会话旧快照',
imageSrc: '/template/old-role.png',
});
const resultProfile = buildProfile({
@@ -133,8 +133,8 @@ describe('useRpgCreationEnterWorld', () => {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: resultProfile,
generatedCustomWorldProfile: resultProfile,
agentSessionProfile: staleResultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
@@ -158,9 +158,11 @@ describe('useRpgCreationEnterWorld', () => {
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
mode: 'test',
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
});
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(resultProfile);
expect(
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
).toBe('/generated-characters/draft-role/portrait.png');

View File

@@ -21,7 +21,7 @@ type UseRpgCreationEnterWorldParams = {
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到基础 draftProfile
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到会话侧旧快照
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
@@ -42,26 +42,22 @@ export function useRpgCreationEnterWorld(
return;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
return;
// 中文注释:作品测试必须复用“结果页当前真相源”。
// 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile
// 如果这里又回退成会话里的 agentSessionProfile就会出现
// “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。
if (isAgentDraftResultView && activeAgentSessionId) {
setGeneratedCustomWorldProfile(generatedCustomWorldProfile);
}
if (!agentSessionProfile) {
return;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
handleCustomWorldSelect(agentSessionProfile, {
mode: 'test',
handleCustomWorldSelect(generatedCustomWorldProfile, {
// 中文注释:作品测试现在复用正式 play 运行链,只保留
// “返回结果页 + 禁止写正式持久化”的入口语义。
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
});
}, [
activeAgentSessionId,
agentSessionProfile,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,

View File

@@ -83,7 +83,10 @@ vi.mock('./RpgRuntimeStageRouter', () => ({
let mockVisibleGameState: GameState;
function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
function createGameState(
runtimeMode: GameState['runtimeMode'],
runtimePersistenceDisabled?: boolean,
): GameState {
return {
worldType: WorldType.CUSTOM,
customWorldProfile: null,
@@ -112,7 +115,7 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
initialItems: [],
},
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
runtimePersistenceDisabled: runtimePersistenceDisabled ?? false,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
@@ -168,8 +171,11 @@ function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
};
}
function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode);
function buildProps(
runtimeMode: GameState['runtimeMode'],
runtimePersistenceDisabled?: boolean,
): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode, runtimePersistenceDisabled);
mockVisibleGameState = gameState;
return {
session: {
@@ -254,24 +260,25 @@ beforeEach(() => {
mockVisibleGameState = createGameState('play');
});
test('测试态显示结束测试按钮并触发退出回调', async () => {
test('结果页测试入口可显示结束测试按钮并触发退出回调', async () => {
const user = userEvent.setup();
const onExitTestRuntime = vi.fn();
const onExitRuntimePreview = vi.fn();
render(
<RpgRuntimeShell
{...buildProps('test')}
onExitTestRuntime={onExitTestRuntime}
{...buildProps('play', true)}
onExitRuntimePreview={onExitRuntimePreview}
showRuntimePreviewExit
/>,
);
await user.click(screen.getByRole('button', { name: '结束测试' }));
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
expect(onExitRuntimePreview).toHaveBeenCalledTimes(1);
});
test('正式运行态不显示结束测试按钮', () => {
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
render(<RpgRuntimeShell {...buildProps('play')} />);
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
});

View File

@@ -37,7 +37,8 @@ export function RpgRuntimeShell({
companions,
audio,
chrome,
onExitTestRuntime,
onExitRuntimePreview,
showRuntimePreviewExit,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -133,7 +134,10 @@ export function RpgRuntimeShell({
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
const isTestRuntime = gameState.runtimeMode === 'test';
const canExitRuntimePreview =
Boolean(gameState.worldType) &&
Boolean(showRuntimePreviewExit) &&
Boolean(onExitRuntimePreview);
useEffect(() => {
if (gameState.worldType && !gameState.playerCharacter) {
@@ -209,7 +213,7 @@ export function RpgRuntimeShell({
</div>
)}
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
{canExitRuntimePreview ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
@@ -218,7 +222,7 @@ export function RpgRuntimeShell({
>
<button
type="button"
onClick={onExitTestRuntime}
onClick={onExitRuntimePreview}
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
>

View File

@@ -111,5 +111,6 @@ export interface RpgRuntimeShellProps {
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
onExitTestRuntime?: () => void;
onExitRuntimePreview?: () => void;
showRuntimePreviewExit?: boolean;
}

View File

@@ -14,6 +14,7 @@ import { getMonsterPresetsByWorld } from './hostileNpcPresets';
import { createSceneHostileNpc } from './hostileNpcs';
import { buildInitialNpcState } from './npcInteractions';
import {
buildNpcBattleFormationFromEncounter,
createSceneEncounterPreview,
hasAutoBattleSceneEncounter,
resolveSceneEncounterPreview,
@@ -495,4 +496,371 @@ describe('sceneEncounterPreviews', () => {
expect(resolved.inBattle).toBe(false);
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
});
it('builds active act npc battle formations with stable back-row slots', () => {
const state = {
...createBaseState(),
worldType: WorldType.CUSTOM,
customWorldProfile: {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc-front',
name: '正面对手',
title: '刀客',
description: '正面对手',
initialAffinity: -30,
imageSrc: '',
role: '敌对角色',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
relationshipHooks: [],
tags: [],
skills: [],
initialItems: [],
},
{
id: 'npc-back-1',
name: '后排甲',
title: '弓手',
description: '后排甲',
initialAffinity: -25,
imageSrc: '',
role: '敌对角色',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
relationshipHooks: [],
tags: [],
skills: [],
initialItems: [],
},
{
id: 'npc-back-2',
name: '后排乙',
title: '术士',
description: '后排乙',
initialAffinity: -20,
imageSrc: '',
role: '敌对角色',
backstory: '',
personality: '',
motivation: '',
combatStyle: '',
relationshipHooks: [],
tags: [],
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [
{
id: 'landmark-raw-1',
name: '旧桥',
description: '旧桥',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'landmark-raw-1',
title: '旧桥章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-raw-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-raw-1',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile,
currentScenePreset: {
id: 'landmark-raw-1',
name: '旧桥',
description: '旧桥',
imageSrc: '/bridge.png',
connectedSceneIds: [],
treasureHints: [],
npcs: [
{
id: 'npc-front',
name: '正面对手',
description: '正面对手',
avatar: '正',
role: '敌对角色',
initialAffinity: -30,
hostile: true,
attributeProfile: {
attributes: {},
combat: {
maxHp: 96,
attack: 12,
defense: 8,
speed: 10,
},
} as SceneNpc['attributeProfile'],
},
{
id: 'npc-back-1',
name: '后排甲',
description: '后排甲',
avatar: '甲',
role: '敌对角色',
initialAffinity: -25,
hostile: true,
attributeProfile: {
attributes: {},
combat: {
maxHp: 82,
attack: 10,
defense: 6,
speed: 9,
},
} as SceneNpc['attributeProfile'],
},
{
id: 'npc-back-2',
name: '后排乙',
description: '后排乙',
avatar: '乙',
role: '敌对角色',
initialAffinity: -20,
hostile: true,
attributeProfile: {
attributes: {},
combat: {
maxHp: 78,
attack: 9,
defense: 5,
speed: 11,
},
} as SceneNpc['attributeProfile'],
},
] satisfies SceneNpc[],
},
currentEncounter: {
id: 'npc-front',
kind: 'npc',
npcName: '正面对手',
npcDescription: '正面对手',
npcAvatar: '正',
context: '敌对角色',
hostile: true,
initialAffinity: -30,
xMeters: 3.2,
},
npcStates: {
'npc-front': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -30,
},
'npc-back-1': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -25,
},
'npc-back-2': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -20,
},
},
} satisfies GameState;
const formation = buildNpcBattleFormationFromEncounter({
state,
encounter: state.currentEncounter!,
});
expect(formation).toHaveLength(3);
expect(formation.map((monster) => monster.encounter?.id)).toEqual([
'npc-front',
'npc-back-1',
'npc-back-2',
]);
expect(
formation.map((monster) => ({
id: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
]);
});
it('keeps scene-act formation order even when the clicked encounter comes from the back row', () => {
const state = {
...createBaseState(),
worldType: WorldType.CUSTOM,
customWorldProfile: {
id: 'custom-profile',
name: '测试世界',
settingText: '',
subtitle: '',
summary: '',
tone: '',
playerGoal: '',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
attributes: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-raw-1',
name: '海底遗址',
description: '海底遗址',
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'landmark-raw-1',
title: '海底章节',
summary: '',
sceneTaskDescription: '',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-raw-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-raw-1',
title: '第一幕',
summary: '',
stageCoverage: ['opening'],
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
primaryNpcId: 'npc-front',
oppositeNpcId: 'npc-front',
eventDescription: '',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '',
transitionHook: '',
},
],
},
],
} as CustomWorldProfile,
currentScenePreset: {
id: 'landmark-raw-1',
name: '海底遗址',
description: '海底遗址',
imageSrc: '/underwater.png',
connectedSceneIds: [],
treasureHints: [],
npcs: [
{
id: 'npc-front',
name: '珊瑚祭司',
description: '前排祭司',
avatar: '祭',
role: '敌对角色',
initialAffinity: -20,
hostile: true,
},
{
id: 'npc-back-1',
name: '赤发护卫',
description: '后排护卫',
avatar: '卫',
role: '敌对角色',
initialAffinity: -20,
hostile: true,
},
{
id: 'npc-back-2',
name: '潮歌侍从',
description: '后排侍从',
avatar: '侍',
role: '敌对角色',
initialAffinity: -20,
hostile: true,
},
] satisfies SceneNpc[],
},
currentEncounter: {
id: 'npc-back-1',
kind: 'npc',
npcName: '赤发护卫',
npcDescription: '后排护卫',
npcAvatar: '卫',
context: '敌对角色',
hostile: true,
initialAffinity: -20,
xMeters: 4.28,
},
npcStates: {
'npc-front': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -20,
},
'npc-back-1': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -20,
},
'npc-back-2': {
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
affinity: -20,
},
},
} satisfies GameState;
const formation = buildNpcBattleFormationFromEncounter({
state,
encounter: state.currentEncounter!,
});
expect(
formation.map((monster) => ({
id: monster.encounter?.id,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
).toEqual([
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
]);
});
});

View File

@@ -4,7 +4,14 @@ import {
resolveActiveSceneActEncounterFocusNpcId,
resolveActiveSceneActEncounterNpcIds,
} from '../services/customWorldSceneActRuntime';
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
import {
AnimationState,
Encounter,
GameState,
SceneHostileNpc,
SceneNpc,
WorldType,
} from '../types';
import { getRecruitedNpcIds } from './companionRoster';
import {
createSceneHostileNpcsFromEncounters,
@@ -27,6 +34,41 @@ export const PREVIEW_ENTITY_X_METERS = 12;
export const RESOLVED_ENTITY_X_METERS = 3.2;
export const CALL_OUT_ENTRY_X_METERS = 18;
export const TREASURE_ENCOUNTERS_ENABLED = false;
const SCENE_ACT_BACK_ROW_BATTLE_X_METERS = Number(
(RESOLVED_ENTITY_X_METERS + 1.08).toFixed(2),
);
const SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS = [62, -46] as const;
function isNpcBattleAlignmentDebugEnabled() {
if (typeof window === 'undefined') {
return false;
}
return (
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
window.location.search.includes('npcBattleAlignmentDebug=1')
);
}
function logNpcBattleFormation(
label: string,
monsters: Array<Pick<SceneHostileNpc, 'id' | 'xMeters' | 'yOffset' | 'encounter'>>,
) {
if (!isNpcBattleAlignmentDebugEnabled()) {
return;
}
console.info(
`[npc-battle-formation] ${label}`,
monsters.map((monster) => ({
id: monster.id,
encounterId: monster.encounter?.id ?? null,
encounterName: monster.encounter?.npcName ?? null,
xMeters: monster.xMeters,
yOffset: monster.yOffset,
})),
);
}
function getNpcEncounterKey(encounter: Encounter) {
return encounter.id ?? encounter.npcName;
@@ -54,18 +96,138 @@ function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounte
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
}
function resolveSceneActEncounterMembers(
state: GameState,
encounter: Encounter,
) {
const currentSceneNpcs = state.currentScenePreset?.npcs ?? [];
if (currentSceneNpcs.length === 0) {
return [];
}
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
profile: state.customWorldProfile,
sceneId: state.currentScenePreset?.id ?? null,
storyEngineMemory: state.storyEngineMemory,
});
if (activeActNpcIds.length <= 1) {
return [];
}
const seenNpcIds = new Set<string>();
return currentSceneNpcs
.filter((candidate) => {
const candidateIds = [
candidate.id,
candidate.characterId,
candidate.name,
candidate.title,
]
.map((value) =>
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
)
.filter(Boolean);
return candidateIds.some((candidateId) => activeActNpcIds.includes(candidateId));
})
.filter((npc): npc is SceneNpc => Boolean(npc))
.filter((npc) => {
if (seenNpcIds.has(npc.id)) {
return false;
}
seenNpcIds.add(npc.id);
return true;
})
.slice(0, 3);
}
function getSceneActBattleSlots(primaryX: number) {
return [
{
xMeters: primaryX,
yOffset: 0,
},
{
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[0],
},
{
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[1],
},
] satisfies Array<Pick<SceneHostileNpc, 'xMeters' | 'yOffset'>>;
}
export function buildNpcBattleFormationFromEncounter(params: {
state: GameState;
encounter: Encounter;
mode?: 'fight' | 'spar';
}) {
const { state, encounter, mode = 'fight' } = params;
const sceneActMembers = resolveSceneActEncounterMembers(state, encounter);
const primaryX =
sceneActMembers.length > 1
? RESOLVED_ENTITY_X_METERS
: encounter.xMeters ?? RESOLVED_ENTITY_X_METERS;
const formationSourceEncounters =
sceneActMembers.length > 1
? sceneActMembers.map((member, index) =>
buildEncounterFromSceneNpc(
member,
index === 0 ? primaryX : SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
),
)
: [encounter];
const slots = getSceneActBattleSlots(primaryX);
const resolvedFormation = formationSourceEncounters.map((memberEncounter, index) => {
const slot = slots[index] ?? slots[slots.length - 1];
const npcState = getResolvedNpcState(state, memberEncounter);
const battleMonster = createNpcBattleMonster(
memberEncounter,
npcState,
mode,
{
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
},
);
return {
...battleMonster,
xMeters: slot.xMeters,
yOffset: slot.yOffset,
facing: getFacingTowardPlayer(slot.xMeters, PLAYER_BASE_X_METERS),
encounter: battleMonster.encounter
? {
...battleMonster.encounter,
xMeters: slot.xMeters,
}
: battleMonster.encounter,
} satisfies SceneHostileNpc;
});
logNpcBattleFormation(
`buildNpcBattleFormationFromEncounter:${encounter.id ?? encounter.npcName}`,
resolvedFormation,
);
return resolvedFormation;
}
function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
const npcState = getResolvedNpcState(state, encounter);
const battleNpcId = getNpcEncounterKey(encounter);
return {
...state,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight', {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
}),
],
// 中文注释:幕预览和正式运行都统一走这一套 NPC 战斗编队生成,
// 避免开战时把同幕后排角色压缩成单体,导致阵容缺失和站位突变。
sceneHostileNpcs: buildNpcBattleFormationFromEncounter({
state,
encounter,
mode: 'fight',
}),
currentEncounter: null,
npcInteractionActive: false,
playerX: 0,

View File

@@ -423,4 +423,49 @@ describe('buildBattlePlan', () => {
}),
);
});
it('prefers fight_defeat over fight_victory when the round ends with player death after local battle settlement', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight' as const,
playerHp: 6,
playerMaxHp: 30,
sceneHostileNpcs: [
{
id: 'npc-opponent',
name: '山道客',
action: '提刀逼近',
description: '测试敌人',
animation: 'idle' as const,
xMeters: 3,
yOffset: 0,
facing: 'left' as const,
attackRange: 1,
speed: 1,
hp: 8,
maxHp: 8,
},
],
};
const plan = buildBattlePlan({
state,
option: {
...createBattleOption(),
functionId: 'battle_all_in_crush',
},
character: createTestCharacter(),
totalSequenceMs: 900,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 1,
});
expect(plan.turns.map((turn) => turn.actor)).toEqual(['player', 'monster']);
expect(plan.finalState.playerHp).toBe(0);
expect(plan.finalState.inBattle).toBe(false);
expect(plan.finalState.currentNpcBattleOutcome).toBe('fight_defeat');
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
});
});

View File

@@ -87,6 +87,22 @@ export type BattlePlan = {
finalState: GameState;
};
function resolveFightBattleOutcome(state: GameState): GameState['currentNpcBattleOutcome'] {
if (state.currentNpcBattleMode === 'spar') {
return state.currentNpcBattleOutcome;
}
if (state.playerHp <= 0) {
return state.currentBattleNpcId ? 'fight_defeat' : state.currentNpcBattleOutcome;
}
if (
state.currentBattleNpcId &&
state.sceneHostileNpcs.every((monster) => monster.hp <= 0)
) {
return 'fight_victory';
}
return state.currentNpcBattleOutcome;
}
function createEmptyCooldowns(character: Character) {
return Object.fromEntries(character.skills.map((skill) => [skill.id, 0]));
}
@@ -543,11 +559,29 @@ export function buildBattlePlan({
const preparedState = simulatedState;
const turns: BattlePlanStep[] = [];
const turnOrder = buildRoundTurnOrder(simulatedState, character);
const pendingMonsterTurnIds = new Set(
turnOrder
.filter(
(turnActor): turnActor is Extract<BattleTurnActor, {actor: 'monster'}> =>
turnActor.actor === 'monster',
)
.map((turnActor) => turnActor.monsterId),
);
for (const turnActor of turnOrder) {
if (!simulatedState.inBattle || simulatedState.playerHp <= 0) {
if (
simulatedState.playerHp <= 0 ||
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.currentNpcBattleOutcome === 'fight_defeat'
) {
break;
}
if (!simulatedState.inBattle && pendingMonsterTurnIds.size === 0) {
break;
}
if (turnActor.actor === 'monster') {
pendingMonsterTurnIds.delete(turnActor.monsterId);
}
if (
turnActor.actor === 'player' &&
@@ -725,20 +759,14 @@ export function buildBattlePlan({
}
: monster,
);
const playerDefeated =
const targetDefeated =
!isNpcSpar &&
resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
const remainingMonsters = playerDefeated
? resolvedMonsters.filter(
(monster) =>
!(monster.id === currentTarget.id && monster.hp <= 0),
)
: resolvedMonsters;
const nextTarget = getClosestHostileNpc(
originalPlayerX,
remainingMonsters,
resolvedMonsters.filter((monster) => monster.hp > 0),
);
simulatedState = {
@@ -757,7 +785,7 @@ export function buildBattlePlan({
simulatedState.playerMana - selectedSkill.manaCost,
),
playerSkillCooldowns: appliedCooldowns,
sceneHostileNpcs: remainingMonsters.map((monster) => ({
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -766,14 +794,17 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: remainingMonsters.length > 0 && simulatedState.playerHp > 0,
: (resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: !isNpcSpar &&
remainingMonsters.length === 0 &&
simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
@@ -787,7 +818,7 @@ export function buildBattlePlan({
appliedCooldowns,
damage: playerDamage,
criticalHit: playerCriticalHit,
defeated: playerDefeated,
defeated: targetDefeated,
endsBattle: wouldEndSpar,
delivery: playerDelivery,
playerHpAfterAction: simulatedState.playerHp,
@@ -870,16 +901,9 @@ export function buildBattlePlan({
const defeated = resolvedMonsters.some(
(monster) => monster.id === currentTarget.id && monster.hp <= 0,
);
const remainingMonsters = defeated
? resolvedMonsters.filter(
(monster) =>
!(monster.id === currentTarget.id && monster.hp <= 0),
)
: resolvedMonsters;
simulatedState = {
...simulatedState,
sceneHostileNpcs: remainingMonsters.map((monster) => ({
sceneHostileNpcs: resolvedMonsters.map((monster) => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -894,11 +918,16 @@ export function buildBattlePlan({
}),
),
inBattle:
remainingMonsters.length > 0 && simulatedState.playerHp > 0,
(resolvedMonsters.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0) &&
simulatedState.playerHp > 0,
currentNpcBattleOutcome:
remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
? 'fight_victory'
: simulatedState.currentNpcBattleOutcome,
pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: resolvedMonsters,
}),
};
turns.push({
@@ -923,7 +952,8 @@ export function buildBattlePlan({
}
const actingMonster = simulatedState.sceneHostileNpcs.find(
(monster) => monster.id === turnActor.monsterId && monster.hp > 0,
(monster) =>
monster.id === turnActor.monsterId,
);
if (!actingMonster) {
continue;
@@ -1035,10 +1065,18 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
turns.push({
@@ -1115,10 +1153,18 @@ export function buildBattlePlan({
inBattle:
isNpcSpar
? !wouldEndSpar
: nextPlayerHp > 0 && simulatedState.sceneHostileNpcs.length > 0,
: nextPlayerHp > 0 &&
(simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0) ||
pendingMonsterTurnIds.size > 0),
currentNpcBattleOutcome: wouldEndSpar
? 'spar_complete'
: simulatedState.currentNpcBattleOutcome,
: pendingMonsterTurnIds.size > 0
? simulatedState.currentNpcBattleOutcome
: resolveFightBattleOutcome({
...simulatedState,
...damagedState,
playerHp: nextPlayerHp,
}),
};
turns.push({
@@ -1144,8 +1190,8 @@ export function buildBattlePlan({
return {
preparedState,
turns,
finalState: {
...simulatedState,
finalState: {
...simulatedState,
companions: resetCompanionCombatPresentation(simulatedState.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -1155,11 +1201,15 @@ export function buildBattlePlan({
simulatedState.currentNpcBattleOutcome === 'spar_complete' ||
simulatedState.playerHp <= 0
? false
: simulatedState.sceneHostileNpcs.length > 0,
: simulatedState.sceneHostileNpcs.some((monster) => monster.hp > 0),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneHostileNpcs,
simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
simulatedState.playerX,
),
currentNpcBattleOutcome: resolveFightBattleOutcome({
...simulatedState,
sceneHostileNpcs: simulatedState.sceneHostileNpcs.filter((monster) => monster.hp > 0),
}),
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,10 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
options?: CustomWorldRuntimeLaunchOptions,
) => {
storyFlow.resetStoryState();
selectCustomWorld(customWorldProfile, { mode: options?.mode });
selectCustomWorld(customWorldProfile, {
mode: options?.mode,
disablePersistence: options?.disablePersistence,
});
};
const handleCharacterSelect = (

View File

@@ -493,11 +493,12 @@ export function useRpgSessionBootstrap() {
const handleCustomWorldSelect = (
customWorldProfile: CustomWorldProfile,
options?: { mode?: GameRuntimeMode },
options?: { mode?: GameRuntimeMode; disablePersistence?: boolean },
) => {
const resolvedWorldType = WorldType.CUSTOM;
const runtimeMode: GameRuntimeMode =
options?.mode === 'play' ? 'play' : 'test';
const runtimeMode: GameRuntimeMode = 'play';
const runtimePersistenceDisabled =
options?.disablePersistence ?? false;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
buildCustomWorldRuntimeCharacters(customWorldProfile),
@@ -510,7 +511,7 @@ export function useRpgSessionBootstrap() {
worldType: resolvedWorldType,
customWorldProfile,
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
runtimePersistenceDisabled,
currentScenePreset: initialScenePreset,
sceneHostileNpcs: [],
currentEncounter: null,
@@ -600,13 +601,11 @@ export function useRpgSessionBootstrap() {
playerCharacter: character,
runtimeMode:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode === 'play'
? 'play'
: 'test'
? (prev.runtimeMode ?? 'play')
: (prev.runtimeMode ?? 'play'),
runtimePersistenceDisabled:
resolvedWorldType === WorldType.CUSTOM
? prev.runtimeMode !== 'play'
? prev.runtimePersistenceDisabled === true
: prev.runtimePersistenceDisabled,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
playerProgression: createInitialPlayerProgressionState(),

View File

@@ -466,7 +466,7 @@ function GameFlowHarness({
<div>
<button
type="button"
onClick={() => handleCustomWorldSelect(profile)}
onClick={() => handleCustomWorldSelect(profile, { mode: 'play' })}
>
</button>
@@ -528,8 +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().runtimeMode).toBe('play');
expect(readSnapshot().runtimePersistenceDisabled).toBe(false);
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');

View File

@@ -436,7 +436,7 @@ export async function streamNpcChatTurn(
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'spar_complete';
battleOutcome: 'victory' | 'defeat' | 'spar_complete';
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;

View File

@@ -94,7 +94,7 @@ export type NpcInteractionAction =
| 'quest_turn_in';
export type TreasureInteractionAction = 'secure' | 'inspect' | 'leave';
export type NpcBattleMode = 'fight' | 'spar';
export type NpcBattleOutcome = 'fight_victory' | 'spar_complete';
export type NpcBattleOutcome = 'fight_victory' | 'fight_defeat' | 'spar_complete';
export type CombatDelivery = 'melee' | 'ranged';
export type CombatActionMode = 'idle' | CombatDelivery;
export type SkillEffectPhase = 'cast' | 'travel' | 'impact';

View File

@@ -138,7 +138,7 @@ export interface StoryNpcChatState {
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'spar_complete';
battleOutcome: 'victory' | 'defeat' | 'spar_complete';
} | null;
}