统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
This commit is contained in:
@@ -8,13 +8,9 @@ import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
test('陶泥儿品牌标识展示统一产品形象', () => {
|
||||
const { container } = render(<RpgEntryBrandLogo />);
|
||||
|
||||
expect(screen.getByRole('img', { name: '陶泥儿 GENARRATIVE' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('img', { name: '陶泥儿 GENARRATIVE' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container
|
||||
.querySelector('.platform-brand-logo__image')
|
||||
?.getAttribute('src'),
|
||||
container.querySelector('.platform-brand-logo__image')?.getAttribute('src'),
|
||||
).toBe('/branding/taonier-product-ip.png');
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
@@ -129,9 +127,8 @@ test('custom world character selection stays stable when character ids are empty
|
||||
this.dispatchEvent(new Event('scroll'));
|
||||
};
|
||||
|
||||
vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
function mockGetBoundingClientRect(this: HTMLElement) {
|
||||
if ((this as HTMLElement).dataset.carouselCard === 'true') {
|
||||
return {
|
||||
width: 240,
|
||||
@@ -157,57 +154,60 @@ test('custom world character selection stays stable when character ids are empty
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮城',
|
||||
settingSummary: '潮水与迷雾交织的港城。',
|
||||
tone: '潮湿、危险、带着试探。',
|
||||
conflictCore: '在涨落之间抢先一步。',
|
||||
customWorldProfile={
|
||||
{
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮城',
|
||||
settingSummary: '潮水与迷雾交织的港城。',
|
||||
tone: '潮湿、危险、带着试探。',
|
||||
conflictCore: '在涨落之间抢先一步。',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '潮骨',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '浪步',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '舟识',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '潮魄',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '契汐',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '回澜',
|
||||
},
|
||||
],
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '潮骨',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '浪步',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '舟识',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '潮魄',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '契汐',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '回澜',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
} as unknown as CustomWorldProfile
|
||||
}
|
||||
onBack={handleBack}
|
||||
onConfirm={handleConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const backButton = screen.getByRole('button', {name: '返回'});
|
||||
const backButton = screen.getByRole('button', { name: '返回' });
|
||||
expect(backButton.className).toContain('platform-action-button--editor-dark');
|
||||
expect(backButton.className).toContain('rounded-full');
|
||||
expect(backButton.className).toContain('px-3');
|
||||
@@ -223,7 +223,9 @@ test('custom world character selection stays stable when character ids are empty
|
||||
await user.click(screen.getByRole('button', { name: /闻潮/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /查看闻潮的详情/u })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /查看闻潮的详情/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /进入营地/u }));
|
||||
@@ -238,8 +240,8 @@ test('custom world character selection stays stable when character ids are empty
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string'
|
||||
&& arg.includes('Encountered two children with the same key'),
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -257,30 +259,32 @@ test('custom world character selection falls back instead of rendering a blank s
|
||||
render(
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{
|
||||
id: 'broken-profile',
|
||||
name: '坏数据',
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:fallback',
|
||||
worldId: 'broken-profile',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '坏数据',
|
||||
settingSummary: '坏数据',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
customWorldProfile={
|
||||
{
|
||||
id: 'broken-profile',
|
||||
name: '坏数据',
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:fallback',
|
||||
worldId: 'broken-profile',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '坏数据',
|
||||
settingSummary: '坏数据',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '骨势' },
|
||||
{ slotId: 'axis_b', name: '身法' },
|
||||
{ slotId: 'axis_c', name: '眼脉' },
|
||||
{ slotId: 'axis_d', name: '心焰' },
|
||||
{ slotId: 'axis_e', name: '尘缘' },
|
||||
{ slotId: 'axis_f', name: '玄息' },
|
||||
],
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '骨势' },
|
||||
{ slotId: 'axis_b', name: '身法' },
|
||||
{ slotId: 'axis_c', name: '眼脉' },
|
||||
{ slotId: 'axis_d', name: '心焰' },
|
||||
{ slotId: 'axis_e', name: '尘缘' },
|
||||
{ slotId: 'axis_f', name: '玄息' },
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
} as unknown as CustomWorldProfile
|
||||
}
|
||||
onBack={handleBack}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
@@ -290,6 +294,6 @@ test('custom world character selection falls back instead of rendering a blank s
|
||||
expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /进入营地/u })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', {name: '返回'}));
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(handleBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCharacterAttributeProfile,
|
||||
} from '../../data/attributeProfileGenerator';
|
||||
import { buildCharacterAttributeProfile } from '../../data/attributeProfileGenerator';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
@@ -105,10 +103,10 @@ function buildSelectionCharacterKey(character: Character, index: number) {
|
||||
}
|
||||
|
||||
const fallbackSeed =
|
||||
character.name.trim()
|
||||
|| character.title.trim()
|
||||
|| character.description.trim()
|
||||
|| 'character';
|
||||
character.name.trim() ||
|
||||
character.title.trim() ||
|
||||
character.description.trim() ||
|
||||
'character';
|
||||
|
||||
return `selection-character-${index}-${fallbackSeed}`;
|
||||
}
|
||||
@@ -141,14 +139,21 @@ function applyCharacterSelectionDraft(
|
||||
function getPersonalityTags(personality: string) {
|
||||
const tags = personality
|
||||
.split(/[,.!?/\\\s]+/u)
|
||||
.map(tag => tag.trim())
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return tags.length > 0 ? [...new Set(tags)] : [personality.trim()].filter(Boolean);
|
||||
return tags.length > 0
|
||||
? [...new Set(tags)]
|
||||
: [personality.trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOrientation) {
|
||||
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
|
||||
function readCarouselProgress(
|
||||
container: HTMLDivElement,
|
||||
orientation: CarouselOrientation,
|
||||
) {
|
||||
const firstCard = container.querySelector<HTMLElement>(
|
||||
'[data-carousel-card="true"]',
|
||||
);
|
||||
if (!firstCard) return 0;
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
@@ -157,17 +162,26 @@ function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOr
|
||||
? styles.rowGap || styles.gap || '0'
|
||||
: styles.columnGap || styles.gap || '0',
|
||||
);
|
||||
const stride = orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
const stride =
|
||||
orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
|
||||
if (stride <= 0) return 0;
|
||||
return orientation === 'vertical' ? container.scrollTop / stride : container.scrollLeft / stride;
|
||||
return orientation === 'vertical'
|
||||
? container.scrollTop / stride
|
||||
: container.scrollLeft / stride;
|
||||
}
|
||||
|
||||
function scrollCarouselToIndex(container: HTMLDivElement | null, index: number, orientation: CarouselOrientation) {
|
||||
function scrollCarouselToIndex(
|
||||
container: HTMLDivElement | null,
|
||||
index: number,
|
||||
orientation: CarouselOrientation,
|
||||
) {
|
||||
if (!container) return;
|
||||
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
|
||||
const firstCard = container.querySelector<HTMLElement>(
|
||||
'[data-carousel-card="true"]',
|
||||
);
|
||||
if (!firstCard) return;
|
||||
|
||||
const styles = window.getComputedStyle(container);
|
||||
@@ -176,15 +190,16 @@ function scrollCarouselToIndex(container: HTMLDivElement | null, index: number,
|
||||
? styles.rowGap || styles.gap || '0'
|
||||
: styles.columnGap || styles.gap || '0',
|
||||
);
|
||||
const stride = orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
const stride =
|
||||
orientation === 'vertical'
|
||||
? firstCard.getBoundingClientRect().height + gap
|
||||
: firstCard.getBoundingClientRect().width + gap;
|
||||
|
||||
const behavior: ScrollBehavior = 'smooth';
|
||||
if (orientation === 'vertical') {
|
||||
container.scrollTo({top: stride * index, behavior});
|
||||
container.scrollTo({ top: stride * index, behavior });
|
||||
} else {
|
||||
container.scrollTo({left: stride * index, behavior});
|
||||
container.scrollTo({ left: stride * index, behavior });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +231,7 @@ function getCharacterCardStyle(index: number, progress: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function CharacterSelectBackButton({onBack}: {onBack: () => void}) {
|
||||
function CharacterSelectBackButton({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<PlatformActionButton
|
||||
surface="editorDark"
|
||||
@@ -249,30 +264,46 @@ export function RpgEntryCharacterSelectView({
|
||||
})),
|
||||
[selectionCharacters],
|
||||
);
|
||||
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
|
||||
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
|
||||
const [selectedCharacterKey, setSelectedCharacterKey] = useState(
|
||||
selectionEntries[0]?.selectionKey ?? '',
|
||||
);
|
||||
const [detailCharacter, setDetailCharacter] = useState<Character | null>(
|
||||
null,
|
||||
);
|
||||
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
|
||||
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
|
||||
const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false);
|
||||
const [characterDraftName, setCharacterDraftName] = useState('');
|
||||
const [characterDraftBackstory, setCharacterDraftBackstory] = useState('');
|
||||
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
|
||||
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
|
||||
const [characterDraftError, setCharacterDraftError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<
|
||||
Record<string, CharacterSelectionDraft>
|
||||
>({});
|
||||
|
||||
const selectedCharacterEntry = useMemo(
|
||||
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
|
||||
() =>
|
||||
selectionEntries.find(
|
||||
(entry) => entry.selectionKey === selectedCharacterKey,
|
||||
) ??
|
||||
selectionEntries[0] ??
|
||||
null,
|
||||
[selectedCharacterKey, selectionEntries],
|
||||
);
|
||||
const selectedCharacter = selectedCharacterEntry?.character ?? null;
|
||||
const selectedCharacterDraft = selectedCharacterEntry
|
||||
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
|
||||
? (characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null)
|
||||
: null;
|
||||
const selectedCharacterPreview = useMemo(
|
||||
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
|
||||
() =>
|
||||
applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
|
||||
[selectedCharacter, selectedCharacterDraft],
|
||||
);
|
||||
const selectedCharacterMeta = selectedCharacter
|
||||
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
|
||||
? getCharacterMeta(selectedCharacter, {
|
||||
name: selectedCharacterDraft?.name,
|
||||
})
|
||||
: null;
|
||||
const attributeSchema = useMemo(
|
||||
() => resolveAttributeSchema(worldType, customWorldProfile),
|
||||
@@ -281,24 +312,32 @@ export function RpgEntryCharacterSelectView({
|
||||
const selectedAttributeProfile = useMemo(
|
||||
() =>
|
||||
selectedCharacter
|
||||
? resolveCharacterAttributeProfile(
|
||||
? (resolveCharacterAttributeProfile(
|
||||
selectedCharacter,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
)
|
||||
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
|
||||
) ??
|
||||
buildCharacterAttributeProfile(selectedCharacter, attributeSchema))
|
||||
: null,
|
||||
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
|
||||
);
|
||||
const selectedCharacterPersonalityTags = useMemo(
|
||||
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
|
||||
() =>
|
||||
selectedCharacterPreview
|
||||
? getPersonalityTags(selectedCharacterPreview.personality)
|
||||
: [],
|
||||
[selectedCharacterPreview],
|
||||
);
|
||||
const focusedCharacterIndex = clampIndex(Math.round(characterCarouselProgress), selectionCharacters.length);
|
||||
const focusedCharacterIndex = clampIndex(
|
||||
Math.round(characterCarouselProgress),
|
||||
selectionCharacters.length,
|
||||
);
|
||||
|
||||
const syncCharacterCarousel = useCallback(() => {
|
||||
if (!characterCarouselRef.current) return;
|
||||
setCharacterCarouselProgress(readCarouselProgress(characterCarouselRef.current, 'horizontal'));
|
||||
setCharacterCarouselProgress(
|
||||
readCarouselProgress(characterCarouselRef.current, 'horizontal'),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -316,7 +355,11 @@ export function RpgEntryCharacterSelectView({
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionEntries.length === 0) return;
|
||||
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
|
||||
if (
|
||||
!selectionEntries.some(
|
||||
(entry) => entry.selectionKey === selectedCharacterKey,
|
||||
)
|
||||
) {
|
||||
const firstEntry = selectionEntries[0];
|
||||
if (firstEntry) {
|
||||
setSelectedCharacterKey(firstEntry.selectionKey);
|
||||
@@ -346,7 +389,7 @@ export function RpgEntryCharacterSelectView({
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterSelectionDrafts(current => ({
|
||||
setCharacterSelectionDrafts((current) => ({
|
||||
...current,
|
||||
[selectedCharacterEntry.selectionKey]: {
|
||||
name: nextName,
|
||||
@@ -373,8 +416,12 @@ export function RpgEntryCharacterSelectView({
|
||||
<CharacterSelectBackButton onBack={onBack} />
|
||||
</div>
|
||||
<div className="mb-4 text-center">
|
||||
<div className="text-2xl font-black text-white sm:text-[2rem]">选择你的角色</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500">左右滑动浏览角色</div>
|
||||
<div className="text-2xl font-black text-white sm:text-[2rem]">
|
||||
选择你的角色
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500">
|
||||
左右滑动浏览角色
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -384,7 +431,9 @@ export function RpgEntryCharacterSelectView({
|
||||
>
|
||||
{selectionEntries.map(({ character, selectionKey }, index) => {
|
||||
const characterDraft = characterSelectionDrafts[selectionKey];
|
||||
const meta = getCharacterMeta(character, {name: characterDraft?.name});
|
||||
const meta = getCharacterMeta(character, {
|
||||
name: characterDraft?.name,
|
||||
});
|
||||
const selected = selectionKey === selectedCharacterKey;
|
||||
|
||||
return (
|
||||
@@ -393,13 +442,19 @@ export function RpgEntryCharacterSelectView({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCharacterKey(selectionKey);
|
||||
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
|
||||
scrollCarouselToIndex(
|
||||
characterCarouselRef.current,
|
||||
index,
|
||||
'horizontal',
|
||||
);
|
||||
}}
|
||||
data-carousel-card="true"
|
||||
className={`character-carousel__card ${selected ? 'character-carousel__card--active' : ''}`}
|
||||
style={getCharacterCardStyle(index, characterCarouselProgress)}
|
||||
>
|
||||
<span className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}>
|
||||
<span
|
||||
className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}
|
||||
>
|
||||
<span className="character-carousel__cover">
|
||||
{selected ? (
|
||||
<CharacterAnimator
|
||||
@@ -412,21 +467,29 @@ export function RpgEntryCharacterSelectView({
|
||||
src={character.portrait}
|
||||
alt={meta.name}
|
||||
className="character-carousel__portrait"
|
||||
style={{imageRendering: 'pixelated'}}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{selected ? (
|
||||
<>
|
||||
<span className="character-carousel__selected-name">{meta.name}</span>
|
||||
<span className="character-carousel__selected-name">
|
||||
{meta.name}
|
||||
</span>
|
||||
<span className="character-carousel__meta character-carousel__meta--selected">
|
||||
<span className="character-carousel__title character-carousel__title--selected">{meta.title}</span>
|
||||
<span className="character-carousel__title character-carousel__title--selected">
|
||||
{meta.title}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="character-carousel__meta">
|
||||
<span className="character-carousel__name">{meta.name}</span>
|
||||
<span className="character-carousel__title">{meta.title}</span>
|
||||
<span className="character-carousel__name">
|
||||
{meta.name}
|
||||
</span>
|
||||
<span className="character-carousel__title">
|
||||
{meta.title}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -436,7 +499,13 @@ export function RpgEntryCharacterSelectView({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[0.85fr_1.15fr]">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel, {paddingX: 12, paddingY: 10})}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.statsPanel, {
|
||||
paddingX: 12,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold text-white">角色属性</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
|
||||
@@ -448,8 +517,12 @@ export function RpgEntryCharacterSelectView({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
|
||||
{attributeSchema.slots.map((slot) => (
|
||||
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
|
||||
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5"
|
||||
>
|
||||
{slot.name}:{' '}
|
||||
{selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -457,10 +530,15 @@ export function RpgEntryCharacterSelectView({
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel character-backstory-panel flex flex-col"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {paddingX: 12, paddingY: 10})}
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 12,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="character-backstory-title text-xs font-bold text-white">背景故事</div>
|
||||
<div className="character-backstory-title text-xs font-bold text-white">
|
||||
背景故事
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCharacterDraftEditor}
|
||||
@@ -470,11 +548,17 @@ export function RpgEntryCharacterSelectView({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col text-[13px] leading-6 text-zinc-300">
|
||||
<div>{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}</div>
|
||||
<div>
|
||||
{selectedCharacterPreview?.backstory ??
|
||||
selectedCharacter.backstory}
|
||||
</div>
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
|
||||
<div className="min-w-0 flex flex-wrap gap-1.5">
|
||||
{selectedCharacterPersonalityTags.map(tag => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
|
||||
{selectedCharacterPersonalityTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -495,9 +579,14 @@ export function RpgEntryCharacterSelectView({
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(selectedCharacterPreview ?? selectedCharacter)}
|
||||
onClick={() =>
|
||||
onConfirm(selectedCharacterPreview ?? selectedCharacter)
|
||||
}
|
||||
className="pixel-nine-slice pixel-pressable mx-auto block w-full max-w-[16rem] text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 9})}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 9,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">进入营地</span>
|
||||
@@ -516,14 +605,18 @@ export function RpgEntryCharacterSelectView({
|
||||
/>
|
||||
<CharacterDraftModal
|
||||
isOpen={showCharacterDraftModal}
|
||||
characterLabel={selectedCharacterMeta ? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}` : '当前角色'}
|
||||
characterLabel={
|
||||
selectedCharacterMeta
|
||||
? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}`
|
||||
: '当前角色'
|
||||
}
|
||||
draftName={characterDraftName}
|
||||
draftBackstory={characterDraftBackstory}
|
||||
onNameChange={value => {
|
||||
onNameChange={(value) => {
|
||||
setCharacterDraftName(value);
|
||||
if (characterDraftError) setCharacterDraftError(null);
|
||||
}}
|
||||
onBackstoryChange={value => {
|
||||
onBackstoryChange={(value) => {
|
||||
setCharacterDraftBackstory(value);
|
||||
if (characterDraftError) setCharacterDraftError(null);
|
||||
}}
|
||||
|
||||
@@ -9451,9 +9451,7 @@ test('creation home desktop rail hides recommend and discover entries', async ()
|
||||
|
||||
test('profile feedback shortcut opens Feishu form in a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openWindow = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null);
|
||||
const openWindow = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const { container } = render(<TestWrapper withAuth />);
|
||||
@@ -9531,17 +9529,25 @@ test('desktop project tab switch clears creation primary styling', async () => {
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '项目' }));
|
||||
await user.click(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '项目' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy();
|
||||
const projectNav = container.querySelector('.platform-desktop-rail');
|
||||
expect(projectNav).toBeTruthy();
|
||||
const createNavButton = within(projectNav as HTMLElement).getByRole('button', {
|
||||
name: '创作',
|
||||
});
|
||||
const projectNavButton = within(projectNav as HTMLElement).getByRole('button', {
|
||||
name: '项目',
|
||||
});
|
||||
const createNavButton = within(projectNav as HTMLElement).getByRole(
|
||||
'button',
|
||||
{
|
||||
name: '创作',
|
||||
},
|
||||
);
|
||||
const projectNavButton = within(projectNav as HTMLElement).getByRole(
|
||||
'button',
|
||||
{
|
||||
name: '项目',
|
||||
},
|
||||
);
|
||||
expect(projectNavButton.className).toContain(
|
||||
'platform-desktop-rail__button--active',
|
||||
);
|
||||
@@ -9565,9 +9571,7 @@ test('direct mobile creation home guides users to desktop without mobile nav ent
|
||||
expect(
|
||||
screen.getByText('图片画布、项目管理和素材库需要更大的操作空间。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('main', { name: '陶泥儿创作主页' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
||||
|
||||
const bottomNav = container.querySelector('.platform-bottom-nav');
|
||||
expect(bottomNav).toBeTruthy();
|
||||
@@ -9582,9 +9586,7 @@ test('direct mobile creation home guides users to desktop without mobile nav ent
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
expect(
|
||||
screen.queryByRole('main', { name: '桌面端创作提示' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('main', { name: '桌面端创作提示' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10581,7 +10583,9 @@ test('direct missing public work detail shows unified dialog before returning ho
|
||||
expect(await screen.findByText('正在读取作品详情...')).toBeTruthy();
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '作品不可用' });
|
||||
expect(within(dialog).getByText('作品不存在或已下架,将返回首页。')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('作品不存在或已下架,将返回首页。'),
|
||||
).toBeTruthy();
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '知道了' }));
|
||||
|
||||
@@ -4468,12 +4468,8 @@ test('logged in mobile recommend page exposes the shared wallet details and rech
|
||||
test('the latest recharge center read wins when wallet and recharge overlap', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockNarrowMobileLayout();
|
||||
let resolveWalletRead!: (
|
||||
center: ProfileRechargeCenterResponse,
|
||||
) => void;
|
||||
let resolveRechargeRead!: (
|
||||
center: ProfileRechargeCenterResponse,
|
||||
) => void;
|
||||
let resolveWalletRead!: (center: ProfileRechargeCenterResponse) => void;
|
||||
let resolveRechargeRead!: (center: ProfileRechargeCenterResponse) => void;
|
||||
const walletReadPromise = new Promise<ProfileRechargeCenterResponse>(
|
||||
(resolve) => {
|
||||
resolveWalletRead = resolve;
|
||||
|
||||
@@ -17,7 +17,9 @@ function formatMembershipPeriodPrefix(periodDays: number) {
|
||||
return '每周期';
|
||||
}
|
||||
|
||||
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
|
||||
export function buildRechargeProductValueLabel(
|
||||
product: ProfileRechargeProduct,
|
||||
) {
|
||||
if (product.kind === 'membership') {
|
||||
const membershipPeriodPoints = product.membershipPeriodPoints;
|
||||
const membershipPeriodDays =
|
||||
@@ -64,7 +66,9 @@ export function buildMembershipLabel(
|
||||
}
|
||||
|
||||
const tierLabel =
|
||||
membership.tier === 'normal' ? '会员' : getMembershipTierLabel(membership.tier);
|
||||
membership.tier === 'normal'
|
||||
? '会员'
|
||||
: getMembershipTierLabel(membership.tier);
|
||||
const resetLabel = membership.cycleResetsAt
|
||||
? ` · ${formatTime(membership.cycleResetsAt)}重置`
|
||||
: '';
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
selectProfileTaskCenterTasks,
|
||||
} from './rpgEntryProfileTaskViewModel';
|
||||
|
||||
function buildTask(
|
||||
overrides: Partial<ProfileTaskItem> = {},
|
||||
): ProfileTaskItem {
|
||||
function buildTask(overrides: Partial<ProfileTaskItem> = {}): ProfileTaskItem {
|
||||
return {
|
||||
taskId: 'task-1',
|
||||
title: '游玩一次',
|
||||
@@ -33,9 +31,7 @@ function buildTask(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCenter(
|
||||
tasks: ProfileTaskItem[],
|
||||
): ProfileTaskCenterResponse {
|
||||
function buildCenter(tasks: ProfileTaskItem[]): ProfileTaskCenterResponse {
|
||||
return {
|
||||
dayKey: 20260603,
|
||||
walletBalance: 12,
|
||||
@@ -65,9 +61,9 @@ test('profile task ViewModel selects one actionable task by status priority and
|
||||
claimable,
|
||||
]),
|
||||
).toEqual([claimable]);
|
||||
expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual(
|
||||
[firstIncomplete],
|
||||
);
|
||||
expect(
|
||||
selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete]),
|
||||
).toEqual([firstIncomplete]);
|
||||
});
|
||||
|
||||
test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => {
|
||||
@@ -118,9 +114,9 @@ test('profile task ViewModel exposes task labels for the modal', () => {
|
||||
expect(getProfileTaskStatusLabel('claimable')).toBe('可领取');
|
||||
expect(buildProfileTaskProgressLabel(task)).toBe('0/1');
|
||||
expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中');
|
||||
expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe(
|
||||
'已领取',
|
||||
);
|
||||
expect(
|
||||
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false),
|
||||
).toBe('已领取');
|
||||
expect(
|
||||
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false),
|
||||
).toBe('领取');
|
||||
|
||||
@@ -32,8 +32,7 @@ export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(
|
||||
({ task }) =>
|
||||
task.status === 'claimable' || task.status === 'incomplete',
|
||||
({ task }) => task.status === 'claimable' || task.status === 'incomplete',
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
|
||||
@@ -205,9 +205,9 @@ test('public gallery ViewModel keeps recommend feed in scored runtime order', ()
|
||||
publishedAt: '2026-05-02T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(buildPlatformRecommendFeedEntries([], [quietPuzzle, hotPuzzle])).toEqual(
|
||||
[hotPuzzle, quietPuzzle],
|
||||
);
|
||||
expect(
|
||||
buildPlatformRecommendFeedEntries([], [quietPuzzle, hotPuzzle]),
|
||||
).toEqual([hotPuzzle, quietPuzzle]);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => {
|
||||
@@ -302,9 +302,9 @@ test('public gallery ViewModel searches compact work codes and ranks name prefix
|
||||
worldName: '静心木鱼',
|
||||
});
|
||||
|
||||
expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual(
|
||||
[nameMatch, codeMatch],
|
||||
);
|
||||
expect(
|
||||
filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥'),
|
||||
).toEqual([nameMatch, codeMatch]);
|
||||
expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([
|
||||
codeMatch,
|
||||
]);
|
||||
@@ -327,15 +327,15 @@ test('public gallery ViewModel keeps source kinds behind one category filter sea
|
||||
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish'),
|
||||
).toBe(true);
|
||||
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('public gallery ViewModel exposes category filter and sort option interface', () => {
|
||||
|
||||
@@ -169,8 +169,7 @@ export function selectAdjacentPlatformRecommendEntry(
|
||||
)
|
||||
: -1;
|
||||
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
|
||||
const nextIndex =
|
||||
(baseIndex + direction + entries.length) % entries.length;
|
||||
const nextIndex = (baseIndex + direction + entries.length) % entries.length;
|
||||
const nextEntry = entries[nextIndex] ?? null;
|
||||
if (
|
||||
nextEntry &&
|
||||
@@ -280,9 +279,7 @@ function normalizePlatformCompactSearchText(value: string | null | undefined) {
|
||||
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
|
||||
}
|
||||
|
||||
export function getPlatformSearchableWorkIds(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
export function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) {
|
||||
const ids = [entry.publicWorkCode, entry.profileId];
|
||||
if ('workId' in entry) {
|
||||
ids.push(entry.workId);
|
||||
@@ -406,11 +403,17 @@ export function filterTodayPublishedEntries(
|
||||
}
|
||||
|
||||
export function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(0, Math.round(('likeCount' in entry && entry.likeCount) || 0));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(('likeCount' in entry && entry.likeCount) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(0, Math.round(('playCount' in entry && entry.playCount) || 0));
|
||||
return Math.max(
|
||||
0,
|
||||
Math.round(('playCount' in entry && entry.playCount) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
|
||||
|
||||
@@ -17,10 +17,7 @@ export function hasRecommendDragStarted(deltaY: number) {
|
||||
return Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
|
||||
}
|
||||
|
||||
export function clampRecommendDragOffset(
|
||||
deltaY: number,
|
||||
stageHeight: number,
|
||||
) {
|
||||
export function clampRecommendDragOffset(deltaY: number, stageHeight: number) {
|
||||
const dragLimit =
|
||||
stageHeight > 0 ? stageHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
|
||||
return Math.max(-dragLimit, Math.min(dragLimit, deltaY));
|
||||
|
||||
@@ -274,7 +274,10 @@ test('maps puzzle clear work to platform gallery card with PC public code', () =
|
||||
expect(resolvePlatformWorldFallbackCoverImage(card)).toBe(
|
||||
'/creation-type-references/puzzle.webp',
|
||||
);
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['拼消消', '霓虹星港']);
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual([
|
||||
'拼消消',
|
||||
'霓虹星港',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolves public work author from display name and public user code before stored author name', () => {
|
||||
|
||||
@@ -663,12 +663,11 @@ export function mapJumpHopWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePuzzleClearThemeTags(summary: PuzzleClearWorkSummaryResponse) {
|
||||
function normalizePuzzleClearThemeTags(
|
||||
summary: PuzzleClearWorkSummaryResponse,
|
||||
) {
|
||||
const themePrompt = summary.themePrompt.trim();
|
||||
return [
|
||||
'拼消消',
|
||||
...(themePrompt ? [themePrompt] : []),
|
||||
];
|
||||
return ['拼消消', ...(themePrompt ? [themePrompt] : [])];
|
||||
}
|
||||
|
||||
function getPuzzleClearRecentPlayCount(
|
||||
@@ -1094,7 +1093,9 @@ export function describePlatformPublicWorkKind(
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
return formatPlatformWorkDisplayTag(
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePlatformWorkAuthorDisplayName(
|
||||
@@ -1195,7 +1196,9 @@ export function describePublicGalleryCardKind(
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return formatPlatformWorkDisplayTag(entry.templateName);
|
||||
}
|
||||
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||||
return formatPlatformWorkDisplayTag(
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
|
||||
@@ -4,9 +4,7 @@ import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
getRpgCreationOperation,
|
||||
} from '../../services/rpg-creation';
|
||||
import { getRpgCreationOperation } from '../../services/rpg-creation';
|
||||
import {
|
||||
createFailedAgentOperation,
|
||||
resolveRpgCreationErrorMessage,
|
||||
@@ -91,7 +89,10 @@ export function useRpgCreationAgentOperationPolling(
|
||||
createFailedAgentOperation({
|
||||
type: 'process_message',
|
||||
phaseLabel: '读取操作状态失败',
|
||||
error: resolveRpgCreationErrorMessage(error, '读取共创操作状态失败。'),
|
||||
error: resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'读取共创操作状态失败。',
|
||||
),
|
||||
}),
|
||||
);
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
|
||||
@@ -270,9 +270,9 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
expect(executePublishWorld).toHaveBeenCalledTimes(1);
|
||||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
|
||||
).toBe('draft-profile');
|
||||
expect(setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id).toBe(
|
||||
'draft-profile',
|
||||
);
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
|
||||
?.imageSrc,
|
||||
@@ -341,7 +341,6 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => {
|
||||
const resultProfile = {
|
||||
...buildProfile({
|
||||
@@ -357,7 +356,8 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
openingCg: {
|
||||
id: 'opening-cg-stardust',
|
||||
status: 'ready' as const,
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||||
storyboardImageSrc:
|
||||
'/generated-custom-world-scenes/opening/storyboard.png',
|
||||
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||||
imageModel: 'gpt-image-2' as const,
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
@@ -468,8 +468,7 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
'/generated-characters/draft-role/portrait.png',
|
||||
);
|
||||
expect(
|
||||
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]
|
||||
?.backgroundImageSrc,
|
||||
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc,
|
||||
).toBe('/assets/custom-world/act-stardust-opening.png');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,10 @@ type UseRpgCreationEnterWorldParams = {
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
syncAgentDraftResultProfile: (
|
||||
profile: CustomWorldProfile,
|
||||
) => Promise<{ profile: CustomWorldProfile | null; view?: RpgCreationResultView | null }>;
|
||||
syncAgentDraftResultProfile: (profile: CustomWorldProfile) => Promise<{
|
||||
profile: CustomWorldProfile | null;
|
||||
view?: RpgCreationResultView | null;
|
||||
}>;
|
||||
executePublishWorld: () => Promise<unknown>;
|
||||
syncAgentCreationResultView: (
|
||||
sessionId: string,
|
||||
@@ -82,7 +83,8 @@ export function useRpgCreationEnterWorld(
|
||||
}
|
||||
|
||||
if (currentAgentSessionStage === 'published') {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const latestView =
|
||||
await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
|
||||
generatedCustomWorldProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
@@ -111,7 +113,8 @@ export function useRpgCreationEnterWorld(
|
||||
syncedResult.view.canEnterWorld;
|
||||
|
||||
if (canEnterPublishedWorld) {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const latestView =
|
||||
await syncAgentCreationResultView(activeAgentSessionId);
|
||||
return chooseMoreCompleteCustomWorldProfile(
|
||||
latestProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
|
||||
@@ -182,9 +182,8 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const setCustomWorldResultViewSource = vi.fn();
|
||||
const suppressAgentDraftResultAutoOpen = vi.fn();
|
||||
let openWork:
|
||||
| ((work: CustomWorldWorkSummary) => Promise<void>)
|
||||
| null = null;
|
||||
let openWork: ((work: CustomWorldWorkSummary) => Promise<void>) | null =
|
||||
null;
|
||||
|
||||
function Harness() {
|
||||
openWork = useRpgEntryLibraryDetail({
|
||||
@@ -310,9 +309,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
schemaId: 'schema-星砂废都',
|
||||
values: { axis_a: 8 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
evidence: [{ slotId: 'axis_a', reason: '能听见星砂潮汐。' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -393,9 +390,10 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
|
||||
|
||||
function Harness() {
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(detailEntry);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(
|
||||
detailEntry,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (selectedDetailEntry) {
|
||||
selectedEntries.push(selectedDetailEntry);
|
||||
@@ -442,10 +440,12 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy();
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc,
|
||||
).toBe('/assets/custom-world/items/sand-compass.png');
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.attributeProfile,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0]
|
||||
?.backgroundImageSrc,
|
||||
@@ -503,9 +503,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
schemaId: 'schema-星砂废都',
|
||||
values: { axis_a: 8 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
evidence: [{ slotId: 'axis_a', reason: '能听见星砂潮汐。' }],
|
||||
},
|
||||
} satisfies CustomWorldProfile['playableNpcs'][number];
|
||||
const fullProfile: CustomWorldProfile = {
|
||||
@@ -547,9 +545,10 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
|
||||
|
||||
function Harness() {
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
|
||||
CustomWorldLibraryEntry<CustomWorldProfile> | null
|
||||
>(detailEntry);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(
|
||||
detailEntry,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (selectedDetailEntry) {
|
||||
selectedEntries.push(selectedDetailEntry);
|
||||
@@ -596,12 +595,12 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
|
||||
?.previewVideoPath,
|
||||
).toBe('/assets/custom-world/shamian/skill.mp4');
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
|
||||
'/assets/custom-world/items/sand-compass.png',
|
||||
);
|
||||
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual(
|
||||
{ axis_a: 8 },
|
||||
);
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc,
|
||||
).toBe('/assets/custom-world/items/sand-compass.png');
|
||||
expect(
|
||||
lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values,
|
||||
).toEqual({ axis_a: 8 });
|
||||
});
|
||||
|
||||
it('详情删除由上层确认弹窗触发,hook 不再调用原生 confirm', async () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
@@ -38,16 +36,12 @@ type UseRpgEntryBootstrapParams = {
|
||||
getProfileDashboard: (
|
||||
options?: RuntimeRequestOptions,
|
||||
) => Promise<ProfileDashboardSummary | null>;
|
||||
handleContinueGame: (
|
||||
snapshot?: HydratedSavedGameSnapshot | null,
|
||||
) => void;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
hasInitialAgentSession: boolean;
|
||||
initialPlatformTab?: PlatformHomeTab;
|
||||
};
|
||||
|
||||
export function useRpgEntryBootstrap(
|
||||
params: UseRpgEntryBootstrapParams,
|
||||
) {
|
||||
export function useRpgEntryBootstrap(params: UseRpgEntryBootstrapParams) {
|
||||
const {
|
||||
user,
|
||||
canAccessProtectedData = Boolean(user),
|
||||
@@ -76,7 +70,9 @@ export function useRpgEntryBootstrap(
|
||||
const [historyEntries, setHistoryEntries] = useState<
|
||||
PlatformBrowseHistoryEntry[]
|
||||
>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
|
||||
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>(
|
||||
[],
|
||||
);
|
||||
const [platformTab, setPlatformTabState] =
|
||||
useState<PlatformHomeTab>(initialPlatformTab);
|
||||
const [platformError, setPlatformError] = useState<string | null>(null);
|
||||
@@ -204,7 +200,9 @@ export function useRpgEntryBootstrap(
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const resumedArchive = await resumeRpgProfileSaveArchive(entry.worldKey);
|
||||
const resumedArchive = await resumeRpgProfileSaveArchive(
|
||||
entry.worldKey,
|
||||
);
|
||||
setSaveEntries((currentEntries) =>
|
||||
currentEntries.map((currentEntry) =>
|
||||
currentEntry.worldKey === resumedArchive.entry.worldKey
|
||||
@@ -242,9 +240,7 @@ export function useRpgEntryBootstrap(
|
||||
setSaveError(null);
|
||||
setIsLoadingPlatform(shouldShowBlockingLoading);
|
||||
setPlatformError(null);
|
||||
setIsLoadingDashboard(
|
||||
canReadProtectedData && shouldShowBlockingLoading,
|
||||
);
|
||||
setIsLoadingDashboard(canReadProtectedData && shouldShowBlockingLoading);
|
||||
setDashboardError(null);
|
||||
if (!canReadProtectedData) {
|
||||
setSavedCustomWorldEntries([]);
|
||||
@@ -307,8 +303,7 @@ export function useRpgEntryBootstrap(
|
||||
if (
|
||||
(canReadProtectedData &&
|
||||
libraryEntriesResult.status === 'rejected') ||
|
||||
(canReadProtectedData &&
|
||||
workEntriesResult.status === 'rejected')
|
||||
(canReadProtectedData && workEntriesResult.status === 'rejected')
|
||||
) {
|
||||
const platformFailure =
|
||||
libraryEntriesResult.status === 'rejected'
|
||||
@@ -337,7 +332,10 @@ export function useRpgEntryBootstrap(
|
||||
setHistoryEntries(historyResult.value);
|
||||
} else if (canReadProtectedData) {
|
||||
setHistoryError(
|
||||
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
|
||||
resolveRpgEntryErrorMessage(
|
||||
historyResult.reason,
|
||||
'读取浏览历史失败。',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ type UseRpgEntryCharacterSelectParams = {
|
||||
gameState: GameState;
|
||||
handleBackToWorldSelect: () => void;
|
||||
setSelectionStage: (stage: 'platform') => void;
|
||||
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
|
||||
handleCharacterSelect: (
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,7 @@ type UseRpgEntryNavigationParams = {
|
||||
/**
|
||||
* 收口 RPG 入口壳层的阶段跳转,避免页面内散落大量匿名 setState 回调。
|
||||
*/
|
||||
export function useRpgEntryNavigation(
|
||||
params: UseRpgEntryNavigationParams,
|
||||
) {
|
||||
export function useRpgEntryNavigation(params: UseRpgEntryNavigationParams) {
|
||||
const { setSelectionStage, setSelectedDetailEntry } = params;
|
||||
|
||||
const backToPlatformHome = useCallback(() => {
|
||||
|
||||
@@ -10,9 +10,7 @@ type UseRpgEntrySaveResumeParams = {
|
||||
* RPG 入口域里的“继续游戏”入口只负责转发恢复动作,
|
||||
* 让壳层组件不直接知道具体的存档恢复实现细节。
|
||||
*/
|
||||
export function useRpgEntrySaveResume(
|
||||
params: UseRpgEntrySaveResumeParams,
|
||||
) {
|
||||
export function useRpgEntrySaveResume(params: UseRpgEntrySaveResumeParams) {
|
||||
const { handleResumeSaveEntry } = params;
|
||||
|
||||
const resumeSelectedSave = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user