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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user