1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -506,6 +506,7 @@ function buildMockPuzzleRun(
|
||||
currentLevel: {
|
||||
runId: `run-${profileId}`,
|
||||
levelIndex: 1,
|
||||
levelId: 'puzzle-level-1',
|
||||
gridSize,
|
||||
profileId,
|
||||
levelName,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -3235,7 +3235,7 @@ export function RpgEntryHomeView({
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过"
|
||||
value={formatDashboardCount(playedWorkCount)}
|
||||
value={`${formatDashboardCount(playedWorkCount)}个`}
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
claimPuzzleWorkPointIncentive,
|
||||
deletePuzzleWork,
|
||||
getPuzzleWorkDetail,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user