This commit is contained in:
2026-05-01 01:30:02 +08:00
parent aabad6407f
commit 2e9d0f4640
92 changed files with 4548 additions and 248 deletions

View File

@@ -190,6 +190,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
test('creation hub shows puzzle point incentive and claims without opening card', async () => {
const user = userEvent.setup();
const onClaimPuzzlePointIncentive = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-incentive',
profileId: 'puzzle-profile-incentive',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '陶泥灯塔',
summary: '拼图作品会展示积分激励。',
themeTags: ['灯塔', '陶泥'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-01T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-01T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 3,
pointIncentiveTotalHalfPoints: 5,
pointIncentiveClaimedPoints: 1,
pointIncentiveTotalPoints: 2.5,
pointIncentiveClaimablePoints: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive}
/>,
);
expect(screen.getByLabelText('积分激励总数 2.5 陶泥币')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 陶泥币')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));
expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-incentive' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub

View File

@@ -47,6 +47,8 @@ type CustomWorldCreationHubProps = {
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
};
function EmptyState({ title }: { title: string }) {
@@ -131,6 +133,8 @@ export function CustomWorldCreationHub({
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -222,6 +226,17 @@ export function CustomWorldCreationHub({
}
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(sourceItem);
};
}
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
@@ -281,6 +296,11 @@ export function CustomWorldCreationHub({
onOpen={() => handleOpenShelfItem(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>

View File

@@ -13,6 +13,7 @@ import {
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
formatCreationPointIncentiveTotal,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
@@ -21,6 +22,8 @@ type CustomWorldWorkCardProps = {
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
@@ -189,12 +192,17 @@ export function CustomWorldWorkCard({
onOpen,
onDelete = null,
deleteBusy = false,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
const displayTitle = formatPlatformWorkDisplayName(item.title);
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
@@ -346,34 +354,81 @@ export function CustomWorldWorkCard({
</div>
{isPublished ? (
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 陶泥币`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 陶泥币`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
) : null}
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
))}
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>

View File

@@ -35,6 +35,12 @@ export type CreationWorkShelfMetric = {
tone: CreationWorkShelfMetricTone;
};
export type CreationWorkShelfPointIncentive = {
totalHalfPoints: number;
totalPoints: number;
claimablePoints: number;
};
export type CreationWorkShelfSource =
| {
kind: 'rpg';
@@ -66,6 +72,7 @@ export type CreationWorkShelfItem = {
canShare: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
pointIncentive?: CreationWorkShelfPointIncentive;
source: CreationWorkShelfSource;
};
@@ -238,6 +245,21 @@ function mapPuzzleWorkToShelfItem(
likeCount: item.likeCount,
})
: [],
pointIncentive:
status === 'published'
? {
totalHalfPoints: normalizeMetricCount(
item.pointIncentiveTotalHalfPoints,
),
totalPoints: normalizePointIncentiveTotal(
item.pointIncentiveTotalPoints,
item.pointIncentiveTotalHalfPoints,
),
claimablePoints: normalizeMetricCount(
item.pointIncentiveClaimablePoints,
),
}
: undefined,
source: { kind: 'puzzle', item },
};
}
@@ -286,6 +308,24 @@ export function formatCreationMetricCount(value?: number | null) {
return `${normalized}`;
}
export function formatCreationPointIncentiveTotal(value?: number | null) {
const normalized = Math.max(0, value ?? 0);
return Number.isInteger(normalized)
? normalized.toFixed(0)
: normalized.toFixed(1);
}
function normalizePointIncentiveTotal(
totalPoints?: number | null,
totalHalfPoints?: number | null,
) {
if (Number.isFinite(totalPoints)) {
return Math.max(0, totalPoints ?? 0);
}
return normalizeMetricCount(totalHalfPoints) / 2;
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {

View File

@@ -48,6 +48,7 @@ import type {
CustomWorldLibraryEntry,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
@@ -125,13 +126,18 @@ import {
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import {
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
listPuzzleWorks,
} from '../../services/puzzle-works';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import {
@@ -141,10 +147,8 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import {
getRpgProfilePlayStats,
resumeRpgProfileSaveArchive,
} from '../../services/rpg-entry/rpgProfileClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
@@ -201,6 +205,16 @@ type PuzzleSaveArchiveState = {
currentLevelId?: unknown;
};
async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
return requestRpgRuntimeJson<
ProfileSaveArchiveResumeResponse<PuzzleSaveArchiveState>
>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复拼图存档失败',
);
}
type AgentResultBlockerView = {
code?: string;
message: string;
@@ -297,6 +311,10 @@ function mapPublicWorkDetailToPuzzleWork(
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: true,
levels:
entry.coverSlides?.map((slide, index) => ({
@@ -729,11 +747,10 @@ function mergePuzzleServiceRuntimeState(
}
const serviceLevel = serviceRun.currentLevel;
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
if (
currentRun.currentLevel.status === 'cleared' &&
serviceLevel.status !== 'cleared'
) {
return {
...currentRun,
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
@@ -741,8 +758,27 @@ function mergePuzzleServiceRuntimeState(
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
leaderboardEntries:
currentRun.currentLevel.leaderboardEntries.length > 0
? currentRun.currentLevel.leaderboardEntries
: currentRun.leaderboardEntries,
};
}
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
return {
...currentRun,
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
status: serviceLevel.status,
startedAtMs: serviceLevel.startedAtMs,
@@ -836,6 +872,8 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] =
useState<string | null>(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
@@ -1569,6 +1607,7 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
@@ -1812,6 +1851,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeReturnStage(returnStage);
setSelectionStage('puzzle-runtime');
void platformBootstrap.refreshSaveArchives();
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-runtime',
@@ -1830,6 +1870,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isPuzzleBusy,
platformBootstrap,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
@@ -1863,6 +1904,10 @@ export function PlatformEntryFlowShellImpl({
playCount: 0,
remixCount: 0,
likeCount: 0,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
levels: draft.levels,
} satisfies PuzzleWorkSummary;
@@ -1963,9 +2008,7 @@ export function PlatformEntryFlowShellImpl({
}
const timerId = window.setInterval(() => {
if (!isLocalPuzzleRun(puzzleRun)) {
return;
}
// 中文注释:正式 run 的棋盘交互也在前端即时裁决,倒计时展示同样走本地时钟;超时落库仍由 onTimeExpired 拉取后端快照完成。
setPuzzleRun((currentRun) =>
currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun,
);
@@ -2009,7 +2052,7 @@ export function PlatformEntryFlowShellImpl({
const syncPuzzleRuntimeTimeout = useCallback(async () => {
if (
!puzzleRun?.currentLevel ||
puzzleRun.currentLevel.status !== 'playing'
puzzleRun.currentLevel.status === 'cleared'
) {
return;
}
@@ -2040,9 +2083,11 @@ export function PlatformEntryFlowShellImpl({
if (!puzzleRun?.currentLevel) {
return null;
}
const expectedStatus =
propKind === 'extendTime' ? 'failed' : 'playing';
if (puzzleRun.currentLevel.status !== expectedStatus) {
const canUseProp =
propKind === 'extendTime'
? puzzleRun.currentLevel.status !== 'cleared'
: puzzleRun.currentLevel.status === 'playing';
if (!canUseProp) {
return null;
}
@@ -2072,6 +2117,7 @@ export function PlatformEntryFlowShellImpl({
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
void platformBootstrap.refreshProfileDashboard();
void platformBootstrap.refreshSaveArchives();
return nextRun;
},
[platformBootstrap, puzzleRun],
@@ -2084,6 +2130,10 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
const restartLevelId = resolvePuzzleRestartLevelId(
puzzleRun,
selectedPuzzleDetail,
);
if (isLocalPuzzleRun(puzzleRun)) {
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
puzzleRunRef.current = nextRun;
@@ -2098,7 +2148,7 @@ export function PlatformEntryFlowShellImpl({
? selectedPuzzleDetail
: undefined,
false,
currentLevel.levelId ?? null,
restartLevelId,
);
}, [
isPuzzleBusy,
@@ -2120,7 +2170,7 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.setSaveError(null);
try {
const resumedArchive = await resumeRpgProfileSaveArchive(
const resumedArchive = await resumePuzzleProfileSaveArchiveRaw(
entry.worldKey,
);
platformBootstrap.setSaveEntries((currentEntries) =>
@@ -2130,8 +2180,7 @@ export function PlatformEntryFlowShellImpl({
: currentEntry,
),
);
const gameState = resumedArchive.snapshot
.gameState as PuzzleSaveArchiveState;
const gameState = resumedArchive.snapshot.gameState;
const profileId =
typeof gameState.currentProfileId === 'string' &&
gameState.currentProfileId.trim()
@@ -2145,7 +2194,13 @@ export function PlatformEntryFlowShellImpl({
gameState.currentLevelId.trim()
? gameState.currentLevelId
: null;
await startPuzzleRunFromProfile(profileId, 'platform', undefined, false, levelId);
await startPuzzleRunFromProfile(
profileId,
'platform',
undefined,
false,
levelId,
);
} catch (error) {
platformBootstrap.setSaveError(
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
@@ -2191,7 +2246,27 @@ export function PlatformEntryFlowShellImpl({
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname));
setIsPuzzleLeaderboardBusy(false);
void advanceLocalPuzzleNextLevel({
run: puzzleRun,
sourceSessionId:
selectedPuzzleDetail?.sourceSessionId ??
puzzleSession?.sessionId ??
null,
})
.then(({ run }) => {
setPuzzleRun((currentRun) => {
if (!currentRun) {
return currentRun;
}
return mergePuzzleServiceRuntimeState(currentRun, run);
});
})
.catch(() => {
// 中文注释:本地试玩缺少后端候选时保留本地排行榜和既有下一关入口,避免结算被探测请求打断。
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
});
return;
}
@@ -2203,6 +2278,7 @@ export function PlatformEntryFlowShellImpl({
}
return mergePuzzleServiceRuntimeState(currentRun, run);
});
void platformBootstrap.refreshSaveArchives();
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
@@ -2215,8 +2291,11 @@ export function PlatformEntryFlowShellImpl({
});
}, [
authUi?.user?.displayName,
platformBootstrap,
puzzleRun,
puzzleSession,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setPuzzleError,
]);
@@ -2263,6 +2342,9 @@ export function PlatformEntryFlowShellImpl({
})
: await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
if (!isLocalPuzzleRun(puzzleRun)) {
void platformBootstrap.refreshSaveArchives();
}
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
} finally {
@@ -2272,6 +2354,7 @@ export function PlatformEntryFlowShellImpl({
}, [
isPuzzleBusy,
isPuzzleLeaderboardBusy,
platformBootstrap,
puzzleRun,
puzzleSession,
resolvePuzzleErrorMessage,
@@ -2565,6 +2648,55 @@ export function PlatformEntryFlowShellImpl({
[],
);
const handleClaimPuzzlePointIncentive = useCallback(
(work: PuzzleWorkSummary) => {
if (claimingPuzzlePointIncentiveProfileId) {
return;
}
runProtectedAction(() => {
setClaimingPuzzlePointIncentiveProfileId(work.profileId);
setPuzzleError(null);
void claimPuzzleWorkPointIncentive(work.profileId)
.then((response) => {
const updatedWork = response.item;
setPuzzleWorks((current) =>
current.map((item) =>
mergePuzzleWorkSummary(item, updatedWork),
),
);
setPuzzleGalleryEntries((current) =>
current.map((item) =>
mergePuzzleWorkSummary(item, updatedWork),
),
);
setSelectedPuzzleDetail((current) =>
current ? mergePuzzleWorkSummary(current, updatedWork) : current,
);
syncUpdatedPublicWorkDetail(
mapPuzzleWorkToPublicWorkDetail(updatedWork),
);
})
.catch((error) => {
setPuzzleError(
resolvePuzzleErrorMessage(error, '领取拼图积分激励失败。'),
);
})
.finally(() => {
setClaimingPuzzlePointIncentiveProfileId(null);
});
});
},
[
claimingPuzzlePointIncentiveProfileId,
resolvePuzzleErrorMessage,
runProtectedAction,
setPuzzleError,
syncUpdatedPublicWorkDetail,
],
);
const likePublicWork = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isPublicWorkDetailBusy) {
@@ -3480,6 +3612,10 @@ export function PlatformEntryFlowShellImpl({
onDeletePuzzle={(item) => {
handleDeletePuzzleWork(item);
}}
onClaimPuzzlePointIncentive={(item) => {
handleClaimPuzzlePointIncentive(item);
}}
claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId}
/>
</Suspense>
);

View File

@@ -156,6 +156,7 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(

View File

@@ -1025,6 +1025,9 @@ function PuzzleLevelListTab({
<Plus className="h-4 w-4" />
</span>
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
</span>
</button>
</div>
);

View File

@@ -88,6 +88,7 @@ const clearedRun: PuzzleRunSnapshot = {
currentLevel: {
runId: 'run-1',
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize: 3,
profileId: 'profile-1',
levelName: '潮雾拼图',
@@ -307,6 +308,48 @@ test('当前作品没有下一关时展示三个相似作品并可选择进入',
vi.useRealTimers();
});
test('当前作品没有下一关时底部入口打开相似作品选择', () => {
vi.useFakeTimers();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '通关完成' })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
vi.useRealTimers();
});
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
const authValue = createAuthValue();
@@ -679,6 +722,95 @@ test('倒计时归零时通知父层同步失败态', () => {
vi.useRealTimers();
});
test('失败弹窗支持重开当前关和续时确认', async () => {
const onRestartLevel = vi.fn();
const onUseProp = vi.fn().mockResolvedValue({
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
remainingMs: 60_000,
},
});
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onRestartLevel={onRestartLevel}
onUseProp={onUseProp}
/>,
);
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
expect(onRestartLevel).toHaveBeenCalledTimes(1);
fireEvent.click(
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(onUseProp).toHaveBeenCalledWith('extendTime');
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '继续1分钟' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);

View File

@@ -756,7 +756,7 @@ export function PuzzleRuntimeShell({
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status !== 'playing') {
if (!run || !currentLevel || currentLevel.status === 'cleared') {
return;
}
if (displayRemainingMs > 0) {

View File

@@ -506,6 +506,7 @@ function buildMockPuzzleRun(
currentLevel: {
runId: `run-${profileId}`,
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize,
profileId,
levelName,

View File

@@ -505,6 +505,18 @@ test('profile total play time card always uses hours', () => {
expect(within(playTimeCard).queryByText('90分')).toBeNull();
});
test('profile played works card shows count unit', () => {
renderProfileView(vi.fn(), {
playedWorldCount: 1,
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';

View File

@@ -3235,7 +3235,7 @@ export function RpgEntryHomeView({
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
value={formatDashboardCount(playedWorkCount)}
value={`${formatDashboardCount(playedWorkCount)}`}
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
/>

View File

@@ -136,6 +136,25 @@ export function useRpgEntryBootstrap(
return nextEntries;
}, [canReadProtectedData, user]);
const refreshSaveArchives = useCallback(async () => {
if (!user || !canReadProtectedData) {
setSaveEntries([]);
setSaveError(null);
return [];
}
setSaveError(null);
try {
const nextEntries = await listRpgProfileSaveArchives();
setSaveEntries(nextEntries);
return nextEntries;
} catch (error) {
setSaveError(resolveRpgEntryErrorMessage(error, '读取存档列表失败。'));
return [];
}
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
setHistoryError(null);
@@ -371,6 +390,7 @@ export function useRpgEntryBootstrap(
refreshCustomWorldWorks,
refreshPublishedGallery,
refreshSavedCustomWorldLibrary,
refreshSaveArchives,
appendBrowseHistoryEntry,
handleResumeSaveEntry,
};

View File

@@ -1450,6 +1450,77 @@ body {
line-height: 1;
}
.creation-work-card-incentive {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: stretch;
gap: 0.38rem;
min-width: 0;
}
.creation-work-card-incentive__metric,
.creation-work-card-incentive__button {
min-width: 0;
border: 1px solid color-mix(in srgb, #6b7cff 22%, transparent);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--platform-neutral-bg) 82%, transparent);
backdrop-filter: blur(14px);
}
.creation-work-card-incentive__metric {
overflow: hidden;
padding: 0.38rem 0.42rem;
}
.creation-work-card-incentive__label {
display: block;
overflow: hidden;
color: var(--platform-text-soft);
font-size: 0.58rem;
font-weight: 800;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
}
.creation-work-card-incentive__value {
display: block;
margin-top: 0.18rem;
overflow: hidden;
color: var(--platform-text-strong);
font-size: 0.95rem;
font-weight: 950;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.creation-work-card-incentive__button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.55rem;
padding: 0 0.58rem;
color: var(--platform-text-strong);
font-size: 0.68rem;
font-weight: 900;
line-height: 1.1;
transition:
transform 180ms ease,
border-color 180ms ease,
opacity 180ms ease;
}
.creation-work-card-incentive__button:hover:not(:disabled) {
transform: translateY(-1px);
border-color: color-mix(in srgb, #6b7cff 44%, transparent);
}
.creation-work-card-incentive__button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.platform-tab {
border: 1px solid var(--platform-subpanel-border);
border-radius: 9999px;
@@ -1864,6 +1935,28 @@ body {
font-size: 0.54rem;
}
.creation-work-card-incentive {
gap: 0.28rem;
}
.creation-work-card-incentive__metric {
padding: 0.34rem 0.34rem;
}
.creation-work-card-incentive__label {
font-size: 0.54rem;
}
.creation-work-card-incentive__value {
font-size: 0.82rem;
}
.creation-work-card-incentive__button {
min-height: 2.34rem;
padding: 0 0.48rem;
font-size: 0.62rem;
}
.platform-tab-panel {
padding-right: 0;
}

View File

@@ -6,8 +6,11 @@ import {
applyLocalPuzzleFreezeTime,
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
restartLocalPuzzleLevel,
resolvePuzzleRestartLevelId,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
@@ -84,6 +87,50 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
}
describe('puzzleLocalRuntime', () => {
test('本地关卡切割和倒计时按正式配置推进并循环', () => {
let run = startLocalPuzzleRun(baseWork);
const actual = [run];
for (let index = 0; index < 15; index += 1) {
run = advanceLocalPuzzleLevel({
...run,
clearedLevelCount: run.clearedLevelCount + 1,
currentLevel: run.currentLevel
? {
...run.currentLevel,
status: 'cleared',
clearedAtMs: Date.now(),
elapsedMs: 1_000,
}
: null,
});
actual.push(run);
}
expect(
actual.map((item) => [
item.currentLevel?.gridSize,
item.currentLevel?.timeLimitMs,
]),
).toEqual([
[3, 300_000],
[4, 300_000],
[5, 300_000],
[5, 210_000],
[5, 210_000],
[6, 240_000],
[5, 210_000],
[7, 270_000],
[5, 240_000],
[7, 270_000],
[5, 210_000],
[6, 240_000],
[5, 210_000],
[7, 270_000],
[5, 240_000],
[7, 270_000],
]);
});
test('每次启动都会生成不同的初始打乱样式', async () => {
const firstRun = startLocalPuzzleRun(baseWork);
await new Promise((resolve) => setTimeout(resolve, 2));
@@ -297,6 +344,8 @@ describe('puzzleLocalRuntime', () => {
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBeNull();
expect(clearedRun.nextLevelMode).toBe('none');
expect(clearedRun.recommendedNextWorks).toEqual([]);
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(clearedRun.leaderboardEntries).toEqual([]);
@@ -310,6 +359,8 @@ describe('puzzleLocalRuntime', () => {
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(nextRun.recommendedNextProfileId).toBeNull();
expect(nextRun.nextLevelMode).toBe('none');
expect(nextRun.recommendedNextWorks).toEqual([]);
});
test('连续推进下一关会重新打乱棋盘', () => {
@@ -376,6 +427,113 @@ describe('puzzleLocalRuntime', () => {
expect(nextRun).toBe(timedRun);
});
test('本地失败关卡可以续时一分钟', () => {
const run = startLocalPuzzleRun(baseWork);
const failedRun = refreshLocalPuzzleTimer({
...run,
currentLevel: run.currentLevel
? {
...run.currentLevel,
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
}
: null,
});
const extendedRun = extendLocalPuzzleTime(failedRun);
expect(extendedRun.currentLevel?.status).toBe('playing');
expect(extendedRun.currentLevel?.remainingMs).toBe(60_000);
expect(extendedRun.currentLevel?.elapsedMs).toBeNull();
expect(extendedRun.currentLevel?.pauseStartedAtMs).toBeNull();
expect(extendedRun.currentLevel?.freezeUntilMs).toBeNull();
});
test('本地失败关卡重新开始会保留关卡索引并重建棋盘', () => {
const run = startLocalPuzzleRun(baseWork);
const failedRun = refreshLocalPuzzleTimer({
...run,
currentLevel: run.currentLevel
? {
...run.currentLevel,
startedAtMs: Date.now() - run.currentLevel.timeLimitMs - 1_000,
}
: null,
});
const restartedRun = restartLocalPuzzleLevel(failedRun);
expect(restartedRun.runId).not.toBe(failedRun.runId);
expect(restartedRun.currentLevel?.status).toBe('playing');
expect(restartedRun.currentLevel?.levelIndex).toBe(
failedRun.currentLevel?.levelIndex,
);
expect(restartedRun.currentLevel?.remainingMs).toBe(
restartedRun.currentLevel?.timeLimitMs,
);
expect(boardPositionSignature(restartedRun)).not.toBe(
boardPositionSignature(failedRun),
);
});
test('失败重开优先使用当前关卡 id旧快照缺失时按关卡序号兜底', () => {
const workWithLevels: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '第二关',
pictureDescription: '第二关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-2.png',
coverAssetId: null,
generationStatus: 'ready',
},
],
};
const run = startLocalPuzzleRun(workWithLevels);
const secondLevelRun = {
...run,
currentLevelIndex: 2,
currentLevel: run.currentLevel
? {
...run.currentLevel,
levelIndex: 2,
levelId: null,
status: 'failed' as const,
}
: null,
};
expect(resolvePuzzleRestartLevelId(secondLevelRun, workWithLevels)).toBe(
'puzzle-level-2',
);
expect(
resolvePuzzleRestartLevelId(
{
...secondLevelRun,
currentLevel: secondLevelRun.currentLevel
? {
...secondLevelRun.currentLevel,
levelId: 'explicit-level',
}
: null,
},
workWithLevels,
),
).toBe('explicit-level');
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -15,17 +15,70 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
const PUZZLE_EXTEND_TIME_DURATION_MS = 60_000;
const PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE: Record<PuzzleGridSize, number> = {
3: 180_000,
4: 300_000,
let localPuzzleRunSequence = 0;
type PuzzleLevelConfig = {
gridSize: PuzzleGridSize;
timeLimitMs: number;
};
// 中文注释:本地兜底必须和后端按同一关卡序号解析切割规格与倒计时。
function resolvePuzzleLevelConfig(levelIndex: number): PuzzleLevelConfig {
const normalizedLevelIndex = Math.max(1, Math.floor(levelIndex || 1));
switch (normalizedLevelIndex) {
case 1:
return { gridSize: 3, timeLimitMs: 300_000 };
case 2:
return { gridSize: 4, timeLimitMs: 300_000 };
case 3:
return { gridSize: 5, timeLimitMs: 300_000 };
case 4:
return { gridSize: 5, timeLimitMs: 210_000 };
default: {
const loopIndex = ((Math.max(5, normalizedLevelIndex) - 5) % 6) + 5;
switch (loopIndex) {
case 5:
return { gridSize: 5, timeLimitMs: 210_000 };
case 6:
return { gridSize: 6, timeLimitMs: 240_000 };
case 7:
return { gridSize: 5, timeLimitMs: 210_000 };
case 8:
return { gridSize: 7, timeLimitMs: 270_000 };
case 9:
return { gridSize: 5, timeLimitMs: 240_000 };
default:
return { gridSize: 7, timeLimitMs: 270_000 };
}
}
}
}
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
return clearedLevelCount >= 3 ? 4 : 3;
return resolvePuzzleLevelConfig(clearedLevelCount + 1).gridSize;
}
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
function buildLocalPuzzleRunId(profileId: string) {
localPuzzleRunSequence = (localPuzzleRunSequence + 1) % 1_000_000;
return `${LOCAL_PUZZLE_RUN_ID_PREFIX}${profileId}-${Date.now()}-${localPuzzleRunSequence}`;
}
export function resolvePuzzleRestartLevelId(
run: PuzzleRunSnapshot,
work: PuzzleWorkSummary | null | undefined,
): string | null {
const currentLevel = run.currentLevel;
if (!currentLevel) {
return null;
}
return (
currentLevel.levelId ??
work?.levels?.[Math.max(0, currentLevel.levelIndex - 1)]?.levelId ??
null
);
}
function buildShuffleSeed(...parts: Array<string | number>) {
let hash = 0x811c9dc5;
for (const part of parts.join('|')) {
@@ -77,7 +130,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
row: Math.floor(index / gridSize),
col: index % gridSize,
}));
for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) {
for (
let attempt = 0;
attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS;
attempt += 1
) {
const shuffled = shufflePositions(
positions,
(seed + Math.imul(attempt, 2654435761)) >>> 0,
@@ -88,7 +145,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
return shuffled;
}
}
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
return (
buildDeterministicNeighborFreePositions(gridSize, seed) ??
buildOriginalNeighborFreePositions(gridSize, seed) ??
positions
);
}
function boardCellKey(row: number, col: number) {
@@ -99,8 +160,8 @@ function clampElapsedMs(value: number) {
return Math.max(1_000, Math.round(value));
}
function resolvePuzzleLevelTimeLimitMs(gridSize: PuzzleGridSize) {
return PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE[gridSize];
function resolvePuzzleLevelTimeLimitMs(levelIndex: number) {
return resolvePuzzleLevelConfig(levelIndex || 1).timeLimitMs;
}
function resolveActiveFreezeElapsedMs(
@@ -110,7 +171,10 @@ function resolveActiveFreezeElapsedMs(
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
return 0;
}
return Math.max(0, Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs);
return Math.max(
0,
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
);
}
function resolveEffectiveElapsedMs(
@@ -135,7 +199,11 @@ function settleExpiredFreeze(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
): PuzzleRuntimeLevelSnapshot {
if (!level.freezeStartedAtMs || !level.freezeUntilMs || nowMs < level.freezeUntilMs) {
if (
!level.freezeStartedAtMs ||
!level.freezeUntilMs ||
nowMs < level.freezeUntilMs
) {
return level;
}
return {
@@ -168,8 +236,8 @@ function withResolvedTimer(run: PuzzleRunSnapshot, nowMs = Date.now()) {
};
}
function buildLevelTimerFields(gridSize: PuzzleGridSize) {
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(gridSize);
function buildLevelTimerFields(levelIndex: number) {
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(levelIndex);
return {
timeLimitMs,
remainingMs: timeLimitMs,
@@ -228,18 +296,6 @@ function buildPiecesFromPositions(
}));
}
function hasAnyCorrectNeighborPair(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 && areCorrectNeighbors(piece, neighborPiece));
}),
);
}
function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
return (
Math.abs(right.correctRow - left.correctRow) +
@@ -250,12 +306,19 @@ function areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
function hasAnyOriginalNeighborPair(pieces: PuzzlePieceState[]) {
const piecesByCell = new Map(
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
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));
const neighborPiece = piecesByCell.get(
boardCellKey(neighbor.row, neighbor.col),
);
return Boolean(
neighborPiece && areOriginalNeighbors(piece, neighborPiece),
);
}),
);
}
@@ -269,6 +332,127 @@ function seededOrderKey(seed: number, value: number) {
return (state ^ (state >>> 16)) >>> 0;
}
function buildDeterministicNeighborFreePositions(
gridSize: PuzzleGridSize,
seed: number,
) {
if (gridSize === 3) {
return buildSeeded3x3NeighborFreePositions(seed);
}
if (gridSize === 4 || gridSize === 6) {
return buildAffineNeighborFreePositions(gridSize, 1, 1, 2, 1, seed);
}
if (gridSize === 5 || gridSize === 7) {
return buildAffineNeighborFreePositions(
gridSize,
0,
1,
2,
gridSize - 1,
seed,
);
}
return null;
}
function buildSeeded3x3NeighborFreePositions(seed: number) {
const layouts: Array<Array<[number, number]>> = [
[
[0, 1],
[1, 0],
[1, 2],
[2, 0],
[0, 2],
[2, 1],
[1, 1],
[2, 2],
[0, 0],
],
[
[0, 1],
[1, 0],
[1, 2],
[2, 0],
[0, 2],
[2, 1],
[2, 2],
[1, 1],
[0, 0],
],
[
[0, 1],
[1, 0],
[1, 2],
[2, 0],
[2, 2],
[0, 0],
[1, 1],
[0, 2],
[2, 1],
],
[
[0, 1],
[1, 0],
[1, 2],
[2, 1],
[0, 2],
[2, 0],
[0, 0],
[2, 2],
[1, 1],
],
[
[0, 1],
[1, 0],
[1, 2],
[2, 2],
[0, 2],
[2, 1],
[1, 1],
[2, 0],
[0, 0],
],
[
[0, 1],
[1, 0],
[2, 1],
[2, 0],
[2, 2],
[0, 2],
[1, 2],
[0, 0],
[1, 1],
],
];
const layout = layouts[Math.abs(seed) % layouts.length] ?? layouts[0];
return (
layout?.map(([row, col]) => ({
row,
col,
})) ?? null
);
}
function buildAffineNeighborFreePositions(
gridSize: PuzzleGridSize,
rowFromRow: number,
rowFromCol: number,
colFromRow: number,
colFromCol: number,
seed: number,
) {
const rowOffset = seed % gridSize;
const colOffset = Math.floor(seed / gridSize) % gridSize;
return Array.from({ length: gridSize * gridSize }, (_, index) => {
const row = Math.floor(index / gridSize);
const col = index % gridSize;
return {
row: (rowFromRow * row + rowFromCol * col + rowOffset) % gridSize,
col: (colFromRow * row + colFromCol * col + colOffset) % gridSize,
};
});
}
function buildOriginalNeighborFreePositions(
gridSize: PuzzleGridSize,
seed: number,
@@ -343,11 +527,14 @@ function violatesOriginalNeighborFreeRule(
return false;
}
const originalNeighbors =
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
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) ===
Math.abs(cell.row - placedCell.row) +
Math.abs(cell.col - placedCell.col) ===
1;
return originalNeighbors && currentNeighbors;
});
@@ -357,7 +544,10 @@ function resolveMergedGroups(
pieces: PuzzlePieceState[],
): PuzzleMergedGroupState[] {
const piecesByCell = new Map(
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
pieces.map((piece) => [
boardCellKey(piece.currentRow, piece.currentCol),
piece,
]),
);
const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
const visited = new Set<string>();
@@ -386,7 +576,9 @@ function resolveMergedGroups(
currentPiece.currentRow,
currentPiece.currentCol,
)) {
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
const neighborPiece = piecesByCell.get(
boardCellKey(neighbor.row, neighbor.col),
);
if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) {
queue.push(neighborPiece.pieceId);
}
@@ -433,7 +625,8 @@ function rebuildBoardSnapshot(
piece.currentCol === piece.correctCol,
);
const allPiecesMergedIntoOneGroup = mergedGroups.some(
(group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
(group) =>
group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
);
const allTilesResolved =
allPiecesInCorrectCells || allPiecesMergedIntoOneGroup;
@@ -560,12 +753,17 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
gridSize,
profileId: nextProfileId,
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
board: buildInitialBoard(gridSize, run.runId, nextProfileId, nextLevelIndex),
board: buildInitialBoard(
gridSize,
run.runId,
nextProfileId,
nextLevelIndex,
),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(gridSize),
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
@@ -577,9 +775,11 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
};
}
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
export function startLocalPuzzleRun(
item: PuzzleWorkSummary,
): PuzzleRunSnapshot {
const gridSize = resolvePuzzleGridSize(0);
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
const runId = buildLocalPuzzleRunId(item.profileId);
const startedAtMs = Date.now();
const firstLevel = item.levels?.[0] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
@@ -608,7 +808,7 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(gridSize),
...buildLevelTimerFields(1),
leaderboardEntries: [],
},
recommendedNextProfileId: null,
@@ -631,7 +831,9 @@ export function swapLocalPuzzlePieces(
}
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
const second = pieces.find(
(piece) => piece.pieceId === payload.secondPieceId,
);
if (!first || !second) {
return timedRun;
}
@@ -641,7 +843,10 @@ export function swapLocalPuzzlePieces(
second.currentRow = firstPosition.row;
second.currentCol = firstPosition.col;
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
return applyNextBoard(
timedRun,
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
);
}
function dragSinglePiece(
@@ -717,7 +922,8 @@ function dragGroup(
col: piece.currentCol,
}))
.filter(
(position) => !targetCellKeys.has(boardCellKey(position.row, position.col)),
(position) =>
!targetCellKeys.has(boardCellKey(position.row, position.col)),
)
.sort((left, right) => left.row - right.row || left.col - right.col);
const occupyingPieces = targetPositions
@@ -733,7 +939,8 @@ function dragGroup(
.filter((piece): piece is PuzzlePieceState => Boolean(piece))
.sort(
(left, right) =>
left.currentRow - right.currentRow || left.currentCol - right.currentCol,
left.currentRow - right.currentRow ||
left.currentCol - right.currentCol,
);
if (occupyingPieces.length !== vacatedPositions.length) {
@@ -796,20 +1003,27 @@ export function dragLocalPuzzlePiece(
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
}
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
return applyNextBoard(
timedRun,
rebuildBoardSnapshot(currentLevel.gridSize, pieces),
);
}
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
export function advanceLocalPuzzleLevel(
run: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
return buildFallbackLocalLevel(run);
}
export function restartLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
export function restartLocalPuzzleLevel(
run: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel) {
return run;
}
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${currentLevel.profileId}-${Date.now()}`;
const runId = buildLocalPuzzleRunId(currentLevel.profileId);
const startedAtMs = Date.now();
return {
...run,
@@ -828,7 +1042,7 @@ export function restartLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapsh
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(currentLevel.gridSize),
...buildLevelTimerFields(currentLevel.levelIndex),
leaderboardEntries: [],
},
};
@@ -876,7 +1090,9 @@ export function submitLocalPuzzleLeaderboard(
};
}
export function refreshLocalPuzzleTimer(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
export function refreshLocalPuzzleTimer(
run: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
return withResolvedTimer(run);
}
@@ -928,7 +1144,9 @@ export function applyLocalPuzzleFreezeTime(
};
}
export function extendLocalPuzzleTime(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
export function extendLocalPuzzleTime(
run: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
const timedRun = withResolvedTimer(run);
const currentLevel = timedRun.currentLevel;
if (!currentLevel || currentLevel.status !== 'failed') {

View File

@@ -1,6 +1,7 @@
export {
getPuzzleWorkDetail,
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
getPuzzleWorkDetail,
listPuzzleWorks,
puzzleWorksClient,
updatePuzzleWork,

View File

@@ -98,7 +98,24 @@ export async function deletePuzzleWork(profileId: string) {
);
}
/**
* 领取当前用户名下拼图作品的整数陶泥币激励。
*/
export async function claimPuzzleWorkPointIncentive(profileId: string) {
return requestJson<PuzzleWorkMutationResponse>(
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}/point-incentive/claim`,
{
method: 'POST',
},
'领取拼图积分激励失败',
{
retry: PUZZLE_WORKS_WRITE_RETRY,
},
);
}
export const puzzleWorksClient = {
claimPointIncentive: claimPuzzleWorkPointIncentive,
delete: deletePuzzleWork,
getDetail: getPuzzleWorkDetail,
list: listPuzzleWorks,