Integrate unfinished server-rs refactor worklists
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user