This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions
+55 -12
View File
@@ -6,6 +6,17 @@ export type AssetReadUrlRequest = {
expireSeconds?: number;
};
type AssetReadUrlResolveOptions = {
signal?: AbortSignal;
expireSeconds?: number;
/**
* 图片内容可能在同一路径下被重新写入。
* 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数,
* 避免结果页仍命中旧签名地址或浏览器图片缓存。
*/
refreshKey?: string | number | null;
};
export type AssetReadUrlResponse = {
read?: {
objectKey?: string;
@@ -100,21 +111,26 @@ function shouldReuseCachedReadUrlFailure(
export async function getSignedAssetReadUrl(
request: AssetReadUrlRequest,
signal?: AbortSignal,
options: {
bypassCache?: boolean;
} = {},
) {
const cacheKey = buildCacheKey(request);
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
const bypassCache = options.bypassCache === true;
const cached =
!bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
if (cached && shouldReuseCachedReadUrl(cached)) {
return cached.signedUrl;
}
const cachedFailure = cacheKey
const cachedFailure = !bypassCache && cacheKey
? signedReadUrlFailureCache.get(cacheKey)
: undefined;
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
throw new Error('资源不存在或暂时不可读取');
}
if (cacheKey) {
if (cacheKey && !bypassCache) {
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
if (pendingRequest) {
return pendingRequest;
@@ -178,26 +194,48 @@ export async function getSignedAssetReadUrl(
}
})();
if (cacheKey) {
if (cacheKey && !bypassCache) {
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
}
try {
return await requestPromise;
} finally {
if (cacheKey) {
if (cacheKey && !bypassCache) {
pendingSignedReadUrlRequests.delete(cacheKey);
}
}
}
function appendCacheBustParam(
url: string,
refreshKey: string | number | null | undefined,
) {
const normalizedRefreshKey =
refreshKey === null || refreshKey === undefined
? ''
: String(refreshKey).trim();
if (!normalizedRefreshKey) {
return url;
}
try {
const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost');
parsedUrl.searchParams.set('_v', normalizedRefreshKey);
if (/^(?:https?:)?\/\//u.test(url)) {
return parsedUrl.toString();
}
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
} catch {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}_v=${encodeURIComponent(normalizedRefreshKey)}`;
}
}
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
export async function resolveAssetReadUrl(
source: string | null | undefined,
options: {
signal?: AbortSignal;
expireSeconds?: number;
} = {},
options: AssetReadUrlResolveOptions = {},
) {
const value = source?.trim() ?? '';
if (!value) {
@@ -209,20 +247,25 @@ export async function resolveAssetReadUrl(
value.startsWith('data:') ||
value.startsWith('blob:')
) {
return value;
return appendCacheBustParam(value, options.refreshKey);
}
if (isGeneratedLegacyPath(value)) {
return getSignedAssetReadUrl(
const signedUrl = await getSignedAssetReadUrl(
{
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
},
options.signal,
{
bypassCache:
options.refreshKey !== null && options.refreshKey !== undefined,
},
);
return appendCacheBustParam(signedUrl, options.refreshKey);
}
return value;
return appendCacheBustParam(value, options.refreshKey);
}
export function clearSignedAssetReadUrlCache() {
+117 -13
View File
@@ -13,6 +13,44 @@ function toSet(values: string[]) {
return new Set(values.map((value) => value.trim()).filter(Boolean));
}
function resolveCustomWorldRuntimeSceneAliases(
profile: CustomWorldProfile,
sceneId: string,
) {
const aliases = toSet([sceneId]);
const campId = profile.camp?.id?.trim() || 'custom-scene-camp';
if (sceneId === 'custom-scene-camp' || sceneId === campId) {
aliases.add(campId);
aliases.add('custom-scene-camp');
}
// 中文注释:部分单元测试和旧快照会传入精简 profile,运行态解析不能假设 landmarks 始终存在。
(profile.landmarks ?? []).forEach((landmark, index) => {
const runtimeSceneId = `custom-scene-landmark-${index + 1}`;
if (sceneId === runtimeSceneId || sceneId === landmark.id) {
aliases.add(runtimeSceneId);
aliases.add(landmark.id);
}
});
return aliases;
}
function doesSceneMatchChapter(
profile: CustomWorldProfile,
sceneId: string,
chapter: SceneChapterBlueprint,
) {
const sceneAliases = resolveCustomWorldRuntimeSceneAliases(profile, sceneId);
const chapterSceneIds = toSet([
chapter.sceneId,
...(chapter.linkedLandmarkIds ?? []),
...(chapter.acts ?? []).map((act) => act.sceneId),
]);
return [...sceneAliases].some((id) => chapterSceneIds.has(id));
}
export function resolveSceneChapterBlueprint(
profile: CustomWorldProfile | null | undefined,
sceneId: string | null | undefined,
@@ -22,8 +60,8 @@ export function resolveSceneChapterBlueprint(
}
return (
profile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
profile.sceneChapterBlueprints?.find((entry) =>
doesSceneMatchChapter(profile, sceneId, entry),
) ?? null
);
}
@@ -33,15 +71,24 @@ export function resolveActiveSceneActBlueprint(params: {
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActBlueprint | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
const runtimeState = params.storyEngineMemory?.currentSceneActState;
const runtimeChapter =
params.profile && runtimeState?.chapterId
? params.profile.sceneChapterBlueprints?.find(
(entry) =>
entry.id === runtimeState.chapterId &&
Boolean(params.sceneId) &&
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
) ?? null
: null;
const chapter =
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id
) {
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
@@ -132,15 +179,23 @@ export function buildInitialSceneActRuntimeState(params: {
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}): SceneActRuntimeState | null {
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
const runtimeState = params.storyEngineMemory?.currentSceneActState;
const runtimeChapter =
params.profile && params.sceneId && runtimeState?.chapterId
? params.profile.sceneChapterBlueprints?.find(
(entry) =>
entry.id === runtimeState.chapterId &&
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
) ?? null
: null;
const chapter =
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
if (!chapter || chapter.acts.length === 0) {
return null;
}
const runtimeState = params.storyEngineMemory?.currentSceneActState;
if (
runtimeState &&
runtimeState.sceneId === chapter.sceneId &&
runtimeState.chapterId === chapter.id &&
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
) {
@@ -167,11 +222,22 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return (
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
.map((entry) => entry.trim())
.filter(Boolean) ?? []
);
const activeAct = resolveActiveSceneActBlueprint(params);
if (!activeAct) {
return [];
}
return [
...new Set(
[
activeAct.primaryNpcId,
activeAct.oppositeNpcId,
...activeAct.encounterNpcIds,
]
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
export function resolveActiveSceneActPrimaryNpcId(params: {
@@ -182,6 +248,28 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
}
export function resolveActiveSceneActOppositeNpcId(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
}
export function resolveActiveSceneActEncounterFocusNpcId(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
storyEngineMemory?: StoryEngineMemoryState | null;
}) {
const activeAct = resolveActiveSceneActBlueprint(params);
return (
activeAct?.oppositeNpcId?.trim() ||
activeAct?.primaryNpcId?.trim() ||
activeAct?.encounterNpcIds[0]?.trim() ||
null
);
}
export function resolveActiveSceneActBackgroundImage(params: {
profile: CustomWorldProfile | null | undefined;
sceneId: string | null | undefined;
@@ -201,6 +289,22 @@ export function canUseLimitedPrimaryNpcChat(params: {
return false;
}
const activeAct = resolveActiveSceneActBlueprint({
profile: params.profile,
sceneId: params.sceneId,
storyEngineMemory: params.storyEngineMemory,
});
const limitedChatNpcIds = toSet([
activeAct?.primaryNpcId ?? '',
activeAct?.oppositeNpcId ?? '',
]);
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
if (limitedChatNpcIds.has(params.npcId)) {
return true;
}
return (
resolveActiveSceneActPrimaryNpcId({
profile: params.profile,
@@ -1,10 +1,12 @@
import { describe, expect, test } from 'vitest';
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from './puzzleLocalRuntime';
const baseWork: PuzzleWorkSummary = {
@@ -25,6 +27,25 @@ const baseWork: PuzzleWorkSummary = {
publishReady: true,
};
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
return pieces.some((piece) =>
pieces.some((candidate) => {
if (piece.pieceId === candidate.pieceId) {
return false;
}
const currentRowDelta = candidate.currentRow - piece.currentRow;
const currentColDelta = candidate.currentCol - piece.currentCol;
const correctRowDelta = candidate.correctRow - piece.correctRow;
const correctColDelta = candidate.correctCol - piece.correctCol;
return (
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
currentRowDelta === correctRowDelta &&
currentColDelta === correctColDelta
);
}),
);
}
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
@@ -52,11 +73,222 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
}
describe('puzzleLocalRuntime', () => {
test('每次启动都会生成不同的初始打乱样式', async () => {
const firstRun = startLocalPuzzleRun(baseWork);
await new Promise((resolve) => setTimeout(resolve, 2));
const secondRun = startLocalPuzzleRun(baseWork);
const firstPositions = firstRun.currentLevel?.board.pieces.map((piece) => [
piece.currentRow,
piece.currentCol,
]);
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
piece.currentRow,
piece.currentCol,
]);
expect(firstPositions).not.toEqual(secondPositions);
});
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);
}
});
test('交换后正确相邻的块会自动合并', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const nextRun = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece) => {
const layout: Record<string, [number, number]> = {
'piece-0': [1, 1],
'piece-1': [0, 1],
'piece-2': [2, 2],
'piece-3': [0, 2],
'piece-4': [1, 0],
'piece-5': [2, 0],
'piece-6': [0, 0],
'piece-7': [1, 2],
'piece-8': [2, 1],
};
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
return {
...piece,
currentRow: current[0],
currentCol: current[1],
mergedGroupId: null,
};
}),
mergedGroups: [],
allTilesResolved: false,
},
},
};
const swapped = swapLocalPuzzlePieces(nextRun, {
firstPieceId: 'piece-0',
secondPieceId: 'piece-6',
});
expect(
swapped.currentLevel?.board.mergedGroups.some(
(group) =>
group.pieceIds.includes('piece-0') &&
group.pieceIds.includes('piece-1'),
),
).toBe(true);
});
test('全部拼块汇成一个大合并块后判定通关', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const solvedByOneGroup = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece, index) => ({
...piece,
currentRow: Math.floor(index / board.cols),
currentCol: (index + 1) % board.cols,
mergedGroupId: 'group-full',
})),
mergedGroups: [
{
groupId: 'group-full',
pieceIds: board.pieces.map((piece) => piece.pieceId),
occupiedCells: board.pieces.map((_, index) => ({
row: Math.floor(index / board.cols),
col: (index + 1) % board.cols,
})),
},
],
allTilesResolved: true,
},
},
};
expect(solvedByOneGroup.currentLevel.board.allTilesResolved).toBe(true);
});
test('大合并块覆盖多个小块时会与被覆盖块逐一交换,不会出现小块消失', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const preparedRun = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece) => {
const layout: Record<string, [number, number, string | null]> = {
'piece-0': [0, 0, 'group-1'],
'piece-1': [0, 1, 'group-1'],
'piece-2': [0, 2, null],
'piece-3': [1, 0, 'group-1'],
'piece-4': [1, 1, 'group-1'],
'piece-5': [1, 2, null],
'piece-6': [2, 0, null],
'piece-7': [2, 1, null],
'piece-8': [2, 2, null],
};
const current = layout[piece.pieceId] ?? [
piece.currentRow,
piece.currentCol,
piece.mergedGroupId,
];
return {
...piece,
currentRow: current[0],
currentCol: current[1],
mergedGroupId: current[2],
};
}),
mergedGroups: [
{
groupId: 'group-1',
pieceIds: ['piece-0', 'piece-1', 'piece-3', 'piece-4'],
occupiedCells: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
{ row: 1, col: 1 },
],
},
],
allTilesResolved: false,
},
},
};
const dragged = dragLocalPuzzlePiece(preparedRun, {
pieceId: 'piece-0',
targetRow: 1,
targetCol: 1,
});
const nextBoard = dragged.currentLevel?.board;
expect(nextBoard).toBeTruthy();
if (!nextBoard) {
return;
}
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
).toMatchObject({ currentRow: 0, currentCol: 0 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-7'),
).toMatchObject({ currentRow: 0, currentCol: 1 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-8'),
).toMatchObject({ currentRow: 1, currentCol: 0 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-0'),
).toMatchObject({ currentRow: 1, currentCol: 1 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-4'),
).toMatchObject({ currentRow: 2, currentCol: 2 });
});
test('通关后提供下一关入口并能推进到新棋盘', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
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);
const nextRun = advanceLocalPuzzleLevel(clearedRun);
@@ -64,6 +296,8 @@ describe('puzzleLocalRuntime', () => {
expect(nextRun.currentLevel?.status).toBe('playing');
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(nextRun.recommendedNextProfileId).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
export type PuzzleHistoryAsset = {
assetObjectId: string;
assetKind: 'puzzle_cover_image';
imageSrc: string;
ownerUserId?: string | null;
ownerLabel: string;
profileId?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
};
/**
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
*/
export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
const params = new URLSearchParams({ kind: 'puzzle_cover_image' });
if (payload.limit) {
params.set('limit', String(payload.limit));
}
const response = await requestJson<{ assets: PuzzleHistoryAsset[] }>(
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
{ method: 'GET' },
'读取历史拼图素材失败',
);
return response.assets;
}
export const puzzleAssetClient = {
listHistoryAssets: listPuzzleHistoryAssets,
};