Simplify custom world result editing controls
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user