Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -25,6 +25,7 @@ import {
import {
acceptQuest,
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
applyQuestProgressFromSpar,
buildQuestAcceptResultText,
buildQuestForEncounter,
@@ -456,8 +457,13 @@ export function createStoryNpcEncounterActions({
]
: provisionalHistory
: appendHistory(gameState, actionText, finalDialogueText);
const progressedQuests = applyQuestProgressFromNpcTalk(
nextState.quests,
encounter.id ?? encounter.npcName,
);
const finalState = {
...nextState,
quests: progressedQuests,
storyHistory: finalHistory,
};
const finalOpeningCampContext = buildOpeningCampChatContext(

View File

@@ -3,6 +3,11 @@ import type {
SetStateAction,
} from 'react';
import {
acceptQuest,
buildChapterQuestForScene,
getChapterQuestForScene,
} from '../../data/questFlow';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
@@ -176,6 +181,67 @@ function findNewInventoryItems(previousState: GameState, nextState: GameState) {
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
}
function ensureSceneChapterQuestState(params: {
previousState: GameState;
nextState: GameState;
}) {
const storyEngineMemory =
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset;
if (
params.nextState.currentScene !== 'Story'
|| !params.nextState.worldType
|| !scene?.id
) {
return {
...params.nextState,
storyEngineMemory,
};
}
const openedSceneChapterIds = dedupeStrings([
...(storyEngineMemory.openedSceneChapterIds ?? []),
], 64);
if (openedSceneChapterIds.includes(scene.id)) {
return {
...params.nextState,
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
},
};
}
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
};
const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id);
if (existingChapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
const chapterQuest = buildChapterQuestForScene({
scene,
worldType: params.nextState.worldType,
});
if (!chapterQuest) {
return {
...params.nextState,
storyEngineMemory: nextMemory,
};
}
return {
...params.nextState,
storyEngineMemory: nextMemory,
quests: acceptQuest(params.nextState.quests, chapterQuest),
};
}
function applyStoryEngineEchoes(params: {
previousState: GameState;
nextState: GameState;
@@ -200,13 +266,17 @@ function applyStoryEngineEchoes(params: {
signals,
contracts,
});
const stateWithSceneChapter = ensureSceneChapterQuestState({
previousState: params.previousState,
nextState: stateWithSignals,
});
const reactions = buildCompanionReactionBatch({
state: stateWithSignals,
state: stateWithSceneChapter,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSignals,
state: stateWithSceneChapter,
reactions,
});
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();

View File

@@ -56,6 +56,7 @@ function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
issuerNpcId: 'npc-trader',
issuerNpcName: 'Trader Lin',
sceneId: 'scene-1',
chapterId: 'chapter:scene:scene-1',
title: 'Deliver the cache',
description: 'Deliver the cache safely.',
summary: 'Help Trader Lin recover the cache.',
@@ -174,4 +175,75 @@ describe('sessionActions', () => {
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
expect(rewardClaim).toHaveProperty('handoff');
});
it('refreshes chapter state after a chapter quest is turned in', () => {
const baseState = {
...createBaseState(),
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '断桥边风声未散。',
imageSrc: '/scene-1.png',
treasureHints: [],
npcs: [],
},
chapterState: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
storyEngineMemory: {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: ['scene-1'],
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: {
id: 'chapter:scene:scene-1',
title: '断桥旧哨·高潮',
theme: '回报遗迹调查',
primaryThreadIds: [],
stage: 'climax' as const,
chapterSummary: '当前章节已逼近最后收束。',
sceneId: 'scene-1',
chapterQuestId: 'quest-1',
},
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
},
} satisfies GameState;
const rewardClaim = applyQuestRewardClaim(baseState, 'quest-1');
expect(rewardClaim).not.toBeNull();
if (!rewardClaim) {
throw new Error('Expected reward claim result');
}
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
});
});

View File

@@ -9,8 +9,13 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import {
advanceChapterState,
resolveCurrentChapterState,
} from '../../services/storyEngine/chapterDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
GameState,
StoryMoment,
@@ -63,10 +68,29 @@ export function applyQuestRewardClaim(
}
: state.npcStates,
}, quest.reward.items);
const chapterState = advanceChapterState({
previousChapter:
nextState.chapterState
?? nextState.storyEngineMemory?.currentChapter
?? null,
nextChapter: resolveCurrentChapterState({
state: nextState,
}),
});
const storyEngineMemory =
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const synchronizedNextState: GameState = {
...nextState,
chapterState,
storyEngineMemory: {
...storyEngineMemory,
currentChapter: chapterState,
},
};
return {
nextState,
handoff: buildGoalHandoffFromState(nextState),
nextState: synchronizedNextState,
handoff: buildGoalHandoffFromState(synchronizedNextState),
};
}

