Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

@@ -124,5 +124,53 @@ describe('buildBattlePlan', () => {
expect(plan.finalState.sceneMonsters).toEqual([]);
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
});
it('reuses sceneHostileNpcs when npc battle entry has not synced sceneMonsters yet', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight' as const,
sceneHostileNpcs: [
{
id: 'npc-opponent',
name: '山道客',
action: '摆开架势,随时准备出手',
description: '拦路的江湖客',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.8,
speed: 7,
hp: 12,
maxHp: 12,
renderKind: 'npc' as const,
encounter: {
id: 'npc-opponent',
kind: 'npc' as const,
npcName: '山道客',
npcDescription: '拦路的江湖客',
npcAvatar: '/npc.png',
context: '山道客',
xMeters: 3.2,
},
},
],
};
const plan = buildBattlePlan({
state,
option: createBattleOption(),
character: createTestCharacter(),
totalSequenceMs: 6000,
turnVisualMs: 820,
resetStageMs: 260,
minTurnCount: 6,
});
expect(plan.turns.length).toBeGreaterThan(0);
expect(plan.preparedState.sceneMonsters).toHaveLength(1);
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
});
});

View File

@@ -1,5 +1,12 @@
import { resolveRoleCombatStats } from '../../data/attributeCombat';
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
import { appendBuildBuffs, resolveCompanionOutgoingDamage, resolveMonsterOutgoingDamage, resolvePlayerOutgoingDamage, tickBuildBuffs } from '../../data/buildDamage';
import {
appendBuildBuffs,
resolveCompanionOutgoingDamageResult,
resolveMonsterOutgoingDamageResult,
resolvePlayerOutgoingDamageResult,
tickBuildBuffs,
} from '../../data/buildDamage';
import {
getSkillDelivery,
} from '../../data/characterCombat';
@@ -45,6 +52,7 @@ export type BattlePlanStep =
selectedSkillId: string | null;
appliedCooldowns: Record<string, number>;
damage: number;
criticalHit?: boolean;
defeated: boolean;
endsBattle: boolean;
delivery: CombatDelivery;
@@ -58,6 +66,7 @@ export type BattlePlanStep =
selectedSkillId: string | null;
appliedCooldowns: Record<string, number>;
damage: number;
criticalHit?: boolean;
defeated: boolean;
endsBattle: boolean;
delivery: CombatDelivery;
@@ -71,6 +80,7 @@ export type BattlePlanStep =
targetCompanionNpcId?: string;
targetX: number;
damage: number;
criticalHit?: boolean;
endsBattle: boolean;
selectedSkillId: string | null;
npcCharacterId: string | null;
@@ -165,7 +175,16 @@ function buildCombatTurnOrder(
actorTimings.set(getCombatActorKey('player'), {
actor: 'player',
nextAt: 0,
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(playerCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
cadence: 1400 / Math.max(
resolveRoleCombatStats(
resolveCharacterAttributeProfile(
playerCharacter,
state.worldType,
state.customWorldProfile,
),
).turnSpeed,
1,
),
});
state.companions
@@ -177,7 +196,16 @@ function buildCombatTurnOrder(
actor: 'companion',
id: companion.npcId,
nextAt: 0,
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(companionCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
cadence: 1400 / Math.max(
resolveRoleCombatStats(
resolveCharacterAttributeProfile(
companionCharacter,
state.worldType,
state.customWorldProfile,
),
).turnSpeed,
1,
),
});
});
@@ -321,15 +349,28 @@ export function buildBattlePlan({
resetStageMs: number;
minTurnCount: number;
}): BattlePlan {
const targetMonster = getClosestMonster(state.playerX, state.sceneMonsters);
const resolvedSceneMonsters =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
const battleState: GameState = {
...state,
sceneMonsters: resolvedSceneMonsters,
sceneHostileNpcs: resolvedSceneMonsters,
};
const targetMonster = getClosestMonster(
battleState.playerX,
battleState.sceneMonsters,
);
if (!targetMonster) {
return {
preparedState: state,
preparedState: battleState,
turns: [],
finalState: {
...state,
...battleState,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
companions: resetCompanionCombatPresentation(state.companions),
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
@@ -340,9 +381,16 @@ export function buildBattlePlan({
}
const functionEffect = getFunctionEffect(option.functionId);
const isNpcSpar = state.currentNpcBattleMode === 'spar';
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
const turnOrder = buildCombatTurnOrder(state, character, sequenceMs, turnVisualMs, resetStageMs, minTurnCount);
const turnOrder = buildCombatTurnOrder(
battleState,
character,
sequenceMs,
turnVisualMs,
resetStageMs,
minTurnCount,
);
const normalizedOption = normalizeSkillProbabilities(option, character);
const npcBattleResources = new Map<string, {
character: Character;
@@ -350,7 +398,7 @@ export function buildBattlePlan({
cooldowns: Record<string, number>;
}>();
state.sceneMonsters.forEach(monster => {
battleState.sceneMonsters.forEach(monster => {
const npcCharacterId = monster.encounter?.characterId ?? null;
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
if (!npcCharacter) return;
@@ -363,9 +411,16 @@ export function buildBattlePlan({
});
let simulatedState: GameState = {
...applyRecoveryEffectToState(state, character, option.functionId),
companions: resetCompanionCombatPresentation(state.companions),
sceneMonsters: resetCombatPresentation(state.sceneMonsters, state.playerX),
...applyRecoveryEffectToState(battleState, character, option.functionId),
companions: resetCompanionCombatPresentation(battleState.companions),
sceneMonsters: resetCombatPresentation(
battleState.sceneMonsters,
battleState.playerX,
),
sceneHostileNpcs: resetCombatPresentation(
battleState.sceneMonsters,
battleState.playerX,
),
activeCombatEffects: [],
playerActionMode: 'idle' as const,
currentNpcBattleOutcome: null,
@@ -373,7 +428,7 @@ export function buildBattlePlan({
const preparedState = simulatedState;
const turns: BattlePlanStep[] = [];
for (const turn of turnOrder) {
for (const [turnIndex, turn] of turnOrder.entries()) {
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
if (!currentTarget) break;
@@ -398,14 +453,16 @@ export function buildBattlePlan({
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damage = isNpcSpar
? 1
: resolvePlayerOutgoingDamage(
const damageResult = isNpcSpar
? null
: resolvePlayerOutgoingDamageResult(
simulatedState,
character,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
`${option.functionId}:player:${turnIndex}:${selectedSkill.id}:${currentTarget.id}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
@@ -460,6 +517,7 @@ export function buildBattlePlan({
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage,
criticalHit: damageResult?.isCritical ?? false,
defeated,
endsBattle: wouldEndSpar,
delivery,
@@ -513,15 +571,17 @@ export function buildBattlePlan({
...cooledDown,
[selectedSkill.id]: selectedSkill.cooldownTurns,
};
const damage = isNpcSpar
? 1
: resolveCompanionOutgoingDamage(
const damageResult = isNpcSpar
? null
: resolveCompanionOutgoingDamageResult(
companionCharacter,
selectedSkill.damage,
functionEffect.damageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:companion:${turnIndex}:${companion.npcId}:${selectedSkill.id}:${targetMonster.id}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
@@ -571,6 +631,7 @@ export function buildBattlePlan({
selectedSkillId: selectedSkill.id,
appliedCooldowns,
damage,
criticalHit: damageResult?.isCritical ?? false,
defeated,
endsBattle: wouldEndSpar,
delivery,
@@ -611,15 +672,17 @@ export function buildBattlePlan({
if (selectedSkill) {
const delivery = getSkillDelivery(selectedSkill);
const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX);
const damage = isNpcSpar
? 1
: resolveCompanionOutgoingDamage(
const damageResult = isNpcSpar
? null
: resolveCompanionOutgoingDamageResult(
npcCombatant.character,
selectedSkill.damage,
functionEffect.incomingDamageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:monster-skill:${turnIndex}:${actingMonster.id}:${selectedSkill.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
npcBattleResources.set(actingMonster.id, {
@@ -662,6 +725,7 @@ export function buildBattlePlan({
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
targetX,
damage,
criticalHit: damageResult?.isCritical ?? false,
endsBattle: wouldEndSpar,
selectedSkillId: selectedSkill.id,
npcCharacterId: npcCombatant.character.id,
@@ -672,15 +736,17 @@ export function buildBattlePlan({
}
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
const damage = isNpcSpar
? 1
: resolveMonsterOutgoingDamage(
const damageResult = isNpcSpar
? null
: resolveMonsterOutgoingDamageResult(
actingMonster,
9,
functionEffect.incomingDamageMultiplier ?? 1,
state.worldType,
state.customWorldProfile,
`${option.functionId}:monster:${turnIndex}:${actingMonster.id}:${randomTarget.kind}:${randomTarget.kind === 'companion' ? randomTarget.npcId : 'player'}`,
);
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
@@ -714,6 +780,7 @@ export function buildBattlePlan({
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
targetX,
damage,
criticalHit: damageResult?.isCritical ?? false,
endsBattle: wouldEndSpar,
selectedSkillId: null,
npcCharacterId: null,
@@ -735,6 +802,10 @@ export function buildBattlePlan({
? false
: simulatedState.sceneMonsters.length > 0,
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneMonsters,
simulatedState.playerX,
),
},
};
}

View File

@@ -0,0 +1,322 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../services/ai', () => ({
generateNextStep: vi.fn(),
}));
import { generateNextStep } from '../../services/ai';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryChoiceActions } from './choiceActions';
function createTestCharacter(): Character {
return {
id: 'test-hero',
name: '测试主角',
title: '游侠',
description: '一名测试用主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [
{
id: 'skill-basic',
name: '试探一击',
animation: AnimationState.ATTACK,
damage: 10,
manaCost: 0,
cooldownTurns: 1,
range: 1,
style: 'steady',
},
],
adventureOpenings: {},
};
}
function createBaseState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createTestCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-opponent': {
affinity: 0,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-opponent',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createBattleOption(functionId = 'battle_all_in_crush'): StoryOption {
return {
functionId,
actionText: '挥刀抢攻',
text: '挥刀抢攻',
visuals: {
playerAnimation: AnimationState.ATTACK,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createFallbackStory(text = 'fallback'): StoryMoment {
return {
text,
options: [],
};
}
const neverNpcEncounter = (
encounter: GameState['currentEncounter'],
): encounter is Encounter => false;
describe('createStoryChoiceActions', () => {
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
const state = createBaseState();
const option = createBattleOption();
const afterSequence = {
...state,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState,
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'battle' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => ({
nextState: {
...afterSequence,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
inBattle: false,
},
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
})),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(generateStoryForState).toHaveBeenCalledTimes(1);
const [{ history }] = generateStoryForState.mock.calls[0] as [
{ history: StoryMoment[] },
];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:山道客已经败下阵来。胜利奖励:无战利品。',
]);
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写'));
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
const mockedGenerateNextStep = vi.mocked(generateNextStep);
mockedGenerateNextStep.mockResolvedValue({
storyText: '你落到山道外侧,呼吸总算稳了下来。',
options: [],
});
const state = {
...createBaseState(),
currentBattleNpcId: null,
currentNpcBattleMode: null,
sceneMonsters: [
{
id: 'wolf-1',
name: '山狼',
action: '低伏逼近',
description: '一头山狼',
animation: 'idle' as const,
xMeters: 3.2,
yOffset: 0,
facing: 'left' as const,
attackRange: 1.4,
speed: 7,
hp: 10,
maxHp: 10,
renderKind: 'npc' as const,
},
],
};
const option = createBattleOption('battle_escape_breakout');
const afterSequence = {
...state,
inBattle: false,
playerX: -1.2,
};
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory: createFallbackStory(),
isLoading: false,
setGameState: vi.fn(),
setCurrentStory: vi.fn(),
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(() => ({
optionKind: 'escape' as const,
battlePlan: null,
afterSequence,
})),
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
buildStoryContextFromState: vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
inBattle: false,
playerX: -1.2,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
})),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(option);
expect(mockedGenerateNextStep).toHaveBeenCalledTimes(1);
const history = mockedGenerateNextStep.mock.calls[0]?.[3] as StoryMoment[];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
]);
});
});

View File

@@ -15,6 +15,7 @@ import {
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
@@ -89,6 +90,51 @@ function buildReasonedOptionCatalog(options: StoryOption[]) {
});
}
function buildCombatResolutionContextText(params: {
baseState: GameState;
afterSequence: GameState;
optionKind: ResolvedChoiceState['optionKind'];
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
}) {
const {
baseState,
afterSequence,
optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
} = params;
if (optionKind === 'escape') {
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
return hostileNames
? `你已经摆脱与${hostileNames}的交战,暂时把对方甩在身后,当前不再处于战斗状态。`
: '你已经成功脱离刚才的交战,当前不再处于战斗状态。';
}
if (
!baseState.inBattle ||
afterSequence.inBattle ||
Boolean(baseState.currentBattleNpcId)
) {
return null;
}
const hostileNames = getResolvedSceneHostileNpcs(baseState)
.map((hostileNpc) => hostileNpc.name)
.join('、');
const lootText =
projectedBattleReward?.items.length
? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}`
: '';
return hostileNames
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
}
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
@@ -227,6 +273,7 @@ export function createStoryChoiceActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -251,6 +298,7 @@ export function createStoryChoiceActions({
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -389,6 +437,20 @@ export function createStoryChoiceActions({
projectedStateWithBattleReward,
character,
);
const combatResolutionContextText = buildCombatResolutionContextText({
baseState: baseChoiceState,
afterSequence: projectedStateWithBattleReward,
optionKind: resolvedChoice.optionKind,
projectedBattleReward,
getResolvedSceneHostileNpcs,
});
const historyForStoryGeneration = combatResolutionContextText
? [
...history,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(combatResolutionContextText, 'result'),
]
: history;
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
@@ -396,7 +458,7 @@ export function createStoryChoiceActions({
gameState.worldType,
character,
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
history,
historyForStoryGeneration,
option.actionText,
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
@@ -443,6 +505,7 @@ export function createStoryChoiceActions({
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
@@ -469,6 +532,8 @@ export function createStoryChoiceActions({
lastFunctionId: option.functionId,
optionCatalog: postBattleOptionCatalog,
});
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
@@ -489,11 +554,16 @@ export function createStoryChoiceActions({
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextHistory = combatResolutionContextText
? [
...historyForStoryGeneration,
createHistoryMoment(response.storyText, 'result', response.options),
]
: [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = incrementRuntimeStats({
...updateQuestLog(
@@ -515,14 +585,15 @@ export function createStoryChoiceActions({
hostileNpcsDefeated: defeatedHostileNpcIds.length,
});
setGameState(nextState);
const recoveredState = applyStoryReasoningRecovery(nextState);
setGameState(recoveredState);
if (projectedBattleReward) {
setBattleReward(projectedBattleReward);
}
setCurrentStory(
buildStoryFromResponse(
nextState,
recoveredState,
character,
{
text: response.storyText,

View File

@@ -6,6 +6,7 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
buildNpcHelpReward,
buildNpcLeaveResultText,
@@ -35,9 +36,11 @@ import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
@@ -60,6 +63,15 @@ type CommitGeneratedStateWithEncounterEntry = (
lastFunctionId?: string,
) => Promise<void> | void;
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type NpcInteractionFlowActions = {
openTradeModal: (encounter: Encounter, actionText: string) => void;
openGiftModal: (encounter: Encounter, actionText: string) => void;
@@ -108,6 +120,7 @@ export function createStoryNpcEncounterActions({
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
@@ -153,6 +166,7 @@ export function createStoryNpcEncounterActions({
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
@@ -218,6 +232,10 @@ export function createStoryNpcEncounterActions({
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
const activeBattleHostiles =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
@@ -233,6 +251,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneMonsters: [],
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
@@ -260,6 +279,7 @@ export function createStoryNpcEncounterActions({
return {
nextState,
resultText: buildNpcSparResultText(
activeBattleHostiles[0]?.name ?? '对方',
NPC_SPAR_AFFINITY_GAIN,
nextAffinity,
),
@@ -269,9 +289,9 @@ export function createStoryNpcEncounterActions({
const lootItems = getNpcLootItems(npcState, character).map((item) =>
cloneInventoryItemForOwner(item, 'player'),
);
const defeatedHostileNpcIds = (
state.sceneHostileNpcs ?? state.sceneMonsters
).map((hostileNpc) => hostileNpc.id);
const defeatedHostileNpcIds = activeBattleHostiles.map(
(hostileNpc) => hostileNpc.id,
);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
@@ -291,6 +311,7 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -324,9 +345,13 @@ export function createStoryNpcEncounterActions({
lootItems.length > 0
? lootItems.map((item) => item.name).join(', ')
: '无战利品';
const defeatedNames =
activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') ||
battleNpcId ||
'对手';
return {
nextState,
resultText: `胜利奖励:${lootText}`,
resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}`,
};
};
@@ -337,7 +362,11 @@ export function createStoryNpcEncounterActions({
actionText: string,
resultText: string,
lastFunctionId?: string,
contextNpcStateOverride?: GameState['npcStates'][string] | null,
options: {
contextNpcStateOverride?: GameState['npcStates'][string] | null;
preserveResultTextInHistory?: boolean;
revealMode?: 'deferred_options' | 'immediate_story';
} = {},
) => {
const provisionalHistory = appendHistory(gameState, actionText, resultText);
const provisionalState = {
@@ -391,11 +420,11 @@ export function createStoryNpcEncounterActions({
character,
encounter,
getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
provisionalHistory,
buildStoryContextFromState(provisionalState, {
lastFunctionId,
...provisionalOpeningCampContext,
encounterNpcStateOverride: contextNpcStateOverride,
encounterNpcStateOverride: options.contextNpcStateOverride,
}),
actionText,
resultText,
@@ -409,19 +438,19 @@ export function createStoryNpcEncounterActions({
streamCompleted = true;
await typewriterPromise;
const finalHistory = appendHistory(
gameState,
actionText,
dialogueText || resultText,
);
const finalDialogueText = dialogueText || resultText;
const finalHistory = options.preserveResultTextInHistory
? finalDialogueText && finalDialogueText !== resultText
? [
...provisionalHistory,
createHistoryMoment(finalDialogueText, 'result'),
]
: provisionalHistory
: appendHistory(gameState, actionText, finalDialogueText);
const finalState = {
...nextState,
storyHistory: finalHistory,
};
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const finalOpeningCampContext = buildOpeningCampChatContext(
finalState,
character,
@@ -429,6 +458,35 @@ export function createStoryNpcEncounterActions({
);
setGameState(finalState);
if (options.revealMode === 'immediate_story') {
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText,
[],
false,
),
);
await new Promise((resolve) => window.setTimeout(resolve, 260));
const nextStory = await generateStoryForState({
state: finalState,
character,
history: finalHistory,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(finalState);
setGameState(recoveredState);
setCurrentStory(nextStory);
return;
}
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const response = await generateNextStep(
gameState.worldType!,
character,
@@ -446,6 +504,8 @@ export function createStoryNpcEncounterActions({
? response.options
: sanitizeOptions(response.options, character, finalState),
);
const recoveredState = applyStoryReasoningRecovery(finalState);
setGameState(recoveredState);
setCurrentStory({
...buildDialogueStoryMoment(
@@ -463,6 +523,12 @@ export function createStoryNpcEncounterActions({
setAiError(
error instanceof Error ? error.message : '角色对话智能生成不可用。',
);
if (options.revealMode === 'immediate_story') {
setCurrentStory(
buildFallbackStoryForState(provisionalState, character, resultText),
);
return;
}
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
setCurrentStory(
@@ -489,7 +555,8 @@ export function createStoryNpcEncounterActions({
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
if (!gameState.playerCharacter) return false;
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const nextState: GameState = {
...gameState,
@@ -498,7 +565,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
@@ -507,11 +574,8 @@ export function createStoryNpcEncounterActions({
};
const handleNpcInteraction = (option: StoryOption) => {
if (
!gameState.playerCharacter ||
!option.interaction ||
!isNpcEncounter(gameState.currentEncounter)
) {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
@@ -603,12 +667,19 @@ export function createStoryNpcEncounterActions({
: nextState.playerInventory,
};
await commitGeneratedState(
await commitNpcChatState(
nextState,
gameState.playerCharacter,
option.actionText,
playerCharacter,
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
preserveResultTextInHistory: true,
revealMode: 'immediate_story',
},
);
committed = true;
} catch (error) {
@@ -663,12 +734,19 @@ export function createStoryNpcEncounterActions({
: nextState.playerInventory,
};
await commitGeneratedState(
await commitNpcChatState(
nextState,
gameState.playerCharacter,
option.actionText,
playerCharacter,
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
preserveResultTextInHistory: true,
revealMode: 'immediate_story',
},
);
committed = true;
} finally {
@@ -681,7 +759,7 @@ export function createStoryNpcEncounterActions({
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter: gameState.playerCharacter,
playerCharacter,
encounter,
npcState,
actionText: option.actionText,
@@ -706,7 +784,7 @@ export function createStoryNpcEncounterActions({
);
void commitNpcChatState(
nextState,
gameState.playerCharacter,
playerCharacter,
encounter,
option.actionText,
npcState.recruited
@@ -722,7 +800,9 @@ export function createStoryNpcEncounterActions({
attributeSummary,
),
option.functionId,
npcState,
{
contextNpcStateOverride: npcState,
},
);
return true;
}
@@ -765,7 +845,7 @@ export function createStoryNpcEncounterActions({
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
@@ -797,7 +877,7 @@ export function createStoryNpcEncounterActions({
);
await commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestAcceptResultText(fallbackQuest),
option.functionId,
@@ -840,7 +920,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
@@ -853,6 +933,7 @@ export function createStoryNpcEncounterActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -878,7 +959,7 @@ export function createStoryNpcEncounterActions({
void commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
@@ -886,6 +967,10 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'fight': {
const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const nextState = {
...gameState,
npcStates: {
@@ -896,9 +981,8 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight'),
],
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -915,7 +999,7 @@ export function createStoryNpcEncounterActions({
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
@@ -923,7 +1007,15 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'spar': {
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
const sparPlayerMaxHp = getNpcSparMaxHp(
playerCharacter,
gameState.worldType,
gameState.customWorldProfile,
);
const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar', {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const nextState = {
...gameState,
npcStates: {
@@ -934,9 +1026,8 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'spar'),
],
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerHp: sparPlayerMaxHp,
playerMaxHp: sparPlayerMaxHp,
@@ -955,7 +1046,7 @@ export function createStoryNpcEncounterActions({
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
playerCharacter,
option.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,

View File

@@ -15,6 +15,11 @@ import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,
} from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcGiftCommitActionText,
@@ -28,7 +33,7 @@ import {
removeInventoryItem,
syncNpcTradeInventory,
} from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/ai';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
@@ -62,7 +67,10 @@ type StoryNpcInteractionRuntime = {
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
extras?: {
lastFunctionId?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
@@ -216,6 +224,150 @@ export function useStoryNpcInteractionFlow({
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
};
const commitNpcReactionAndGenerate = async ({
nextState,
encounter,
actionText,
resultText,
lastFunctionId,
contextNpcStateOverride,
}: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
lastFunctionId: string;
contextNpcStateOverride?: GameState['npcStates'][string] | null;
}) => {
if (!gameState.playerCharacter || !gameState.worldType) {
return;
}
const provisionalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(resultText, 'result'),
];
const provisionalState = {
...nextState,
storyHistory: provisionalHistory,
};
setGameState(provisionalState);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
);
let dialogueText = '';
let streamedTargetText = '';
let displayedText = '';
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
displayedText,
[],
true,
),
);
await new Promise(resolve =>
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
);
}
})();
try {
dialogueText = await streamNpcChatDialogue(
gameState.worldType,
gameState.playerCharacter,
encounter,
runtime.getStoryGenerationHostileNpcs(provisionalState),
provisionalHistory,
runtime.buildStoryContextFromState(provisionalState, {
lastFunctionId,
encounterNpcStateOverride: contextNpcStateOverride,
}),
actionText,
resultText,
{
onUpdate: text => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
const finalDialogueText = dialogueText.trim() || displayedText.trim();
const finalHistory = finalDialogueText
? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')]
: provisionalHistory;
const finalState = {
...nextState,
storyHistory: finalHistory,
};
setGameState(finalState);
runtime.setCurrentStory(
runtime.buildDialogueStoryMoment(
encounter.npcName,
finalDialogueText || resultText,
[],
false,
),
);
await new Promise(resolve => window.setTimeout(resolve, 260));
const nextStory = await runtime.generateStoryForState({
state: finalState,
character: gameState.playerCharacter,
history: finalHistory,
choice: actionText,
lastFunctionId,
});
runtime.setCurrentStory(nextStory);
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to continue npc interaction reaction:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
const fallbackHistory = provisionalHistory;
const fallbackState = {
...nextState,
storyHistory: fallbackHistory,
};
setGameState(fallbackState);
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(
fallbackState,
gameState.playerCharacter,
resultText,
),
);
} finally {
runtime.setIsLoading(false);
}
};
const buildRecruitmentOutcome = (
encounter: Encounter,
releasedNpcId?: string | null,
@@ -248,6 +400,8 @@ export function useStoryNpcInteractionFlow({
recruitKey,
recruitCharacter,
npcState.affinity,
gameState.worldType,
gameState.customWorldProfile,
);
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
@@ -256,7 +410,8 @@ export function useStoryNpcInteractionFlow({
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
@@ -444,14 +599,14 @@ export function useStoryNpcInteractionFlow({
setGameState(updateNpcState(gameState, encounter, () => npcState));
}
setTradeModal({
encounter,
actionText,
mode: 'buy',
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
});
setTradeModal(
buildNpcTradeModalState(
gameState,
encounter,
actionText,
npcState.inventory,
),
);
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
@@ -465,19 +620,18 @@ export function useStoryNpcInteractionFlow({
);
if (!selectedItemId) return;
setGiftModal({
encounter,
actionText,
selectedItemId,
});
setGiftModal(
buildNpcGiftModalState(
gameState,
encounter,
actionText,
selectedItemId,
),
);
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal({
encounter,
actionText,
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
});
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
};
const clearNpcInteractionUi = () => {
@@ -518,16 +672,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcTradeTransactionActionText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
}),
buildNpcTradeTransactionResultText({
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
@@ -535,8 +689,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
return;
}
@@ -563,16 +718,16 @@ export function useStoryNpcInteractionFlow({
};
setTradeModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcTradeTransactionActionText({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
}),
buildNpcTradeTransactionResultText({
resultText: buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
@@ -580,8 +735,9 @@ export function useStoryNpcInteractionFlow({
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
lastFunctionId: 'npc_trade',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
};
const confirmGift = () => {
@@ -624,13 +780,20 @@ export function useStoryNpcInteractionFlow({
};
setGiftModal(null);
void commitGeneratedState(
void commitNpcReactionAndGenerate({
nextState,
gameState.playerCharacter,
buildNpcGiftCommitActionText(encounter, giftItem),
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
'npc_gift',
);
encounter,
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
resultText: buildNpcGiftResultText(
encounter,
giftItem,
affinityGain,
nextAffinity,
attributeSummary ?? undefined,
),
lastFunctionId: 'npc_gift',
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
});
};
return {

View File

@@ -7,6 +7,7 @@ import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -97,6 +98,8 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
@@ -146,6 +149,8 @@ export function createStoryProgressionActions({
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);

View File

@@ -6,6 +6,7 @@ import type {
export type TradeModalState = {
encounter: Encounter;
actionText: string;
introText: string | null;
mode: 'buy' | 'sell';
selectedNpcItemId: string | null;
selectedPlayerItemId: string | null;
@@ -15,12 +16,14 @@ export type TradeModalState = {
export type GiftModalState = {
encounter: Encounter;
actionText: string;
introText: string | null;
selectedItemId: string | null;
};
export type RecruitModalState = {
encounter: Encounter;
actionText: string;
introText: string | null;
selectedReleaseNpcId: string | null;
};

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import {
buildCustomWorldPlayableCharacters,
createCharacterSkillCooldowns,
getCharacterMaxHp,
getCharacterMaxMana,
setRuntimeCharacterOverrides,
} from '../data/characterPresets';
@@ -16,7 +17,7 @@ import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
const PLAYER_MAX_HP = 180;
const PLAYER_BASE_MAX_HP = 180;
export type {BottomTab} from '../types/navigation';
@@ -71,8 +72,8 @@ function createInitialGameState(): GameState {
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: PLAYER_MAX_HP,
playerMaxHp: PLAYER_MAX_HP,
playerHp: PLAYER_BASE_MAX_HP,
playerMaxHp: PLAYER_BASE_MAX_HP,
playerMana: 0,
playerMaxMana: 0,
playerSkillCooldowns: {},
@@ -165,6 +166,11 @@ export function useGameFlow() {
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
: null;
const initialEquipment = buildInitialEquipmentLoadout(character);
const playerMaxHp = getCharacterMaxHp(
character,
gameState.worldType,
gameState.customWorldProfile,
);
setGameState(prev =>
ensureSceneEncounterPreview(
@@ -188,8 +194,8 @@ export function useGameFlow() {
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: PLAYER_MAX_HP,
playerMaxHp: PLAYER_MAX_HP,
playerHp: playerMaxHp,
playerMaxHp: playerMaxHp,
playerMana: getCharacterMaxMana(character),
playerMaxMana: getCharacterMaxMana(character),
playerSkillCooldowns: createCharacterSkillCooldowns(character),

View File

@@ -1,6 +1,6 @@
import {useCallback, useEffect, useState} from 'react';
import { getCharacterMaxMana } from '../data/characterPresets';
import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets';
import { normalizeRoster } from '../data/companionRoster';
import { getInitialPlayerCurrency } from '../data/economy';
import {
@@ -16,7 +16,6 @@ import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../pers
import { GameState, StoryMoment } from '../types';
import { BottomTab } from './useGameFlow';
const PLAYER_BASE_MAX_HP = 180;
const AUTO_SAVE_DELAY_MS = 400;
function normalizeSavedStory(story: StoryMoment | null) {
@@ -94,10 +93,16 @@ function normalizeSavedGameState(gameState: GameState) {
? normalizedEncounterState.playerEquipment
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
const playerMaxHp = getCharacterMaxHp(
normalizedEncounterState.playerCharacter,
normalizedEncounterState.worldType,
normalizedEncounterState.customWorldProfile,
);
return applyEquipmentLoadoutToState({
...normalizedCommonState,
playerMaxHp: PLAYER_BASE_MAX_HP,
playerHp: Math.min(normalizedEncounterState.playerHp, PLAYER_BASE_MAX_HP),
playerMaxHp,
playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp),
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
playerEquipment: createEmptyEquipmentLoadout(),

View File

@@ -41,6 +41,7 @@ import {
resolveFunctionOption,
sortStoryOptionsByPriority,
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
import {
Character,
@@ -475,7 +476,9 @@ function getStoryGenerationHostileNpcs(state: GameState) {
}
function getResolvedSceneHostileNpcs(state: GameState) {
return state.sceneHostileNpcs ?? state.sceneMonsters;
return state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
}
function sanitizeOptions(
@@ -1439,6 +1442,7 @@ export function useStoryGeneration({
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
@@ -1482,6 +1486,7 @@ export function useStoryGeneration({
character: gameState.playerCharacter,
history: [],
});
setGameState(applyStoryReasoningRecovery(gameState));
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to start story:', error);
@@ -1508,6 +1513,7 @@ export function useStoryGeneration({
gameState.sceneHostileNpcs,
gameState.worldType,
isLoading,
setGameState,
startOpeningAdventure,
]);