Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
322
src/hooks/story/choiceActions.test.ts
Normal file
322
src/hooks/story/choiceActions.test.ts
Normal 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:你已经摆脱与山狼的交战,暂时把对方甩在身后,当前不再处于战斗状态。',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user