Integrate unfinished server-rs refactor worklists

This commit is contained in:
2026-04-30 13:39:06 +08:00
parent 62934b0809
commit 7ab0933f6d
676 changed files with 24487 additions and 21531 deletions

View File

@@ -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(),
};
}

View File

@@ -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,
},
);
}

View File

@@ -1,5 +1,6 @@
export {
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from './bigFishLocalRuntime';
export { recordBigFishPlay } from './bigFishRuntimeClient';
getBigFishRun,
recordBigFishPlay,
startBigFishRun,
submitBigFishInput,
} from './bigFishRuntimeClient';

View File

@@ -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,
},
{

View File

@@ -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({

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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: '服务端返回的新故事',

View File

@@ -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,
};