This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

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

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

View File

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

View File

@@ -5,5 +5,6 @@ export {
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
} from './puzzleRuntimeClient';

View File

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

View File

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

View File

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

View File

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

View File

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