Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -1,224 +1,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
BigFishAssetSlotResponse,
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishRuntimeShell } from './components/big-fish-runtime/BigFishRuntimeShell';
|
||||
|
||||
const BIG_FISH_BACKGROUND_IMAGE =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
||||
<defs>
|
||||
<linearGradient id="water" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8" />
|
||||
<stop offset="0.52" stop-color="#0f766e" />
|
||||
<stop offset="1" stop-color="#020617" />
|
||||
</linearGradient>
|
||||
<radialGradient id="light" cx="50%" cy="12%" r="52%">
|
||||
<stop offset="0" stop-color="#ecfeff" stop-opacity="0.72" />
|
||||
<stop offset="1" stop-color="#ecfeff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="720" height="1280" fill="url(#water)" />
|
||||
<rect width="720" height="1280" fill="url(#light)" />
|
||||
<path d="M70 324 C164 268 256 384 362 320 C492 242 582 330 656 282" fill="none" stroke="#a7f3d0" stroke-width="16" stroke-linecap="round" opacity="0.28" />
|
||||
<path d="M34 760 C156 700 238 806 372 724 C520 634 606 746 704 682" fill="none" stroke="#bae6fd" stroke-width="18" stroke-linecap="round" opacity="0.18" />
|
||||
<circle cx="120" cy="210" r="18" fill="#ecfeff" opacity="0.36" />
|
||||
<circle cx="548" cy="410" r="12" fill="#ecfeff" opacity="0.28" />
|
||||
<circle cx="304" cy="590" r="10" fill="#ecfeff" opacity="0.24" />
|
||||
<path d="M0 1060 C128 1010 244 1096 366 1030 C492 962 612 1026 720 976 V1280 H0 Z" fill="#022c22" opacity="0.62" />
|
||||
</svg>`);
|
||||
|
||||
const WORLD_MIN_X = 60;
|
||||
const WORLD_MAX_X = 780;
|
||||
const WORLD_MIN_Y = 80;
|
||||
const WORLD_MAX_Y = 1240;
|
||||
const PLAYER_SPEED = 20;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildEntity(
|
||||
entityId: string,
|
||||
level: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): BigFishRuntimeEntityResponse {
|
||||
return {
|
||||
entityId,
|
||||
level,
|
||||
position: { x, y },
|
||||
radius: 12 + level * 5,
|
||||
offscreenSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialRun(): BigFishRuntimeSnapshotResponse {
|
||||
const leader = buildEntity('player-leader', 1, 360, 640);
|
||||
return {
|
||||
runId: `local-big-fish-run-${Date.now()}`,
|
||||
sessionId: 'local-big-fish-session',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel: 5,
|
||||
leaderEntityId: leader.entityId,
|
||||
ownedEntities: [leader],
|
||||
wildEntities: [
|
||||
buildEntity('wild-small-1', 1, 250, 560),
|
||||
buildEntity('wild-small-2', 1, 470, 760),
|
||||
buildEntity('wild-mid-1', 2, 560, 520),
|
||||
buildEntity('wild-mid-2', 3, 210, 820),
|
||||
buildEntity('wild-boss-1', 5, 610, 930),
|
||||
],
|
||||
cameraCenter: { ...leader.position },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['按住屏幕任意位置,再拖动控制方向。'],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function distanceBetween(
|
||||
first: BigFishRuntimeEntityResponse,
|
||||
second: BigFishRuntimeEntityResponse,
|
||||
) {
|
||||
return Math.hypot(
|
||||
first.position.x - second.position.x,
|
||||
first.position.y - second.position.y,
|
||||
);
|
||||
}
|
||||
|
||||
function respawnWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
||||
const offset = tick * 37 + entity.level * 53;
|
||||
return {
|
||||
...entity,
|
||||
position: {
|
||||
x: WORLD_MIN_X + (offset % Math.floor(WORLD_MAX_X - WORLD_MIN_X)),
|
||||
y: WORLD_MIN_Y + ((offset * 7) % Math.floor(WORLD_MAX_Y - WORLD_MIN_Y)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
||||
const phase = tick * 0.32 + entity.level * 1.7;
|
||||
const speed = 6 + entity.level * 0.8;
|
||||
const nextX = entity.position.x + Math.cos(phase) * speed;
|
||||
const nextY = entity.position.y + Math.sin(phase * 0.73) * speed;
|
||||
return {
|
||||
...entity,
|
||||
position: {
|
||||
x: clamp(nextX, WORLD_MIN_X, WORLD_MAX_X),
|
||||
y: clamp(nextY, WORLD_MIN_Y, WORLD_MAX_Y),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyLocalInput(
|
||||
run: BigFishRuntimeSnapshotResponse,
|
||||
input: SubmitBigFishInputRequest,
|
||||
): BigFishRuntimeSnapshotResponse {
|
||||
if (run.status !== 'running') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const leader = run.ownedEntities.find(
|
||||
(entity) => entity.entityId === run.leaderEntityId,
|
||||
);
|
||||
if (!leader) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextLeader = {
|
||||
...leader,
|
||||
position: {
|
||||
x: clamp(leader.position.x + input.x * PLAYER_SPEED, WORLD_MIN_X, WORLD_MAX_X),
|
||||
y: clamp(leader.position.y + input.y * PLAYER_SPEED, WORLD_MIN_Y, WORLD_MAX_Y),
|
||||
},
|
||||
};
|
||||
|
||||
let nextPlayerLevel = run.playerLevel;
|
||||
const nextEvents = [...run.eventLog];
|
||||
const nextWildEntities = run.wildEntities.map((entity) => {
|
||||
const movedEntity = moveWildEntity(entity, run.tick + 1);
|
||||
const touched = distanceBetween(nextLeader, movedEntity) <= nextLeader.radius + movedEntity.radius;
|
||||
if (!touched) {
|
||||
return movedEntity;
|
||||
}
|
||||
|
||||
if (movedEntity.level <= nextPlayerLevel) {
|
||||
nextPlayerLevel = Math.min(run.winLevel, nextPlayerLevel + 1);
|
||||
nextEvents.push(`吞噬 Lv.${movedEntity.level},成长到 Lv.${nextPlayerLevel}`);
|
||||
return respawnWildEntity(movedEntity, run.tick + nextPlayerLevel);
|
||||
}
|
||||
|
||||
nextEvents.push(`撞上 Lv.${movedEntity.level},暂时避开更大的鱼。`);
|
||||
return movedEntity;
|
||||
});
|
||||
|
||||
const scaledLeader = {
|
||||
...nextLeader,
|
||||
level: nextPlayerLevel,
|
||||
radius: 12 + nextPlayerLevel * 5,
|
||||
};
|
||||
const status = nextPlayerLevel >= run.winLevel ? 'won' : 'running';
|
||||
if (status === 'won' && run.status !== 'won') {
|
||||
nextEvents.push('已经成长为海域霸主。');
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
status,
|
||||
tick: run.tick + 1,
|
||||
playerLevel: nextPlayerLevel,
|
||||
ownedEntities: [scaledLeader],
|
||||
wildEntities: nextWildEntities,
|
||||
cameraCenter: { ...scaledLeader.position },
|
||||
lastInput: input,
|
||||
eventLog: nextEvents.slice(-5),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function BigFishPlaygroundApp() {
|
||||
const [run, setRun] = useState(buildInitialRun);
|
||||
const assetSlots = useMemo<BigFishAssetSlotResponse[]>(
|
||||
() => [
|
||||
{
|
||||
slotId: 'local-big-fish-background',
|
||||
assetKind: 'stage_background',
|
||||
status: 'ready',
|
||||
assetUrl: BIG_FISH_BACKGROUND_IMAGE,
|
||||
promptSnapshot: '本地直达入口占位海域背景',
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmitInput = useCallback((payload: SubmitBigFishInputRequest) => {
|
||||
setRun((currentRun) => applyLocalInput(currentRun, payload));
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
setRun(buildInitialRun());
|
||||
}, []);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
useEffect(() => {
|
||||
window.location.assign('/');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BigFishRuntimeShell
|
||||
run={run}
|
||||
assetSlots={assetSlots}
|
||||
onBack={handleExit}
|
||||
onRestart={handleRestart}
|
||||
onSubmitInput={handleSubmitInput}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
|
||||
@@ -49,10 +49,6 @@ function dispatchPointerEvent(
|
||||
}
|
||||
|
||||
describe('BigFishRuntimeShell', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('renders restart and exit actions after a failed run', () => {
|
||||
const onBack = vi.fn();
|
||||
const onRestart = vi.fn();
|
||||
@@ -111,8 +107,7 @@ describe('BigFishRuntimeShell', () => {
|
||||
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps moving in the last sampled direction after drag ends', () => {
|
||||
vi.useFakeTimers();
|
||||
test('keeps moving in the last sampled direction after drag ends', async () => {
|
||||
const onSubmitInput = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
@@ -141,9 +136,10 @@ describe('BigFishRuntimeShell', () => {
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
await waitFor(() => {
|
||||
expect(onSubmitInput).toHaveBeenCalledWith({ x: 1, y: 0 });
|
||||
});
|
||||
const callCountAfterDrag = onSubmitInput.mock.calls.length;
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: 1,
|
||||
@@ -151,8 +147,9 @@ describe('BigFishRuntimeShell', () => {
|
||||
clientY: 100,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(220);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitInput.mock.calls.length).toBeGreaterThan(callCountAfterDrag);
|
||||
});
|
||||
|
||||
expect(onSubmitInput).toHaveBeenLastCalledWith({ x: 1, y: 0 });
|
||||
|
||||
@@ -56,9 +56,9 @@ import {
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
recordBigFishPlay,
|
||||
startLocalBigFishRuntimeRun,
|
||||
startBigFishRun as startBigFishRuntimeRun,
|
||||
submitBigFishInput as submitBigFishRuntimeInput,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import {
|
||||
deleteBigFishWork,
|
||||
@@ -456,6 +456,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
>(null);
|
||||
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
|
||||
useState<BigFishRuntimeSessionSource>(null);
|
||||
const bigFishInputInFlightRef = useRef(false);
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
@@ -1182,7 +1183,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const startBigFishRun = useCallback(() => {
|
||||
const startBigFishRun = useCallback(async () => {
|
||||
if (!bigFishSession) {
|
||||
return;
|
||||
}
|
||||
@@ -1191,16 +1192,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('draft');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRun(null);
|
||||
try {
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('draft');
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
}
|
||||
}, [
|
||||
bigFishSession,
|
||||
refreshBigFishShelf,
|
||||
@@ -1208,7 +1218,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const restartBigFishRun = useCallback(() => {
|
||||
const restartBigFishRun = useCallback(async () => {
|
||||
if (!bigFishSession && !bigFishRun) {
|
||||
return;
|
||||
}
|
||||
@@ -1222,23 +1232,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (bigFishSession) {
|
||||
setBigFishRuntimeShare(null);
|
||||
}
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
|
||||
setBigFishRun(
|
||||
startLocalBigFishRuntimeRun({
|
||||
session: bigFishSession,
|
||||
work: bigFishRuntimeWork,
|
||||
}),
|
||||
);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRun(null);
|
||||
try {
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
bigFishRun,
|
||||
bigFishRuntimeWork,
|
||||
bigFishSession,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
@@ -1327,18 +1340,31 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const submitBigFishInput = useCallback(
|
||||
(payload: SubmitBigFishInputRequest) => {
|
||||
if (!bigFishRun || bigFishRun.status !== 'running') {
|
||||
async (payload: SubmitBigFishInputRequest) => {
|
||||
if (
|
||||
!bigFishRun ||
|
||||
bigFishRun.status !== 'running' ||
|
||||
bigFishInputInFlightRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBigFishRun((currentRun) =>
|
||||
currentRun
|
||||
? advanceLocalBigFishRuntimeRun(currentRun, payload)
|
||||
: currentRun,
|
||||
);
|
||||
bigFishInputInFlightRef.current = true;
|
||||
try {
|
||||
const { run } = await submitBigFishRuntimeInput(
|
||||
bigFishRun.runId,
|
||||
payload,
|
||||
);
|
||||
setBigFishRun(run);
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '同步大鱼吃小鱼输入失败。'),
|
||||
);
|
||||
} finally {
|
||||
bigFishInputInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[bigFishRun],
|
||||
[bigFishRun, resolveBigFishErrorMessage, setBigFishError],
|
||||
);
|
||||
|
||||
const reportBigFishObservedPlayTime = useCallback(() => {
|
||||
@@ -1793,7 +1819,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
(item: BigFishWorkSummary) => {
|
||||
async (item: BigFishWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||
@@ -1808,18 +1834,27 @@ export function PlatformEntryFlowShellImpl({
|
||||
title: item.title,
|
||||
publicWorkCode,
|
||||
});
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource('work');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||
);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
setBigFishRun(null);
|
||||
try {
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||
);
|
||||
});
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
@@ -2018,10 +2053,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
(entry) => entry.sourceSessionId === sessionId,
|
||||
);
|
||||
if (matchedEntry) {
|
||||
startBigFishRunFromWork(matchedEntry);
|
||||
void startBigFishRunFromWork(matchedEntry);
|
||||
return;
|
||||
}
|
||||
startBigFishRunFromWork({
|
||||
void startBigFishRunFromWork({
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getBigFishCreationSession,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import { startLocalBigFishRuntimeRun } from '../../services/big-fish-runtime';
|
||||
import { startBigFishRun } from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
@@ -155,8 +155,9 @@ vi.mock('../../services/big-fish-gallery', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-runtime', () => ({
|
||||
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
|
||||
startLocalBigFishRuntimeRun: vi.fn(),
|
||||
recordBigFishPlay: vi.fn().mockResolvedValue({ items: [] }),
|
||||
startBigFishRun: vi.fn(),
|
||||
submitBigFishInput: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-agent', () => ({
|
||||
@@ -1091,28 +1092,30 @@ beforeEach(() => {
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
|
||||
runId: 'big-fish-run-1',
|
||||
sessionId: 'big-fish-session-public-1',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel: 8,
|
||||
leaderEntityId: 'owned-1',
|
||||
ownedEntities: [
|
||||
{
|
||||
entityId: 'owned-1',
|
||||
level: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
radius: 12,
|
||||
offscreenSeconds: 0,
|
||||
},
|
||||
],
|
||||
wildEntities: [],
|
||||
cameraCenter: { x: 0, y: 0 },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['机械鱼群开始巡游。'],
|
||||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||||
vi.mocked(startBigFishRun).mockResolvedValue({
|
||||
run: {
|
||||
runId: 'big-fish-run-1',
|
||||
sessionId: 'big-fish-session-public-1',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel: 8,
|
||||
leaderEntityId: 'owned-1',
|
||||
ownedEntities: [
|
||||
{
|
||||
entityId: 'owned-1',
|
||||
level: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
radius: 12,
|
||||
offscreenSeconds: 0,
|
||||
},
|
||||
],
|
||||
wildEntities: [],
|
||||
cameraCenter: { x: 0, y: 0 },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['机械鱼群开始巡游。'],
|
||||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
@@ -2067,11 +2070,9 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startLocalBigFishRuntimeRun).toHaveBeenCalledWith({
|
||||
work: expect.objectContaining({
|
||||
sourceSessionId: 'big-fish-session-public-1',
|
||||
}),
|
||||
});
|
||||
expect(startBigFishRun).toHaveBeenCalledWith(
|
||||
'big-fish-session-public-1',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
|
||||
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -4,10 +4,18 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AnimationState, WorldType, type GameState } from '../../types';
|
||||
import {
|
||||
AnimationState,
|
||||
WorldType,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
} from '../../types';
|
||||
import { RpgRuntimeShell } from './RpgRuntimeShell';
|
||||
import type { RpgRuntimeShellProps } from './types';
|
||||
|
||||
const noop = () => {};
|
||||
const asyncFalse = async () => false;
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => null,
|
||||
}));
|
||||
@@ -34,7 +42,7 @@ vi.mock('./useRpgRuntimeShellViewModel', () => ({
|
||||
shouldMountNpcModals: false,
|
||||
visibleGameState: mockVisibleGameState,
|
||||
visibleCurrentStory: {
|
||||
storyText: '测试故事',
|
||||
text: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
sceneTransitionPhase: 'idle',
|
||||
@@ -97,22 +105,23 @@ function createGameState(
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
personality: '冷静',
|
||||
motivation: '完成测试',
|
||||
combatStyle: '均衡',
|
||||
role: '主角',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
imageSrc: '',
|
||||
initialAffinity: 0,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
assetFolder: '',
|
||||
assetVariant: '',
|
||||
attributes: {
|
||||
strength: 5,
|
||||
agility: 5,
|
||||
intelligence: 5,
|
||||
spirit: 5,
|
||||
},
|
||||
backstoryReveal: {
|
||||
publicSummary: '测试',
|
||||
privateChatUnlockAffinity: 60,
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled: runtimePersistenceDisabled ?? false,
|
||||
@@ -176,14 +185,15 @@ function buildProps(
|
||||
runtimePersistenceDisabled?: boolean,
|
||||
): RpgRuntimeShellProps {
|
||||
const gameState = createGameState(runtimeMode, runtimePersistenceDisabled);
|
||||
const currentStory: StoryMoment = {
|
||||
text: '测试故事',
|
||||
options: [],
|
||||
};
|
||||
mockVisibleGameState = gameState;
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: {
|
||||
storyText: '测试故事',
|
||||
options: [],
|
||||
},
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
aiError: null,
|
||||
bottomTab: 'adventure',
|
||||
@@ -201,36 +211,66 @@ function buildProps(
|
||||
exitNpcChat: () => false,
|
||||
handleMapTravelToScene: () => false,
|
||||
npcUi: {
|
||||
isNpcModalOpen: false,
|
||||
currentNpcEncounter: null,
|
||||
selectedNpc: null,
|
||||
isGeneratingNpcResponse: false,
|
||||
npcResponseError: null,
|
||||
generatedNpcText: '',
|
||||
npcResponseOptions: [],
|
||||
selectedOptionId: null,
|
||||
tradeModal: null,
|
||||
giftModal: null,
|
||||
recruitModal: null,
|
||||
setTradeMode: noop,
|
||||
selectTradeNpcItem: noop,
|
||||
selectTradePlayerItem: noop,
|
||||
setTradeQuantity: noop,
|
||||
closeTradeModal: noop,
|
||||
confirmTrade: noop,
|
||||
selectGiftItem: noop,
|
||||
closeGiftModal: noop,
|
||||
confirmGift: noop,
|
||||
selectRecruitRelease: noop,
|
||||
closeRecruitModal: noop,
|
||||
confirmRecruit: noop,
|
||||
},
|
||||
characterChatUi: {
|
||||
isCharacterChatModalOpen: false,
|
||||
activeCharacter: null,
|
||||
modal: null,
|
||||
openChat: noop,
|
||||
closeChat: noop,
|
||||
setDraft: noop,
|
||||
useSuggestion: noop,
|
||||
refreshSuggestions: noop,
|
||||
sendDraft: noop,
|
||||
},
|
||||
inventoryUi: {
|
||||
isInventoryOpen: false,
|
||||
useInventoryItem: asyncFalse,
|
||||
equipInventoryItem: asyncFalse,
|
||||
unequipItem: asyncFalse,
|
||||
playerCurrency: 0,
|
||||
currencyText: '0',
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
craftRecipe: asyncFalse,
|
||||
dismantleItem: asyncFalse,
|
||||
reforgeItem: asyncFalse,
|
||||
},
|
||||
battleRewardUi: {
|
||||
isRewardModalOpen: false,
|
||||
rewards: [],
|
||||
reward: null,
|
||||
dismiss: noop,
|
||||
},
|
||||
questUi: {
|
||||
isQuestPanelOpen: false,
|
||||
acknowledgeQuestCompletion: noop,
|
||||
claimQuestReward: () => null,
|
||||
},
|
||||
npcChatQuestOfferUi: {
|
||||
isOfferModalOpen: false,
|
||||
pendingQuest: null,
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
},
|
||||
goalUi: {
|
||||
isGoalPanelOpen: false,
|
||||
entries: [],
|
||||
goalStack: {
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
},
|
||||
pulse: null,
|
||||
dismissPulse: noop,
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useStoryInventoryActions({
|
||||
} = runtime;
|
||||
const [serverInventoryView, setServerInventoryView] =
|
||||
useState<RuntimeStoryInventoryView | null>(null);
|
||||
const runtimeSessionId = gameState.runtimeSessionId;
|
||||
const storySessionId = gameState.storySessionId;
|
||||
const runtimeActionVersion = gameState.runtimeActionVersion;
|
||||
const currentScene = gameState.currentScene;
|
||||
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
|
||||
@@ -56,7 +56,7 @@ export function useStoryInventoryActions({
|
||||
void loadRpgRuntimeInventoryView(
|
||||
{
|
||||
gameState: {
|
||||
runtimeSessionId,
|
||||
storySessionId,
|
||||
runtimeActionVersion,
|
||||
},
|
||||
},
|
||||
@@ -80,7 +80,7 @@ export function useStoryInventoryActions({
|
||||
currentScene,
|
||||
hasPlayerCharacter,
|
||||
runtimeActionVersion,
|
||||
runtimeSessionId,
|
||||
storySessionId,
|
||||
setAiError,
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,20 +3,15 @@ import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapsho
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
getRpgRuntimeStorySessionId,
|
||||
getRpgStoryRuntimeProjection,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryProjectionMoment,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -27,15 +22,13 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
||||
}) {
|
||||
// 中文注释:状态目录只从服务端持久化 session 读取,
|
||||
// 前端不再上传本地 GameState 快照参与动作合法性解析。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
const response = await getRpgStoryRuntimeProjection({
|
||||
storySessionId: getRpgRuntimeStorySessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText: response.presentation.storyText,
|
||||
const options = resolveRpgRuntimeStoryProjectionMoment({
|
||||
projection: response,
|
||||
gameState: params.gameState,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
@@ -59,24 +52,26 @@ export async function resumeServerRuntimeStory(
|
||||
|
||||
// 中文注释:继续游戏后向服务端刷新一次状态,
|
||||
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
const response = await getRpgStoryRuntimeProjection({
|
||||
storySessionId: getRpgRuntimeStorySessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const runtimeOptions = response.options;
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
fallbackGameState: hydratedSnapshot.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
response.currentNarrativeText || runtimeOptions.length > 0
|
||||
? resolveRpgRuntimeStoryProjectionMoment({
|
||||
projection: response,
|
||||
gameState: hydratedSnapshot.gameState,
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
: hydratedSnapshot.currentStory;
|
||||
const resumedSnapshot = {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
runtimeSessionId: response.storySession.runtimeSessionId,
|
||||
storySessionId: response.storySession.storySessionId,
|
||||
runtimeActionVersion: response.serverVersion,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
|
||||
return {
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getRuntimeStoryStateMock,
|
||||
getStoryRuntimeProjectionMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeStorySessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getRuntimeStoryStateMock: vi.fn(),
|
||||
getStoryRuntimeProjectionMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeStorySessionIdMock: vi.fn(() => 'storysess-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
@@ -22,13 +24,15 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
getRpgStoryRuntimeProjection: getStoryRuntimeProjectionMock,
|
||||
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRpgRuntimeStorySessionId: getRuntimeStorySessionIdMock,
|
||||
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
getStoryRuntimeProjection: getStoryRuntimeProjectionMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeStorySessionId: getRuntimeStorySessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
});
|
||||
@@ -52,6 +56,7 @@ function createStory(text: string): StoryMoment {
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeActionVersion: 7,
|
||||
} as GameState;
|
||||
}
|
||||
@@ -59,6 +64,7 @@ function createGameState(): GameState {
|
||||
function createTravelGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeActionVersion: 7,
|
||||
worldType: WorldType.WUXIA,
|
||||
currentScene: 'Story',
|
||||
@@ -172,6 +178,7 @@ function createRuntimeNpcBattleSnapshot(
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
currentScene: 'Story',
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
@@ -249,10 +256,12 @@ function createRuntimeNpcBattleSnapshot(
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
getStoryRuntimeProjectionMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeStorySessionIdMock.mockReset();
|
||||
getRuntimeStorySessionIdMock.mockReturnValue('storysess-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
@@ -261,46 +270,53 @@ describe('runtimeStoryCoordinator', () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 3,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
currentNarrativeText: '服务端故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
@@ -308,8 +324,8 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
|
||||
storySessionId: 'storysess-main',
|
||||
clientVersion: 7,
|
||||
});
|
||||
expect(options).toEqual([
|
||||
@@ -443,73 +459,72 @@ describe('runtimeStoryCoordinator', () => {
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
const serverHydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('服务端快照故事'),
|
||||
bottomTab: 'character' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端恢复后的故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 8,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
actor: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端恢复后的故事',
|
||||
options: [],
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverHydratedSnapshot,
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端恢复后的故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
|
||||
storySessionId: 'storysess-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.hydratedSnapshot.gameState).toEqual(
|
||||
expect.objectContaining({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeActionVersion: 8,
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '服务端恢复后的故事',
|
||||
@@ -545,7 +560,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
|
||||
expect(getStoryRuntimeProjectionMock).not.toHaveBeenCalled();
|
||||
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
@@ -844,78 +859,71 @@ describe('runtimeStoryCoordinator', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
it('refreshes mid-battle story options from projection while keeping local snapshot shape', async () => {
|
||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 7,
|
||||
});
|
||||
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 8,
|
||||
playerHp: 39,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-bandit',
|
||||
name: '断桥匪首',
|
||||
hp: 14,
|
||||
maxHp: 32,
|
||||
description: '拦路的刀客',
|
||||
},
|
||||
] as unknown as GameState['sceneHostileNpcs'],
|
||||
storySessionId: 'storysess-main',
|
||||
});
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '断桥匪首还在步步逼近。',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 8,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 39,
|
||||
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_guard_break',
|
||||
actionText: '破架重击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
actor: {
|
||||
hp: 39,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '断桥匪首还在步步逼近。',
|
||||
options: [],
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawServerBattleSnapshot,
|
||||
options: [
|
||||
{
|
||||
functionId: 'battle_guard_break',
|
||||
actionText: '破架重击',
|
||||
scope: 'combat',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '断桥匪首还在步步逼近。',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
expect(getStoryRuntimeProjectionMock).toHaveBeenCalledWith({
|
||||
storySessionId: 'storysess-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot.gameState.runtimeActionVersion).toBe(8);
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
hp: 14,
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
|
||||
@@ -18,6 +18,9 @@ import { useRpgSessionBootstrap } from './rpg-session';
|
||||
const aiServiceMocks = vi.hoisted(() => ({
|
||||
streamNpcChatTurn: vi.fn(),
|
||||
}));
|
||||
const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({
|
||||
beginRpgRuntimeStorySession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/aiService', async () => {
|
||||
const actual =
|
||||
@@ -31,6 +34,18 @@ vi.mock('../services/aiService', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../services/rpg-runtime/rpgRuntimeStoryClient')
|
||||
>('../services/rpg-runtime/rpgRuntimeStoryClient');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
beginRpgRuntimeStorySession:
|
||||
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession,
|
||||
};
|
||||
});
|
||||
|
||||
function buildBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
@@ -401,6 +416,163 @@ function readSnapshot() {
|
||||
};
|
||||
}
|
||||
|
||||
function findRuntimeNpc(profile: ReturnType<typeof buildSavedProfile>) {
|
||||
const npc = profile.storyNpcs.find((candidate) => candidate.id === 'story-act-only');
|
||||
if (!npc) {
|
||||
throw new Error('test npc story-act-only not found');
|
||||
}
|
||||
|
||||
return npc;
|
||||
}
|
||||
|
||||
function buildRuntimeStoryBootstrapSnapshot(params: {
|
||||
profile: ReturnType<typeof buildSavedProfile>;
|
||||
character: NonNullable<ReturnType<typeof buildCustomWorldPlayableCharacters>[number]>;
|
||||
}) {
|
||||
const npc = findRuntimeNpc(params.profile);
|
||||
const playableSource = params.profile.playableNpcs.find(
|
||||
(candidate) => candidate.id === params.character.id,
|
||||
);
|
||||
const initialItems = playableSource?.initialItems ?? [];
|
||||
const currentScenePreset = {
|
||||
id: 'custom-scene-camp',
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
imageSrc: '',
|
||||
connectedSceneIds: ['custom-scene-landmark-1', 'custom-scene-landmark-2'],
|
||||
};
|
||||
const weapon = initialItems.find(
|
||||
(item) => item.id === 'item-playable-1',
|
||||
);
|
||||
const relic = initialItems.find(
|
||||
(item) => item.id === 'item-playable-3',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 1,
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-29T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: params.profile,
|
||||
playerCharacter: params.character,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeActionVersion: 1,
|
||||
runtimeMode: 'play',
|
||||
runtimePersistenceDisabled: false,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
playerProgression: {
|
||||
level: 1,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 0,
|
||||
xpToNextLevel: 100,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: ['chapter-1'],
|
||||
currentSceneActState: {
|
||||
sceneId: 'custom-scene-camp',
|
||||
chapterId: 'chapter-1',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
completedActIds: [],
|
||||
visitedActIds: ['act-1'],
|
||||
},
|
||||
},
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: 'scenario-pack:tide',
|
||||
activeCampaignPackId: 'campaign-pack:tide',
|
||||
characterChats: {},
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
id: 'story-act-only',
|
||||
kind: 'npc',
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: '',
|
||||
context: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
||||
characterId: npc.id,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
title: npc.title,
|
||||
backstory: npc.backstory,
|
||||
personality: npc.personality,
|
||||
motivation: npc.motivation,
|
||||
combatStyle: npc.combatStyle,
|
||||
relationshipHooks: npc.relationshipHooks,
|
||||
tags: npc.tags,
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
skills: npc.skills,
|
||||
initialItems: npc.initialItems,
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 180,
|
||||
playerMaxHp: 180,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 0,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: initialItems,
|
||||
playerEquipment: {
|
||||
weapon: weapon ?? null,
|
||||
armor: {
|
||||
id: 'test-armor',
|
||||
category: '防具',
|
||||
name: '潮雾外衣',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: ['防具'],
|
||||
equipmentSlotId: 'armor',
|
||||
},
|
||||
relic: relic ?? null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function GameFlowHarness({
|
||||
openingOppositeNpcId,
|
||||
}: {
|
||||
@@ -415,6 +587,14 @@ function GameFlowHarness({
|
||||
[profile],
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
if (selectedCharacter) {
|
||||
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue(
|
||||
buildRuntimeStoryBootstrapSnapshot({
|
||||
profile,
|
||||
character: selectedCharacter,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
|
||||
@@ -127,6 +127,7 @@ describe('runtimeSnapshot', () => {
|
||||
},
|
||||
runtimeActionVersion: 3,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
storySessionId: null,
|
||||
} as GameState,
|
||||
currentStory: createStory('服务端恢复故事'),
|
||||
bottomTab: 'inventory',
|
||||
|
||||
@@ -287,6 +287,11 @@ export function normalizeSavedGameState(gameState: GameState) {
|
||||
typeof hydratableState.runtimeSessionId === 'string'
|
||||
? hydratableState.runtimeSessionId
|
||||
: null,
|
||||
storySessionId:
|
||||
typeof hydratableState.storySessionId === 'string' &&
|
||||
hydratableState.storySessionId.trim()
|
||||
? hydratableState.storySessionId.trim()
|
||||
: null,
|
||||
} satisfies HydratedGameState);
|
||||
}
|
||||
|
||||
@@ -315,6 +320,8 @@ export function isHydratedSnapshotState(
|
||||
typeof gameState.runtimeActionVersion === 'number' &&
|
||||
(gameState.runtimeSessionId === null ||
|
||||
typeof gameState.runtimeSessionId === 'string') &&
|
||||
(gameState.storySessionId === null ||
|
||||
typeof gameState.storySessionId === 'string') &&
|
||||
(!gameState.playerCharacter ||
|
||||
Boolean(
|
||||
gameState.playerEquipment &&
|
||||
|
||||
@@ -10,6 +10,7 @@ export type HydratedGameState = GameState & {
|
||||
playerEquipment: GameState['playerEquipment'];
|
||||
runtimeActionVersion: number;
|
||||
runtimeSessionId: string | null;
|
||||
storySessionId: string | null;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import type {
|
||||
BigFishGameDraftResponse,
|
||||
BigFishRuntimeEntityResponse,
|
||||
BigFishRuntimeSnapshotResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
|
||||
const VIEW_WIDTH = 720;
|
||||
const VIEW_HEIGHT = 1280;
|
||||
const WORLD_HALF_WIDTH = 1400;
|
||||
const WORLD_HALF_HEIGHT = 2400;
|
||||
const DEFAULT_LEVEL_COUNT = 8;
|
||||
const DEFAULT_WILD_COUNT = 28;
|
||||
const LEADER_SPEED = 210;
|
||||
const FOLLOWER_SPEED = 170;
|
||||
const WILD_SPEED = 74;
|
||||
const MERGE_COUNT = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function entityRadius(level: number) {
|
||||
return 18 + level * 4;
|
||||
}
|
||||
|
||||
function normalizeVector(x: number, y: number) {
|
||||
const length = Math.hypot(x, y);
|
||||
if (length <= 0.001) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
return { x: x / length, y: y / length };
|
||||
}
|
||||
|
||||
function distance(
|
||||
first: BigFishRuntimeEntityResponse,
|
||||
second: BigFishRuntimeEntityResponse,
|
||||
) {
|
||||
return Math.hypot(
|
||||
first.position.x - second.position.x,
|
||||
first.position.y - second.position.y,
|
||||
);
|
||||
}
|
||||
|
||||
function buildEntity(
|
||||
entityId: string,
|
||||
level: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): BigFishRuntimeEntityResponse {
|
||||
return {
|
||||
entityId,
|
||||
level,
|
||||
position: { x, y },
|
||||
radius: entityRadius(level),
|
||||
offscreenSeconds: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveWinLevel(
|
||||
draft?: BigFishGameDraftResponse | null,
|
||||
work?: BigFishWorkSummary | null,
|
||||
) {
|
||||
return draft?.runtimeParams.winLevel ?? work?.levelCount ?? DEFAULT_LEVEL_COUNT;
|
||||
}
|
||||
|
||||
function resolveWildTargetCount(draft?: BigFishGameDraftResponse | null) {
|
||||
return Math.max(DEFAULT_WILD_COUNT, draft?.runtimeParams.spawnTargetCount ?? 0);
|
||||
}
|
||||
|
||||
function spawnLevel(playerLevel: number, winLevel: number, index: number) {
|
||||
if (playerLevel <= 1 && index % 4 < 2) {
|
||||
return 1;
|
||||
}
|
||||
const deltas = [-2, -1, 1, 2];
|
||||
const delta = deltas[index % deltas.length] ?? 1;
|
||||
return clamp(playerLevel + delta, 1, winLevel);
|
||||
}
|
||||
|
||||
function spawnPosition(center: { x: number; y: number }, index: number) {
|
||||
const side = index % 4;
|
||||
const offset = ((index * 97) % 980) - 490;
|
||||
if (side === 0) {
|
||||
return { x: center.x - VIEW_WIDTH * 0.72, y: center.y + offset };
|
||||
}
|
||||
if (side === 1) {
|
||||
return { x: center.x + VIEW_WIDTH * 0.72, y: center.y + offset };
|
||||
}
|
||||
if (side === 2) {
|
||||
return { x: center.x + offset, y: center.y - VIEW_HEIGHT * 0.64 };
|
||||
}
|
||||
return { x: center.x + offset, y: center.y + VIEW_HEIGHT * 0.64 };
|
||||
}
|
||||
|
||||
function buildWildEntity(
|
||||
tick: number,
|
||||
index: number,
|
||||
playerLevel: number,
|
||||
winLevel: number,
|
||||
center: { x: number; y: number },
|
||||
) {
|
||||
const level = spawnLevel(playerLevel, winLevel, index);
|
||||
const position = spawnPosition(center, index);
|
||||
return buildEntity(`wild-${tick}-${index}`, level, position.x, position.y);
|
||||
}
|
||||
|
||||
export function startLocalBigFishRuntimeRun({
|
||||
session,
|
||||
work,
|
||||
}: {
|
||||
session?: BigFishSessionSnapshotResponse | null;
|
||||
work?: BigFishWorkSummary | null;
|
||||
}): BigFishRuntimeSnapshotResponse {
|
||||
const winLevel = resolveWinLevel(session?.draft, work);
|
||||
const wildCount = resolveWildTargetCount(session?.draft);
|
||||
const leader = buildEntity('owned-1', 1, 0, 0);
|
||||
const wildEntities = [
|
||||
buildEntity('wild-open-1', 1, 92, 0),
|
||||
buildEntity('wild-open-2', 1, -118, 46),
|
||||
];
|
||||
while (wildEntities.length < wildCount) {
|
||||
wildEntities.push(
|
||||
buildWildEntity(0, wildEntities.length, 1, winLevel, leader.position),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
runId: `local-big-fish-run-${Date.now()}`,
|
||||
sessionId: session?.sessionId ?? work?.sourceSessionId ?? 'local-big-fish-session',
|
||||
status: 'running',
|
||||
tick: 0,
|
||||
playerLevel: 1,
|
||||
winLevel,
|
||||
leaderEntityId: leader.entityId,
|
||||
ownedEntities: [leader],
|
||||
wildEntities,
|
||||
cameraCenter: { ...leader.position },
|
||||
lastInput: { x: 0, y: 0 },
|
||||
eventLog: ['开局生成同级可收编目标'],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function moveLeader(
|
||||
leader: BigFishRuntimeEntityResponse,
|
||||
input: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return {
|
||||
...leader,
|
||||
position: {
|
||||
x: clamp(
|
||||
leader.position.x + input.x * LEADER_SPEED * 0.1,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
leader.position.y + input.y * LEADER_SPEED * 0.1,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveFollower(
|
||||
follower: BigFishRuntimeEntityResponse,
|
||||
leader: BigFishRuntimeEntityResponse,
|
||||
index: number,
|
||||
) {
|
||||
const slotY = Math.sin(index * 0.7) * 42;
|
||||
const target = {
|
||||
x: leader.position.x - 52 - index * 10,
|
||||
y: leader.position.y + slotY,
|
||||
};
|
||||
const delta = {
|
||||
x: target.x - follower.position.x,
|
||||
y: target.y - follower.position.y,
|
||||
};
|
||||
const direction = normalizeVector(delta.x, delta.y);
|
||||
const step = Math.min(FOLLOWER_SPEED * 0.1, Math.hypot(delta.x, delta.y));
|
||||
return {
|
||||
...follower,
|
||||
position: {
|
||||
x: follower.position.x + direction.x * step,
|
||||
y: follower.position.y + direction.y * step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function moveWildEntity(entity: BigFishRuntimeEntityResponse, tick: number) {
|
||||
const phase = tick * 0.23 + entity.level * 0.91 + entity.entityId.length * 0.13;
|
||||
return {
|
||||
...entity,
|
||||
position: {
|
||||
x: clamp(
|
||||
entity.position.x + Math.cos(phase) * (WILD_SPEED + entity.level * 3) * 0.1,
|
||||
-WORLD_HALF_WIDTH,
|
||||
WORLD_HALF_WIDTH,
|
||||
),
|
||||
y: clamp(
|
||||
entity.position.y + Math.sin(phase * 0.72) * (WILD_SPEED + entity.level * 3) * 0.1,
|
||||
-WORLD_HALF_HEIGHT,
|
||||
WORLD_HALF_HEIGHT,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOwnedEntities(
|
||||
ownedEntities: BigFishRuntimeEntityResponse[],
|
||||
tick: number,
|
||||
) {
|
||||
let nextOwned = [...ownedEntities];
|
||||
const events: string[] = [];
|
||||
let changed = true;
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let level = 1; level < 32; level += 1) {
|
||||
const sameLevel = nextOwned
|
||||
.map((entity, index) => ({ entity, index }))
|
||||
.filter(({ entity }) => entity.level === level)
|
||||
.slice(0, MERGE_COUNT);
|
||||
if (sameLevel.length < MERGE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const center = sameLevel.reduce(
|
||||
(acc, { entity }) => ({
|
||||
x: acc.x + entity.position.x / MERGE_COUNT,
|
||||
y: acc.y + entity.position.y / MERGE_COUNT,
|
||||
}),
|
||||
{ x: 0, y: 0 },
|
||||
);
|
||||
const removeSet = new Set(sameLevel.map(({ index }) => index));
|
||||
nextOwned = nextOwned.filter((_, index) => !removeSet.has(index));
|
||||
nextOwned.push(
|
||||
buildEntity(`owned-merge-${level + 1}-${tick}`, level + 1, center.x, center.y),
|
||||
);
|
||||
events.push(`3 个 ${level} 级实体合成 ${level + 1} 级`);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { ownedEntities: nextOwned, events };
|
||||
}
|
||||
|
||||
function isOffscreen(
|
||||
entity: BigFishRuntimeEntityResponse,
|
||||
cameraCenter: { x: number; y: number },
|
||||
) {
|
||||
return (
|
||||
entity.position.x + entity.radius < cameraCenter.x - VIEW_WIDTH / 2 ||
|
||||
entity.position.x - entity.radius > cameraCenter.x + VIEW_WIDTH / 2 ||
|
||||
entity.position.y + entity.radius < cameraCenter.y - VIEW_HEIGHT / 2 ||
|
||||
entity.position.y - entity.radius > cameraCenter.y + VIEW_HEIGHT / 2
|
||||
);
|
||||
}
|
||||
|
||||
function refreshLeader(ownedEntities: BigFishRuntimeEntityResponse[]) {
|
||||
return [...ownedEntities].sort((left, right) => {
|
||||
if (right.level !== left.level) {
|
||||
return right.level - left.level;
|
||||
}
|
||||
return left.entityId.localeCompare(right.entityId);
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceLocalBigFishRuntimeRun(
|
||||
run: BigFishRuntimeSnapshotResponse,
|
||||
input: SubmitBigFishInputRequest,
|
||||
): BigFishRuntimeSnapshotResponse {
|
||||
if (run.status !== 'running') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextTick = run.tick + 1;
|
||||
const normalizedInput = normalizeVector(input.x, input.y);
|
||||
const sortedOwned = refreshLeader(run.ownedEntities);
|
||||
const currentLeader = sortedOwned[0];
|
||||
if (!currentLeader) {
|
||||
return { ...run, status: 'failed', eventLog: ['己方实体归零,本局失败'] };
|
||||
}
|
||||
|
||||
const nextLeader = moveLeader(currentLeader, normalizedInput);
|
||||
let ownedEntities = [
|
||||
nextLeader,
|
||||
...sortedOwned.slice(1).map((entity, index) =>
|
||||
moveFollower(entity, nextLeader, index + 1),
|
||||
),
|
||||
];
|
||||
let wildEntities = run.wildEntities.map((entity) =>
|
||||
moveWildEntity(entity, nextTick),
|
||||
);
|
||||
const events = [...run.eventLog];
|
||||
const removedWild = new Set<string>();
|
||||
const removedOwned = new Set<string>();
|
||||
const newlyOwned: BigFishRuntimeEntityResponse[] = [];
|
||||
|
||||
for (const owned of ownedEntities) {
|
||||
if (removedOwned.has(owned.entityId)) {
|
||||
continue;
|
||||
}
|
||||
for (const wild of wildEntities) {
|
||||
if (removedWild.has(wild.entityId)) {
|
||||
continue;
|
||||
}
|
||||
if (distance(owned, wild) > owned.radius + wild.radius) {
|
||||
continue;
|
||||
}
|
||||
if (owned.level >= wild.level) {
|
||||
removedWild.add(wild.entityId);
|
||||
newlyOwned.push(
|
||||
buildEntity(
|
||||
`owned-from-${wild.entityId}-${nextTick}`,
|
||||
wild.level,
|
||||
wild.position.x,
|
||||
wild.position.y,
|
||||
),
|
||||
);
|
||||
events.push(`收编 ${wild.level} 级实体`);
|
||||
} else {
|
||||
removedOwned.add(owned.entityId);
|
||||
events.push(`${owned.level} 级己方实体被 ${wild.level} 级野生实体吃掉`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ownedEntities = ownedEntities
|
||||
.filter((entity) => !removedOwned.has(entity.entityId))
|
||||
.concat(newlyOwned);
|
||||
wildEntities = wildEntities.filter((entity) => !removedWild.has(entity.entityId));
|
||||
|
||||
const mergeResult = mergeOwnedEntities(ownedEntities, nextTick);
|
||||
ownedEntities = refreshLeader(mergeResult.ownedEntities);
|
||||
events.push(...mergeResult.events);
|
||||
|
||||
const playerLevel = Math.max(...ownedEntities.map((entity) => entity.level), 0);
|
||||
const leader = ownedEntities[0] ?? null;
|
||||
const cameraCenter = leader ? { ...leader.position } : run.cameraCenter;
|
||||
wildEntities = wildEntities
|
||||
.map((entity) => {
|
||||
const shouldCull =
|
||||
entity.level === playerLevel ||
|
||||
entity.level >= playerLevel + 3 ||
|
||||
entity.level + 3 <= playerLevel;
|
||||
const offscreenSeconds =
|
||||
shouldCull && isOffscreen(entity, cameraCenter)
|
||||
? entity.offscreenSeconds + 0.1
|
||||
: 0;
|
||||
return { ...entity, offscreenSeconds };
|
||||
})
|
||||
.filter((entity) => entity.offscreenSeconds < 3);
|
||||
|
||||
while (wildEntities.length < DEFAULT_WILD_COUNT) {
|
||||
wildEntities.push(
|
||||
buildWildEntity(
|
||||
nextTick,
|
||||
wildEntities.length + nextTick,
|
||||
Math.max(playerLevel, 1),
|
||||
run.winLevel,
|
||||
cameraCenter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const status =
|
||||
ownedEntities.length === 0
|
||||
? 'failed'
|
||||
: playerLevel >= run.winLevel
|
||||
? 'won'
|
||||
: 'running';
|
||||
if (status === 'failed') {
|
||||
events.push('己方实体归零,本局失败');
|
||||
} else if (status === 'won') {
|
||||
events.push('获得最高等级实体,通关');
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
status,
|
||||
tick: nextTick,
|
||||
playerLevel,
|
||||
leaderEntityId: leader?.entityId ?? null,
|
||||
ownedEntities,
|
||||
wildEntities,
|
||||
cameraCenter,
|
||||
lastInput: normalizedInput,
|
||||
eventLog: events.slice(-5),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
BigFishSessionResponse,
|
||||
BigFishRunResponse,
|
||||
RecordBigFishPlayRequest,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
@@ -18,7 +20,7 @@ export function recordBigFishPlay(
|
||||
sessionId: string,
|
||||
payload: RecordBigFishPlayRequest,
|
||||
) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -31,3 +33,44 @@ export function recordBigFishPlay(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function startBigFishRun(sessionId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'启动大鱼吃小鱼玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getBigFishRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼玩法失败',
|
||||
);
|
||||
}
|
||||
|
||||
export function submitBigFishInput(
|
||||
runId: string,
|
||||
payload: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'同步大鱼吃小鱼输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from './bigFishLocalRuntime';
|
||||
export { recordBigFishPlay } from './bigFishRuntimeClient';
|
||||
getBigFishRun,
|
||||
recordBigFishPlay,
|
||||
startBigFishRun,
|
||||
submitBigFishInput,
|
||||
} from './bigFishRuntimeClient';
|
||||
|
||||
@@ -151,12 +151,20 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
minProgress: 44,
|
||||
expectedDurationMs: 36_000,
|
||||
},
|
||||
{
|
||||
id: 'scene-link',
|
||||
label: '建立场景连接',
|
||||
detail: '正在整理关键场景之间的入口、连接和章节线索。',
|
||||
matchers: ['建立场景连接'],
|
||||
minProgress: 66,
|
||||
expectedDurationMs: 8_000,
|
||||
},
|
||||
{
|
||||
id: 'playable-detail',
|
||||
label: '补全可扮演角色细节',
|
||||
detail: '正在补全可扮演角色的叙事基础与档案细节。',
|
||||
matchers: ['补全可扮演角色'],
|
||||
minProgress: 66,
|
||||
minProgress: 76,
|
||||
expectedDurationMs: 32_000,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,11 +27,11 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
it('reads browse history from the profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
@@ -40,7 +40,7 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
it('writes browse history through the profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -53,7 +53,7 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -68,7 +68,7 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
it('syncs browse history through the profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
@@ -83,7 +83,7 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -93,11 +93,11 @@ describe('rpgEntry profile browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
it('clears browse history through the profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
@@ -160,11 +160,11 @@ describe('rpgEntry save archive routes', () => {
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
it('reads save archives from the profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
'/api/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
@@ -173,7 +173,7 @@ describe('rpgEntry save archive routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
it('resumes a save archive through the profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
@@ -192,7 +192,7 @@ describe('rpgEntry save archive routes', () => {
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
'/api/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -23,11 +23,11 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
it('reads browse history from the profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
@@ -36,7 +36,7 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
it('writes browse history through the profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -49,7 +49,7 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -64,7 +64,7 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
it('syncs browse history through the profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
@@ -79,7 +79,7 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -89,11 +89,11 @@ describe('rpgProfileClient browse history routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
it('clears browse history through the profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
'/api/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
@@ -112,11 +112,11 @@ describe('rpgProfileClient save archive routes', () => {
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
it('reads save archives from the profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
'/api/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
@@ -125,7 +125,7 @@ describe('rpgProfileClient save archive routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
it('resumes a save archive through the profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
@@ -144,7 +144,7 @@ describe('rpgProfileClient save archive routes', () => {
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
'/api/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -8,10 +8,15 @@ export {
|
||||
streamRpgNpcRecruitDialogue,
|
||||
} from './rpgRuntimeChatClient';
|
||||
export {
|
||||
beginRpgStorySession,
|
||||
getRpgRuntimeActionSnapshot,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStorySessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
continueRpgStorySession,
|
||||
getRpgStoryRuntimeProjection,
|
||||
getRpgStorySessionState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
loadRpgRuntimeInventoryView,
|
||||
@@ -21,7 +26,10 @@ export {
|
||||
type RpgRuntimeStoryClientOptions,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryInventoryView,
|
||||
type RuntimeStoryProjectionResult,
|
||||
type RuntimeStoryResponse,
|
||||
type StorySessionMutationResult,
|
||||
type StorySessionStateResult,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
export {
|
||||
|
||||
@@ -35,9 +35,12 @@ export function requestRpgRuntimeJson<T>(
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
const normalizedPath = path.startsWith('/profile/')
|
||||
? `/api${path}`
|
||||
: `${RUNTIME_API_BASE}${path}`;
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
normalizedPath,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
|
||||
@@ -15,14 +15,19 @@ vi.mock('../apiClient', async () => {
|
||||
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
beginRpgStorySession,
|
||||
beginRpgRuntimeStorySession,
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
continueRpgStorySession,
|
||||
getRpgStorySessionState,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStorySessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
loadRpgRuntimeInventoryView,
|
||||
resolveRpgRuntimeStoryProjectionMoment,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
@@ -33,6 +38,134 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('creates story sessions through the new story session endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
latestNarrativeText: '篝火正在燃烧。',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
storyEvent: {
|
||||
eventId: 'storyevt-main',
|
||||
storySessionId: 'storysess-main',
|
||||
eventKind: 'session_started',
|
||||
narrativeText: '篝火正在燃烧。',
|
||||
choiceFunctionId: null,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await beginRpgStorySession({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
});
|
||||
|
||||
expect(result.storySession.storySessionId).toBe('storysess-main');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: '营地开场',
|
||||
}),
|
||||
}),
|
||||
'创建故事会话失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('continues story sessions through the new story session endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '你继续向前。',
|
||||
latestChoiceFunctionId: 'story_continue',
|
||||
status: 'active',
|
||||
version: 2,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:01.000Z',
|
||||
},
|
||||
storyEvent: {
|
||||
eventId: 'storyevt-next',
|
||||
storySessionId: 'storysess-main',
|
||||
eventKind: 'story_continued',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
createdAt: '2026-04-29T00:00:01.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await continueRpgStorySession({
|
||||
storySessionId: ' storysess-main ',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions/continue',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storySessionId: 'storysess-main',
|
||||
narrativeText: '你继续向前。',
|
||||
choiceFunctionId: 'story_continue',
|
||||
}),
|
||||
}),
|
||||
'继续故事会话失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads story session state through the new state endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:02.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
});
|
||||
|
||||
await getRpgStorySessionState({ storySessionId: ' storysess-main ' });
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/story/sessions/storysess-main/state',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取故事会话状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('starts runtime sessions through the backend bootstrap endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-server-1',
|
||||
@@ -185,145 +318,180 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads runtime story state by server session id', async () => {
|
||||
it('reads runtime story state by story session id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
options: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
clientVersion: 7,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/runtime-main',
|
||||
'/api/story/sessions/storysess-main/runtime-projection',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
'读取运行时故事投影失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads backend inventory view from runtime story state', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-inventory',
|
||||
serverVersion: 5,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
inventory: {
|
||||
playerCurrency: 90,
|
||||
currencyText: '90 铜钱',
|
||||
inBattle: false,
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
currencyText: '18 铜钱',
|
||||
requirements: [
|
||||
{
|
||||
id: 'material:any',
|
||||
label: '任意材料',
|
||||
quantity: 3,
|
||||
owned: 3,
|
||||
},
|
||||
],
|
||||
canCraft: true,
|
||||
action: {
|
||||
functionId: 'forge_craft',
|
||||
actionText: '制作精炼锭材',
|
||||
payload: { recipeId: 'synthesis-refined-ingot' },
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
it('rejects missing story session id instead of falling back to runtime id', async () => {
|
||||
expect(() =>
|
||||
getRpgRuntimeStorySessionId({
|
||||
storySessionId: '',
|
||||
}),
|
||||
).toThrow('运行时故事会话不存在,无法读取服务端投影');
|
||||
|
||||
await expect(
|
||||
loadRpgRuntimeInventoryView({
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
storySessionId: null,
|
||||
runtimeActionVersion: 5,
|
||||
},
|
||||
currentStory: null,
|
||||
} as never,
|
||||
}),
|
||||
).rejects.toThrow('运行时故事会话不存在,无法读取服务端投影');
|
||||
|
||||
await expect(
|
||||
continueRpgStorySession({
|
||||
storySessionId: '',
|
||||
narrativeText: '继续',
|
||||
}),
|
||||
).rejects.toThrow('故事会话不存在,无法继续故事');
|
||||
|
||||
await expect(
|
||||
getRpgStorySessionState({
|
||||
storySessionId: '',
|
||||
}),
|
||||
).rejects.toThrow('故事会话不存在,无法读取故事会话状态');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads backend inventory view from story runtime projection', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-inventory',
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '背包状态',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 5,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 5,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 90,
|
||||
currencyText: '90 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
currencyText: '18 铜钱',
|
||||
requirements: [
|
||||
{
|
||||
id: 'material:any',
|
||||
label: '任意材料',
|
||||
quantity: 3,
|
||||
owned: 3,
|
||||
},
|
||||
],
|
||||
canCraft: true,
|
||||
action: {
|
||||
functionId: 'forge_craft',
|
||||
actionText: '制作精炼锭材',
|
||||
payload: { recipeId: 'synthesis-refined-ingot' },
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
options: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
|
||||
const view = await loadRpgRuntimeInventoryView({
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-inventory',
|
||||
storySessionId: 'storysess-inventory',
|
||||
runtimeActionVersion: 5,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft');
|
||||
expect(view.playerCurrency).toBe(90);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/runtime-inventory',
|
||||
'/api/story/sessions/storysess-inventory/runtime-projection',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
'读取运行时故事投影失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -415,9 +583,78 @@ describe('rpgRuntimeStoryClient', () => {
|
||||
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
|
||||
'runtime-main',
|
||||
);
|
||||
expect(getRpgRuntimeStorySessionId({ storySessionId: ' storysess-1 ' })).toBe(
|
||||
'storysess-1',
|
||||
);
|
||||
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('builds story moments from story runtime projection options', () => {
|
||||
const story = resolveRpgRuntimeStoryProjectionMoment({
|
||||
projection: {
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '兜底故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 5,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 5,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
detailText: '推进当前话题',
|
||||
scope: 'npc',
|
||||
payload: { npcId: 'npc-merchant' },
|
||||
enabled: false,
|
||||
reason: '对方暂时不想说话',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端投影故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端投影故事');
|
||||
expect(story.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
disabled: true,
|
||||
disabledReason: '对方暂时不想说话',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
|
||||
@@ -14,6 +14,14 @@ import type {
|
||||
RuntimeStoryBootstrapResponse,
|
||||
RuntimeStoryOptionView,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
BeginStorySessionRequest,
|
||||
ContinueStoryRequest,
|
||||
StorySessionMutationResponse,
|
||||
StorySessionStateResponse,
|
||||
StoryRuntimeOptionProjection,
|
||||
StoryRuntimeProjectionResponse,
|
||||
} from '../../../packages/shared/src/contracts/story';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -21,6 +29,7 @@ import { AnimationState } from '../../types';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const STORY_SESSIONS_API_BASE = '/api/story/sessions';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
@@ -49,6 +58,9 @@ export type RuntimeStoryBootstrapResult = RuntimeStoryBootstrapResponse<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type StorySessionMutationResult = StorySessionMutationResponse;
|
||||
export type StorySessionStateResult = StorySessionStateResponse;
|
||||
export type RuntimeStoryProjectionResult = StoryRuntimeProjectionResponse;
|
||||
export type RuntimeStoryInventoryView =
|
||||
RuntimeStoryResponse['viewModel']['inventory'];
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
@@ -72,6 +84,23 @@ function requestRuntimeStoryJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function requestStorySessionJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${STORY_SESSIONS_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
@@ -98,12 +127,84 @@ function createRuntimeStoryOption(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectionOptionScope(
|
||||
scope: string,
|
||||
): RuntimeStoryOptionView['scope'] {
|
||||
return scope === 'combat' || scope === 'npc' ? scope : 'story';
|
||||
}
|
||||
|
||||
function mapRuntimeProjectionOption(
|
||||
option: StoryRuntimeOptionProjection,
|
||||
): RuntimeStoryOptionView {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
detailText: option.detailText ?? undefined,
|
||||
scope: normalizeProjectionOptionScope(option.scope),
|
||||
payload: option.payload ?? undefined,
|
||||
disabled: option.enabled ? undefined : true,
|
||||
reason: option.enabled ? undefined : (option.reason ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRuntimeProjectionInventory(
|
||||
projection: StoryRuntimeProjectionResponse,
|
||||
): RuntimeStoryInventoryView {
|
||||
return {
|
||||
playerCurrency: projection.actor.currency,
|
||||
currencyText: projection.actor.currencyText,
|
||||
inBattle: projection.status.inBattle,
|
||||
backpackItems:
|
||||
projection.inventory
|
||||
.backpackItems as RuntimeStoryInventoryView['backpackItems'],
|
||||
equipmentSlots:
|
||||
projection.inventory
|
||||
.equipmentSlots as RuntimeStoryInventoryView['equipmentSlots'],
|
||||
forgeRecipes:
|
||||
projection.inventory
|
||||
.forgeRecipes as RuntimeStoryInventoryView['forgeRecipes'],
|
||||
};
|
||||
}
|
||||
|
||||
function getRuntimeProjectionStoryText(
|
||||
projection: Pick<
|
||||
StoryRuntimeProjectionResponse,
|
||||
'currentNarrativeText' | 'storySession'
|
||||
>,
|
||||
) {
|
||||
return (
|
||||
projection.currentNarrativeText?.trim() ||
|
||||
projection.storySession.latestNarrativeText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(
|
||||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||||
) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
export function getRuntimeStorySessionId(
|
||||
gameState: Pick<GameState, 'storySessionId'>,
|
||||
) {
|
||||
return normalizeStorySessionId(
|
||||
gameState.storySessionId,
|
||||
'运行时故事会话不存在,无法读取服务端投影',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeStorySessionId(
|
||||
storySessionId: string | null | undefined,
|
||||
message: string,
|
||||
) {
|
||||
const normalizedStorySessionId = storySessionId?.trim();
|
||||
if (!normalizedStorySessionId) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return normalizedStorySessionId;
|
||||
}
|
||||
|
||||
export function getRuntimeClientVersion(
|
||||
gameState: Pick<GameState, 'runtimeActionVersion'>,
|
||||
) {
|
||||
@@ -146,6 +247,17 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export function buildStoryMomentFromRuntimeProjection(params: {
|
||||
projection: StoryRuntimeProjectionResponse;
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}): StoryMoment {
|
||||
return buildStoryMomentFromRuntimeOptions({
|
||||
storyText: getRuntimeProjectionStoryText(params.projection),
|
||||
options: params.projection.options.map(mapRuntimeProjectionOption),
|
||||
gameState: params.gameState,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
@@ -185,48 +297,114 @@ export function resolveRuntimeStoryMoment(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
export async function beginStorySession(
|
||||
params: BeginStorySessionRequest,
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestStorySessionJson<StorySessionMutationResult>(
|
||||
'',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'创建故事会话失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function continueStorySession(
|
||||
params: ContinueStoryRequest,
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const storySessionId = normalizeStorySessionId(
|
||||
params.storySessionId,
|
||||
'故事会话不存在,无法继续故事',
|
||||
);
|
||||
|
||||
return requestStorySessionJson<StorySessionMutationResult>(
|
||||
'/continue',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...params,
|
||||
storySessionId,
|
||||
}),
|
||||
},
|
||||
'继续故事会话失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStorySessionState(
|
||||
params: { storySessionId: string },
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const storySessionId = normalizeStorySessionId(
|
||||
params.storySessionId,
|
||||
'故事会话不存在,无法读取故事会话状态',
|
||||
);
|
||||
|
||||
return requestStorySessionJson<StorySessionStateResult>(
|
||||
`/${encodeURIComponent(storySessionId)}/state`,
|
||||
{ method: 'GET' },
|
||||
'读取故事会话状态失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStoryRuntimeProjection(
|
||||
params: {
|
||||
sessionId: string;
|
||||
storySessionId: string;
|
||||
clientVersion?: number;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
|
||||
// 中文注释:runtime story 状态读取只按服务端持久化 sessionId 拉取,
|
||||
// 不再允许前端上传本地 GameState 快照参与解析。
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(normalizedSessionId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
const storySessionId = normalizeStorySessionId(
|
||||
params.storySessionId,
|
||||
'运行时故事会话不存在,无法读取服务端投影',
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
// 中文注释:当前 BFF route 以 storySessionId 为唯一读取键;
|
||||
// clientVersion 保留在调用签名里,等待后端增量投影契约稳定后再接查询参数。
|
||||
return requestStorySessionJson<RuntimeStoryProjectionResult>(
|
||||
`/${encodeURIComponent(storySessionId)}/runtime-projection`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事投影失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
params: {
|
||||
storySessionId: string;
|
||||
clientVersion?: number;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
// 中文注释:读取侧正式切到 story session scoped 投影;
|
||||
// 这里不允许用 runtimeSessionId 兜底,避免两个会话主键被悄悄混用。
|
||||
return getStoryRuntimeProjection(params, options);
|
||||
}
|
||||
|
||||
export async function loadRuntimeInventoryView(
|
||||
params: {
|
||||
gameState: Pick<GameState, 'runtimeSessionId' | 'runtimeActionVersion'>;
|
||||
gameState: Pick<GameState, 'storySessionId' | 'runtimeActionVersion'>;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
// 中文注释:背包 / 装备 / 锻造 view 只读取后端已持久化的 runtime session;
|
||||
// 中文注释:背包 / 装备 / 锻造 view 只读取 story runtime 投影;
|
||||
// 前端不再用本地背包、货币或装备状态重算配方可用性。
|
||||
const response = await getRuntimeStoryState(
|
||||
{
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
storySessionId: getRuntimeStorySessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
return response.viewModel.inventory;
|
||||
return mapRuntimeProjectionInventory(response);
|
||||
}
|
||||
|
||||
export async function beginRuntimeStorySession(
|
||||
@@ -303,25 +481,38 @@ export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
}
|
||||
|
||||
export const beginRpgRuntimeStorySession = beginRuntimeStorySession;
|
||||
export const beginRpgStorySession = beginStorySession;
|
||||
export const continueRpgStorySession = continueStorySession;
|
||||
export const getRpgStoryRuntimeProjection = getStoryRuntimeProjection;
|
||||
export const getRpgStorySessionState = getStorySessionState;
|
||||
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
|
||||
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
|
||||
export const getRpgRuntimeSessionId = getRuntimeSessionId;
|
||||
export const getRpgRuntimeStorySessionId = getRuntimeStorySessionId;
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
|
||||
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
|
||||
export const loadRpgRuntimeInventoryView = loadRuntimeInventoryView;
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
export const resolveRpgRuntimeStoryProjectionMoment =
|
||||
buildStoryMomentFromRuntimeProjection;
|
||||
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
|
||||
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
|
||||
|
||||
export const rpgRuntimeStoryClient = {
|
||||
beginSession: beginRpgRuntimeStorySession,
|
||||
beginStorySession: beginRpgStorySession,
|
||||
continueStorySession: continueRpgStorySession,
|
||||
getActionSnapshot: getRpgRuntimeActionSnapshot,
|
||||
getClientVersion: getRpgRuntimeClientVersion,
|
||||
getInventoryView: loadRpgRuntimeInventoryView,
|
||||
getSessionId: getRpgRuntimeSessionId,
|
||||
getStoryRuntimeProjection: getRpgStoryRuntimeProjection,
|
||||
getStorySessionId: getRpgRuntimeStorySessionId,
|
||||
getStorySessionState: getRpgStorySessionState,
|
||||
getState: getRpgRuntimeStoryState,
|
||||
resolveAction: resolveRpgRuntimeStoryAction,
|
||||
resolveProjectionMoment: resolveRpgRuntimeStoryProjectionMoment,
|
||||
resolveMoment: resolveRpgRuntimeStoryMoment,
|
||||
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface GameState {
|
||||
customWorldProfile: CustomWorldProfile | null;
|
||||
playerCharacter: Character | null;
|
||||
runtimeSessionId?: string | null;
|
||||
storySessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
runtimeMode?: GameRuntimeMode;
|
||||
runtimePersistenceDisabled?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user