1
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
|
||||
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
@@ -12,16 +12,25 @@ const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
* 读取大鱼吃小鱼公开广场列表。
|
||||
*/
|
||||
export async function listBigFishGallery() {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
try {
|
||||
return await requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼广场失败',
|
||||
{
|
||||
retry: BIG_FISH_GALLERY_READ_RETRY,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
return { items: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const bigFishGalleryClient = {
|
||||
|
||||
70
src/services/customWorldRoleReferences.ts
Normal file
70
src/services/customWorldRoleReferences.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
CustomWorldRoleProfile,
|
||||
} from '../types';
|
||||
|
||||
type CustomWorldRoleReferenceProfile = {
|
||||
playableNpcs: CustomWorldRoleProfile[];
|
||||
storyNpcs: CustomWorldRoleProfile[];
|
||||
};
|
||||
|
||||
function normalizeRoleReference(value: string | null | undefined) {
|
||||
return (value ?? '')
|
||||
.trim()
|
||||
.replace(/^character-npc[-:]/i, '')
|
||||
.replace(/^(playable|story|role|npc)[-_:]/i, '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[((].*?[))]/g, '');
|
||||
}
|
||||
|
||||
function getRoleReferenceAliases(role: CustomWorldRoleProfile) {
|
||||
return [
|
||||
role.id,
|
||||
role.name,
|
||||
role.title,
|
||||
`${role.name}${role.title}`,
|
||||
`${role.title}${role.name}`,
|
||||
`${role.role}${role.name}`,
|
||||
`${role.name}${role.role}`,
|
||||
]
|
||||
.map(normalizeRoleReference)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function findCustomWorldRoleByReference(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
reference: string | null | undefined,
|
||||
) {
|
||||
const normalizedReference = normalizeRoleReference(reference);
|
||||
if (!profile || !normalizedReference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roles = [...profile.storyNpcs, ...profile.playableNpcs];
|
||||
return (
|
||||
roles.find((role) =>
|
||||
getRoleReferenceAliases(role).includes(normalizedReference),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRoleIdReference(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
reference: string | null | undefined,
|
||||
) {
|
||||
const role = findCustomWorldRoleByReference(profile, reference);
|
||||
return role?.id ?? reference?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRoleIdReferences(
|
||||
profile: CustomWorldRoleReferenceProfile | null | undefined,
|
||||
references: Array<string | null | undefined>,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
references
|
||||
.map((reference) => resolveCustomWorldRoleIdReference(profile, reference))
|
||||
.map((reference) => reference.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SceneConnectionInfo,
|
||||
StoryEngineMemoryState,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences';
|
||||
|
||||
function toSet(values: string[]) {
|
||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||
@@ -227,17 +228,11 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
@@ -245,7 +240,9 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
resolveActiveSceneActBlueprint(params)?.primaryNpcId,
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
@@ -253,7 +250,9 @@ export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
resolveActiveSceneActBlueprint(params)?.oppositeNpcId,
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
@@ -262,12 +261,11 @@ export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
return (
|
||||
activeAct?.oppositeNpcId?.trim() ||
|
||||
activeAct?.primaryNpcId?.trim() ||
|
||||
activeAct?.encounterNpcIds[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
return resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct?.oppositeNpcId,
|
||||
activeAct?.primaryNpcId,
|
||||
activeAct?.encounterNpcIds[0],
|
||||
])[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBackgroundImage(params: {
|
||||
@@ -295,13 +293,18 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
});
|
||||
|
||||
const limitedChatNpcIds = toSet([
|
||||
activeAct?.primaryNpcId ?? '',
|
||||
activeAct?.oppositeNpcId ?? '',
|
||||
]);
|
||||
const limitedChatNpcIds = toSet(
|
||||
resolveCustomWorldRoleIdReferences(params.profile, [
|
||||
activeAct?.primaryNpcId,
|
||||
activeAct?.oppositeNpcId,
|
||||
]),
|
||||
);
|
||||
const normalizedNpcId =
|
||||
resolveCustomWorldRoleIdReferences(params.profile, [params.npcId])[0] ??
|
||||
params.npcId;
|
||||
|
||||
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
|
||||
if (limitedChatNpcIds.has(params.npcId)) {
|
||||
if (limitedChatNpcIds.has(normalizedNpcId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -310,7 +313,7 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
profile: params.profile,
|
||||
sceneId: params.sceneId,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
}) === params.npcId
|
||||
}) === normalizedNpcId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ export {
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -27,7 +27,7 @@ const baseWork: PuzzleWorkSummary = {
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
return pieces.some((piece) =>
|
||||
pieces.some((candidate) => {
|
||||
if (piece.pieceId === candidate.pieceId) {
|
||||
@@ -39,13 +39,18 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const correctColDelta = candidate.correctCol - piece.correctCol;
|
||||
return (
|
||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||
currentRowDelta === correctRowDelta &&
|
||||
currentColDelta === correctColDelta
|
||||
Math.abs(correctRowDelta) + Math.abs(correctColDelta) === 1
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function boardPositionSignature(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
return run.currentLevel?.board.pieces
|
||||
.map((piece) => `${piece.currentRow}:${piece.currentCol}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
@@ -89,13 +94,13 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
|
||||
test('初始棋盘没有任何自动合并块', () => {
|
||||
test('初始棋盘没有任何原图相邻块贴边', () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
|
||||
expect(board?.mergedGroups).toEqual([]);
|
||||
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,12 +288,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
clearedRun.currentLevel?.leaderboardEntries.some(
|
||||
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
@@ -300,4 +301,17 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
|
||||
test('连续推进下一关会重新打乱棋盘', () => {
|
||||
const firstClearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
const secondRun = advanceLocalPuzzleLevel(firstClearedRun);
|
||||
const secondClearedRun = solveCurrentLevel(secondRun);
|
||||
const thirdRun = advanceLocalPuzzleLevel(secondClearedRun);
|
||||
|
||||
expect(secondRun.currentLevelIndex).toBe(2);
|
||||
expect(thirdRun.currentLevelIndex).toBe(3);
|
||||
expect(boardPositionSignature(secondRun)).not.toBe(boardPositionSignature(thirdRun));
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleGridSize,
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
);
|
||||
ensureBoardIsNotSolved(shuffled, gridSize);
|
||||
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
||||
if (!hasAnyCorrectNeighborPair(pieces)) {
|
||||
if (!hasAnyOriginalNeighborPair(pieces)) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return positions.slice().reverse();
|
||||
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
@@ -90,48 +89,6 @@ function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function rankLeaderboardEntries(
|
||||
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
return entries
|
||||
.map((entry) => ({ ...entry }))
|
||||
.sort((left, right) => left.elapsedMs - right.elapsedMs)
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
|
||||
function buildLocalLeaderboardEntries(
|
||||
elapsedMs: number,
|
||||
playerNickname: string,
|
||||
levelIndex: number,
|
||||
gridSize: PuzzleGridSize,
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
|
||||
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
|
||||
return rankLeaderboardEntries([
|
||||
{
|
||||
nickname: playerNickname.trim() || '玩家',
|
||||
elapsedMs: normalizedElapsedMs,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
|
||||
},
|
||||
{
|
||||
nickname: '月港拼图手',
|
||||
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
|
||||
},
|
||||
{
|
||||
nickname: '雾灯收藏家',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
@@ -179,6 +136,119 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
return (
|
||||
Math.abs(right.correctRow - left.correctRow) +
|
||||
Math.abs(right.correctCol - left.correctCol) ===
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areOriginalNeighbors(piece, neighborPiece));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function seededOrderKey(seed: number, value: number) {
|
||||
let state = (seed ^ Math.imul(value, 2654435761)) >>> 0;
|
||||
state ^= state >>> 16;
|
||||
state = Math.imul(state, 2246822507) >>> 0;
|
||||
state ^= state >>> 13;
|
||||
state = Math.imul(state, 3266489909) >>> 0;
|
||||
return (state ^ (state >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function buildOriginalNeighborFreePositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
seed: number,
|
||||
) {
|
||||
const total = gridSize * gridSize;
|
||||
const pieceOrder = Array.from({ length: total }, (_, index) => index).sort(
|
||||
(left, right) =>
|
||||
seededOrderKey(seed ^ 0xa0761d64, left) -
|
||||
seededOrderKey(seed ^ 0xa0761d64, right),
|
||||
);
|
||||
const cellOrder = Array.from({ length: total }, (_, index) => ({
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
})).sort(
|
||||
(left, right) =>
|
||||
seededOrderKey(seed ^ 0xe7037ed1, left.row * 16 + left.col) -
|
||||
seededOrderKey(seed ^ 0xe7037ed1, right.row * 16 + right.col),
|
||||
);
|
||||
const placements: Array<PuzzleCellPosition | null> = Array.from(
|
||||
{ length: total },
|
||||
() => null,
|
||||
);
|
||||
const usedCells = new Set<string>();
|
||||
|
||||
const placePiece = (depth: number): boolean => {
|
||||
const pieceIndex = pieceOrder[depth];
|
||||
if (pieceIndex === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const cell of cellOrder) {
|
||||
const cellKey = boardCellKey(cell.row, cell.col);
|
||||
if (usedCells.has(cellKey)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
cell.row === Math.floor(pieceIndex / gridSize) &&
|
||||
cell.col === pieceIndex % gridSize
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
violatesOriginalNeighborFreeRule(gridSize, pieceIndex, cell, placements)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
placements[pieceIndex] = cell;
|
||||
usedCells.add(cellKey);
|
||||
if (placePiece(depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
usedCells.delete(cellKey);
|
||||
placements[pieceIndex] = null;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return placePiece(0) && placements.every(Boolean)
|
||||
? (placements as PuzzleCellPosition[])
|
||||
: null;
|
||||
}
|
||||
|
||||
function violatesOriginalNeighborFreeRule(
|
||||
gridSize: PuzzleGridSize,
|
||||
pieceIndex: number,
|
||||
cell: PuzzleCellPosition,
|
||||
placements: Array<PuzzleCellPosition | null>,
|
||||
) {
|
||||
return placements.some((placedCell, placedIndex) => {
|
||||
if (!placedCell) {
|
||||
return false;
|
||||
}
|
||||
const originalNeighbors =
|
||||
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
|
||||
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
|
||||
1;
|
||||
const currentNeighbors =
|
||||
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
|
||||
1;
|
||||
return originalNeighbors && currentNeighbors;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMergedGroups(
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleMergedGroupState[] {
|
||||
@@ -306,15 +376,6 @@ function applyNextBoard(
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
const leaderboardEntries =
|
||||
justCleared && elapsedMs
|
||||
? buildLocalLeaderboardEntries(
|
||||
elapsedMs,
|
||||
run.currentLevel.authorDisplayName,
|
||||
run.currentLevel.levelIndex,
|
||||
run.currentLevel.gridSize,
|
||||
)
|
||||
: run.currentLevel.leaderboardEntries;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
@@ -324,9 +385,9 @@ function applyNextBoard(
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries,
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
@@ -112,6 +113,27 @@ export async function advancePuzzleNextLevel(runId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交通关成绩并读取真实排行榜。
|
||||
*/
|
||||
export async function submitPuzzleLeaderboard(
|
||||
runId: string,
|
||||
payload: SubmitPuzzleLeaderboardRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/leaderboard`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交拼图排行榜失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
|
||||
*/
|
||||
@@ -137,6 +159,7 @@ export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
};
|
||||
|
||||
@@ -202,31 +202,42 @@ test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelop
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers agent draft profile', () => {
|
||||
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.summary).toBe('fallback');
|
||||
expect(profile?.id).toBe('draft-profile-1');
|
||||
expect(profile?.playableNpcs[0]?.id).toBe('draft-playable-1');
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.playableNpcs).toEqual([]);
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||
test('buildRpgCreationPreviewFromSession falls back to draft legacy result profile', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
draftProfile: {
|
||||
...sessionWithPreview.draftProfile,
|
||||
legacyResultProfile: {
|
||||
...sessionWithPreview.resultPreview!.preview,
|
||||
id: 'legacy-result-profile-1',
|
||||
name: '草稿内嵌结果页',
|
||||
summary: 'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('草稿内嵌结果页');
|
||||
expect(profile?.summary).toBe(
|
||||
'resultPreview 缺失时继续使用 draft 内嵌的结果页快照。',
|
||||
);
|
||||
expect(profile?.id).toBe('legacy-result-profile-1');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not treat draftProfile as runtime profile', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-playable-1/portrait.png',
|
||||
);
|
||||
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
|
||||
'稿骨',
|
||||
'稿步',
|
||||
'稿识',
|
||||
'稿魄',
|
||||
'稿契',
|
||||
'稿澜',
|
||||
]);
|
||||
expect(profile).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,19 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
function buildCustomWorldProfileFromDraftLegacyResult(
|
||||
draftProfile: CustomWorldAgentSessionSnapshot['draftProfile'],
|
||||
): CustomWorldProfile | null {
|
||||
if (!draftProfile || typeof draftProfile !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeCustomWorldProfileRecord(
|
||||
(draftProfile as { legacyResultProfile?: unknown }).legacyResultProfile ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview:
|
||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||
@@ -15,14 +28,14 @@ export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return (
|
||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null) ??
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview)
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||
buildCustomWorldProfileFromDraftLegacyResult(session?.draftProfile ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 主入口保持命名稳定,优先消费 Agent 草稿真相源,缺失时才回退到 resultPreview。
|
||||
* 主入口保持命名稳定,只消费结果页运行态快照,避免作品测试读到旧草稿骨架。
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
@@ -30,6 +43,6 @@ export const rpgCreationPreviewAdapter = {
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user