Simplify custom world result editing controls
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState } from '../../types';
|
||||
import { buildChapterQuestForScene } from '../../data/questFlow';
|
||||
import { AnimationState, type GameState, WorldType } from '../../types';
|
||||
import { advanceChapterState, resolveCurrentChapterState } from './chapterDirector';
|
||||
|
||||
function createState(signalCount: number): GameState {
|
||||
@@ -73,6 +74,54 @@ function createState(signalCount: number): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function createSceneChapterState() {
|
||||
const quest = buildChapterQuestForScene({
|
||||
scene: {
|
||||
id: 'scene-court',
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分。',
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-maid',
|
||||
name: '旧宫侍女',
|
||||
description: '她总知道哪条回廊最近不该过去。',
|
||||
avatar: '侍',
|
||||
role: '宫人',
|
||||
hostile: false,
|
||||
},
|
||||
{
|
||||
id: 'hostile-shadow',
|
||||
name: '旧宫戍影',
|
||||
description: '巡行在回廊里的敌影。',
|
||||
avatar: '戍',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'monster-11',
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
treasureHints: ['回廊暗格里的香囊'],
|
||||
},
|
||||
worldType: WorldType.WUXIA,
|
||||
});
|
||||
|
||||
if (!quest) {
|
||||
throw new Error('Expected chapter quest');
|
||||
}
|
||||
|
||||
return {
|
||||
...createState(0),
|
||||
currentScenePreset: {
|
||||
id: 'scene-court',
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分。',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: ['回廊暗格里的香囊'],
|
||||
npcs: [],
|
||||
},
|
||||
quests: [quest],
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
describe('chapterDirector', () => {
|
||||
it('resolves chapter stages from signal intensity', () => {
|
||||
expect(resolveCurrentChapterState({ state: createState(1) }).stage).toBe('opening');
|
||||
@@ -89,4 +138,58 @@ describe('chapterDirector', () => {
|
||||
|
||||
expect(next.id).toBe(previous.id);
|
||||
});
|
||||
|
||||
it('binds the current chapter to the current scene chapter quest', () => {
|
||||
const openingState = createSceneChapterState();
|
||||
const openingChapter = resolveCurrentChapterState({ state: openingState });
|
||||
expect(openingChapter.id).toBe('chapter:scene:scene-court');
|
||||
expect(openingChapter.sceneId).toBe('scene-court');
|
||||
expect(openingChapter.chapterQuestId).toBe('quest:chapter:scene-court');
|
||||
expect(openingChapter.stage).toBe('opening');
|
||||
|
||||
const turningState: GameState = {
|
||||
...openingState,
|
||||
quests: [
|
||||
{
|
||||
...openingState.quests[0]!,
|
||||
steps: openingState.quests[0]!.steps?.map((step) =>
|
||||
step.id === 'step_scene_opening'
|
||||
? { ...step, progress: step.requiredCount }
|
||||
: step.id === 'step_scene_pressure'
|
||||
? { ...step, progress: step.requiredCount }
|
||||
: step,
|
||||
),
|
||||
activeStepId: 'step_scene_turning',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: turningState }).stage).toBe('turning_point');
|
||||
|
||||
const climaxState: GameState = {
|
||||
...turningState,
|
||||
quests: [
|
||||
{
|
||||
...turningState.quests[0]!,
|
||||
steps: turningState.quests[0]!.steps?.map((step) => ({
|
||||
...step,
|
||||
progress: step.requiredCount,
|
||||
})),
|
||||
activeStepId: null,
|
||||
status: 'ready_to_turn_in',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: climaxState }).stage).toBe('climax');
|
||||
|
||||
const aftermathState: GameState = {
|
||||
...climaxState,
|
||||
quests: [
|
||||
{
|
||||
...climaxState.quests[0]!,
|
||||
status: 'turned_in',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(resolveCurrentChapterState({ state: aftermathState }).stage).toBe('aftermath');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChapterState, CustomWorldProfile, GameState } from '../../types';
|
||||
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import type { ChapterState, CustomWorldProfile, GameState, QuestLogEntry } from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 4) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
@@ -26,6 +27,86 @@ function resolveChapterTheme(profile: CustomWorldProfile | null | undefined, pri
|
||||
return profile?.themePack?.displayName ?? profile?.summary ?? '旅程推进';
|
||||
}
|
||||
|
||||
function getStageLabel(stage: ChapterState['stage']) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return '序章';
|
||||
case 'expansion':
|
||||
return '展开';
|
||||
case 'turning_point':
|
||||
return '转折';
|
||||
case 'climax':
|
||||
return '高潮';
|
||||
case 'aftermath':
|
||||
return '余波';
|
||||
default:
|
||||
return '推进';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSceneChapterQuest(state: GameState) {
|
||||
const sceneId = state.currentScenePreset?.id;
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chapterId = buildSceneChapterId(sceneId);
|
||||
return state.quests.find((quest) =>
|
||||
quest.chapterId === chapterId
|
||||
&& quest.status !== 'failed'
|
||||
&& quest.status !== 'expired',
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function deriveChapterStageFromQuest(quest: QuestLogEntry): ChapterState['stage'] {
|
||||
if (quest.status === 'turned_in') {
|
||||
return 'aftermath';
|
||||
}
|
||||
|
||||
if (isQuestReadyToClaim(quest)) {
|
||||
return 'climax';
|
||||
}
|
||||
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
const activeStepIndex = activeStep
|
||||
? Math.max(0, quest.steps?.findIndex((step) => step.id === activeStep.id) ?? 0)
|
||||
: -1;
|
||||
|
||||
if (activeStepIndex <= 0) {
|
||||
return 'opening';
|
||||
}
|
||||
|
||||
if (activeStepIndex === 1) {
|
||||
return 'expansion';
|
||||
}
|
||||
|
||||
return 'turning_point';
|
||||
}
|
||||
|
||||
function buildSceneChapterSummary(params: {
|
||||
sceneName: string;
|
||||
quest: QuestLogEntry;
|
||||
stage: ChapterState['stage'];
|
||||
}) {
|
||||
const {sceneName, quest, stage} = params;
|
||||
const activeStep = getQuestActiveStep(quest);
|
||||
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return `${sceneName} 的这一章刚刚开启。${activeStep?.revealText ?? quest.description}`;
|
||||
case 'expansion':
|
||||
return `${sceneName} 的压力正在展开。${activeStep?.revealText ?? quest.summary}`;
|
||||
case 'turning_point':
|
||||
return `${sceneName} 的线索正在改写当前判断。${activeStep?.revealText ?? quest.summary}`;
|
||||
case 'climax':
|
||||
return `${sceneName} 的核心矛盾已经被推到最后一步,只差把这一章正式收束。`;
|
||||
case 'aftermath':
|
||||
return `${sceneName} 这一章已经完成收束,余波和下一段去向正在显形。`;
|
||||
default:
|
||||
return `${sceneName} 的这一章仍在推进中。`;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCurrentChapterState(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
@@ -39,6 +120,32 @@ export function resolveCurrentChapterState(params: {
|
||||
);
|
||||
const signalCount = storyEngineMemory?.recentSignalIds?.length ?? 0;
|
||||
const chronicleCount = storyEngineMemory?.chronicle?.length ?? 0;
|
||||
const sceneChapterQuest = resolveSceneChapterQuest(state);
|
||||
const currentSceneId = state.currentScenePreset?.id ?? null;
|
||||
const currentSceneName = state.currentScenePreset?.name ?? '当前区域';
|
||||
|
||||
if (sceneChapterQuest && currentSceneId) {
|
||||
const stage = deriveChapterStageFromQuest(sceneChapterQuest);
|
||||
const theme = sceneChapterQuest.title || resolveChapterTheme(profile, threadTitles);
|
||||
return {
|
||||
id: buildSceneChapterId(currentSceneId),
|
||||
title: `${currentSceneName}·${getStageLabel(stage)}`,
|
||||
theme,
|
||||
primaryThreadIds: dedupeStrings([
|
||||
sceneChapterQuest.threadId,
|
||||
...activeThreadIds,
|
||||
], 3),
|
||||
stage,
|
||||
chapterSummary: buildSceneChapterSummary({
|
||||
sceneName: currentSceneName,
|
||||
quest: sceneChapterQuest,
|
||||
stage,
|
||||
}),
|
||||
sceneId: currentSceneId,
|
||||
chapterQuestId: sceneChapterQuest.id,
|
||||
} satisfies ChapterState;
|
||||
}
|
||||
|
||||
const stage = resolveChapterStage({
|
||||
signalCount,
|
||||
chronicleCount,
|
||||
@@ -46,15 +153,7 @@ export function resolveCurrentChapterState(params: {
|
||||
currentStage: state.chapterState?.stage ?? storyEngineMemory?.currentChapter?.stage ?? null,
|
||||
});
|
||||
const theme = resolveChapterTheme(profile, threadTitles);
|
||||
const title = `${theme || '旅程'}·${stage === 'opening'
|
||||
? '序章'
|
||||
: stage === 'expansion'
|
||||
? '展开'
|
||||
: stage === 'turning_point'
|
||||
? '转折'
|
||||
: stage === 'climax'
|
||||
? '高潮'
|
||||
: '余波'}`;
|
||||
const title = `${theme || '旅程'}·${getStageLabel(stage)}`;
|
||||
|
||||
return {
|
||||
id: `chapter:${dedupeStrings(activeThreadIds, 2).join('+') || 'default'}:${stage}`,
|
||||
@@ -63,6 +162,8 @@ export function resolveCurrentChapterState(params: {
|
||||
primaryThreadIds: dedupeStrings(activeThreadIds, 3),
|
||||
stage,
|
||||
chapterSummary: `${title} 当前围绕 ${theme || '旅程主线'} 推进。`,
|
||||
sceneId: null,
|
||||
chapterQuestId: null,
|
||||
} satisfies ChapterState;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function createQuest(overrides: Partial<QuestLogEntry> & Pick<QuestLogEntry, 'id
|
||||
issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`,
|
||||
issuerNpcName: overrides.issuerNpcName ?? '林朔',
|
||||
sceneId: overrides.sceneId ?? 'scene-ruins',
|
||||
chapterId: overrides.chapterId ?? null,
|
||||
title: overrides.title,
|
||||
description: overrides.description ?? `${overrides.title} 的说明`,
|
||||
summary: overrides.summary ?? `${overrides.title} 的摘要`,
|
||||
@@ -181,6 +182,31 @@ describe('goalDirector', () => {
|
||||
expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进');
|
||||
});
|
||||
|
||||
it('prefers the current scene chapter quest over unrelated ready quests', () => {
|
||||
const currentSceneQuest = createQuest({
|
||||
id: 'quest-chapter-scene-court',
|
||||
title: '查明宫苑内庭',
|
||||
sceneId: 'scene-court',
|
||||
chapterId: 'chapter:scene:scene-court',
|
||||
status: 'active',
|
||||
});
|
||||
const unrelatedReadyQuest = createQuest({
|
||||
id: 'quest-ready-other',
|
||||
title: '回报断桥调查',
|
||||
sceneId: 'scene-bridge',
|
||||
status: 'ready_to_turn_in',
|
||||
});
|
||||
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: [unrelatedReadyQuest, currentSceneQuest],
|
||||
worldType: null,
|
||||
currentSceneId: 'scene-court',
|
||||
currentSceneName: '宫苑内庭',
|
||||
});
|
||||
|
||||
expect(goalStack.activeGoal?.sourceId).toBe('quest-chapter-scene-court');
|
||||
});
|
||||
|
||||
it('annotates options with advance/support affordances and builds quest reward handoff', () => {
|
||||
const readyQuest = createQuest({
|
||||
id: 'quest-ready',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isContinueAdventureOption } from '../../data/functionCatalog';
|
||||
import { getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import { buildSceneChapterId, getQuestActiveStep, isQuestReadyToClaim } from '../../data/questFlow';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
import type {
|
||||
CampEvent,
|
||||
@@ -466,12 +466,20 @@ function buildCampEventSupportGoal(currentCampEvent: CampEvent) {
|
||||
} satisfies GoalStackEntry;
|
||||
}
|
||||
|
||||
function resolvePrimaryQuest(quests: QuestLogEntry[]) {
|
||||
function resolvePrimaryQuest(quests: QuestLogEntry[], currentSceneId?: string | null) {
|
||||
const liveQuests = quests.filter(isLiveQuest);
|
||||
if (liveQuests.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentSceneChapterId = currentSceneId ? buildSceneChapterId(currentSceneId) : null;
|
||||
const currentSceneChapterQuest = currentSceneChapterId
|
||||
? liveQuests.find((quest) => quest.chapterId === currentSceneChapterId) ?? null
|
||||
: null;
|
||||
if (currentSceneChapterQuest) {
|
||||
return currentSceneChapterQuest;
|
||||
}
|
||||
|
||||
return liveQuests.find((quest) => isQuestReadyToClaim(quest))
|
||||
?? liveQuests.find((quest) => quest.status === 'active')
|
||||
?? liveQuests.find((quest) => quest.status === 'discovered')
|
||||
@@ -486,6 +494,7 @@ export function buildGoalStackState(params: {
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
}) {
|
||||
const {
|
||||
@@ -495,9 +504,10 @@ export function buildGoalStackState(params: {
|
||||
journeyBeat = null,
|
||||
setpieceDirective = null,
|
||||
currentCampEvent = null,
|
||||
currentSceneId = null,
|
||||
currentSceneName = null,
|
||||
} = params;
|
||||
const primaryQuest = resolvePrimaryQuest(quests);
|
||||
const primaryQuest = resolvePrimaryQuest(quests, currentSceneId);
|
||||
const northStarGoal = setpieceDirective
|
||||
? buildSetpieceNorthStarGoal(setpieceDirective)
|
||||
: chapterState
|
||||
@@ -766,6 +776,7 @@ export function buildGoalHandoffFromState(state: GameState): GoalHandoff | null
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState: state.chapterState ?? state.storyEngineMemory?.currentChapter ?? null,
|
||||
journeyBeat: state.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective: state.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
|
||||
@@ -95,4 +95,34 @@ describe('storyChronicle', () => {
|
||||
expect(chronicle.length).toBeGreaterThan(0);
|
||||
expect(summary).toContain('封桥旧案·展开');
|
||||
});
|
||||
|
||||
it('dedupes unchanged chapter chronicle entries', () => {
|
||||
const chapterState = {
|
||||
id: 'chapter:scene:scene-court',
|
||||
title: '宫苑内庭·展开',
|
||||
theme: '宫苑旧案',
|
||||
primaryThreadIds: ['thread-court'],
|
||||
stage: 'expansion' as const,
|
||||
chapterSummary: '宫苑内庭的这一章正在展开。',
|
||||
sceneId: 'scene-court',
|
||||
chapterQuestId: 'quest:chapter:scene-court',
|
||||
};
|
||||
|
||||
const firstChronicle = appendChronicleEntries({
|
||||
state,
|
||||
chapterState,
|
||||
});
|
||||
const secondChronicle = appendChronicleEntries({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...state.storyEngineMemory!,
|
||||
chronicle: firstChronicle,
|
||||
},
|
||||
},
|
||||
chapterState,
|
||||
});
|
||||
|
||||
expect(secondChronicle.filter((entry) => entry.id === 'chronicle:chapter:chapter:scene:scene-court')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,18 @@ function createChronicleId(category: ChronicleEntry['category'], key: string) {
|
||||
return `chronicle:${category}:${key}`;
|
||||
}
|
||||
|
||||
function dedupeChronicleEntries(entries: ChronicleEntry[]) {
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
const signature = `${entry.id}::${entry.summary}`;
|
||||
if (seen.has(signature)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(signature);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function appendChronicleEntries(params: {
|
||||
state: GameState;
|
||||
chapterState?: ChapterState | null;
|
||||
@@ -80,7 +92,7 @@ export function appendChronicleEntries(params: {
|
||||
});
|
||||
}
|
||||
|
||||
return [...existing, ...additions].slice(-18);
|
||||
return dedupeChronicleEntries([...existing, ...additions]).slice(-18);
|
||||
}
|
||||
|
||||
export function buildChronicleSummary(state: GameState) {
|
||||
|
||||
@@ -119,6 +119,31 @@ function cloneThemePack(mode: string, preset: ThemePackPreset): ThemePack {
|
||||
};
|
||||
}
|
||||
|
||||
function collectSemanticAnchorLexicon(
|
||||
profile: Pick<CustomWorldProfile, 'ownedSettingLayers'>,
|
||||
) {
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
|
||||
if (!semanticAnchor && !expressionProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dedupeStrings([
|
||||
...(semanticAnchor?.genreSignals ?? []),
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
...(semanticAnchor?.forceSystemTypes ?? []),
|
||||
...(semanticAnchor?.atmosphereTags ?? []),
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
...(expressionProfile?.namingDirectives ?? []),
|
||||
...(expressionProfile?.clueDirectives ?? []),
|
||||
...(expressionProfile?.revealDirectives ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveThemeModeFromWorldType(
|
||||
worldType: WorldTemplateType | WorldType | null | undefined,
|
||||
) {
|
||||
@@ -180,30 +205,67 @@ export function buildThemePackFromWorldProfile(
|
||||
| 'templateWorldType'
|
||||
| 'majorFactions'
|
||||
| 'coreConflicts'
|
||||
| 'ownedSettingLayers'
|
||||
> & {
|
||||
templateWorldType: WorldTemplateType | WorldType;
|
||||
},
|
||||
) {
|
||||
const mode = detectCustomWorldThemeMode(profile);
|
||||
const base = cloneThemePack(mode, THEME_PACK_PRESETS[mode]!);
|
||||
const ownedThemePack = profile.ownedSettingLayers?.expressionProfile?.themePack;
|
||||
if (ownedThemePack) {
|
||||
return normalizeThemePack(ownedThemePack, base);
|
||||
}
|
||||
|
||||
const lexicon = collectProfileLexicon(profile);
|
||||
const semanticLexicon = collectSemanticAnchorLexicon(profile);
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
|
||||
return normalizeThemePack(
|
||||
{
|
||||
...base,
|
||||
institutionLexicon: dedupeStrings([
|
||||
...base.institutionLexicon,
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...lexicon.filter((item) => item.length >= 2),
|
||||
]),
|
||||
tabooLexicon: dedupeStrings([
|
||||
...base.tabooLexicon,
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(profile.coreConflicts ?? []).slice(0, 4),
|
||||
]),
|
||||
artifactClasses: dedupeStrings([
|
||||
...base.artifactClasses,
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
]),
|
||||
conflictForms: dedupeStrings([
|
||||
...base.conflictForms,
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(profile.coreConflicts ?? []).slice(0, 3),
|
||||
]),
|
||||
clueForms: dedupeStrings([
|
||||
...base.clueForms,
|
||||
...(expressionProfile?.clueDirectives ?? []),
|
||||
...(profile.majorFactions ?? []).slice(0, 3),
|
||||
]),
|
||||
toneRange: dedupeStrings([profile.tone, ...base.toneRange]),
|
||||
namingPatterns: dedupeStrings([
|
||||
...base.namingPatterns,
|
||||
...(expressionProfile?.namingDirectives ?? []),
|
||||
]),
|
||||
revealStyles: dedupeStrings([
|
||||
...base.revealStyles,
|
||||
...(expressionProfile?.revealDirectives ?? []),
|
||||
]),
|
||||
toneRange: dedupeStrings([
|
||||
profile.tone,
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
...base.toneRange,
|
||||
]),
|
||||
actorArchetypes: dedupeStrings([
|
||||
...base.actorArchetypes,
|
||||
...semanticLexicon.filter((item) => item.length >= 2),
|
||||
]),
|
||||
},
|
||||
base,
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: [],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: null,
|
||||
|
||||
Reference in New Issue
Block a user