This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -53,6 +53,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
updatedAt: new Date(0).toISOString(),
publishedAt: new Date(0).toISOString(),
playCount: 0,
likeCount: 0,
publishReady: true,
};
}

View File

@@ -105,6 +105,7 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
publishReady: true,
},
]}
@@ -158,6 +159,7 @@ test('creation hub shows RPG public work code from published library entry', ()
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]}
loading={false}
@@ -249,6 +251,7 @@ test('creation hub work code copy button copies without opening the card', async
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
publishReady: true,
},
]}

View File

@@ -65,6 +65,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
likeCount: 0,
publishReady: true,
},
]}

View File

@@ -54,7 +54,10 @@ import {
getBigFishCreationSession,
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
listBigFishGallery,
remixBigFishGalleryWork,
} from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
@@ -91,6 +94,7 @@ import {
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
@@ -110,6 +114,8 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
remixRpgEntryWorldGallery,
recordRpgEntryWorldGalleryPlay,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -138,6 +144,7 @@ import {
} from './platformEntryShared';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
@@ -152,15 +159,19 @@ type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
type PuzzleRuntimeReturnStage = 'puzzle-result' | 'puzzle-gallery-detail';
type PuzzleRuntimeReturnStage =
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'work-detail'
| 'platform';
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type AgentResultBlockerView = {
code?: string;
message: string;
};
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook',
'publish_missing_player_premise',
@@ -201,6 +212,59 @@ function mergePlatformPublicGalleryEntries(
);
}
function mapRpgGalleryCardToPublicWorkDetail(
entry: CustomWorldGalleryCard,
): PlatformPublicGalleryCard {
return entry;
}
function mapPuzzleWorkToPublicWorkDetail(
item: PuzzleWorkSummary,
): PlatformPublicGalleryCard {
return mapPuzzleWorkToPlatformGalleryCard(item);
}
function mapBigFishWorkToPublicWorkDetail(
item: BigFishWorkSummary,
): PlatformPublicGalleryCard {
return mapBigFishWorkToPlatformGalleryCard(item);
}
function mapPublicWorkDetailToBigFishWork(
entry: PlatformPublicGalleryCard,
): BigFishWorkSummary | null {
if (!isBigFishGalleryEntry(entry)) {
return null;
}
const levelCount = Number.parseInt(
entry.themeTags.find((tag) => /^\d+$/u.test(tag))?.replace('', '') ??
'0',
10,
);
return {
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
levelCount: Number.isNaN(levelCount) ? 0 : levelCount,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
};
}
function readProfileTextField(
profile: CustomWorldProfile | null,
paths: string[],
@@ -439,6 +503,12 @@ export function PlatformEntryFlowShellImpl({
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
useState<PlatformPublicGalleryCard | null>(null);
const [publicWorkDetailError, setPublicWorkDetailError] = useState<
string | null
>(null);
const [isPublicWorkDetailBusy, setIsPublicWorkDetailBusy] = useState(false);
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
BigFishWorkSummary[]
@@ -454,8 +524,8 @@ export function PlatformEntryFlowShellImpl({
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] =
useState<BigFishRuntimeReturnStage>('platform');
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -980,10 +1050,13 @@ export function PlatformEntryFlowShellImpl({
response.session.publishedProfileId,
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
const detailEntry = mapPuzzleWorkToPublicWorkDetail(galleryDetail.item);
setSelectedPublicWorkDetail(detailEntry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
'work-detail',
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
),
);
@@ -1060,12 +1133,15 @@ export function PlatformEntryFlowShellImpl({
// 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。
setShowCreationTypeModal(false);
setSelectedDetailEntry(null);
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(null);
setIsPublicWorkDetailBusy(false);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishRuntimeReturnStage('platform');
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1088,6 +1164,7 @@ export function PlatformEntryFlowShellImpl({
if (
selectionStage !== 'platform' &&
selectionStage !== 'work-detail' &&
selectionStage !== 'detail' &&
selectionStage !== 'puzzle-gallery-detail'
) {
@@ -1150,7 +1227,7 @@ export function PlatformEntryFlowShellImpl({
setBigFishRun(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishRuntimeReturnStage('platform');
setBigFishGenerationState(null);
bigFishFlow.leaveFlow();
}, [bigFishFlow]);
@@ -1192,7 +1269,7 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRuntimeReturnStage('big-fish-result');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
@@ -1221,9 +1298,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
if (bigFishSession) {
setBigFishRuntimeShare(null);
setBigFishRuntimeReturnStage('big-fish-result');
}
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(
startLocalBigFishRuntimeRun({
session: bigFishSession,
@@ -1245,7 +1322,10 @@ export function PlatformEntryFlowShellImpl({
]);
const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => {
async (
profileId: string,
returnStage: PuzzleRuntimeReturnStage = 'work-detail',
) => {
if (isPuzzleBusy) {
return;
}
@@ -1258,7 +1338,7 @@ export function PlatformEntryFlowShellImpl({
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setPuzzleRuntimeReturnStage(returnStage);
setSelectionStage('puzzle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
@@ -1297,6 +1377,8 @@ export function PlatformEntryFlowShellImpl({
updatedAt: now,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
} satisfies PuzzleWorkSummary;
},
@@ -1733,6 +1815,85 @@ export function PlatformEntryFlowShellImpl({
],
);
const openPublicWorkDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
if (entry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', entry.publicWorkCode),
);
}
},
[setSelectionStage],
);
const openRpgPublicWorkDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
try {
const detailEntry =
await detailNavigation.loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
setSelectedPublicWorkDetail(
mapRpgGalleryCardToPublicWorkDetail(detailEntry),
);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', detailEntry.publicWorkCode),
);
}
} catch (error) {
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '读取作品详情失败。'),
);
} finally {
setIsPublicWorkDetailBusy(false);
}
},
[detailNavigation, setSelectedDetailEntry, setSelectionStage],
);
const openPuzzlePublicWorkDetail = useCallback(
async (
profileId: string,
returnTarget: PuzzleDetailReturnTarget = {
tab: platformBootstrap.platformTab,
},
) => {
setIsPuzzleBusy(true);
setIsPublicWorkDetailBusy(true);
setPuzzleError(null);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
try {
const { item } = await getPuzzleGalleryDetail(profileId);
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
} catch (error) {
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
);
} finally {
setIsPuzzleBusy(false);
setIsPublicWorkDetailBusy(false);
}
},
[
openPublicWorkDetail,
platformBootstrap.platformTab,
resolvePuzzleErrorMessage,
setPuzzleError,
],
);
const openPuzzleDetail = useCallback(
async (
profileId: string,
@@ -1793,7 +1954,10 @@ export function PlatformEntryFlowShellImpl({
);
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
(
item: BigFishWorkSummary,
returnStage: BigFishRuntimeReturnStage = 'work-detail',
) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
@@ -1809,7 +1973,7 @@ export function PlatformEntryFlowShellImpl({
publicWorkCode,
});
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('work');
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
@@ -1824,6 +1988,157 @@ export function PlatformEntryFlowShellImpl({
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
const startSelectedPublicWork = useCallback(() => {
if (!selectedPublicWorkDetail || isPublicWorkDetailBusy) {
return;
}
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
startBigFishRunFromWork(work);
return;
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
void startPuzzleRunFromProfile(selectedPublicWorkDetail.profileId);
return;
}
const launchEntry =
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
? selectedDetailEntry
: null;
if (!launchEntry) {
setPublicWorkDetailError('作品详情尚未读取完成。');
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
void recordRpgEntryWorldGalleryPlay(
launchEntry.ownerUserId,
launchEntry.profileId,
)
.then((updatedEntry) => {
setSelectedDetailEntry(updatedEntry);
setSelectedPublicWorkDetail(
mapRpgGalleryCardToPublicWorkDetail(updatedEntry),
);
handleCustomWorldSelect(updatedEntry.profile);
})
.catch((error) => {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '记录作品游玩失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
});
}, [
handleCustomWorldSelect,
isPublicWorkDetailBusy,
runProtectedAction,
selectedDetailEntry,
selectedPublicWorkDetail,
startBigFishRunFromWork,
startPuzzleRunFromProfile,
]);
const remixPublicWork = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isPublicWorkDetailBusy) {
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
if (isBigFishGalleryEntry(entry)) {
void remixBigFishGalleryWork(entry.profileId)
.then((response) => {
bigFishFlow.setSession(response.session);
enterCreateTab();
setSelectionStage('big-fish-result');
})
.catch((error) => {
setPublicWorkDetailError(
resolveBigFishErrorMessage(error, 'Remix 大鱼吃小鱼作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
return;
}
if (isPuzzleGalleryEntry(entry)) {
void remixPuzzleGalleryWork(entry.profileId)
.then((response) => {
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
enterCreateTab();
setSelectionStage('puzzle-result');
})
.catch((error) => {
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, 'Remix 拼图作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
return;
}
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
.then((response) => {
const nextEntry = response.entry;
setSelectedDetailEntry(nextEntry);
platformBootstrap.setSavedCustomWorldEntries([
nextEntry,
...platformBootstrap.savedCustomWorldEntries.filter(
(entry) => entry.profileId !== nextEntry.profileId,
),
]);
detailNavigation.openSavedCustomWorldEditor(nextEntry);
})
.catch((error) => {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, 'Remix RPG 作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
});
},
[
bigFishFlow,
detailNavigation,
enterCreateTab,
isPublicWorkDetailBusy,
platformBootstrap,
puzzleFlow,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
runProtectedAction,
setSelectionStage,
],
);
const remixSelectedPublicWork = useCallback(() => {
if (!selectedPublicWorkDetail) {
return;
}
remixPublicWork(selectedPublicWorkDetail);
}, [remixPublicWork, selectedPublicWorkDetail]);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
@@ -1856,7 +2171,7 @@ export function PlatformEntryFlowShellImpl({
const tryOpenGalleryEntry = async () => {
const entry =
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
const card = {
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
@@ -1872,7 +2187,12 @@ export function PlatformEntryFlowShellImpl({
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
} satisfies CustomWorldGalleryCard;
setSelectedDetailEntry(entry);
openPublicWorkDetail(card);
};
const tryOpenPuzzleGalleryEntry = async () => {
const entries =
@@ -1887,7 +2207,7 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到拼图作品。');
}
await openPuzzleDetail(matchedEntry.profileId, {
await openPuzzlePublicWorkDetail(matchedEntry.profileId, {
tab: platformBootstrap.platformTab,
});
};
@@ -1904,7 +2224,7 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到大鱼吃小鱼作品。');
}
await startBigFishRunFromWork(matchedEntry);
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
};
try {
@@ -1959,14 +2279,13 @@ export function PlatformEntryFlowShellImpl({
}
},
[
detailNavigation,
bigFishGalleryEntries,
openPuzzleDetail,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
platformBootstrap.platformTab,
puzzleGalleryEntries,
refreshBigFishGallery,
refreshPuzzleGallery,
startBigFishRunFromWork,
],
);
@@ -1997,7 +2316,7 @@ export function PlatformEntryFlowShellImpl({
const profileId =
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
if (profileId) {
void openPuzzleDetail(profileId, { tab: 'profile' });
void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' });
}
return;
}
@@ -2018,25 +2337,29 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.sourceSessionId === sessionId,
);
if (matchedEntry) {
startBigFishRunFromWork(matchedEntry);
openPublicWorkDetail(
mapBigFishWorkToPublicWorkDetail(matchedEntry),
);
return;
}
startBigFishRunFromWork({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
});
openPublicWorkDetail(
mapBigFishWorkToPublicWorkDetail({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
}),
);
})
.catch((error) => {
setBigFishError(
@@ -2052,33 +2375,33 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
void detailNavigation.openGalleryDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
});
void openRpgPublicWorkDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
});
},
[
detailNavigation,
openPuzzleDetail,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
openRpgPublicWorkDetail,
refreshBigFishGallery,
resolveBigFishErrorMessage,
runProtectedAction,
startBigFishRunFromWork,
],
);
@@ -2234,7 +2557,7 @@ export function PlatformEntryFlowShellImpl({
isBigFishCreationVisible
? (item) => {
runProtectedAction(() => {
void startBigFishRunFromWork(item);
void startBigFishRunFromWork(item, 'platform');
});
}
: null
@@ -2254,7 +2577,7 @@ export function PlatformEntryFlowShellImpl({
}}
onExperiencePuzzle={(profileId) => {
runProtectedAction(() => {
void startPuzzleRunFromProfile(profileId);
void startPuzzleRunFromProfile(profileId, 'platform');
});
}}
onDeletePuzzle={(item) => {
@@ -2310,42 +2633,18 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
if (isBigFishGalleryEntry(entry)) {
runProtectedAction(() => {
void startBigFishRunFromWork({
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishReady: true,
levelCount: Number.parseInt(
entry.themeTags
.find((tag) => /^\d+$/u.test(tag))
?.replace('级', '') ?? '0',
10,
),
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
});
});
openPublicWorkDetail(entry);
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId, {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry);
});
void openRpgPublicWorkDetail(entry);
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
@@ -2382,6 +2681,28 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
<motion.div
key="platform-work-detail"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<PlatformWorkDetailView
entry={selectedPublicWorkDetail}
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
error={publicWorkDetailError}
onBack={() => {
setPublicWorkDetailError(null);
setSelectionStage('platform');
}}
onStart={startSelectedPublicWork}
onRemix={remixSelectedPublicWork}
/>
</motion.div>
)}
{selectionStage === 'detail' && (
<motion.div
key="platform-detail"
@@ -2396,6 +2717,22 @@ export function PlatformEntryFlowShellImpl({
{detailNavigation.detailError || '正在读取作品详情...'}
</div>
</div>
) : selectedDetailEntry.visibility !== 'draft' ? (
<PlatformWorkDetailView
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
isBusy={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
entryNavigation.backToPlatformHome();
}}
onStart={handleStartSelectedWorld}
onRemix={() => {
remixPublicWork(
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry),
);
}}
/>
) : (
<PlatformEntryWorldDetailView
entry={selectedDetailEntry}
@@ -2428,7 +2765,7 @@ export function PlatformEntryFlowShellImpl({
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
selectedDetailEntry.visibility !== 'draft' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
@@ -2626,11 +2963,7 @@ export function PlatformEntryFlowShellImpl({
error={bigFishError}
onBack={() => {
reportBigFishObservedPlayTime();
setSelectionStage(
bigFishRuntimeSessionSource === 'draft'
? 'big-fish-result'
: 'platform',
);
setSelectionStage(bigFishRuntimeReturnStage);
}}
onRestart={() => {
reportBigFishObservedPlayTime();
@@ -2783,6 +3116,7 @@ export function PlatformEntryFlowShellImpl({
onStartGame={() => {
void startPuzzleRunFromProfile(
selectedPuzzleDetail.profileId,
'puzzle-gallery-detail',
);
}}
/>

View File

@@ -0,0 +1,242 @@
import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldStats,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
isBusy: boolean;
error: string | null;
onBack: () => void;
onStart: () => void;
onRemix: () => void;
}
function formatCompactCount(value: number) {
if (value >= 10000) {
const normalized = value / 10000;
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}`;
}
return `${value}`;
}
function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
return 'RPG';
}
export function PlatformWorkDetailView({
entry,
isBusy,
error,
onBack,
onStart,
onRemix,
}: PlatformWorkDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const tags = useMemo(
() =>
[
getSourceLabel(entry),
...buildPlatformWorldTags(entry).map((tag) => tag.trim()),
]
.filter(Boolean)
.slice(0, 4),
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const statItems = [
{ label: '改造次数', value: formatCompactCount(stats.remixCount) },
{ label: '游玩次数', value: formatCompactCount(stats.playCount) },
{ label: '点赞次数', value: formatCompactCount(stats.likeCount) },
{
label: '上线日期',
value: formatPlatformWorldTime(stats.publishedAt),
},
];
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</div>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
{coverImage ? (
<>
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="platform-work-detail__cover-blur"
/>
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className="platform-work-detail__cover-image"
/>
</>
) : (
<div className="platform-work-detail__cover-fallback" />
)}
</section>
<section className="platform-work-detail__summary">
<div className="platform-work-detail__meta-row">
<div className="platform-work-detail__app-icon">
{coverImage ? (
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
entry.worldName.slice(0, 1)
)}
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">
{entry.worldName}
</div>
<div className="platform-work-detail__author">
{entry.authorDisplayName}
</div>
</div>
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
Remix
</button>
</div>
<div className="platform-work-detail__stats">
{statItems.map((item) => (
<div key={item.label} className="platform-work-detail__stat">
<div className="platform-work-detail__stat-label">
{item.label}
</div>
<div className="platform-work-detail__stat-value">
{item.value}
</div>
</div>
))}
</div>
</section>
<section className="platform-work-detail__body">
<div className="platform-work-detail__chips">
{tags.map((tag) => (
<span key={tag} className="platform-work-detail__chip">
{tag}
</span>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'work-detail'
| 'detail'
| 'agent-workspace'
| 'big-fish-agent-workspace'

View File

@@ -28,6 +28,7 @@ const detailItem = {
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 7,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;

View File

@@ -708,6 +708,7 @@ beforeEach(() => {
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
},
entries: [],
});
@@ -1449,6 +1450,7 @@ test('clicking a public work while logged out routes through requireAuth', async
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -1543,6 +1545,7 @@ test('creation hub clears all private work shelves immediately after logout stat
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1583,6 +1586,7 @@ test('published puzzle works appear on home and category public shelves', async
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1666,6 +1670,7 @@ test('published puzzle detail returns to the source platform tab', async () => {
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1921,6 +1926,7 @@ test('puzzle draft card restores the bound agent session and opens the result vi
updatedAt: '2026-04-22T12:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1967,6 +1973,7 @@ test('published puzzle work card restores its source session for editing', async
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
},
],
@@ -2007,6 +2014,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
@@ -3185,6 +3193,7 @@ test('creation hub published work can open detail view before deleting from deta
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)
@@ -3272,6 +3281,7 @@ test('creation hub published work enters existing detail view', async () => {
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3345,6 +3355,7 @@ test('creation hub published work experience button enters world directly', asyn
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3421,6 +3432,7 @@ test('creation hub published work delete button removes the work directly from c
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)

View File

@@ -132,6 +132,7 @@ const puzzlePublicEntry = {
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
likeCount: 12,
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
@@ -404,7 +405,7 @@ test('public gallery cards hide work code until detail is opened', async () => {
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});

View File

@@ -8,6 +8,7 @@ import {
Clock3,
Coins,
Copy,
Heart,
House,
LogIn,
MessageCircle,
@@ -129,6 +130,18 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category' | 'pc' | 'instant';
const MOBILE_HOME_CHANNELS: Array<{
id: MobileHomeChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日游戏' },
{ id: 'category', label: '游戏分类' },
{ id: 'pc', label: 'PC游戏' },
{ id: 'instant', label: '即点即玩' },
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
@@ -303,7 +316,6 @@ function WorldCard({
className?: string;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const tags = [
...new Set(
buildPlatformWorldTags(entry)
@@ -311,66 +323,79 @@ function WorldCard({
.filter(Boolean),
),
].slice(0, 3);
const likeCount = getPlatformWorldLikeCount(entry);
const cardLabel = `${entry.worldName}${formatCompactCount(likeCount)}点赞`;
return (
<button
type="button"
onClick={onClick}
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
aria-label={cardLabel}
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
{badge}
</span>
<span className="platform-pill platform-pill--neutral px-2.5">
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
{metaLabel}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'}
{entry.subtitle ? (
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
{entry.subtitle}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral px-2.5"
>
{tag}
</span>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
<div className="platform-public-work-card__likes shrink-0 text-right">
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
<Heart className="h-3.5 w-3.5 fill-current" />
<span>{formatCompactCount(likeCount)}</span>
</div>
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral max-w-full px-2.5"
>
<span className="truncate">{tag}</span>
</span>
)}
</div>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
</span>
)}
</div>
</div>
</button>
@@ -740,6 +765,21 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
: describePlatformThemeLabel(entry.themeMode);
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
function formatCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return `${normalizedValue}`;
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
@@ -1435,6 +1475,8 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
@@ -1644,6 +1686,19 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
mobileHomeChannel === 'recommend'
? [...featuredShelf, ...latestEntries]
: latestEntries;
sourceEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
@@ -1666,39 +1721,21 @@ export function RpgEntryHomeView({
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<span className="platform-pill platform-pill--warm shrink-0">
</span>
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
</div>
</div>
<div className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
</div>
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
<span className="min-w-0 break-all"></span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
</div>
</button>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{MOBILE_HOME_CHANNELS.map((channel) => {
const active = mobileHomeChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setMobileHomeChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
</div>
) : null}
<section>
<SectionHeader title="精选推荐" detail="为你挑选" />
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:featured`}
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
badge={
mobileHomeChannel === 'recommend'
? '推荐'
: describePublicGalleryCardKind(entry)
}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有作品。" />
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
</div>
@@ -1783,7 +1803,7 @@ export function RpgEntryHomeView({
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
className="w-full min-w-0"
/>
))}
</div>
@@ -2226,7 +2246,7 @@ export function RpgEntryHomeView({
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[16rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>
@@ -2304,6 +2324,7 @@ export function RpgEntryHomeView({
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
})
}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
@@ -2344,7 +2365,7 @@ export function RpgEntryHomeView({
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>

View File

@@ -1,9 +1,9 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
@@ -30,6 +30,9 @@ export type PlatformPuzzleGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -47,6 +50,9 @@ export type PlatformBigFishGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -90,6 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
@@ -111,12 +120,24 @@ export function mapBigFishWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
publishedAt: entry.publishedAt ?? null,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;

View File

@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
},
entries: [],
});

View File

@@ -161,6 +161,27 @@ export function useRpgEntryLibraryDetail(
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const loadGalleryDetailEntry = useCallback(
async (entry: CustomWorldGalleryCard) => {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
return detailEntry;
},
[appendBrowseHistoryEntry],
);
const openGalleryDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
@@ -168,26 +189,13 @@ export function useRpgEntryLibraryDetail(
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
const detailEntry = await loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
@@ -197,7 +205,11 @@ export function useRpgEntryLibraryDetail(
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
[
loadGalleryDetailEntry,
setSelectedDetailEntry,
setSelectionStage,
],
);
const openSavedCustomWorldEditor = useCallback(
@@ -489,6 +501,7 @@ export function useRpgEntryLibraryDetail(
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
loadGalleryDetailEntry,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,

View File

@@ -1,11 +1,11 @@
import {type Dispatch, type SetStateAction,useState} from 'react';
import { type Dispatch, type SetStateAction, useState } from 'react';
import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/aiService';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
CharacterChatRecord,
@@ -47,12 +47,17 @@ export interface CharacterChatUi {
sendDraft: () => void;
}
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
return state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
};
export function getCharacterChatRecord(
state: GameState,
characterId: string,
): CharacterChatRecord {
return (
state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
}
);
}
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
@@ -66,7 +71,10 @@ export function buildLocalCharacterChatSummary(
) {
const latestTurns = history
.slice(-4)
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
.map(
(turn) =>
`${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
@@ -111,7 +119,9 @@ type CharacterChatTargetStatus = {
affinity?: number | null;
};
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
function buildTargetStatus(
target: CharacterChatTarget,
): CharacterChatTargetStatus {
return {
roleLabel: target.roleLabel,
hp: target.hp,
@@ -129,9 +139,13 @@ export function useCharacterChatFlow({
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
buildStoryContextFromState: (
state: GameState,
extras?: { currentStory?: null },
) => StoryGenerationContext;
}) {
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
const [characterChatModal, setCharacterChatModal] =
useState<CharacterChatModalState | null>(null);
const loadCharacterChatSuggestions = async (
target: CharacterChatTarget,
@@ -139,7 +153,7 @@ export function useCharacterChatFlow({
summary: string,
) => {
if (!gameState.worldType || !gameState.playerCharacter) {
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -151,7 +165,7 @@ export function useCharacterChatFlow({
return;
}
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -172,7 +186,7 @@ export function useCharacterChatFlow({
buildTargetStatus(target),
);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -183,7 +197,7 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to generate character chat suggestions:', error);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -213,7 +227,11 @@ export function useCharacterChatFlow({
};
const sendCharacterChatDraft = async () => {
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
if (
!characterChatModal ||
!gameState.worldType ||
!gameState.playerCharacter
) {
return;
}
@@ -223,7 +241,10 @@ export function useCharacterChatFlow({
}
const target = characterChatModal.target;
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
const existingRecord = getCharacterChatRecord(
gameState,
target.character.id,
);
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
const nextMessages = trimCharacterChatHistory([
...baseMessages,
@@ -233,12 +254,12 @@ export function useCharacterChatFlow({
},
]);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
draft: '',
messages: [...nextMessages, {speaker: 'character', text: ''}],
messages: [...nextMessages, { speaker: 'character', text: '' }],
suggestions: [],
isSending: true,
isLoadingSuggestions: true,
@@ -261,12 +282,12 @@ export function useCharacterChatFlow({
draft,
buildTargetStatus(target),
{
onUpdate: text => {
setCharacterChatModal(current =>
onUpdate: (text) => {
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
messages: [...nextMessages, {speaker: 'character', text}],
messages: [...nextMessages, { speaker: 'character', text }],
}
: current,
);
@@ -275,7 +296,7 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to stream character panel chat reply:', error);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -283,10 +304,12 @@ export function useCharacterChatFlow({
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知智能生成错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
error:
error instanceof Error ? error.message : '未知智能生成错误',
suggestions:
current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
}
: current,
);
@@ -315,7 +338,11 @@ export function useCharacterChatFlow({
);
} catch (error) {
console.error('Failed to summarize character chat:', error);
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
nextSummary = buildLocalCharacterChatSummary(
target.character,
finalMessages,
existingRecord.summary,
);
}
const nextRecord: CharacterChatRecord = {
@@ -324,10 +351,10 @@ export function useCharacterChatFlow({
updatedAt: new Date().toISOString(),
};
setGameState(current =>
setGameState((current) =>
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
);
setCharacterChatModal(current =>
setCharacterChatModal((current) =>
current && current.target.character.id === target.character.id
? {
...current,
@@ -346,8 +373,14 @@ export function useCharacterChatFlow({
modal: characterChatModal,
openChat: openCharacterChat,
closeChat: () => setCharacterChatModal(null),
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
setDraft: (value: string) =>
setCharacterChatModal((current) =>
current ? { ...current, draft: value } : current,
),
useSuggestion: (value: string) =>
setCharacterChatModal((current) =>
current ? { ...current, draft: value } : current,
),
refreshSuggestions: () => {
if (!characterChatModal) {
return;

View File

@@ -1,7 +1,4 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
@@ -25,7 +22,12 @@ import {
} from './storyChoiceRuntime';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
type RuntimeStatsIncrements = Partial<
Pick<
GameState['runtimeStats'],
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
>
>;
type BuildFallbackStoryForState = (
state: GameState,
@@ -63,6 +65,7 @@ type BuildStoryContextFromState = (
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -115,7 +118,11 @@ export function createStoryChoiceActions({
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
@@ -127,14 +134,24 @@ export function createStoryChoiceActions({
buildStoryFromResponse: BuildStoryFromResponse;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
getCampCompanionTravelScene?: (
state: GameState,
character: Character,
) => GameState['currentScenePreset'] | null;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: (
@@ -149,8 +166,12 @@ export function createStoryChoiceActions({
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption?: (option: StoryOption) => boolean;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isNpcEncounter?: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName?: string;
turnVisualMs: number;
@@ -160,7 +181,10 @@ export function createStoryChoiceActions({
if (!gameState.worldType || !character || isLoading) return;
if (option.disabled) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
if (
currentStory?.deferredOptions?.length &&
isContinueAdventureOption(option)
) {
if (currentStory.deferredRuntimeState) {
setGameState({
...gameState,
@@ -209,9 +233,9 @@ export function createStoryChoiceActions({
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
option.functionId === npcPreviewTalkFunctionId &&
isRegularNpcEncounter(gameState.currentEncounter) &&
!gameState.npcInteractionActive
) {
setAiError(null);
enterNpcInteraction(gameState.currentEncounter, option.actionText);

View File

@@ -1,20 +1,13 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import {
getCharacterById,
} from '../../data/characterPresets';
import { getCharacterById } from '../../data/characterPresets';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalIntroText,
} from '../../data/functionCatalog';
import {
buildNpcTradeTransactionActionText,
} from '../../data/npcInteractions';
import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
@@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = {
state: GameState,
extras?: {
lastFunctionId?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
@@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = {
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
};
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
function buildOfflineRecruitDialogue(
encounter: Encounter,
releasedCompanionName?: string | null,
) {
const releaseLine = releasedCompanionName
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
@@ -92,10 +91,11 @@ function normalizeRecruitDialogue(
const rawLines = dialogueText
.replace(/\r/g, '')
.split('\n')
.map(line => line.trim())
.map((line) => line.trim())
.filter(Boolean);
const refusalPattern = /|||||||||||/u;
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
const refusalPattern =
/|||||||||||/u;
const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line));
const npcPrefix = `${encounter.npcName}`;
const playerPrefix = '你:';
const releaseLine = releasedCompanionName
@@ -108,14 +108,17 @@ function normalizeRecruitDialogue(
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
];
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
const workingLines =
sanitizedLines.length > 0
? sanitizedLines.slice(0, 5)
: defaultLines.slice(0, 3);
if (!workingLines.some((line) => line.startsWith(playerPrefix))) {
const firstDefaultLine = defaultLines[0];
if (firstDefaultLine) {
workingLines.unshift(firstDefaultLine);
}
}
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
if (!workingLines.some((line) => line.startsWith(npcPrefix))) {
const secondDefaultLine = defaultLines[1];
if (secondDefaultLine) {
workingLines.push(secondDefaultLine);
@@ -125,9 +128,9 @@ function normalizeRecruitDialogue(
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
const lastWorkingLine = workingLines[workingLines.length - 1];
if (
workingLines.length === 0
|| !lastWorkingLine?.startsWith(npcPrefix)
|| refusalPattern.test(lastWorkingLine)
workingLines.length === 0 ||
!lastWorkingLine?.startsWith(npcPrefix) ||
refusalPattern.test(lastWorkingLine)
) {
workingLines.push(acceptanceLine);
} else {
@@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
getNpcEncounterKey: (encounter: Encounter) => string;
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
getResolvedNpcState: (
state: GameState,
encounter: Encounter,
) => GameState['npcStates'][string];
updateNpcState: (
state: GameState,
encounter: Encounter,
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
updater: (
npcState: GameState['npcStates'][string],
) => GameState['npcStates'][string],
) => GameState;
cloneInventoryItemForOwner: (
item: InventoryItem,
@@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({
}) {
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
null,
);
const resolveRecruitmentOnServer = async (params: {
encounter: Encounter;
@@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc recruit action on the server:', error);
console.error(
'Failed to resolve npc recruit action on the server:',
error,
);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 招募执行失败',
);
@@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({
const releasedCompanionName = releasedNpcId
? (() => {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
const releasedCompanion = gameState.companions.find(
(item) => item.npcId === releasedNpcId,
);
return releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
? (getCharacterById(releasedCompanion.characterId)?.name ?? null)
: null;
})()
: null;
@@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({
setRecruitModal(null);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
);
let dialogueText = '';
let streamedTargetText = '';
@@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
while (
!streamCompleted ||
displayedText.length < streamedTargetText.length
) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
displayedText,
[],
true,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
);
}
})();
@@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({
runtime.getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
runtime.buildStoryContextFromState(provisionalState, {
currentStory: runtime.currentStory,
lastFunctionId: 'npc_recruit',
}),
actionText,
recruitPromptSummary,
{
onUpdate: text => {
onUpdate: (text) => {
streamedTargetText = text;
},
},
@@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
dialogueText =
displayedText ||
buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(
error instanceof Error ? error.message : '未知智能生成错误',
);
}
const finalDialogueText = normalizeRecruitDialogue(
encounter,
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
dialogueText ||
displayedText ||
buildOfflineRecruitDialogue(encounter, releasedCompanionName),
releasedCompanionName,
);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
await new Promise(resolve => window.setTimeout(resolve, 260));
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText,
[],
false,
),
);
await new Promise((resolve) => window.setTimeout(resolve, 260));
await resolveRecruitmentOnServer({
encounter,
actionText,
@@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({
mode: 'buy' | 'sell',
): RuntimeNpcTradeItemView[] =>
mode === 'buy'
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
? (gameState.runtimeNpcInteraction?.trade.buyItems ?? [])
: (gameState.runtimeNpcInteraction?.trade.sellItems ?? []);
const findRuntimeTradeItem = (modal: TradeModalState) => {
const itemId =
@@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
setTradeModal(
{
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
},
);
setTradeModal({
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
});
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
@@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
null;
setGiftModal(
buildNpcGiftModalState(
encounter,
actionText,
selectedItemId,
),
);
setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId));
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
setRecruitModal(
buildNpcRecruitModalState(gameState, encounter, actionText),
);
};
const clearNpcInteractionUi = () => {
@@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc runtime action on the server:', error);
console.error(
'Failed to resolve npc runtime action on the server:',
error,
);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 交互执行失败',
);
@@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({
tradeModal,
giftModal,
recruitModal,
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
if (!current) return current;
return {
...current,
mode,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
}),
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedNpcItemId: itemId,
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedPlayerItemId: itemId,
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) => setTradeModal(current => current
? {
setTradeMode: (mode: 'buy' | 'sell') =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current),
mode,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
}),
selectTradeNpcItem: (itemId: string) =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedNpcItemId: itemId,
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) =>
setTradeModal((current) => {
if (!current) return current;
return {
...current,
selectedPlayerItemId: itemId,
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) =>
setTradeModal((current) =>
current
? {
...current,
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current,
),
closeTradeModal: () => setTradeModal(null),
confirmTrade,
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
selectGiftItem: (itemId: string) =>
setGiftModal((current) =>
current ? { ...current, selectedItemId: itemId } : current,
),
closeGiftModal: () => setGiftModal(null),
confirmGift,
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
selectRecruitRelease: (npcId: string) =>
setRecruitModal((current) =>
current ? { ...current, selectedReleaseNpcId: npcId } : current,
),
closeRecruitModal: () => setRecruitModal(null),
confirmRecruit: () => {
if (!recruitModal) return;

View File

@@ -55,6 +55,7 @@ type BuildStoryContextFromState = (
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
recentActionResult?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -161,7 +162,10 @@ export async function runLocalStoryChoiceContinuation(params: {
params.option,
params.character,
);
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
if (
resolvedChoice.optionKind === 'battle' ||
resolvedChoice.optionKind === 'escape'
) {
throw new Error(
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation${params.option.functionId}`,
);
@@ -194,6 +198,7 @@ export async function runLocalStoryChoiceContinuation(params: {
observeSignsRequested:
params.option.functionId === 'idle_observe_signs',
recentActionResult: combatResolutionContextText,
currentStory: params.currentStory,
}),
projectedAvailableOptions
? { availableOptions: projectedAvailableOptions }
@@ -239,11 +244,11 @@ export async function runLocalStoryChoiceContinuation(params: {
lastObserveSignsSceneId:
params.option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
: (afterSequence.lastObserveSignsSceneId ?? null),
lastObserveSignsReport:
params.option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
: (afterSequence.lastObserveSignsReport ?? null),
storyHistory: nextHistory,
},
{},

View File

@@ -22,6 +22,7 @@ export type ChoiceRuntimeController = {
recentActionResult?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { AnimationState, type GameState, type StoryMoment } from '../../types';
import { buildStoryContextFromState } from './storyContextBuilder';
function createState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: 'WUXIA',
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 3,
runtimeMode: 'play',
runtimePersistenceDisabled: false,
playerHp: 30,
playerMaxHp: 40,
playerMana: 12,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
animationState: AnimationState.IDLE,
playerSkillCooldowns: {},
currentScenePreset: {
id: 'forest-trail',
name: '林间小径',
description: '风声穿过树梢。',
},
...overrides,
} as GameState;
}
describe('storyContextBuilder', () => {
it('keeps normal play context lightweight', () => {
const context = buildStoryContextFromState(createState());
expect(context.runtimeSessionId).toBe('runtime-main');
expect(context.runtimeSnapshot).toBeUndefined();
});
it('attaches transient snapshot for disabled persistence runtime', () => {
const state = createState({
runtimeSessionId: 'runtime-preview',
runtimePersistenceDisabled: true,
});
const currentStory: StoryMoment = {
text: '断桥客站在风口,等你先开口。',
options: [],
};
const context = buildStoryContextFromState(state, { currentStory });
expect(context.runtimeSnapshot).toEqual({
bottomTab: 'adventure',
gameState: state,
currentStory,
});
});
});

View File

@@ -1,5 +1,5 @@
import type { StoryGenerationContext } from '../../services/aiTypes';
import type { GameState } from '../../types';
import type { GameState, StoryMoment } from '../../types';
export type StoryContextBuilderExtras = {
pendingSceneEncounter?: boolean;
@@ -9,8 +9,17 @@ export type StoryContextBuilderExtras = {
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
currentStory?: StoryMoment | null;
};
function shouldAttachTransientRuntimeSnapshot(state: GameState) {
return (
state.runtimePersistenceDisabled === true ||
state.runtimeMode === 'preview' ||
state.runtimeMode === 'test'
);
}
/**
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
@@ -22,6 +31,15 @@ export function buildStoryContextFromState(
return {
runtimeSessionId: state.runtimeSessionId ?? null,
runtimeActionVersion: state.runtimeActionVersion,
runtimeSnapshot: shouldAttachTransientRuntimeSnapshot(state)
? {
// 中文注释:禁存运行态不会写入正式 runtime_snapshot
// 聊天/续写请求需要携带本地临时快照供 server-rs 投影上下文。
bottomTab: 'adventure',
gameState: state,
currentStory: extras.currentStory ?? null,
}
: undefined,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,

View File

@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;

View File

@@ -18,6 +18,7 @@ type BuildStoryContextFromState = (
state: GameState,
extras?: {
lastFunctionId?: string | null;
currentStory?: StoryMoment | null;
},
) => StoryGenerationContext;
@@ -163,8 +164,11 @@ export async function generateStoryForStateWithCoordinator(params: {
const context = params.choice
? params.buildStoryContextFromState(params.state, {
lastFunctionId: params.lastFunctionId,
currentStory: params.currentStory,
})
: params.buildStoryContextFromState(params.state);
: params.buildStoryContextFromState(params.state, {
currentStory: params.currentStory,
});
const response = params.choice
? await params.requestNextStep(
worldType,

View File

@@ -63,6 +63,7 @@ type BuildStoryContextExtras = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
@@ -259,7 +260,9 @@ export function createStoryNpcEncounterActions({
},
});
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
const buildPendingQuestOfferOptions = (
encounter: Encounter,
): StoryOption[] => [
buildNpcChatQuestOfferOption(
encounter,
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
@@ -336,8 +339,7 @@ export function createStoryNpcEncounterActions({
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
@@ -353,7 +355,10 @@ export function createStoryNpcEncounterActions({
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const reopenedNpcState = getResolvedNpcState(
params.nextState,
params.encounter,
);
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
@@ -365,7 +370,10 @@ export function createStoryNpcEncounterActions({
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
buildNpcChatOption(
params.encounter,
`继续和${params.encounter.npcName}对话`,
);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
@@ -487,7 +495,9 @@ export function createStoryNpcEncounterActions({
);
const restoredEncounter =
state.sparReturnEncounter ??
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
(state.currentEncounter?.kind === 'npc'
? state.currentEncounter
: null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
@@ -756,7 +766,8 @@ export function createStoryNpcEncounterActions({
functionId: option.functionId,
actionText: option.actionText,
detailText: option.detailText ?? null,
action: option.interaction?.kind === 'npc' ? option.interaction.action : null,
action:
option.interaction?.kind === 'npc' ? option.interaction.action : null,
}));
const isHostileChat =
directive?.isHostileChat === true ||
@@ -883,10 +894,9 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const resolvedStateOptions = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
@@ -1105,7 +1115,9 @@ export function createStoryNpcEncounterActions({
};
};
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
const buildHostileNpcEscapeOptions = (
character: Character,
): StoryOption[] => {
const currentScene = gameState.currentScenePreset;
const worldType = gameState.worldType;
const options: StoryOption[] = [];
@@ -1120,34 +1132,24 @@ export function createStoryNpcEncounterActions({
seenSceneIds.add(connection.sceneId);
const targetScene = getScenePresetById(worldType, connection.sceneId);
const targetSceneName =
targetScene?.name ??
connection.summary?.trim() ??
connection.sceneId;
targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId;
options.push(
buildHostileNpcEscapeOption(
character,
`逃往${targetSceneName}`,
{
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
},
),
buildHostileNpcEscapeOption(character, `逃往${targetSceneName}`, {
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
}),
);
}
options.push(
buildHostileNpcEscapeOption(
character,
'逃回当前场景起点',
{
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
},
),
buildHostileNpcEscapeOption(character, '逃回当前场景起点', {
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
}),
);
}
@@ -1326,6 +1328,7 @@ export function createStoryNpcEncounterActions({
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
currentStory,
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
@@ -1484,6 +1487,7 @@ export function createStoryNpcEncounterActions({
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
currentStory,
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
@@ -1594,17 +1598,17 @@ export function createStoryNpcEncounterActions({
chatDirective.remainingTurns ??
null,
limitReason: chatDirective.limitReason ?? null,
terminationMode: chatDirective.terminationMode ?? null,
terminationReason:
terminationMode: chatDirective.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
chatDirective.terminationReason ??
null,
isHostileChat: chatDirective.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
chatDirective.closingMode ??
'free',
forceExitAfterTurn:
isHostileChat: chatDirective.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
chatDirective.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
chatDirective.forceExitAfterTurn ??
false,
@@ -1615,9 +1619,7 @@ export function createStoryNpcEncounterActions({
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
];
const closingDialogue = [...nextDialogue];
const shouldUseHostileClosureOptions =
shouldUseHostileNpcChatClosureOptions(
resolvedChatDirective,
@@ -1756,13 +1758,9 @@ export function createStoryNpcEncounterActions({
return false;
}
void handleNpcChatTurn(
encounter,
`我先结束这轮交谈,继续往前走。`,
{
forcePlayerExit: true,
},
);
void handleNpcChatTurn(encounter, `我先结束这轮交谈,继续往前走。`, {
forcePlayerExit: true,
});
return true;
};
@@ -1814,7 +1812,10 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
if (
!currentStory?.npcChatState &&
!npcState.firstMeaningfulContactResolved
) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,

View File

@@ -790,6 +790,22 @@ body {
filter: brightness(0.98);
}
.platform-public-work-card {
background: var(--platform-subpanel-fill);
}
.platform-public-work-card__cover {
background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%);
}
.platform-public-work-card__body {
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
}
.platform-public-work-card__likes {
min-width: 3.2rem;
}
.platform-pill {
display: inline-flex;
align-items: center;
@@ -1243,6 +1259,66 @@ body {
max-width: 100%;
}
.platform-mobile-home-stage {
gap: 0.75rem;
}
.platform-mobile-home-stage .platform-desktop-search {
border-radius: 9999px;
padding: 0.64rem 0.9rem;
}
.platform-mobile-home-channelbar {
margin-right: -0.25rem;
padding-left: 0.08rem;
}
.platform-mobile-home-channel {
position: relative;
min-height: 2rem;
border: 0;
background: transparent;
color: var(--platform-text-soft);
font-size: 0.92rem;
font-weight: 700;
white-space: nowrap;
}
.platform-mobile-home-channel--active {
color: var(--platform-text-strong);
}
.platform-mobile-home-channel--active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.1rem;
height: 0.16rem;
border-radius: 9999px;
background: var(--platform-warm-text);
}
.platform-mobile-home-feed {
min-width: 0;
}
.platform-public-work-card {
width: 100%;
border-radius: 0.9rem;
background: color-mix(in srgb, var(--platform-subpanel-fill) 82%, #050506 18%);
}
.platform-public-work-card__cover {
border-radius: 0;
}
.platform-public-work-card__body {
min-height: 5.4rem;
padding: 0.68rem 0.76rem 0.72rem;
background: color-mix(in srgb, var(--platform-subpanel-fill) 78%, #050506 22%);
}
.platform-mobile-hero-secondary {
display: none;
}
@@ -2959,6 +3035,305 @@ button {
scrollbar-width: none;
}
.platform-work-detail {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: #151515;
color: #f7f7f7;
}
.platform-work-detail__topbar {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) 3rem;
align-items: center;
gap: 0.5rem;
padding: calc(env(safe-area-inset-top, 0px) + 0.7rem) 1rem 0.75rem;
background: #020202;
}
.platform-work-detail__title {
min-width: 0;
text-align: center;
font-size: 1.35rem;
font-weight: 900;
}
.platform-work-detail__icon-button {
display: inline-flex;
height: 2.5rem;
width: 2.5rem;
align-items: center;
justify-content: center;
border: 0;
border-radius: 999px;
background: transparent;
color: #f8f8f8;
}
.platform-work-detail__icon-button:disabled {
opacity: 0.45;
}
.platform-work-detail__scroll {
min-height: 0;
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.platform-work-detail__cover {
position: relative;
display: flex;
min-height: clamp(17rem, 54vh, 28rem);
align-items: center;
justify-content: center;
overflow: hidden;
background: #0c0c0c;
}
.platform-work-detail__cover-blur {
position: absolute;
inset: -1rem;
height: calc(100% + 2rem);
width: calc(100% + 2rem);
object-fit: cover;
opacity: 0.55;
filter: blur(24px);
transform: scale(1.06);
}
.platform-work-detail__cover::after {
position: absolute;
inset: 0;
content: '';
background:
linear-gradient(180deg, rgb(0 0 0 / 5%), rgb(0 0 0 / 42%)),
radial-gradient(circle at center, transparent 0 34%, rgb(0 0 0 / 36%) 100%);
}
.platform-work-detail__cover-image {
position: relative;
z-index: 1;
max-height: min(72vw, 24rem);
width: min(58vw, 22rem);
max-width: 82%;
object-fit: contain;
box-shadow: 0 1.6rem 4rem rgb(0 0 0 / 38%);
}
.platform-work-detail__cover-fallback {
aspect-ratio: 3 / 4;
width: min(54vw, 20rem);
border-radius: 0.8rem;
background:
linear-gradient(135deg, rgb(20 184 166 / 55%), rgb(250 204 21 / 34%)),
#202020;
}
.platform-work-detail__summary {
border-bottom: 0.5rem solid #070707;
background: #181818;
padding: 1.4rem 1rem 1rem;
}
.platform-work-detail__meta-row {
display: grid;
grid-template-columns: 4.35rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
}
.platform-work-detail__app-icon {
display: flex;
aspect-ratio: 1;
width: 4.35rem;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 1rem;
background: #2b2b2b;
color: #e7e7e7;
font-size: 1.45rem;
font-weight: 900;
}
.platform-work-detail__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: clamp(1.15rem, 5vw, 1.75rem);
font-weight: 900;
}
.platform-work-detail__author {
margin-top: 0.45rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgb(255 255 255 / 55%);
font-size: 0.9rem;
}
.platform-work-detail__remix {
display: inline-flex;
min-width: 5.2rem;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
border: 0;
border-radius: 1rem;
background: #12bfb1;
color: #041918;
padding: 0.6rem 0.75rem;
font-size: 0.9rem;
font-weight: 900;
}
.platform-work-detail__remix:disabled,
.platform-work-detail__start:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.platform-work-detail__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
margin-top: 1.35rem;
}
.platform-work-detail__stat {
min-width: 0;
border-left: 1px solid rgb(255 255 255 / 10%);
padding: 0 0.5rem;
}
.platform-work-detail__stat:first-child {
border-left: 0;
padding-left: 0;
}
.platform-work-detail__stat-label {
color: rgb(255 255 255 / 52%);
font-size: clamp(0.68rem, 2.8vw, 0.82rem);
line-height: 1.2;
}
.platform-work-detail__stat-value {
margin-top: 0.35rem;
min-width: 0;
overflow-wrap: anywhere;
color: rgb(255 255 255 / 82%);
font-size: clamp(1rem, 4.2vw, 1.35rem);
font-weight: 900;
line-height: 1.12;
}
.platform-work-detail__body {
background: #181818;
padding: 1.4rem 1rem 7rem;
}
.platform-work-detail__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.platform-work-detail__chip,
.platform-work-detail__code {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 0.7rem;
background: transparent;
color: rgb(255 255 255 / 82%);
padding: 0.42rem 0.75rem;
font-size: 0.85rem;
font-weight: 700;
}
.platform-work-detail__copy {
margin: 0;
color: rgb(255 255 255 / 88%);
font-size: clamp(1rem, 4vw, 1.2rem);
line-height: 1.8;
}
.platform-work-detail__code {
margin-top: 1.3rem;
}
.platform-work-detail__error,
.platform-work-detail__toast {
margin-top: 1rem;
border-radius: 0.85rem;
padding: 0.8rem 0.9rem;
font-size: 0.88rem;
}
.platform-work-detail__error {
border: 1px solid rgb(248 113 113 / 38%);
background: rgb(127 29 29 / 38%);
color: #fecaca;
}
.platform-work-detail__toast {
border: 1px solid rgb(45 212 191 / 26%);
background: rgb(15 118 110 / 26%);
color: #ccfbf1;
}
.platform-work-detail__bottom {
position: sticky;
bottom: 0;
z-index: 5;
display: grid;
padding: 0.9rem 1rem calc(env(safe-area-inset-bottom, 0px) + 0.9rem);
background: linear-gradient(180deg, rgb(24 24 24 / 12%), #181818 32%);
}
.platform-work-detail__start {
display: inline-flex;
min-height: 3.4rem;
align-items: center;
justify-content: center;
gap: 0.55rem;
border: 0;
border-radius: 999px;
background: #10bdb1;
color: #f7fffe;
font-size: 1.25rem;
font-weight: 900;
box-shadow: 0 1.2rem 2.5rem rgb(16 189 177 / 24%);
}
@media (min-width: 768px) {
.platform-work-detail {
border-radius: 1.2rem;
}
.platform-work-detail__summary,
.platform-work-detail__body,
.platform-work-detail__bottom {
padding-left: max(2rem, calc((100% - 58rem) / 2));
padding-right: max(2rem, calc((100% - 58rem) / 2));
}
.platform-work-detail__topbar {
padding-left: 2rem;
padding-right: 2rem;
}
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -15,7 +15,9 @@ import {
describe('appPageRoutes', () => {
it('normalizes page paths for stable matching', () => {
expect(normalizeAppPath('')).toBe('/');
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe('/creation/rpg/agent');
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe(
'/creation/rpg/agent',
);
});
it('resolves platform entry stages from independent paths', () => {
@@ -52,11 +54,11 @@ describe('appPageRoutes', () => {
it('builds and reads public work detail query routes', () => {
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
'/worlds/detail?work=CW-00000001',
);
expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe(
'https://example.test/worlds/detail?work=CW-00000001',
'/works/detail?work=CW-00000001',
);
expect(
buildPublicWorkDetailUrl('CW-00000001', 'https://example.test'),
).toBe('https://example.test/works/detail?work=CW-00000001');
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
'CW-00000001',
);

View File

@@ -6,6 +6,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg/agent'],
['custom-world-generating', '/creation/rpg/generating'],
@@ -57,7 +58,7 @@ export function readPublicWorkCodeFromLocationSearch(search: string) {
}
export function buildPublicWorkDetailPath(publicWorkCode: string) {
return buildPublicWorkStagePath('detail', publicWorkCode);
return buildPublicWorkStagePath('work-detail', publicWorkCode);
}
export function buildPublicWorkStagePath(

View File

@@ -45,6 +45,7 @@ import {
streamCharacterPanelChatReply,
streamNpcRecruitDialogue,
} from './ai';
import { streamNpcChatTurn } from './aiService';
import type { StoryGenerationContext } from './aiTypes';
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
@@ -457,6 +458,50 @@ function createSseResponse(text: string) {
} as Response;
}
function createNpcChatTurnSseResponse(reply: string) {
const encoder = new TextEncoder();
const completePayload = {
npcReply: reply,
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
suggestions: [],
functionSuggestions: [],
pendingQuestOffer: null,
chatDirective: null,
};
const chunks = [
encoder.encode(
`event: reply_delta\ndata: ${JSON.stringify({ text: reply })}\n\n`,
),
encoder.encode(
`event: complete\ndata: ${JSON.stringify(completePayload)}\n\n`,
),
encoder.encode('data: [DONE]\n\n'),
];
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
if (index >= chunks.length) {
return { done: true, value: undefined };
}
const value = chunks[index];
index += 1;
return { done: false, value };
},
};
},
},
text: async () => '',
} as Response;
}
describe('ai runtime client orchestration', () => {
const playerCharacter = createCharacter();
const targetCharacter = createCharacter({
@@ -466,6 +511,17 @@ describe('ai runtime client orchestration', () => {
personality: 'Dry, practical, and quietly protective.',
});
const context = createContext();
const transientSnapshot: NonNullable<
StoryGenerationContext['runtimeSnapshot']
> = {
bottomTab: 'adventure',
gameState: {
worldType: WorldType.WUXIA,
runtimeSessionId: 'runtime-preview',
runtimePersistenceDisabled: true,
} as NonNullable<StoryGenerationContext['runtimeSnapshot']>['gameState'],
currentStory: null,
};
const targetStatus = createTargetStatus();
const monsters: SceneHostileNpc[] = [];
const storyHistory: StoryMoment[] = [];
@@ -633,6 +689,86 @@ describe('ai runtime client orchestration', () => {
);
});
it('attaches transient snapshot to session based chat requests only when provided', async () => {
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
text: '先确认眼下的局势。\n问清对方的真实目的。\n保持距离继续观察。',
}),
);
await generateCharacterPanelChatSuggestions(
WorldType.WUXIA,
playerCharacter,
targetCharacter,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'',
targetStatus,
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/character/suggestions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
targetCharacter,
conversationHistory: [],
conversationSummary: '',
targetStatus,
}),
}),
);
});
it('attaches transient snapshot to npc chat turn session requests', async () => {
const encounter = createEncounter();
fetchMock.mockResolvedValue(
createNpcChatTurnSseResponse('先把眼前的事说清楚。'),
);
const result = await streamNpcChatTurn(
WorldType.WUXIA,
playerCharacter,
encounter,
monsters,
storyHistory,
createContext({
runtimeSessionId: 'runtime-preview',
runtimeSnapshot: transientSnapshot,
}),
[],
'你刚才看见了什么?',
{ chattedCount: 0 },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/chat/npc/turn/stream',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-preview',
snapshot: transientSnapshot,
encounter,
conversationHistory: [],
dialogue: [],
playerMessage: '你刚才看见了什么?',
npcState: { chattedCount: 0 },
npcInitiatesConversation: false,
questOfferContext: null,
combatContext: null,
chatDirective: null,
}),
}),
);
expect(result.npcReply).toBe('先把眼前的事说清楚。');
});
it('streams npc recruit dialogue from the runtime api server', async () => {
const onUpdate = vi.fn();
const encounter = createEncounter();

View File

@@ -42,6 +42,10 @@ function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
function getRuntimeSnapshotFromContext(context: StoryGenerationContext) {
return context.runtimeSnapshot;
}
async function requestPlainText(
url: string,
payload: unknown,
@@ -240,9 +244,11 @@ export async function generateCharacterPanelChatSuggestions(
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
@@ -278,9 +284,11 @@ export async function generateCharacterPanelChatSummary(
targetStatus: CharacterChatTargetStatus,
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
previousSummary,
@@ -318,9 +326,11 @@ export async function streamCharacterPanelChatReply(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
targetCharacter,
conversationHistory,
conversationSummary,
@@ -359,9 +369,11 @@ export async function streamNpcChatDialogue(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
topic,
resultSummary,
@@ -411,6 +423,7 @@ export async function streamNpcChatTurn(
} = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const commonChatPayload = {
encounter,
conversationHistory: conversationHistory ?? [],
@@ -429,15 +442,18 @@ export async function streamNpcChatTurn(
chatDirective: options.chatDirective
? {
...options.chatDirective,
functionOptions: options.chatDirective.functionOptions?.map((item) => ({
...item,
})),
functionOptions: options.chatDirective.functionOptions?.map(
(item) => ({
...item,
}),
),
}
: null,
};
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
...commonChatPayload,
} satisfies NpcChatTurnRequest)
: ({
@@ -548,9 +564,11 @@ export async function streamNpcRecruitDialogue(
options: TextStreamOptions = {},
) {
const sessionId = getRuntimeSessionIdFromContext(context);
const snapshot = getRuntimeSnapshotFromContext(context);
const payload = sessionId
? ({
sessionId,
...(snapshot ? { snapshot } : {}),
encounter,
invitationText,
recruitSummary,

View File

@@ -21,6 +21,7 @@ import type {
EquipmentLoadout,
FacingDirection,
FactionTensionState,
GameState,
GoalStackState,
InventoryItem,
JourneyBeat,
@@ -43,6 +44,7 @@ import type {
WorldMutation,
WorldType,
} from '../types';
import type { SavedGameSnapshotInput } from '../../packages/shared/src/contracts/runtime';
import type { ConversationPressure, ConversationSituation } from '../types';
export interface StoryRequestOptions {
@@ -90,6 +92,7 @@ export interface CustomWorldSceneImageResult {
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
runtimeActionVersion?: number;
runtimeSnapshot?: SavedGameSnapshotInput<GameState, string, StoryMoment>;
playerHp: number;
playerMaxHp: number;
playerMana: number;

View File

@@ -1,3 +1,4 @@
import type { BigFishSessionResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { ApiClientError, type ApiRetryOptions, requestJson } from '../apiClient';
@@ -36,6 +37,20 @@ export async function listBigFishGallery() {
}
}
/**
* 将公开大鱼吃小鱼作品复制为当前用户草稿。
*/
export async function remixBigFishGalleryWork(sessionId: string) {
return requestJson<BigFishSessionResponse>(
`${BIG_FISH_GALLERY_API_BASE}/${encodeURIComponent(sessionId)}/remix`,
{
method: 'POST',
},
'Remix 大鱼吃小鱼作品失败',
);
}
export const bigFishGalleryClient = {
list: listBigFishGallery,
remix: remixBigFishGalleryWork,
};

View File

@@ -1,4 +1,5 @@
export {
bigFishGalleryClient,
listBigFishGallery,
remixBigFishGalleryWork,
} from './bigFishGalleryClient';

View File

@@ -2,4 +2,5 @@ export {
getPuzzleGalleryDetail,
listPuzzleGallery,
puzzleGalleryClient,
remixPuzzleGalleryWork,
} from './puzzleGalleryClient';

View File

@@ -2,6 +2,7 @@ import type {
PuzzleWorksResponse,
PuzzleWorkSummary,
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
@@ -43,7 +44,21 @@ export async function getPuzzleGalleryDetail(profileId: string) {
);
}
/**
* 将公开拼图作品复制为当前用户的草稿。
*/
export async function remixPuzzleGalleryWork(profileId: string) {
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}/remix`,
{
method: 'POST',
},
'Remix 拼图作品失败',
);
}
export const puzzleGalleryClient = {
getDetail: getPuzzleGalleryDetail,
list: listPuzzleGallery,
remix: remixPuzzleGalleryWork,
};

View File

@@ -26,6 +26,7 @@ const baseWork: PuzzleWorkSummary = {
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
playCount: 0,
likeCount: 0,
publishReady: true,
};

View File

@@ -4,6 +4,8 @@ export {
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
rpgEntryLibraryClient,
type RuntimeRequestOptions,
unpublishRpgEntryWorldProfile,

View File

@@ -78,6 +78,43 @@ export async function getRpgEntryWorldGalleryDetailByCode(
return response.entry;
}
export async function remixRpgEntryWorldGallery(
ownerUserId: string,
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<
CustomWorldLibraryMutationResponse<CustomWorldProfile>
>(
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/remix`,
{ method: 'POST' },
'Remix 作品失败',
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
}
export async function recordRpgEntryWorldGalleryPlay(
ownerUserId: string,
profileId: string,
options: RuntimeRequestOptions = {},
) {
const response = await requestRpgRuntimeJson<
CustomWorldGalleryDetailResponse<CustomWorldProfile>
>(
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}/play`,
{ method: 'POST' },
'记录作品游玩失败',
options,
);
return response.entry;
}
export async function upsertRpgEntryWorldProfile(
profile: CustomWorldProfile,
options: RuntimeRequestOptions = {},
@@ -162,6 +199,8 @@ export const rpgEntryLibraryClient = {
listWorldGallery: listRpgEntryWorldGallery,
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode,
remixWorldGallery: remixRpgEntryWorldGallery,
recordWorldGalleryPlay: recordRpgEntryWorldGalleryPlay,
upsertWorldProfile: upsertRpgEntryWorldProfile,
deleteWorldProfile: deleteRpgEntryWorldProfile,
publishWorldProfile: publishRpgEntryWorldProfile,