View File

@@ -7,6 +7,9 @@ import {
NPC_TRADE_FUNCTION,
shouldNpcRecruitOpenModal,
} from '../../data/functionCatalog';
import {
applyQuestProgressFromSceneReached,
} from '../../data/questFlow';
import {
buildInitialNpcState,
getPreferredGiftItemId,
@@ -139,6 +142,7 @@ export function buildMapTravelResolution(
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
scenesTraveled: 1,
}),
quests: applyQuestProgressFromSceneReached(state.quests, targetScene.id),
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,

View File

@@ -120,11 +120,11 @@ export function useGameFlow() {
setGameState(createInitialGameState());
};
const handleWorldSelect = (type: WorldType, customWorldProfile: CustomWorldProfile | null = null) => {
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
const resolvedWorldType = WorldType.CUSTOM;
setRuntimeCustomWorldProfile(customWorldProfile);
setRuntimeCharacterOverrides(
customWorldProfile ? buildCustomWorldRuntimeCharacters(customWorldProfile) : null,
buildCustomWorldRuntimeCharacters(customWorldProfile),
);
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false);
@@ -217,7 +217,10 @@ export function useGameFlow() {
playerSkillCooldowns: createCharacterSkillCooldowns(character),
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(gameState.worldType),
playerCurrency: getInitialPlayerCurrency(
gameState.worldType,
gameState.customWorldProfile,
),
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: initialEncounter && initialNpcState
@@ -248,7 +251,7 @@ export function useGameFlow() {
isMapOpen,
setIsMapOpen,
resetGame,
handleWorldSelect,
handleCustomWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
};

View File

@@ -18,7 +18,7 @@ import {
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { GameState, StoryMoment, WorldType } from '../types';
import { BottomTab } from './useGameFlow';
const AUTO_SAVE_DELAY_MS = 400;
@@ -31,6 +31,14 @@ function normalizeSavedStory(story: StoryMoment | null) {
} satisfies StoryMoment;
}
function isPlayableSavedGameState(gameState: GameState | null | undefined) {
return Boolean(
gameState
&& gameState.worldType === WorldType.CUSTOM
&& gameState.customWorldProfile,
);
}
function normalizeCharacterChats(gameState: GameState) {
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
characterId,
@@ -100,7 +108,10 @@ function normalizeSavedGameState(gameState: GameState) {
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency
: getInitialPlayerCurrency(gameState.worldType),
: getInitialPlayerCurrency(
gameState.worldType,
normalizedEncounterState.customWorldProfile,
),
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
roster: normalizedRoster,
npcStates: Object.fromEntries(
@@ -171,7 +182,8 @@ export function useGamePersistence({
const [hasSavedGame, setHasSavedGame] = useState(false);
useEffect(() => {
setHasSavedGame(Boolean(readSavedSnapshot()));
const snapshot = readSavedSnapshot();
setHasSavedGame(isPlayableSavedGameState(snapshot?.gameState ?? null));
}, []);
useEffect(() => {
@@ -187,7 +199,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(gameState));
}
}, AUTO_SAVE_DELAY_MS);
@@ -214,7 +226,7 @@ export function useGamePersistence({
});
if (didSave) {
setHasSavedGame(true);
setHasSavedGame(isPlayableSavedGameState(nextGameState));
}
return didSave;
@@ -232,6 +244,11 @@ export function useGamePersistence({
return false;
}
if (!isPlayableSavedGameState(snapshot.gameState)) {
clearSavedGame();
return false;
}
resetStoryState();
setGameState(normalizeSavedGameState(snapshot.gameState));
setBottomTab(snapshot.bottomTab ?? 'adventure');

View File

@@ -639,6 +639,7 @@ function buildStoryContextFromState(
const goalStack = buildGoalStackState({
quests: state.quests,
worldType: state.worldType,
currentSceneId: state.currentScenePreset?.id ?? null,
chapterState,
journeyBeat,
setpieceDirective,
@@ -1865,6 +1866,7 @@ export function useStoryGeneration({
buildGoalStackState({
quests: gameState.quests,
worldType: gameState.worldType,
currentSceneId: gameState.currentScenePreset?.id ?? null,
chapterState:
gameState.chapterState
?? gameState.storyEngineMemory?.currentChapter
@@ -1878,6 +1880,7 @@ export function useStoryGeneration({
}),
[
gameState.chapterState,
gameState.currentScenePreset?.id,
gameState.currentScenePreset?.name,
gameState.quests,
gameState.storyEngineMemory?.currentCampEvent,