按后台配置扣除创作泥点

前端创作表单泥点预校验改为读取入口契约配置

拼图和抓大鹅初始生成后端扣费改为解析后台配置

汪汪声浪初始三图生成按入口总成本拆分扣费

创作工作台按钮和确认弹窗展示后台配置泥点成本

补充泥点扣费回归测试并同步文档与共享记忆
This commit is contained in:
2026-06-08 15:47:48 +08:00
parent 3ca5a460f1
commit 5ea9f0a120
21 changed files with 425 additions and 45 deletions

View File

@@ -159,6 +159,7 @@ import {
} from '../../services/big-fish-works';
import {
type CreationEntryConfig,
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
fetchCreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
@@ -686,10 +687,6 @@ async function buildRecommendRuntimeAuthOptions(
return RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
}
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
const timestamp = new Date(rawTime).getTime();
@@ -4128,6 +4125,18 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'visual-novel',
);
const resolveCreationEntryMudPointCost = useCallback(
(id: PlatformCreationTypeId) =>
creationEntryTypes.find((item) => item.id === id)?.mudPointCost ??
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
[creationEntryTypes],
);
const puzzleDraftGenerationPointCost =
resolveCreationEntryMudPointCost('puzzle');
const match3DDraftGenerationPointCost =
resolveCreationEntryMudPointCost('match3d');
const barkBattleDraftGenerationPointCost =
resolveCreationEntryMudPointCost('bark-battle');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
@@ -6944,21 +6953,30 @@ export function PlatformEntryFlowShellImpl({
setPuzzleCreationError(null);
setPuzzleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
puzzleDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
ensureEnoughDraftGenerationPointsFromServer,
puzzleDraftGenerationPointCost,
]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
MATCH3D_DRAFT_GENERATION_POINT_COST,
match3DDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
ensureEnoughDraftGenerationPointsFromServer,
match3DDraftGenerationPointCost,
]);
const preflightBarkBattleDraftGeneration = useCallback(async () => {
setBarkBattleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
BARK_BATTLE_DRAFT_GENERATION_POINT_COST,
barkBattleDraftGenerationPointCost,
);
}, [ensureEnoughDraftGenerationPointsFromServer]);
}, [
barkBattleDraftGenerationPointCost,
ensureEnoughDraftGenerationPointsFromServer,
]);
const draftGenerationPointNoticeDescription = draftGenerationPointNotice
? draftGenerationPointNotice.title === '读取泥点余额失败'
? '当前表单不会丢失,关闭后可继续编辑,稍后再试。'
@@ -7973,7 +7991,7 @@ export function PlatformEntryFlowShellImpl({
buildPendingPuzzleDraftMetadata(payload),
);
if (shouldConsumePuzzleDraftPoints) {
adjustProfileWalletBalanceLocally(-PUZZLE_DRAFT_GENERATION_POINT_COST);
adjustProfileWalletBalanceLocally(-puzzleDraftGenerationPointCost);
}
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = nextSession.sessionId;
@@ -8126,7 +8144,7 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (shouldConsumePuzzleDraftPoints) {
adjustProfileWalletBalanceLocally(PUZZLE_DRAFT_GENERATION_POINT_COST);
adjustProfileWalletBalanceLocally(puzzleDraftGenerationPointCost);
}
const failedGenerationState =
resolveFinishedMiniGameDraftGenerationState(
@@ -8176,6 +8194,7 @@ export function PlatformEntryFlowShellImpl({
isViewingPuzzleGeneration,
preflightPuzzleDraftGeneration,
puzzleFlow,
puzzleDraftGenerationPointCost,
refreshPuzzleShelf,
recoverCompletedPuzzleDraftGeneration,
refreshPlatformDashboardSilently,
@@ -8235,7 +8254,7 @@ export function PlatformEntryFlowShellImpl({
nextSession.sessionId,
buildPendingMatch3DDraftMetadata(payload),
);
adjustProfileWalletBalanceLocally(-MATCH3D_DRAFT_GENERATION_POINT_COST);
adjustProfileWalletBalanceLocally(-match3DDraftGenerationPointCost);
selectionStageRef.current = 'match3d-generating';
activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId;
setSelectionStage('match3d-generating');
@@ -8378,7 +8397,7 @@ export function PlatformEntryFlowShellImpl({
await refreshMatch3DShelf().catch(() => undefined);
}
}
adjustProfileWalletBalanceLocally(MATCH3D_DRAFT_GENERATION_POINT_COST);
adjustProfileWalletBalanceLocally(match3DDraftGenerationPointCost);
const failedGenerationState =
resolveFinishedMiniGameDraftGenerationState(
generationState,
@@ -8453,6 +8472,7 @@ export function PlatformEntryFlowShellImpl({
[
adjustProfileWalletBalanceLocally,
match3dRuntimeAdapter,
match3DDraftGenerationPointCost,
isViewingMatch3DGeneration,
markDraftGenerating,
markDraftFailed,
@@ -18384,7 +18404,10 @@ export function PlatformEntryFlowShellImpl({
>
<UnifiedCreationWorkspace
playId="match3d"
spec={getUnifiedSpec('match3d')}
spec={{
...getUnifiedSpec('match3d'),
mudPointCost: match3DDraftGenerationPointCost,
}}
session={match3dSession}
isBusy={isStreamingMatch3DReply}
error={match3dError}
@@ -19491,7 +19514,10 @@ export function PlatformEntryFlowShellImpl({
>
<UnifiedCreationWorkspace
playId="puzzle"
spec={getUnifiedSpec('puzzle')}
spec={{
...getUnifiedSpec('puzzle'),
mudPointCost: puzzleDraftGenerationPointCost,
}}
session={puzzleSession}
isBusy={isStreamingPuzzleReply}
error={puzzleError}

View File

@@ -16,6 +16,7 @@ export type PlatformCreationTypeCard = {
subtitle: string;
badge: string;
imageSrc: string;
mudPointCost: number;
mudPointCostLabel: string;
locked: boolean;
categoryId: string;
@@ -144,6 +145,9 @@ export function derivePlatformCreationTypes(
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
mudPointCost: normalizeMudPointCost(
item.unifiedCreationSpec?.mudPointCost,
),
mudPointCostLabel: formatMudPointCostText(
item.unifiedCreationSpec?.mudPointCost,
),

View File

@@ -77,6 +77,7 @@ import { listBigFishWorks } from '../../services/big-fish-works';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
type UnifiedCreationSpec,
} from '../../services/creationEntryConfigService';
import {
cancelCreativeAgentSession,
@@ -184,6 +185,10 @@ import {
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import {
getUnifiedCreationSpec,
type UnifiedCreationPlayId,
} from '../unified-creation/unifiedCreationSpecs';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
@@ -356,6 +361,16 @@ async function findPlatformTabPanel(tab: string) {
return getPlatformTabPanel(tab);
}
function buildTestUnifiedCreationSpec(
playId: UnifiedCreationPlayId,
mudPointCost: number,
): UnifiedCreationSpec {
return {
...getUnifiedCreationSpec(playId),
mudPointCost,
};
}
const testCreationEntryConfig = {
startCard: {
title: '新建作品',
@@ -423,6 +438,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('puzzle', 8),
},
{
id: 'match3d',
@@ -437,6 +453,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('match3d', 12),
},
{
id: 'bark-battle',
@@ -451,6 +468,7 @@ const testCreationEntryConfig = {
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: buildTestUnifiedCreationSpec('bark-battle', 6),
},
{
id: 'square-hole',
@@ -587,6 +605,7 @@ vi.mock('../../services/rpg-entry', () => ({
}));
vi.mock('../../services/creationEntryConfigService', () => ({
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST: 10,
fetchCreationEntryConfig: vi.fn(),
}));
@@ -3963,7 +3982,7 @@ test('direct bark battle runtime public code opens published runtime', async ()
test('bark battle form checks mud points before creating image assets', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 2,
walletBalance: 5,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -3980,7 +3999,7 @@ test('bark battle form checks mud points before creating image assets', async ()
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
within(noticeDialog).getByText('本次需要 6 泥点,当前 5 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
@@ -5309,7 +5328,7 @@ test('puzzle text-only form stays generating when compile starts background imag
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 1,
walletBalance: 7,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -5323,7 +5342,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
within(noticeDialog).getByText('本次需要 8 泥点,当前 7 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
@@ -5334,7 +5353,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
test('match3d form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 9,
walletBalance: 11,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -5350,7 +5369,7 @@ test('match3d form checks mud points before creating a draft', async () => {
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
within(noticeDialog).getByText('本次需要 12 泥点,当前 11 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();

View File

@@ -68,6 +68,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
showBackButton={false}
title={null}
unifiedChrome
mudPointCost={props.spec.mudPointCost ?? undefined}
/>
</UnifiedCreationPage>
);
@@ -86,6 +87,7 @@ export function UnifiedCreationWorkspace(props: UnifiedCreationWorkspaceProps) {
showBackButton={false}
title={null}
unifiedChrome
mudPointCost={props.spec.mudPointCost ?? undefined}
/>
</UnifiedCreationPage>
);

View File

@@ -65,6 +65,13 @@ function confirmMatch3DPointCost() {
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
function confirmMatch3DPointCostText(text: string) {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText(text)).toBeTruthy();
}
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
const onCreateFromForm = vi.fn();
const onExecuteAction = vi.fn();
@@ -112,6 +119,26 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace shows configured mud point cost', () => {
render(
<Match3DCreationWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
mudPointCost={12}
/>,
);
expect(screen.getByText('消耗12泥点')).toBeTruthy();
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '陶泥甜品店' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmMatch3DPointCostText('消耗 12 泥点');
});
test('match3d workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<Match3DCreationWorkspace

View File

@@ -20,6 +20,7 @@ type Match3DCreationWorkspaceProps = {
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
mudPointCost?: number;
};
type Match3DFormState = {
@@ -117,6 +118,7 @@ export function Match3DCreationWorkspace({
showBackButton = true,
title = '抓大鹅',
unifiedChrome = false,
mudPointCost = 10,
}: Match3DCreationWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -324,7 +326,7 @@ export function Match3DCreationWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
10
{mudPointCost}
</span>
</span>
</button>
@@ -345,7 +347,7 @@ export function Match3DCreationWorkspace({
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
10
{mudPointCost}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button

View File

@@ -173,6 +173,13 @@ function confirmPuzzlePointCost() {
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
}
function confirmPuzzlePointCostText(text: string) {
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗泥点',
});
expect(within(confirmDialog).getByText(text)).toBeTruthy();
}
test('puzzle workspace submits the work form instead of agent chat', () => {
const onCreateFromForm = vi.fn();
@@ -216,6 +223,27 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace shows configured mud point cost', () => {
render(
<PuzzleCreationWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
mudPointCost={8}
/>,
);
expect(screen.getByText('消耗8泥点')).toBeTruthy();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一座雨后的陶泥小镇。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCostText('消耗 8 泥点');
});
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<PuzzleCreationWorkspace

View File

@@ -50,6 +50,7 @@ type PuzzleCreationWorkspaceProps = {
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
mudPointCost?: number;
};
type PuzzleFormState = {
@@ -248,6 +249,7 @@ export function PuzzleCreationWorkspace({
showBackButton = true,
title = '拼图',
unifiedChrome = false,
mudPointCost = 2,
}: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -667,7 +669,7 @@ export function PuzzleCreationWorkspace({
inputError={referenceImageError}
error={error}
submitLabel="生成拼图游戏草稿"
submitCostLabel={formState.aiRedraw ? '消耗2泥点' : null}
submitCostLabel={formState.aiRedraw ? `消耗${mudPointCost}泥点` : null}
submitDisabled={!canSubmit}
labels={{
imageField: '拼图画面',
@@ -760,7 +762,7 @@ export function PuzzleCreationWorkspace({
</div>
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
2
{mudPointCost}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button

View File

@@ -172,6 +172,7 @@ describe('barkBattleCreationClient', () => {
body: JSON.stringify({
slot: 'player-character',
draftId: 'draft-1',
billingPurpose: null,
config: {
title: '汪汪冠军杯',
description: '',
@@ -224,5 +225,16 @@ describe('barkBattleCreationClient', () => {
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
});
expect(requestJsonMock).toHaveBeenNthCalledWith(
1,
'/api/creation/bark-battle/images/generate',
expect.objectContaining({
body: expect.stringContaining(
'"billingPurpose":"initial_draft_generation"',
),
}),
'生成汪汪声浪素材失败',
expect.anything(),
);
});
});

View File

@@ -376,10 +376,12 @@ export function regenerateBarkBattleImageAsset(payload: {
slot: BarkBattleAssetSlot;
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
billingPurpose?: BarkBattleImageAssetGenerateRequest['billingPurpose'];
}): Promise<BarkBattleGeneratedImageAsset> {
const request: BarkBattleImageAssetGenerateRequest = {
slot: payload.slot,
draftId: payload.draftId ?? null,
billingPurpose: payload.billingPurpose ?? null,
config: payload.config,
};
return requestJson<BarkBattleGeneratedImageAsset>(
@@ -418,6 +420,7 @@ export async function generateAllBarkBattleImageAssets(payload: {
slot,
config: payload.config,
draftId: payload.draftId,
billingPurpose: 'initial_draft_generation',
}),
slot,
)