This commit is contained in:
272
src/hooks/combat/battlePlan.test.ts
Normal file
272
src/hooks/combat/battlePlan.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {AnimationState, type Character, type GameState, type StoryOption, WorldType} from '../../types';
|
||||
import {buildBattlePlan} from './battlePlan';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: 'Test Hero',
|
||||
title: 'Hero',
|
||||
description: 'A test character',
|
||||
backstory: 'A test 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: 'Basic Strike',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 1,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
{
|
||||
id: 'skill-heavy',
|
||||
name: 'Heavy Strike',
|
||||
animation: AnimationState.SKILL1,
|
||||
damage: 18,
|
||||
manaCost: 4,
|
||||
cooldownTurns: 2,
|
||||
range: 1,
|
||||
style: 'burst',
|
||||
},
|
||||
],
|
||||
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,
|
||||
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: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createBattleOption(): StoryOption {
|
||||
return {
|
||||
functionId: 'battle_all_in_crush',
|
||||
actionText: 'Attack',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildBattlePlan', () => {
|
||||
it('short-circuits when there are no monsters', () => {
|
||||
const state = createBaseState();
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: createBattleOption(),
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns).toEqual([]);
|
||||
expect(plan.finalState.inBattle).toBe(false);
|
||||
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
|
||||
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
|
||||
});
|
||||
|
||||
it('builds a battle plan when npc battle entry already provides sceneHostileNpcs', () => {
|
||||
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.sceneHostileNpcs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses runtimePayload skillId for local battle fallback skill resolution', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerMana: 20,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option = {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_use_skill',
|
||||
runtimePayload: { skillId: 'skill-heavy' },
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option,
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 900,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
const playerTurn = plan.turns.find((turn) => turn.actor === 'player');
|
||||
|
||||
expect(playerTurn).toEqual(
|
||||
expect.objectContaining({
|
||||
selectedSkillId: 'skill-heavy',
|
||||
appliedCooldowns: expect.objectContaining({ 'skill-heavy': 2 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not turn recovery fallback into a random player attack', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerHp: 40,
|
||||
playerMana: 3,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: '山狼',
|
||||
action: '压低身体',
|
||||
description: '测试敌人',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: {
|
||||
...createBattleOption(),
|
||||
functionId: 'battle_recover_breath',
|
||||
},
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 900,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 1,
|
||||
});
|
||||
|
||||
expect(plan.turns.some((turn) => turn.actor === 'player')).toBe(false);
|
||||
expect(plan.preparedState.playerHp).toBeGreaterThan(state.playerHp);
|
||||
expect(plan.preparedState.playerMana).toBeGreaterThan(state.playerMana);
|
||||
});
|
||||
});
|
||||
823
src/hooks/combat/battlePlan.ts
Normal file
823
src/hooks/combat/battlePlan.ts
Normal file
@@ -0,0 +1,823 @@
|
||||
import { resolveRoleCombatStats } from '../../data/attributeCombat';
|
||||
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
resolveCompanionOutgoingDamageResult,
|
||||
resolveMonsterOutgoingDamageResult,
|
||||
resolvePlayerOutgoingDamageResult,
|
||||
tickBuildBuffs,
|
||||
} from '../../data/buildDamage';
|
||||
import {
|
||||
getSkillDelivery,
|
||||
} from '../../data/characterCombat';
|
||||
import {
|
||||
getCharacterById,
|
||||
getCharacterMaxMana,
|
||||
} from '../../data/characterPresets';
|
||||
import { getEquipmentBonuses } from '../../data/equipmentEffects';
|
||||
import {
|
||||
getClosestHostileNpc,
|
||||
getFacingTowardPlayer,
|
||||
settleHostileNpcAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatDelivery,
|
||||
CompanionState,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
AnimationState,
|
||||
} from '../../types';
|
||||
import {
|
||||
chooseWeightedSkill,
|
||||
chooseWeightedSkillForStyle,
|
||||
inferCombatStyle,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
|
||||
type TurnActor = 'player' | 'companion' | 'monster';
|
||||
|
||||
export type BattlePlanStep =
|
||||
| {
|
||||
actor: 'player';
|
||||
targetHostileNpcId: string;
|
||||
originalPlayerX: number;
|
||||
strikeX: number;
|
||||
cooledDown: Record<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'companion';
|
||||
companionNpcId: string;
|
||||
targetHostileNpcId: string;
|
||||
strikeOffsetX: number;
|
||||
cooledDown: Record<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'monster';
|
||||
monsterId: string;
|
||||
originalMonsterX: number;
|
||||
strikeX: number;
|
||||
target: 'player' | 'companion';
|
||||
targetCompanionNpcId?: string;
|
||||
targetX: number;
|
||||
damage: number;
|
||||
criticalHit?: boolean;
|
||||
endsBattle: boolean;
|
||||
selectedSkillId: string | null;
|
||||
npcCharacterId: string | null;
|
||||
delivery: CombatDelivery;
|
||||
};
|
||||
|
||||
export type BattlePlan = {
|
||||
preparedState: GameState;
|
||||
turns: BattlePlanStep[];
|
||||
finalState: GameState;
|
||||
};
|
||||
|
||||
function createEmptyCooldowns(character: Character) {
|
||||
return Object.fromEntries(character.skills.map(skill => [skill.id, 0]));
|
||||
}
|
||||
|
||||
function normalizeCooldowns(character: Character, cooldowns: Record<string, number>) {
|
||||
return Object.fromEntries(character.skills.map(skill => [skill.id, Math.max(0, cooldowns[skill.id] ?? 0)]));
|
||||
}
|
||||
|
||||
function isCompanionAlive(companion: CompanionState) {
|
||||
return companion.hp > 0;
|
||||
}
|
||||
|
||||
export function resetCompanionCombatPresentation(companions: CompanionState[]) {
|
||||
return companions.map(companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.IDLE : AnimationState.DIE,
|
||||
actionMode: 'idle' as const,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
transitionMs: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateCompanionState(
|
||||
companions: CompanionState[],
|
||||
npcId: string,
|
||||
updater: (companion: CompanionState) => CompanionState,
|
||||
) {
|
||||
return companions.map(companion => companion.npcId === npcId ? updater(companion) : companion);
|
||||
}
|
||||
|
||||
function getCompanionSlotIndex(companions: CompanionState[], npcId: string) {
|
||||
return Math.max(0, companions.findIndex(companion => companion.npcId === npcId));
|
||||
}
|
||||
|
||||
export function getCompanionAnchorX(playerX: number, companions: CompanionState[], npcId: string) {
|
||||
const slotIndex = getCompanionSlotIndex(companions, npcId);
|
||||
return Number((playerX - (slotIndex % 2 === 0 ? 0.38 : 0.18)).toFixed(2));
|
||||
}
|
||||
|
||||
function getCompanionStrikeOffset(companions: CompanionState[], npcId: string) {
|
||||
const slotIndex = getCompanionSlotIndex(companions, npcId);
|
||||
return slotIndex % 2 === 0 ? 54 : 44;
|
||||
}
|
||||
|
||||
function getLivingPartyTargets(state: GameState) {
|
||||
const targets: Array<{ kind: 'player' } | { kind: 'companion'; npcId: string }> = [];
|
||||
if (state.playerHp > 0) {
|
||||
targets.push({ kind: 'player' });
|
||||
}
|
||||
if (state.currentNpcBattleMode === 'spar') {
|
||||
return targets;
|
||||
}
|
||||
state.companions.filter(isCompanionAlive).forEach(companion => {
|
||||
targets.push({ kind: 'companion', npcId: companion.npcId });
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function chooseRandomPartyTarget(state: GameState) {
|
||||
const targets = getLivingPartyTargets(state);
|
||||
if (targets.length === 0) return null;
|
||||
return targets[Math.floor(Math.random() * targets.length)] ?? null;
|
||||
}
|
||||
|
||||
function getCombatActorKey(actor: TurnActor, id?: string) {
|
||||
return id ? `${actor}:${id}` : actor;
|
||||
}
|
||||
|
||||
function buildCombatTurnOrder(
|
||||
state: GameState,
|
||||
playerCharacter: Character,
|
||||
sequenceMs: number,
|
||||
turnVisualMs: number,
|
||||
resetStageMs: number,
|
||||
minTurnCount: number,
|
||||
) {
|
||||
const actorTimings = new Map<string, { actor: TurnActor; id?: string; nextAt: number; cadence: number }>();
|
||||
|
||||
actorTimings.set(getCombatActorKey('player'), {
|
||||
actor: 'player',
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max(
|
||||
resolveRoleCombatStats(
|
||||
resolveCharacterAttributeProfile(
|
||||
playerCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
),
|
||||
).turnSpeed,
|
||||
1,
|
||||
),
|
||||
});
|
||||
|
||||
state.companions
|
||||
.filter(companion => state.currentNpcBattleMode !== 'spar' && isCompanionAlive(companion))
|
||||
.forEach(companion => {
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
if (!companionCharacter) return;
|
||||
actorTimings.set(getCombatActorKey('companion', companion.npcId), {
|
||||
actor: 'companion',
|
||||
id: companion.npcId,
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max(
|
||||
resolveRoleCombatStats(
|
||||
resolveCharacterAttributeProfile(
|
||||
companionCharacter,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
),
|
||||
).turnSpeed,
|
||||
1,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
state.sceneHostileNpcs.forEach(monster => {
|
||||
actorTimings.set(getCombatActorKey('monster', monster.id), {
|
||||
actor: 'monster',
|
||||
id: monster.id,
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max(monster.speed, 1),
|
||||
});
|
||||
});
|
||||
|
||||
const turnOrder: Array<{ actor: TurnActor; id?: string }> = [];
|
||||
|
||||
while (turnOrder.length < minTurnCount || turnOrder.length * (turnVisualMs + resetStageMs) < sequenceMs) {
|
||||
const availableActors = [...actorTimings.values()].filter(item => {
|
||||
if (item.actor === 'player') return state.playerHp > 0;
|
||||
if (item.actor === 'companion') {
|
||||
return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion));
|
||||
}
|
||||
return state.sceneHostileNpcs.some(monster => monster.id === item.id && monster.hp > 0);
|
||||
});
|
||||
|
||||
if (availableActors.length === 0) break;
|
||||
|
||||
availableActors.sort((a, b) => a.nextAt - b.nextAt || a.cadence - b.cadence);
|
||||
const nextActor = availableActors[0];
|
||||
if (!nextActor) break;
|
||||
turnOrder.push({ actor: nextActor.actor, id: nextActor.id });
|
||||
nextActor.nextAt += nextActor.cadence;
|
||||
}
|
||||
|
||||
return turnOrder;
|
||||
}
|
||||
|
||||
export function applyDamageToPartyTarget(
|
||||
state: GameState,
|
||||
target: { kind: 'player' } | { kind: 'companion'; npcId: string },
|
||||
damage: number,
|
||||
) {
|
||||
if (target.kind === 'player') {
|
||||
const adjustedDamage = Math.max(
|
||||
1,
|
||||
Math.round(damage * getEquipmentBonuses(state.playerEquipment).incomingDamageMultiplier),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerHp: Math.max(0, state.playerHp - adjustedDamage),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: updateCompanionState(
|
||||
state.companions,
|
||||
target.npcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
hp: Math.max(0, companion.hp - damage),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function tickSkillCooldowns(character: Character, cooldowns: Record<string, number>) {
|
||||
const normalized = normalizeCooldowns(character, cooldowns);
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).map(([skillId, turns]) => [skillId, Math.max(0, turns - 1)]),
|
||||
);
|
||||
}
|
||||
|
||||
function getRequestedSkillId(option: StoryOption) {
|
||||
return typeof option.runtimePayload?.skillId === 'string'
|
||||
? option.runtimePayload.skillId
|
||||
: null;
|
||||
}
|
||||
|
||||
function choosePlayerSkillForOption(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
option: StoryOption,
|
||||
) {
|
||||
const requestedSkillId = getRequestedSkillId(option);
|
||||
if (requestedSkillId) {
|
||||
const requestedSkill = character.skills.find(skill => skill.id === requestedSkillId) ?? null;
|
||||
if (!requestedSkill) return null;
|
||||
if ((cooldowns[requestedSkill.id] ?? 0) > 0 || mana < requestedSkill.manaCost) return null;
|
||||
return requestedSkill;
|
||||
}
|
||||
|
||||
return chooseWeightedSkill(character, mana, cooldowns, option);
|
||||
}
|
||||
|
||||
export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) {
|
||||
if (!monster) return 'right' as const;
|
||||
return monster.xMeters >= playerX ? 'right' : 'left';
|
||||
}
|
||||
|
||||
export function getMeleeStrikeX(attackerX: number, defenderX: number) {
|
||||
return defenderX > attackerX
|
||||
? Number((defenderX - 0.1).toFixed(1))
|
||||
: Number((defenderX + 0.1).toFixed(1));
|
||||
}
|
||||
|
||||
export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: number, defenderX: number) {
|
||||
return getSkillDelivery(skill) === 'ranged'
|
||||
? attackerX
|
||||
: getMeleeStrikeX(attackerX, defenderX);
|
||||
}
|
||||
|
||||
export function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
|
||||
return settleHostileNpcAnimations(monsters).map(monster => ({
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyRecoveryEffectToState(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
functionId: string,
|
||||
) {
|
||||
const effect = getFunctionEffect(functionId);
|
||||
if (
|
||||
(effect.healAmount ?? 0) <= 0 &&
|
||||
(effect.manaRestore ?? 0) <= 0 &&
|
||||
(effect.cooldownTickBonus ?? 0) <= 0
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let cooldowns = state.playerSkillCooldowns;
|
||||
|
||||
for (let index = 0; index < (effect.cooldownTickBonus ?? 0); index += 1) {
|
||||
cooldowns = tickSkillCooldowns(character, cooldowns);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerHp: Math.min(state.playerMaxHp, state.playerHp + (effect.healAmount ?? 0)),
|
||||
playerMana: Math.min(state.playerMaxMana, state.playerMana + (effect.manaRestore ?? 0)),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBattlePlan({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
totalSequenceMs,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
minTurnCount,
|
||||
}: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
totalSequenceMs: number;
|
||||
turnVisualMs: number;
|
||||
resetStageMs: number;
|
||||
minTurnCount: number;
|
||||
}): BattlePlan {
|
||||
const battleState: GameState = {
|
||||
...state,
|
||||
};
|
||||
const targetMonster = getClosestHostileNpc(
|
||||
battleState.playerX,
|
||||
battleState.sceneHostileNpcs,
|
||||
);
|
||||
if (!targetMonster) {
|
||||
return {
|
||||
preparedState: battleState,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...battleState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const isRecoveryAction = option.functionId === 'battle_recover_breath';
|
||||
const isNpcSpar = battleState.currentNpcBattleMode === 'spar';
|
||||
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
|
||||
const turnOrder = buildCombatTurnOrder(
|
||||
battleState,
|
||||
character,
|
||||
sequenceMs,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
minTurnCount,
|
||||
);
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const npcBattleResources = new Map<string, {
|
||||
character: Character;
|
||||
mana: number;
|
||||
cooldowns: Record<string, number>;
|
||||
}>();
|
||||
|
||||
battleState.sceneHostileNpcs.forEach(monster => {
|
||||
const npcCharacterId = monster.encounter?.characterId ?? null;
|
||||
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
|
||||
if (!npcCharacter) return;
|
||||
|
||||
npcBattleResources.set(monster.id, {
|
||||
character: npcCharacter,
|
||||
mana: getCharacterMaxMana(npcCharacter),
|
||||
cooldowns: createEmptyCooldowns(npcCharacter),
|
||||
});
|
||||
});
|
||||
|
||||
let simulatedState: GameState = {
|
||||
...applyRecoveryEffectToState(battleState, character, option.functionId),
|
||||
companions: resetCompanionCombatPresentation(battleState.companions),
|
||||
sceneHostileNpcs: resetCombatPresentation(
|
||||
battleState.sceneHostileNpcs,
|
||||
battleState.playerX,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerActionMode: 'idle' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
};
|
||||
const preparedState = simulatedState;
|
||||
const turns: BattlePlanStep[] = [];
|
||||
|
||||
for (const [turnIndex, turn] of turnOrder.entries()) {
|
||||
const currentTarget = getClosestHostileNpc(simulatedState.playerX, simulatedState.sceneHostileNpcs);
|
||||
if (!currentTarget) break;
|
||||
|
||||
if (turn.actor === 'player') {
|
||||
if (simulatedState.playerHp <= 0) continue;
|
||||
|
||||
const cooledDown = tickSkillCooldowns(character, simulatedState.playerSkillCooldowns);
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
playerSkillCooldowns: cooledDown,
|
||||
};
|
||||
|
||||
// 后端单技能按钮通过 runtimePayload.skillId 指定技能,本地兜底也必须保持同一语义。
|
||||
const selectedSkill = isRecoveryAction
|
||||
? null
|
||||
: choosePlayerSkillForOption(character, simulatedState.playerMana, cooledDown, normalizedOption);
|
||||
if (!selectedSkill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalPlayerX = simulatedState.playerX;
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeX = getSkillStrikeX(selectedSkill, originalPlayerX, currentTarget.xMeters);
|
||||
const appliedCooldowns = {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
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.sceneHostileNpcs.map(monster =>
|
||||
monster.id === currentTarget.id
|
||||
? {
|
||||
...monster,
|
||||
hp: isNpcSpar
|
||||
? Math.max(1, monster.hp - damage)
|
||||
: Math.max(0, monster.hp - damage),
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === currentTarget.id && monster.hp <= 0);
|
||||
const remainingMonsters = defeated
|
||||
? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0))
|
||||
: resolvedMonsters;
|
||||
const nextTarget = getClosestHostileNpc(originalPlayerX, remainingMonsters);
|
||||
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
playerX: originalPlayerX,
|
||||
playerFacing: getFacingForPlayer(originalPlayerX, nextTarget ?? null),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
tickBuildBuffs(simulatedState.activeBuildBuffs),
|
||||
selectedSkill.buildBuffs,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost),
|
||||
playerSkillCooldowns: appliedCooldowns,
|
||||
sceneHostileNpcs: remainingMonsters.map(monster => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'player',
|
||||
targetHostileNpcId: currentTarget.id,
|
||||
originalPlayerX,
|
||||
strikeX,
|
||||
cooledDown,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
});
|
||||
|
||||
if (!simulatedState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turn.actor === 'companion') {
|
||||
const companion = simulatedState.companions.find(item => item.npcId === turn.id && isCompanionAlive(item));
|
||||
if (!companion) continue;
|
||||
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
if (!companionCharacter) continue;
|
||||
|
||||
const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId);
|
||||
const targetMonster = getClosestHostileNpc(companionX, simulatedState.sceneHostileNpcs);
|
||||
if (!targetMonster) break;
|
||||
|
||||
const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns);
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
companions: updateCompanionState(
|
||||
simulatedState.companions,
|
||||
companion.npcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: cooledDown,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const selectedSkill = chooseWeightedSkillForStyle(
|
||||
companionCharacter,
|
||||
companion.mana,
|
||||
cooledDown,
|
||||
inferCombatStyle(option),
|
||||
);
|
||||
if (!selectedSkill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeOffsetX = delivery === 'melee'
|
||||
? getCompanionStrikeOffset(simulatedState.companions, companion.npcId)
|
||||
: 0;
|
||||
const appliedCooldowns = {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
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.sceneHostileNpcs.map(monster =>
|
||||
monster.id === targetMonster.id
|
||||
? {
|
||||
...monster,
|
||||
hp: isNpcSpar
|
||||
? Math.max(1, monster.hp - damage)
|
||||
: Math.max(0, monster.hp - damage),
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === targetMonster.id && monster.hp <= 0);
|
||||
const remainingMonsters = defeated
|
||||
? resolvedMonsters.filter(monster => !(monster.id === targetMonster.id && monster.hp <= 0))
|
||||
: resolvedMonsters;
|
||||
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
companions: updateCompanionState(
|
||||
resetCompanionCombatPresentation(simulatedState.companions),
|
||||
companion.npcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: appliedCooldowns,
|
||||
}),
|
||||
),
|
||||
sceneHostileNpcs: remainingMonsters.map(monster => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'companion',
|
||||
companionNpcId: companion.npcId,
|
||||
targetHostileNpcId: targetMonster.id,
|
||||
strikeOffsetX,
|
||||
cooledDown,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
});
|
||||
|
||||
if (!simulatedState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actingMonster = simulatedState.sceneHostileNpcs.find(monster => monster.id === turn.id && monster.hp > 0);
|
||||
if (!actingMonster) continue;
|
||||
|
||||
const randomTarget = chooseRandomPartyTarget(simulatedState);
|
||||
if (!randomTarget) break;
|
||||
|
||||
const originalMonsterX = actingMonster.xMeters;
|
||||
const targetX = randomTarget.kind === 'player'
|
||||
? simulatedState.playerX
|
||||
: getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, randomTarget.npcId);
|
||||
const npcCombatant = npcBattleResources.get(actingMonster.id);
|
||||
|
||||
if (npcCombatant) {
|
||||
const cooledDown = tickSkillCooldowns(npcCombatant.character, npcCombatant.cooldowns);
|
||||
const selectedSkill = chooseWeightedSkillForStyle(
|
||||
npcCombatant.character,
|
||||
npcCombatant.mana,
|
||||
cooledDown,
|
||||
inferCombatStyle(option),
|
||||
);
|
||||
|
||||
npcBattleResources.set(actingMonster.id, {
|
||||
...npcCombatant,
|
||||
cooldowns: cooledDown,
|
||||
});
|
||||
|
||||
if (selectedSkill) {
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX);
|
||||
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, {
|
||||
character: npcCombatant.character,
|
||||
mana: npcCombatant.mana,
|
||||
cooldowns: {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
},
|
||||
});
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
|
||||
simulatedState = {
|
||||
...damagedState,
|
||||
companions: resetCompanionCombatPresentation(damagedState.companions),
|
||||
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
|
||||
...monster,
|
||||
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(
|
||||
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
simulatedState.playerX,
|
||||
),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
playerHp: isNpcSpar && randomTarget.kind === 'player'
|
||||
? Math.max(1, damagedState.playerHp)
|
||||
: damagedState.playerHp,
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
|
||||
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'monster',
|
||||
monsterId: actingMonster.id,
|
||||
originalMonsterX,
|
||||
strikeX,
|
||||
target: randomTarget.kind,
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
npcCharacterId: npcCombatant.character.id,
|
||||
delivery,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
|
||||
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);
|
||||
simulatedState = {
|
||||
...damagedState,
|
||||
companions: resetCompanionCombatPresentation(damagedState.companions),
|
||||
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
|
||||
...monster,
|
||||
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(
|
||||
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
simulatedState.playerX,
|
||||
),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
playerHp: isNpcSpar && randomTarget.kind === 'player'
|
||||
? Math.max(1, damagedState.playerHp)
|
||||
: damagedState.playerHp,
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
|
||||
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'monster',
|
||||
monsterId: actingMonster.id,
|
||||
originalMonsterX,
|
||||
strikeX,
|
||||
target: randomTarget.kind,
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
criticalHit: damageResult?.isCritical ?? false,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: null,
|
||||
npcCharacterId: null,
|
||||
delivery: 'melee',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
preparedState,
|
||||
turns,
|
||||
finalState: {
|
||||
...simulatedState,
|
||||
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
|
||||
? false
|
||||
: simulatedState.sceneHostileNpcs.length > 0,
|
||||
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
|
||||
},
|
||||
};
|
||||
}
|
||||
188
src/hooks/combat/escapeFlow.test.ts
Normal file
188
src/hooks/combat/escapeFlow.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../data/stateFunctions', () => ({
|
||||
getFunctionEffect: () => ({
|
||||
escapeDistance: 5,
|
||||
escapeDurationMs: 5000,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type SceneHostileNpc,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildEscapeAfterSequence,
|
||||
playEscapeSequenceWithStorySync,
|
||||
} from './escapeFlow';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createMonster(): SceneHostileNpc {
|
||||
return {
|
||||
id: 'wolf',
|
||||
name: 'Wolf',
|
||||
action: 'Growls',
|
||||
description: 'A test wolf.',
|
||||
animation: 'idle',
|
||||
xMeters: 3.5,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.2,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: {
|
||||
id: 'npc-1',
|
||||
kind: 'npc',
|
||||
npcName: 'Bandit',
|
||||
npcDescription: 'A bandit',
|
||||
npcAvatar: 'B',
|
||||
context: 'bandit',
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [createMonster()],
|
||||
playerX: 0.2,
|
||||
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: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-1',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createEscapeOption(): StoryOption {
|
||||
return {
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: 'Run',
|
||||
text: 'Run',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: -0.6,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'left',
|
||||
scrollWorld: true,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('escapeFlow', () => {
|
||||
it('builds a deterministic escape state without playback timing state', () => {
|
||||
const state = createState();
|
||||
const resolved = buildEscapeAfterSequence(state, createEscapeOption());
|
||||
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBeNull();
|
||||
expect(resolved.sceneHostileNpcs).toEqual([]);
|
||||
expect(resolved.playerFacing).toBe('right');
|
||||
expect(resolved.scrollWorld).toBe(false);
|
||||
expect(resolved.playerX).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
it('waits for the story response before settling the escape presentation', async () => {
|
||||
const state = createState();
|
||||
const option = createEscapeOption();
|
||||
const finalState = buildEscapeAfterSequence(state, option);
|
||||
const committedStates: GameState[] = [];
|
||||
let sleepCalls = 0;
|
||||
let resolveStoryResponse!: () => void;
|
||||
const waitForStoryResponse = new Promise<void>(resolve => {
|
||||
resolveStoryResponse = resolve;
|
||||
});
|
||||
|
||||
const result = await playEscapeSequenceWithStorySync({
|
||||
setGameState: (nextState: GameState) => {
|
||||
committedStates.push(nextState);
|
||||
},
|
||||
state,
|
||||
option,
|
||||
finalState,
|
||||
sync: { waitForStoryResponse },
|
||||
sleepMs: async () => {
|
||||
sleepCalls += 1;
|
||||
if (sleepCalls === 22) {
|
||||
resolveStoryResponse();
|
||||
}
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(sleepCalls).toBeGreaterThan(21);
|
||||
expect(committedStates[0]?.animationState).toBe(AnimationState.RUN);
|
||||
expect(committedStates.at(-1)?.playerFacing).toBe('right');
|
||||
expect(result.scrollWorld).toBe(false);
|
||||
expect(result.playerFacing).toBe('right');
|
||||
});
|
||||
});
|
||||
150
src/hooks/combat/escapeFlow.ts
Normal file
150
src/hooks/combat/escapeFlow.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
getFacingTowardPlayer,
|
||||
settleHostileNpcAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type SceneHostileNpc,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const ESCAPE_RUN_MS = 5000;
|
||||
const ESCAPE_TICK_MS = 250;
|
||||
const ESCAPE_TURN_PAUSE_MS = 180;
|
||||
|
||||
export type EscapePlaybackSync = {
|
||||
waitForStoryResponse?: Promise<void>;
|
||||
};
|
||||
|
||||
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
|
||||
return settleHostileNpcAnimations(monsters).map(monster => ({
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
|
||||
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
|
||||
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
|
||||
return Number(Math.max(-1.6, Math.min(state.playerX - settleOffset, -1)).toFixed(2));
|
||||
}
|
||||
|
||||
export function buildEscapeAfterSequence(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) {
|
||||
const escapePlayerX = getEscapeSettlePlayerX(state, option);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: escapePlayerX,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
export async function playEscapeSequenceWithStorySync(params: {
|
||||
setGameState: SetGameStateFn;
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
finalState: GameState;
|
||||
sync?: EscapePlaybackSync;
|
||||
sleepMs?: (ms: number) => Promise<void>;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
finalState,
|
||||
sync,
|
||||
sleepMs = sleep,
|
||||
} = params;
|
||||
let currentState = state;
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const escapeDurationMs = functionEffect.escapeDurationMs ?? ESCAPE_RUN_MS;
|
||||
const escapeDistance = functionEffect.escapeDistance ?? 5;
|
||||
const runTicks = Math.max(1, Math.ceil(escapeDurationMs / ESCAPE_TICK_MS));
|
||||
const playerStep = Number((-escapeDistance / runTicks).toFixed(2));
|
||||
const settlePlayerX = finalState.playerX;
|
||||
let storyResponseReady = !sync?.waitForStoryResponse;
|
||||
let elapsedMs = 0;
|
||||
|
||||
void sync?.waitForStoryResponse?.then(() => {
|
||||
storyResponseReady = true;
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, currentState.playerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
while (elapsedMs < escapeDurationMs || !storyResponseReady) {
|
||||
const nextPlayerX = Number((currentState.playerX + playerStep).toFixed(2));
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: nextPlayerX,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, nextPlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
elapsedMs += ESCAPE_TICK_MS;
|
||||
await sleepMs(ESCAPE_TICK_MS);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...finalState,
|
||||
playerX: settlePlayerX,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleepMs(ESCAPE_TURN_PAUSE_MS);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerFacing: 'right',
|
||||
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, settlePlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
return currentState;
|
||||
}
|
||||
743
src/hooks/combat/playback.ts
Normal file
743
src/hooks/combat/playback.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
tickBuildBuffs,
|
||||
} from '../../data/buildDamage';
|
||||
import {
|
||||
getCharacterAnimationDurationMs,
|
||||
getSkillCasterAnimation,
|
||||
} from '../../data/characterCombat';
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getClosestHostileNpc,
|
||||
getFacingTowardPlayer,
|
||||
MONSTERS_BY_WORLD,
|
||||
} from '../../data/hostileNpcs';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CharacterSkillDefinition,
|
||||
type GameState,
|
||||
type SceneHostileNpc,
|
||||
type StoryOption,
|
||||
type WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
classifyCombatOption,
|
||||
} from '../combatStoryUtils';
|
||||
import { playIdleSequence } from '../idleAdventureFlow';
|
||||
import {
|
||||
applyDamageToPartyTarget,
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
getFacingForPlayer,
|
||||
resetCompanionCombatPresentation,
|
||||
updateCompanionState,
|
||||
} from './battlePlan';
|
||||
import {
|
||||
type EscapePlaybackSync,
|
||||
playEscapeSequenceWithStorySync as playEscapeSequenceWithStorySyncFromEngine,
|
||||
} from './escapeFlow';
|
||||
import type { ResolvedChoiceState } from './resolvedChoice';
|
||||
import {
|
||||
buildSkillEffects,
|
||||
getSkillReleaseDelayMs,
|
||||
} from './skillEffects';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getSkillById(character: Character, skillId: string) {
|
||||
return character.skills.find(skill => skill.id === skillId) ?? null;
|
||||
}
|
||||
|
||||
function getMonsterAnimationDurationMs(
|
||||
worldType: WorldType | null,
|
||||
monsterId: string,
|
||||
animation: 'idle' | 'move' | 'attack' | 'die',
|
||||
turnVisualMs: number,
|
||||
) {
|
||||
if (!worldType) return turnVisualMs;
|
||||
const config = MONSTERS_BY_WORLD[worldType].find(monster => monster.id === monsterId);
|
||||
const clip = config?.animations[animation];
|
||||
if (!clip) return turnVisualMs;
|
||||
|
||||
const fps = clip.fps ?? 12;
|
||||
const stepCount = Math.max(1, clip.frames - 1);
|
||||
return Math.max(turnVisualMs, Math.ceil((stepCount * 1000) / fps + 120));
|
||||
}
|
||||
|
||||
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneHostileNpc[]) {
|
||||
if (option.visuals.monsterChanges.length > 0) return option.visuals.monsterChanges;
|
||||
|
||||
const fallbackMonster = monsters[0];
|
||||
if (!fallbackMonster) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: fallbackMonster.id,
|
||||
action: classifyCombatOption(option) === 'escape'
|
||||
? `${fallbackMonster.name}立刻追了上来`
|
||||
: `${fallbackMonster.name}振身迎击玩家的进攻`,
|
||||
animation: classifyCombatOption(option) === 'escape' ? 'move' as const : 'attack' as const,
|
||||
moveMeters: classifyCombatOption(option) === 'escape' ? -0.2 : 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type CombatPlaybackParams = {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
turnVisualMs: number;
|
||||
resetStageMs: number;
|
||||
};
|
||||
|
||||
async function playSkillEffects(params: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
currentState: GameState;
|
||||
attacker: {
|
||||
character: Character;
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
facing: 'left' | 'right';
|
||||
monsterId?: string;
|
||||
};
|
||||
target: {
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
monsterId?: string;
|
||||
};
|
||||
skill: CharacterSkillDefinition;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
attacker,
|
||||
target,
|
||||
skill,
|
||||
} = params;
|
||||
let currentState = params.currentState;
|
||||
const phases = buildSkillEffects(attacker, target, skill);
|
||||
|
||||
if (phases.cast.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.cast,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.castDurationMs);
|
||||
}
|
||||
|
||||
if (phases.travel.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.travel,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.travelDurationMs);
|
||||
}
|
||||
|
||||
if (phases.impact.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.impact,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.impactDurationMs);
|
||||
}
|
||||
|
||||
if (phases.cast.length > 0 || phases.travel.length > 0 || phases.impact.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
async function playBattleSequence(params: CombatPlaybackParams & {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
plan: BattlePlan;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
plan,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
} = params;
|
||||
let currentState = state;
|
||||
|
||||
if (plan.preparedState !== state) {
|
||||
currentState = plan.preparedState;
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
}
|
||||
|
||||
for (const step of plan.turns) {
|
||||
if (step.actor === 'player') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerSkillCooldowns: step.cooledDown,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
|
||||
if (!skill) {
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
|
||||
if (!currentTarget) {
|
||||
break;
|
||||
}
|
||||
|
||||
const casterAnimation = getSkillCasterAnimation(skill);
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(character, skill)
|
||||
: getCharacterAnimationDurationMs(character, casterAnimation);
|
||||
const shouldDashIntoMelee =
|
||||
step.delivery === 'melee' && Math.abs(step.strikeX - step.originalPlayerX) > 0.01;
|
||||
|
||||
if (shouldDashIntoMelee) {
|
||||
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(character, AnimationState.DASH));
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.strikeX,
|
||||
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
|
||||
animationState: AnimationState.DASH,
|
||||
playerActionMode: 'melee',
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(dashDuration);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.strikeX,
|
||||
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
|
||||
animationState: casterAnimation,
|
||||
playerActionMode: step.delivery,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
tickBuildBuffs(currentState.activeBuildBuffs),
|
||||
skill.buildBuffs,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerMana: currentState.playerMana,
|
||||
playerSkillCooldowns: step.appliedCooldowns,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
currentState = await playSkillEffects({
|
||||
setGameState,
|
||||
currentState,
|
||||
attacker: {
|
||||
character,
|
||||
xMeters: step.strikeX,
|
||||
origin: 'player',
|
||||
facing: currentState.playerFacing,
|
||||
},
|
||||
target: {
|
||||
xMeters: currentTarget.xMeters,
|
||||
origin: 'monster',
|
||||
monsterId: currentTarget.id,
|
||||
},
|
||||
skill,
|
||||
});
|
||||
|
||||
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
|
||||
monster.id === step.targetHostileNpcId
|
||||
? {
|
||||
...monster,
|
||||
hp: Math.max(0, monster.hp - step.damage),
|
||||
action: step.defeated
|
||||
? `${monster.name}被${skill.name}击败了`
|
||||
: `${monster.name}受到了${skill.name}的打击`,
|
||||
animation: step.defeated ? ('die' as const) : monster.animation,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.defeated) {
|
||||
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
|
||||
const remainingMonsters = resolvedMonsters.filter(
|
||||
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: remainingMonsters,
|
||||
inBattle: remainingMonsters.length > 0,
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextTarget = getClosestHostileNpc(step.originalPlayerX, currentState.sceneHostileNpcs);
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.originalPlayerX,
|
||||
playerFacing: getFacingForPlayer(step.originalPlayerX, nextTarget ?? null),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
if (!currentState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.actor === 'companion') {
|
||||
const companion = currentState.companions.find(item => item.npcId === step.companionNpcId);
|
||||
if (!companion || companion.hp <= 0) continue;
|
||||
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
|
||||
const skill = companionCharacter && step.selectedSkillId ? getSkillById(companionCharacter, step.selectedSkillId) : null;
|
||||
if (!companionCharacter || !currentTarget || !skill) {
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dashAnimation = companionCharacter.animationMap?.[AnimationState.DASH]
|
||||
? AnimationState.DASH
|
||||
: AnimationState.RUN;
|
||||
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(companionCharacter, dashAnimation));
|
||||
const casterAnimation = getSkillCasterAnimation(skill);
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(companionCharacter, skill)
|
||||
: getCharacterAnimationDurationMs(companionCharacter, casterAnimation);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
resetCompanionCombatPresentation(currentState.companions),
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: step.cooledDown,
|
||||
}),
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
currentState.companions,
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
animationState: dashAnimation,
|
||||
actionMode: 'melee',
|
||||
offsetX: step.strikeOffsetX,
|
||||
transitionMs: dashDuration,
|
||||
}),
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(dashDuration);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
currentState.companions,
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
animationState: casterAnimation,
|
||||
actionMode: step.delivery,
|
||||
offsetX: step.delivery === 'melee' ? step.strikeOffsetX : 0,
|
||||
transitionMs: 0,
|
||||
mana: currentCompanion.mana,
|
||||
skillCooldowns: step.appliedCooldowns,
|
||||
}),
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
|
||||
monster.id === step.targetHostileNpcId
|
||||
? {
|
||||
...monster,
|
||||
hp: Math.max(0, monster.hp - step.damage),
|
||||
action: step.defeated
|
||||
? `${monster.name}被${companionCharacter.name}当场击败了`
|
||||
: `${monster.name}被${companionCharacter.name}逼退了半步`,
|
||||
animation: step.defeated ? ('die' as const) : monster.animation,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.defeated) {
|
||||
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
|
||||
const remainingMonsters = resolvedMonsters.filter(
|
||||
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: remainingMonsters,
|
||||
inBattle: remainingMonsters.length > 0,
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
if (!currentState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actingMonster = currentState.sceneHostileNpcs.find(monster => monster.id === step.monsterId);
|
||||
if (!actingMonster) continue;
|
||||
|
||||
const npcCharacter = step.npcCharacterId ? getCharacterById(step.npcCharacterId) : null;
|
||||
const npcSkill = npcCharacter && step.selectedSkillId ? getSkillById(npcCharacter, step.selectedSkillId) : null;
|
||||
|
||||
if (npcCharacter && npcSkill) {
|
||||
const casterAnimation = getSkillCasterAnimation(npcSkill);
|
||||
const releaseDelay = (npcSkill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(npcCharacter, npcSkill)
|
||||
: getCharacterAnimationDurationMs(npcCharacter, casterAnimation);
|
||||
|
||||
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
|
||||
if (monster.id !== step.monsterId) {
|
||||
return {
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: step.strikeX,
|
||||
animation: step.delivery === 'melee' ? ('attack' as const) : ('idle' as const),
|
||||
action: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? `${monster.name}对${npcSkill.name}的目标发起突袭`
|
||||
: `${monster.name}施展了${npcSkill.name}`,
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
characterAnimation: casterAnimation,
|
||||
combatMode: step.delivery,
|
||||
};
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: attackedMonsters,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
currentState.companions,
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
actionMode: 'idle',
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(currentState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
playerFacing: getFacingForPlayer(
|
||||
currentState.playerX,
|
||||
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
currentState = await playSkillEffects({
|
||||
setGameState,
|
||||
currentState,
|
||||
attacker: {
|
||||
character: npcCharacter,
|
||||
xMeters: step.strikeX,
|
||||
origin: 'monster',
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
monsterId: step.monsterId,
|
||||
},
|
||||
target: {
|
||||
xMeters: step.targetX,
|
||||
origin: 'player',
|
||||
},
|
||||
skill: npcSkill,
|
||||
});
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(
|
||||
currentState,
|
||||
step.target === 'player'
|
||||
? { kind: 'player' as const }
|
||||
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
|
||||
step.damage,
|
||||
);
|
||||
currentState = {
|
||||
...damagedState,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
resetCompanionCombatPresentation(damagedState.companions),
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(damagedState.companions),
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: attackedMonsters.map(monster =>
|
||||
monster.id === step.monsterId
|
||||
? {
|
||||
...monster,
|
||||
xMeters: step.originalMonsterX,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
};
|
||||
setGameState(currentState);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneHostileNpcs);
|
||||
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
|
||||
if (monster.id !== step.monsterId) {
|
||||
return {
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
};
|
||||
}
|
||||
|
||||
const sourceChange = baseChanges.find(change => change.id === monster.id);
|
||||
return {
|
||||
...monster,
|
||||
xMeters: step.strikeX,
|
||||
animation: 'attack' as const,
|
||||
action: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? `${monster.name}閻氭稒澧ら崥鎴濇倱娴肩⒒`
|
||||
: (sourceChange?.action ?? `${monster.name}閻氭稒澧ゆ稉濠冩降`),
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
};
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: attackedMonsters,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
currentState.companions,
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
actionMode: 'idle',
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(currentState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
playerFacing: getFacingForPlayer(
|
||||
currentState.playerX,
|
||||
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(turnVisualMs);
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(
|
||||
currentState,
|
||||
step.target === 'player'
|
||||
? { kind: 'player' as const }
|
||||
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
|
||||
step.damage,
|
||||
);
|
||||
currentState = {
|
||||
...damagedState,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
resetCompanionCombatPresentation(damagedState.companions),
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(damagedState.companions),
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneHostileNpcs: attackedMonsters.map(monster =>
|
||||
monster.id === step.monsterId
|
||||
? {
|
||||
...monster,
|
||||
xMeters: step.originalMonsterX,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
setGameState(plan.finalState);
|
||||
return plan.finalState;
|
||||
}
|
||||
|
||||
export function createCombatPlayback(params: CombatPlaybackParams) {
|
||||
const {
|
||||
setGameState,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
} = params;
|
||||
|
||||
const playResolvedChoice = async (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => {
|
||||
if (resolvedChoice.optionKind === 'battle') {
|
||||
return playBattleSequence({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
plan: resolvedChoice.battlePlan!,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedChoice.optionKind === 'escape') {
|
||||
return playEscapeSequenceWithStorySyncFromEngine({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
finalState: resolvedChoice.afterSequence,
|
||||
sync,
|
||||
});
|
||||
}
|
||||
|
||||
return playIdleSequence({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
finalState: resolvedChoice.afterSequence,
|
||||
applyRecoveryEffectToState,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
playResolvedChoice,
|
||||
};
|
||||
}
|
||||
|
||||
284
src/hooks/combat/resolvedChoice.test.ts
Normal file
284
src/hooks/combat/resolvedChoice.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
description: 'A quiet camp.',
|
||||
imageSrc: '/camp.png',
|
||||
forwardSceneId: 'scene-2',
|
||||
connectedSceneIds: ['scene-2', 'scene-3'],
|
||||
connections: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
forwardSceneId: 'scene-3',
|
||||
connectedSceneIds: ['scene-3'],
|
||||
connections: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
{
|
||||
id: 'scene-3',
|
||||
name: 'Ruin',
|
||||
description: 'A ruined gate.',
|
||||
imageSrc: '/ruin.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/scenePresets', () => ({
|
||||
getForwardScenePreset: () => scenes[1],
|
||||
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
||||
scenes.find((scene) => scene.id === sceneId) ?? null,
|
||||
getScenePresetsByWorld: () => scenes,
|
||||
getSceneFriendlyNpcs: () => [],
|
||||
getSceneHostileNpcs: () => [],
|
||||
getSceneHostileNpcPresetIds: () => [],
|
||||
getTravelScenePreset: () => scenes[1],
|
||||
getWorldCampScenePreset: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../data/stateFunctions', () => ({
|
||||
getFunctionById: (functionId: string) => ({
|
||||
id: functionId,
|
||||
state: functionId.startsWith('idle_') ? 'idle' : 'battle',
|
||||
category: functionId === 'battle_escape_breakout' ? 'escape' : functionId.startsWith('idle_') ? 'idle' : 'battle',
|
||||
}),
|
||||
getFunctionEffect: () => ({
|
||||
sceneShift: 0,
|
||||
escapeDistance: 5,
|
||||
escapeDurationMs: 5000,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BattlePlan } from './battlePlan';
|
||||
import { buildResolvedChoiceState } from './resolvedChoice';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: 'Test Hero',
|
||||
title: 'Hero',
|
||||
description: 'A test character',
|
||||
backstory: 'A test 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: 'Basic Strike',
|
||||
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,
|
||||
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: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId: string): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildResolvedChoiceState', () => {
|
||||
it('keeps battle planning in the pure resolution layer', () => {
|
||||
const state = createBaseState();
|
||||
const character = createTestCharacter();
|
||||
const battlePlan: BattlePlan = {
|
||||
preparedState: state,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...state,
|
||||
inBattle: false,
|
||||
},
|
||||
};
|
||||
const buildBattlePlan = vi.fn(() => battlePlan);
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('battle_all_in_crush'),
|
||||
character,
|
||||
buildBattlePlan,
|
||||
});
|
||||
|
||||
expect(buildBattlePlan).toHaveBeenCalledWith(state, expect.objectContaining({ functionId: 'battle_all_in_crush' }), character);
|
||||
expect(resolved.optionKind).toBe('battle');
|
||||
expect(resolved.battlePlan).toBe(battlePlan);
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape results without playback timing concerns', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: 'Wolf',
|
||||
action: 'growls',
|
||||
description: 'A wolf',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('battle_escape_breakout'),
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.optionKind).toBe('escape');
|
||||
expect(resolved.battlePlan).toBeNull();
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
expect(resolved.afterSequence.sceneHostileNpcs).toEqual([]);
|
||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
||||
});
|
||||
|
||||
it('keeps idle follow-up generation separate from combat planning', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('idle_observe_signs'),
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.optionKind).toBe('idle');
|
||||
expect(resolved.battlePlan).toBeNull();
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
});
|
||||
|
||||
it('uses explicit target scene id from travel option payload', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: scenes[0] as GameState['currentScenePreset'],
|
||||
inBattle: false,
|
||||
};
|
||||
const option = {
|
||||
...createOption('idle_travel_next_scene'),
|
||||
runtimePayload: {
|
||||
targetSceneId: 'scene-3',
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option,
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.afterSequence.currentScenePreset?.id).toBe('scene-3');
|
||||
});
|
||||
});
|
||||
132
src/hooks/combat/resolvedChoice.ts
Normal file
132
src/hooks/combat/resolvedChoice.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getScenePresetById,
|
||||
getScenePresetsByWorld,
|
||||
getTravelScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { classifyCombatOption } from '../combatStoryUtils';
|
||||
import { buildIdleAfterSequence } from '../idleAdventureFlow';
|
||||
import {
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
} from './battlePlan';
|
||||
import { buildEscapeAfterSequence } from './escapeFlow';
|
||||
|
||||
type OptionKind = 'battle' | 'escape' | 'idle';
|
||||
|
||||
export type ResolvedChoiceState = {
|
||||
optionKind: OptionKind;
|
||||
battlePlan: BattlePlan | null;
|
||||
afterSequence: GameState;
|
||||
};
|
||||
|
||||
function getShiftedScenePreset(
|
||||
worldType: WorldType | null,
|
||||
currentScenePreset: GameState['currentScenePreset'],
|
||||
sceneShift: number | undefined,
|
||||
): GameState['currentScenePreset'] {
|
||||
if (!worldType || sceneShift === undefined || sceneShift === 0) return currentScenePreset;
|
||||
if (!currentScenePreset) return getScenePresetsByWorld(worldType)[0] ?? null;
|
||||
|
||||
if (sceneShift > 0) {
|
||||
let nextScene = currentScenePreset;
|
||||
for (let index = 0; index < sceneShift; index += 1) {
|
||||
nextScene = getForwardScenePreset(worldType, nextScene.id) ?? nextScene;
|
||||
}
|
||||
return nextScene;
|
||||
}
|
||||
|
||||
const scenes = getScenePresetsByWorld(worldType);
|
||||
if (scenes.length === 0) return currentScenePreset;
|
||||
const currentIndex = scenes.findIndex(scene => scene.id === currentScenePreset.id);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex = ((safeIndex + sceneShift) % scenes.length + scenes.length) % scenes.length;
|
||||
return scenes[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function getSceneTargetForFunction(
|
||||
worldType: WorldType | null,
|
||||
currentScenePreset: GameState['currentScenePreset'],
|
||||
option: StoryOption,
|
||||
): GameState['currentScenePreset'] {
|
||||
if (!worldType) return currentScenePreset;
|
||||
|
||||
if (option.functionId === 'idle_travel_next_scene') {
|
||||
const targetSceneId =
|
||||
typeof option.runtimePayload?.targetSceneId === 'string'
|
||||
? option.runtimePayload.targetSceneId
|
||||
: null;
|
||||
if (targetSceneId) {
|
||||
return getScenePresetById(worldType, targetSceneId) ?? currentScenePreset;
|
||||
}
|
||||
|
||||
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
|
||||
}
|
||||
|
||||
return getShiftedScenePreset(
|
||||
worldType,
|
||||
currentScenePreset,
|
||||
getFunctionEffect(option.functionId).sceneShift,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildResolvedChoiceState(params: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
buildBattlePlan: (state: GameState, option: StoryOption, character: Character) => BattlePlan;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
buildBattlePlan,
|
||||
} = params;
|
||||
const nextScenePreset = getSceneTargetForFunction(
|
||||
state.worldType,
|
||||
state.currentScenePreset,
|
||||
option,
|
||||
);
|
||||
const optionKind = classifyCombatOption(option);
|
||||
|
||||
if (optionKind === 'battle') {
|
||||
const battlePlan = buildBattlePlan(state, option, character);
|
||||
const battleResult: GameState = {
|
||||
...battlePlan.finalState,
|
||||
currentScenePreset: nextScenePreset ?? battlePlan.finalState.currentScenePreset,
|
||||
};
|
||||
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan,
|
||||
afterSequence: battleResult,
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan: null,
|
||||
afterSequence: buildEscapeAfterSequence(state, option),
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan: null,
|
||||
afterSequence: buildIdleAfterSequence({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
nextScenePreset,
|
||||
applyRecoveryEffectToState,
|
||||
}),
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
105
src/hooks/combat/skillEffects.ts
Normal file
105
src/hooks/combat/skillEffects.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
getCharacterAnimationDurationMs,
|
||||
getSequenceDurationMs,
|
||||
getSkillCasterAnimation,
|
||||
getSkillDelivery,
|
||||
resolveSequenceFrames,
|
||||
} from '../../data/characterCombat';
|
||||
import type {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatVisualEffect,
|
||||
} from '../../types';
|
||||
|
||||
const RANGED_MONSTER_FOOT_LOCK_OFFSET_Y = -56;
|
||||
|
||||
function createCombatEffectId() {
|
||||
return `combat-effect-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
|
||||
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
|
||||
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
|
||||
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
|
||||
}
|
||||
|
||||
export function buildSkillEffects(
|
||||
attacker: {
|
||||
character: Character;
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
facing: 'left' | 'right';
|
||||
monsterId?: string;
|
||||
},
|
||||
target: {
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
monsterId?: string;
|
||||
},
|
||||
skill: CharacterSkillDefinition,
|
||||
) {
|
||||
const phases = {
|
||||
cast: [] as CombatVisualEffect[],
|
||||
travel: [] as CombatVisualEffect[],
|
||||
impact: [] as CombatVisualEffect[],
|
||||
castDurationMs: 0,
|
||||
travelDurationMs: 0,
|
||||
impactDurationMs: 0,
|
||||
};
|
||||
|
||||
const deliveryRanged = getSkillDelivery(skill) === 'ranged';
|
||||
|
||||
for (const effect of skill.effects ?? []) {
|
||||
const frames = resolveSequenceFrames(attacker.character, effect.sequence);
|
||||
if (frames.length === 0) continue;
|
||||
|
||||
const durationMs = effect.durationMs ?? getSequenceDurationMs(effect.sequence, frames.length);
|
||||
const origin = effect.anchor === 'target' ? target : attacker;
|
||||
const isProjectile =
|
||||
effect.motion === 'projectile'
|
||||
|| (effect.phase === 'travel' && deliveryRanged);
|
||||
const fallbackProjectileStartOffsetX = attacker.facing === 'right' ? 18 : -18;
|
||||
const startOffsetX = effect.startOffsetX ?? (isProjectile ? fallbackProjectileStartOffsetX : 0);
|
||||
const endOffsetX = effect.endOffsetX ?? (isProjectile ? 0 : startOffsetX);
|
||||
const startAnchorOffsetY = !isProjectile
|
||||
&& deliveryRanged
|
||||
&& origin.origin === 'monster'
|
||||
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
|
||||
: 0;
|
||||
const endAnchorOffsetY = isProjectile
|
||||
&& deliveryRanged
|
||||
&& target.origin === 'monster'
|
||||
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
|
||||
: startAnchorOffsetY;
|
||||
const instance: CombatVisualEffect = {
|
||||
id: createCombatEffectId(),
|
||||
frames,
|
||||
fps: effect.sequence.fps ?? 10,
|
||||
startX: isProjectile ? attacker.xMeters : origin.xMeters,
|
||||
endX: isProjectile ? target.xMeters : origin.xMeters,
|
||||
startOrigin: isProjectile ? attacker.origin : origin.origin,
|
||||
endOrigin: isProjectile ? target.origin : origin.origin,
|
||||
startMonsterId: isProjectile ? attacker.monsterId : origin.monsterId,
|
||||
endMonsterId: isProjectile ? target.monsterId : origin.monsterId,
|
||||
startAnchorOffsetY,
|
||||
endAnchorOffsetY,
|
||||
startOffsetX,
|
||||
endOffsetX,
|
||||
startYOffset: effect.startYOffset ?? 56,
|
||||
endYOffset: effect.endYOffset ?? effect.startYOffset ?? 56,
|
||||
durationMs,
|
||||
sizePx: effect.sizePx ?? 96,
|
||||
scale: effect.scale ?? 1,
|
||||
facing: attacker.facing,
|
||||
zIndex: effect.phase === 'impact' ? 28 : 24,
|
||||
traveling: isProjectile,
|
||||
};
|
||||
|
||||
phases[effect.phase].push(instance);
|
||||
if (effect.phase === 'cast') phases.castDurationMs = Math.max(phases.castDurationMs, durationMs);
|
||||
if (effect.phase === 'travel') phases.travelDurationMs = Math.max(phases.travelDurationMs, durationMs);
|
||||
if (effect.phase === 'impact') phases.impactDurationMs = Math.max(phases.impactDurationMs, durationMs);
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
429
src/hooks/combatStoryUtils.ts
Normal file
429
src/hooks/combatStoryUtils.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { createFallbackOption } from '../data/hostileNpcs';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
getFunctionById,
|
||||
getFunctionEffect,
|
||||
getFunctionSkillWeights,
|
||||
resolveFunctionOption,
|
||||
} from '../data/stateFunctions';
|
||||
import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
const FALLBACK_STORY: StoryMoment = {
|
||||
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
|
||||
options: [
|
||||
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
|
||||
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
|
||||
],
|
||||
};
|
||||
|
||||
type CombatStyle = 'all_in' | 'steady' | 'escape';
|
||||
|
||||
function getDefaultSkillWeight(skill: Character['skills'][number], style: CombatStyle) {
|
||||
if (style === 'escape') return 0;
|
||||
|
||||
if (style === 'all_in') {
|
||||
if (skill.style === 'finisher') return 5;
|
||||
if (skill.style === 'burst') return 4;
|
||||
if (skill.style === 'mobility') return 2.5;
|
||||
if (skill.style === 'projectile') return 2;
|
||||
return 1.5;
|
||||
}
|
||||
|
||||
if (skill.style === 'steady') return 4;
|
||||
if (skill.style === 'mobility') return 2.5;
|
||||
if (skill.style === 'projectile') return 2.2;
|
||||
if (skill.style === 'burst') return 2;
|
||||
return 1.4;
|
||||
}
|
||||
|
||||
export function classifyCombatOption(option: StoryOption) {
|
||||
const functionMeta = getFunctionById(option.functionId);
|
||||
if (functionMeta?.category === 'escape') return 'escape' as const;
|
||||
if (functionMeta?.state === 'idle') return 'idle' as const;
|
||||
return 'battle' as const;
|
||||
}
|
||||
|
||||
export function inferCombatStyle(option: StoryOption): CombatStyle {
|
||||
const category = getFunctionById(option.functionId)?.category;
|
||||
const text = option.text ?? option.actionText;
|
||||
if (category === 'escape') return 'escape';
|
||||
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
|
||||
if (classifyCombatOption(option) === 'escape') return 'escape';
|
||||
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
function getAvailableSkills(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
option: StoryOption,
|
||||
) {
|
||||
return character.skills
|
||||
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
|
||||
.map(skill => ({
|
||||
skill,
|
||||
weight: option.skillProbabilities?.[skill.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function chooseWeightedSkill(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
option: StoryOption,
|
||||
) {
|
||||
const available = getAvailableSkills(character, mana, cooldowns, option);
|
||||
if (available.length === 0) return null;
|
||||
|
||||
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
|
||||
let roll = Math.random() * total;
|
||||
|
||||
for (const item of available) {
|
||||
roll -= Math.max(item.weight, 0.01);
|
||||
if (roll <= 0) {
|
||||
return item.skill;
|
||||
}
|
||||
}
|
||||
|
||||
return available[available.length - 1]?.skill ?? null;
|
||||
}
|
||||
|
||||
export function chooseWeightedSkillForStyle(
|
||||
character: Character,
|
||||
mana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
style: CombatStyle,
|
||||
) {
|
||||
const available = character.skills
|
||||
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
|
||||
.map(skill => ({
|
||||
skill,
|
||||
weight: getDefaultSkillWeight(skill, style),
|
||||
}));
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
|
||||
let roll = Math.random() * total;
|
||||
|
||||
for (const item of available) {
|
||||
roll -= Math.max(item.weight, 0.01);
|
||||
if (roll <= 0) {
|
||||
return item.skill;
|
||||
}
|
||||
}
|
||||
|
||||
return available[available.length - 1]?.skill ?? null;
|
||||
}
|
||||
|
||||
export function normalizeSkillProbabilities(option: StoryOption, character: Character) {
|
||||
const functionWeights = getFunctionSkillWeights(option.functionId);
|
||||
const style = inferCombatStyle(option);
|
||||
const weighted = character.skills.map(skill => {
|
||||
const styleWeight = functionWeights?.[skill.style];
|
||||
const rawWeight = typeof styleWeight === 'number' ? styleWeight : option.skillProbabilities?.[skill.id];
|
||||
const fallbackWeight = getDefaultSkillWeight(skill, style);
|
||||
return {
|
||||
skillId: skill.id,
|
||||
weight: Math.max(0, typeof rawWeight === 'number' ? rawWeight : fallbackWeight),
|
||||
};
|
||||
});
|
||||
|
||||
const total = weighted.reduce((sum, item) => sum + item.weight, 0);
|
||||
const normalized = total > 0
|
||||
? Object.fromEntries(weighted.map(item => [item.skillId, Number((item.weight / total).toFixed(4))]))
|
||||
: Object.fromEntries(character.skills.map(skill => [skill.id, Number((1 / character.skills.length).toFixed(4))]));
|
||||
|
||||
return {
|
||||
...option,
|
||||
skillProbabilities: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function createSingleActionBattleOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
playerAnimation: AnimationState,
|
||||
detailText?: string,
|
||||
extras: Partial<StoryOption> = {},
|
||||
) {
|
||||
return {
|
||||
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
|
||||
detailText,
|
||||
...extras,
|
||||
} satisfies StoryOption;
|
||||
}
|
||||
|
||||
function getBasicAttackDamage(character: Character) {
|
||||
return Math.max(
|
||||
8,
|
||||
Math.round(
|
||||
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function pickPreferredBattleItem(state: GameState, character: Character) {
|
||||
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
|
||||
(turns) => turns > 0,
|
||||
);
|
||||
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
|
||||
|
||||
return state.playerInventory
|
||||
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
|
||||
.map((item) => {
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (!effect) return null;
|
||||
|
||||
const score =
|
||||
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
|
||||
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
|
||||
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
|
||||
effect.buildBuffs.length * 8;
|
||||
|
||||
return { item, effect, score };
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
candidate,
|
||||
): candidate is {
|
||||
item: GameState['playerInventory'][number];
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
|
||||
score: number;
|
||||
} => Boolean(candidate),
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.score - left.score ||
|
||||
right.effect.hpRestore - left.effect.hpRestore ||
|
||||
right.effect.manaRestore - left.effect.manaRestore ||
|
||||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
|
||||
)[0] ?? null;
|
||||
}
|
||||
|
||||
function buildBattleItemSummary(
|
||||
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
|
||||
) {
|
||||
const parts = [
|
||||
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
|
||||
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
|
||||
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
|
||||
effect.buildBuffs.length > 0
|
||||
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(' / ') || '立即结算一次物品效果';
|
||||
}
|
||||
|
||||
function buildSingleActionBattleOptions(state: GameState, character: Character) {
|
||||
const preferredItem = pickPreferredBattleItem(state, character);
|
||||
|
||||
return [
|
||||
createSingleActionBattleOption(
|
||||
'battle_attack_basic',
|
||||
'普通攻击',
|
||||
AnimationState.ATTACK,
|
||||
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
|
||||
),
|
||||
createSingleActionBattleOption(
|
||||
'battle_recover_breath',
|
||||
'恢复',
|
||||
AnimationState.IDLE,
|
||||
'回血 12 / 回蓝 9 / 冷却 -1',
|
||||
),
|
||||
preferredItem
|
||||
? createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
`使用物品:${preferredItem.item.name}`,
|
||||
AnimationState.ACQUIRE,
|
||||
buildBattleItemSummary(preferredItem.effect),
|
||||
{
|
||||
runtimePayload: { itemId: preferredItem.item.id },
|
||||
},
|
||||
)
|
||||
: createSingleActionBattleOption(
|
||||
'inventory_use',
|
||||
'使用物品',
|
||||
AnimationState.ACQUIRE,
|
||||
'当前没有可直接结算的战斗消耗品',
|
||||
{
|
||||
disabled: true,
|
||||
disabledReason: '暂无可用物品',
|
||||
},
|
||||
),
|
||||
...character.skills.map((skill) => {
|
||||
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
|
||||
const detailText = [
|
||||
`耗蓝 ${skill.manaCost}`,
|
||||
`伤害 ${skill.damage}`,
|
||||
`冷却 ${skill.cooldownTurns}`,
|
||||
].join(' / ');
|
||||
|
||||
if (remainingCooldown > 0) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (skill.manaCost > state.playerMana) {
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
disabled: true,
|
||||
disabledReason: '灵力不足',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return createSingleActionBattleOption(
|
||||
'battle_use_skill',
|
||||
skill.name,
|
||||
skill.animation,
|
||||
detailText,
|
||||
{
|
||||
runtimePayload: { skillId: skill.id },
|
||||
},
|
||||
);
|
||||
}),
|
||||
createSingleActionBattleOption(
|
||||
'battle_escape_breakout',
|
||||
'逃跑',
|
||||
AnimationState.RUN,
|
||||
'立刻脱离当前战斗',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getFallbackOptionsForState(state: GameState, character: Character) {
|
||||
if (state.inBattle) {
|
||||
return buildSingleActionBattleOptions(state, character);
|
||||
}
|
||||
|
||||
if (!state.worldType) {
|
||||
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: state.inBattle,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: state.sceneHostileNpcs,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const options = getDefaultFunctionIdsForContext(functionContext)
|
||||
.map(functionId => resolveFunctionOption(functionId, functionContext))
|
||||
.filter(Boolean) as StoryOption[];
|
||||
|
||||
return options.length > 0
|
||||
? options.map(option => normalizeSkillProbabilities(option, character))
|
||||
: FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
|
||||
}
|
||||
|
||||
export function buildFallbackStoryMoment(state: GameState, character: Character): StoryMoment {
|
||||
const primaryMonster = state.sceneHostileNpcs.find(monster => monster.hp > 0) ?? state.sceneHostileNpcs[0];
|
||||
const text = state.inBattle && primaryMonster
|
||||
? `${primaryMonster.name}${primaryMonster.action},战斗还没有结束。`
|
||||
: `${state.currentScenePreset?.name ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;
|
||||
|
||||
return {
|
||||
text,
|
||||
options: getFallbackOptionsForState(state, character),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOptionImpactSummary(
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
hp: number,
|
||||
maxHp: number,
|
||||
mana: number,
|
||||
maxMana: number,
|
||||
cooldowns: Record<string, number>,
|
||||
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
|
||||
) {
|
||||
if (option.functionId === 'battle_attack_basic') {
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
|
||||
}
|
||||
|
||||
if (option.functionId === 'battle_use_skill') {
|
||||
const skillId =
|
||||
typeof option.runtimePayload?.skillId === 'string'
|
||||
? option.runtimePayload.skillId
|
||||
: '';
|
||||
const skill = character.skills.find((candidate) => candidate.id === skillId);
|
||||
if (!skill) return null;
|
||||
|
||||
return currentNpcBattleMode === 'spar'
|
||||
? '切磋伤害 1'
|
||||
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
|
||||
}
|
||||
|
||||
const functionMeta = getFunctionById(option.functionId);
|
||||
if (!functionMeta) return null;
|
||||
|
||||
if (functionMeta.category === 'recovery') {
|
||||
const effect = getFunctionEffect(option.functionId);
|
||||
const parts: string[] = [];
|
||||
|
||||
if ((effect.healAmount ?? 0) > 0) {
|
||||
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
|
||||
parts.push(`回血 ${healAmount}`);
|
||||
}
|
||||
|
||||
if ((effect.manaRestore ?? 0) > 0) {
|
||||
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
|
||||
parts.push(`回蓝 ${manaRestore}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
|
||||
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
}
|
||||
|
||||
if (functionMeta.category !== 'battle') return null;
|
||||
|
||||
if (currentNpcBattleMode === 'spar') {
|
||||
return '切磋伤害 1';
|
||||
}
|
||||
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const availableSkills = getAvailableSkills(character, mana, cooldowns, normalizedOption).sort(
|
||||
(a, b) => b.weight - a.weight,
|
||||
);
|
||||
const topSkill = availableSkills[0]?.skill;
|
||||
if (!topSkill) return '耗蓝 -- / 伤害 --';
|
||||
|
||||
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
|
||||
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
|
||||
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
|
||||
}
|
||||
9
src/hooks/generatedState.ts
Normal file
9
src/hooks/generatedState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {Character, GameState} from '../types';
|
||||
|
||||
export type CommitGeneratedState = (
|
||||
nextState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
315
src/hooks/idleAdventureFlow.ts
Normal file
315
src/hooks/idleAdventureFlow.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../data/encounterTransition';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneCallOutEncounter,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../data/sceneEncounterPreviews';
|
||||
import { AnimationState, Character, GameState, StoryOption } from '../types';
|
||||
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const RESET_STAGE_MS = 260;
|
||||
const CALL_OUT_APPROACH_DURATION_MS = 1800;
|
||||
const CALL_OUT_APPROACH_TICK_MS = 180;
|
||||
const CALL_OUT_ALERT_PAUSE_MS = 260;
|
||||
const OBSERVE_SIGNS_DURATION_MS = 5000;
|
||||
const OBSERVE_SIGNS_MIN_PAUSE_MS = 500;
|
||||
const OBSERVE_SIGNS_MAX_PAUSE_MS = 2000;
|
||||
|
||||
type RecoveryApplier = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
functionId: string,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function randomBetween(min: number, max: number) {
|
||||
return Math.round(min + Math.random() * (max - min));
|
||||
}
|
||||
|
||||
async function playEncounterEntrySequence(
|
||||
setGameState: Dispatch<SetStateAction<GameState>>,
|
||||
startState: GameState,
|
||||
finalState: GameState,
|
||||
durationMs: number,
|
||||
tickMs: number,
|
||||
) {
|
||||
if (!hasEncounterEntity(finalState)) {
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
const runTicks = Math.max(1, Math.ceil(durationMs / tickMs));
|
||||
const tickDurationMs = Math.max(1, Math.round(durationMs / runTicks));
|
||||
let currentState = startState;
|
||||
|
||||
setGameState(currentState);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
currentState = interpolateEncounterTransitionState(startState, finalState, progress);
|
||||
setGameState(currentState);
|
||||
await sleep(tickDurationMs);
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function buildIdleAfterSequence(params: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
nextScenePreset: GameState['currentScenePreset'];
|
||||
applyRecoveryEffectToState: RecoveryApplier;
|
||||
}) {
|
||||
const { state, option, character, nextScenePreset, applyRecoveryEffectToState } = params;
|
||||
let afterSequence = applyRecoveryEffectToState(state, character, option.functionId);
|
||||
|
||||
if (option.functionId === 'idle_call_out') {
|
||||
const baseState = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
} as GameState;
|
||||
const callOutState = {
|
||||
...baseState,
|
||||
...createSceneCallOutEncounter(baseState),
|
||||
} as GameState;
|
||||
|
||||
afterSequence = callOutState.sceneHostileNpcs.length > 0 || callOutState.currentEncounter
|
||||
? resolveSceneEncounterPreview(callOutState)
|
||||
: baseState;
|
||||
} else if (option.functionId === 'idle_explore_forward') {
|
||||
afterSequence = resolveSceneEncounterPreview({
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
} as GameState);
|
||||
} else if (option.functionId === 'idle_travel_next_scene') {
|
||||
const travelBaseState = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
} as GameState;
|
||||
const travelEntryState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
} as GameState;
|
||||
afterSequence = hasEncounterEntity(travelEntryState)
|
||||
? resolveSceneEncounterPreview(travelEntryState)
|
||||
: travelBaseState;
|
||||
} else {
|
||||
afterSequence = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentScenePreset: nextScenePreset,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
return afterSequence;
|
||||
}
|
||||
|
||||
export async function playIdleSequence(params: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
finalState: GameState;
|
||||
applyRecoveryEffectToState: RecoveryApplier;
|
||||
}) {
|
||||
const { setGameState, state, option, character, finalState, applyRecoveryEffectToState } = params;
|
||||
let currentState = applyRecoveryEffectToState(state, character, option.functionId);
|
||||
|
||||
if (currentState !== state) {
|
||||
setGameState(currentState);
|
||||
await sleep(RESET_STAGE_MS);
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_observe_signs') {
|
||||
let elapsedMs = 0;
|
||||
let nextFacing: 'left' | 'right' = currentState.playerFacing === 'left' ? 'right' : 'left';
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: 'observe_signs',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
while (elapsedMs < OBSERVE_SIGNS_DURATION_MS) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: 'observe_signs',
|
||||
playerFacing: nextFacing,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
const remainingMs = OBSERVE_SIGNS_DURATION_MS - elapsedMs;
|
||||
const pauseMs = Math.min(
|
||||
remainingMs,
|
||||
randomBetween(OBSERVE_SIGNS_MIN_PAUSE_MS, OBSERVE_SIGNS_MAX_PAUSE_MS),
|
||||
);
|
||||
await sleep(pauseMs);
|
||||
elapsedMs += pauseMs;
|
||||
nextFacing = nextFacing === 'left' ? 'right' : 'left';
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_explore_forward') {
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_call_out') {
|
||||
const callOutAcquireDurationMs = Math.max(
|
||||
CALL_OUT_ALERT_PAUSE_MS,
|
||||
getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(callOutAcquireDurationMs);
|
||||
|
||||
const approachState = {
|
||||
...finalState,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
const entryState = buildEncounterEntryState(approachState, CALL_OUT_ENTRY_X_METERS);
|
||||
const approachedState = await playEncounterEntrySequence(
|
||||
setGameState,
|
||||
entryState,
|
||||
approachState,
|
||||
CALL_OUT_APPROACH_DURATION_MS,
|
||||
CALL_OUT_APPROACH_TICK_MS,
|
||||
);
|
||||
if (
|
||||
approachedState.animationState !== finalState.animationState ||
|
||||
approachedState.playerActionMode !== finalState.playerActionMode ||
|
||||
approachedState.scrollWorld !== finalState.scrollWorld
|
||||
) {
|
||||
setGameState(finalState);
|
||||
}
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_travel_next_scene') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(TURN_VISUAL_MS);
|
||||
|
||||
const entryState = buildEncounterEntryState(finalState, CALL_OUT_ENTRY_X_METERS);
|
||||
return playEncounterEntrySequence(
|
||||
setGameState,
|
||||
entryState,
|
||||
finalState,
|
||||
CALL_OUT_APPROACH_DURATION_MS,
|
||||
CALL_OUT_APPROACH_TICK_MS,
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlayerX = Number((state.playerX + option.visuals.playerMoveMeters).toFixed(2));
|
||||
const shouldAnimateMove =
|
||||
option.visuals.playerAnimation !== AnimationState.IDLE || Math.abs(option.visuals.playerMoveMeters) > 0;
|
||||
|
||||
if (shouldAnimateMove) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: nextPlayerX,
|
||||
playerFacing: option.visuals.playerFacing,
|
||||
animationState: option.visuals.playerAnimation,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: option.visuals.scrollWorld,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(TURN_VISUAL_MS);
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
376
src/hooks/rpg-runtime-story/characterChat.ts
Normal file
376
src/hooks/rpg-runtime-story/characterChat.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/aiService';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
CharacterChatRecord,
|
||||
CharacterChatTurn,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
const MAX_CHARACTER_CHAT_TURNS = 24;
|
||||
|
||||
export type CharacterChatTarget = {
|
||||
character: Character;
|
||||
npcId: string | null;
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
export type CharacterChatModalState = {
|
||||
target: CharacterChatTarget;
|
||||
draft: string;
|
||||
messages: CharacterChatTurn[];
|
||||
suggestions: string[];
|
||||
summary: string;
|
||||
isSending: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export interface CharacterChatUi {
|
||||
modal: CharacterChatModalState | null;
|
||||
openChat: (target: CharacterChatTarget) => void;
|
||||
closeChat: () => void;
|
||||
setDraft: (value: string) => void;
|
||||
useSuggestion: (value: string) => void;
|
||||
refreshSuggestions: () => void;
|
||||
sendDraft: () => void;
|
||||
}
|
||||
|
||||
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
|
||||
return state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
return history.slice(-MAX_CHARACTER_CHAT_TURNS);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSummary(
|
||||
character: Character,
|
||||
history: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${character.name} 在私下对话中变得更加开放。最近的交流:${latestTurns}`
|
||||
: `${character.name} 愿意继续私下对话,并逐渐更加信任玩家。`;
|
||||
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSuggestions(character: Character) {
|
||||
return [
|
||||
'请更清楚地告诉我你的意思。',
|
||||
`${character.name},你真正担心的是什么?`,
|
||||
'暂时放下大局。我想更好地了解你。',
|
||||
];
|
||||
}
|
||||
|
||||
function buildCharacterChatRecordUpdate(
|
||||
state: GameState,
|
||||
characterId: string,
|
||||
record: CharacterChatRecord,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
characterChats: {
|
||||
...state.characterChats,
|
||||
[characterId]: record,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CharacterChatTargetStatus = {
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: target.roleLabel,
|
||||
hp: target.hp,
|
||||
maxHp: target.maxHp,
|
||||
mana: target.mana,
|
||||
maxMana: target.maxMana,
|
||||
affinity: target.affinity ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
|
||||
const loadCharacterChatSuggestions = async (
|
||||
target: CharacterChatTarget,
|
||||
messages: CharacterChatTurn[],
|
||||
summary: string,
|
||||
) => {
|
||||
if (!gameState.worldType || !gameState.playerCharacter) {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
isLoadingSuggestions: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
messages,
|
||||
summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions,
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate character chat suggestions:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openCharacterChat = (target: CharacterChatTarget) => {
|
||||
const record = getCharacterChatRecord(gameState, target.character.id);
|
||||
|
||||
setCharacterChatModal({
|
||||
target,
|
||||
draft: '',
|
||||
messages: record.history,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
summary: record.summary,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
void loadCharacterChatSuggestions(target, record.history, record.summary);
|
||||
};
|
||||
|
||||
const sendCharacterChatDraft = async () => {
|
||||
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = characterChatModal.draft.trim();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = characterChatModal.target;
|
||||
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
|
||||
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
|
||||
const nextMessages = trimCharacterChatHistory([
|
||||
...baseMessages,
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: draft,
|
||||
},
|
||||
]);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft: '',
|
||||
messages: [...nextMessages, {speaker: 'character', text: ''}],
|
||||
suggestions: [],
|
||||
isSending: true,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
let replyText = '';
|
||||
|
||||
try {
|
||||
replyText = await streamCharacterPanelChatReply(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
nextMessages,
|
||||
existingRecord.summary,
|
||||
draft,
|
||||
buildTargetStatus(target),
|
||||
{
|
||||
onUpdate: text => {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: [...nextMessages, {speaker: 'character', text}],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream character panel chat reply:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft,
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMessages = trimCharacterChatHistory([
|
||||
...nextMessages,
|
||||
{
|
||||
speaker: 'character' as const,
|
||||
text: replyText,
|
||||
},
|
||||
]);
|
||||
|
||||
let nextSummary = existingRecord.summary;
|
||||
try {
|
||||
nextSummary = await generateCharacterPanelChatSummary(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
finalMessages,
|
||||
existingRecord.summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to summarize character chat:', error);
|
||||
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
|
||||
}
|
||||
|
||||
const nextRecord: CharacterChatRecord = {
|
||||
history: finalMessages,
|
||||
summary: nextSummary,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setGameState(current =>
|
||||
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
|
||||
);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: finalMessages,
|
||||
summary: nextSummary,
|
||||
isSending: false,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
await loadCharacterChatSuggestions(target, finalMessages, nextSummary);
|
||||
};
|
||||
|
||||
const characterChatUi: CharacterChatUi = {
|
||||
modal: characterChatModal,
|
||||
openChat: openCharacterChat,
|
||||
closeChat: () => setCharacterChatModal(null),
|
||||
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
refreshSuggestions: () => {
|
||||
if (!characterChatModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCharacterChatSuggestions(
|
||||
characterChatModal.target,
|
||||
characterChatModal.messages,
|
||||
characterChatModal.summary,
|
||||
);
|
||||
},
|
||||
sendDraft: () => {
|
||||
void sendCharacterChatDraft();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
characterChatModal,
|
||||
setCharacterChatModal,
|
||||
characterChatUi,
|
||||
openCharacterChat,
|
||||
sendCharacterChatDraft,
|
||||
loadCharacterChatSuggestions,
|
||||
clearCharacterChatModal: () => setCharacterChatModal(null),
|
||||
};
|
||||
}
|
||||
749
src/hooks/rpg-runtime-story/choiceActions.test.ts
Normal file
749
src/hooks/rpg-runtime-story/choiceActions.test.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
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,
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const deferredOptions = [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '继续向前探索',
|
||||
text: '继续向前探索',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
] satisfies StoryOption[];
|
||||
const continueOption: StoryOption = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '查看后续',
|
||||
text: '查看后续',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '对话已经完成',
|
||||
options: [continueOption],
|
||||
deferredOptions,
|
||||
};
|
||||
const setCurrentStory = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const handleNpcInteraction = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(
|
||||
(option: StoryOption) =>
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(continueOption);
|
||||
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
});
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(handleNpcInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies deferred runtime state when story_continue_adventure reveals the next act', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const deferredOptions = [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察下一幕的线索',
|
||||
text: '观察下一幕的线索',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
] satisfies StoryOption[];
|
||||
const continueOption: StoryOption = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
text: '继续冒险',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '对话已经完成',
|
||||
options: [continueOption],
|
||||
deferredOptions,
|
||||
deferredRuntimeState: {
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-bridge',
|
||||
chapterId: 'scene-bridge-chapter',
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['scene-bridge-act-1'],
|
||||
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
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.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(
|
||||
(option: StoryOption) =>
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(continueOption);
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
...state,
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
},
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
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.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
}),
|
||||
);
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
|
||||
const state: GameState = {
|
||||
...createBaseState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc' as const,
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const option: StoryOption = {
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
interaction: {
|
||||
kind: 'npc' as const,
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade' as const,
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
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(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
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.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
});
|
||||
|
||||
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
|
||||
const encounter: Encounter = {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
};
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
};
|
||||
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const handleNpcBattleConversationContinuation = vi.fn(() => true);
|
||||
|
||||
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.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
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),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nextState: expect.objectContaining({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
encounter,
|
||||
actionText: '挥刀抢攻',
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
battleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.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,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
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,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: -1.2,
|
||||
};
|
||||
const setBattleReward = vi.fn();
|
||||
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
}));
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'escape' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState,
|
||||
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.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
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),
|
||||
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:你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
]);
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
lastFunctionId: 'battle_escape_breakout',
|
||||
recentActionResult: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
}),
|
||||
);
|
||||
expect(setBattleReward).toHaveBeenCalledTimes(1);
|
||||
expect(setBattleReward).toHaveBeenCalledWith(null);
|
||||
expect(incrementRuntimeStats).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
292
src/hooks/rpg-runtime-story/choiceActions.ts
Normal file
292
src/hooks/rpg-runtime-story/choiceActions.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type {
|
||||
CommitGeneratedStateWithEncounterEntry,
|
||||
GenerateStoryForState,
|
||||
} from './progressionActions';
|
||||
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
|
||||
import {
|
||||
runCampTravelHomeChoice,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
if (option.disabled) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset:
|
||||
currentStory.deferredRuntimeState.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
}
|
||||
setCurrentStory({
|
||||
...currentStory,
|
||||
options: currentStory.deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await runCampTravelHomeChoice({
|
||||
gameState,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
isNpcEncounter,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenLocalRuntimeNpcModal(option)) {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
buildFallbackStoryForState,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
enterNpcInteraction(gameState.currentEncounter, option.actionText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc') {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
setAiError(null);
|
||||
await handleTreasureInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
await runLocalStoryChoiceContinuation({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
finalizeNpcBattleResult,
|
||||
isRegularNpcEncounter,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
};
|
||||
}
|
||||
88
src/hooks/rpg-runtime-story/goalFlow.ts
Normal file
88
src/hooks/rpg-runtime-story/goalFlow.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGoalStackState,
|
||||
createGoalPulseSnapshot,
|
||||
deriveGoalPulseEvent,
|
||||
} from '../../services/storyEngine/goalDirector';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GoalFlowUi } from './uiTypes';
|
||||
|
||||
export function useStoryGoalFlow(gameState: GameState) {
|
||||
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
|
||||
const previousGoalPulseSnapshotRef =
|
||||
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
|
||||
|
||||
const runtimeGoalStack = useMemo(
|
||||
() =>
|
||||
buildGoalStackState({
|
||||
quests: gameState.quests,
|
||||
worldType: gameState.worldType,
|
||||
currentSceneId: gameState.currentScenePreset?.id ?? null,
|
||||
chapterState:
|
||||
gameState.chapterState ??
|
||||
gameState.storyEngineMemory?.currentChapter ??
|
||||
null,
|
||||
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective:
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
currentCampEvent:
|
||||
gameState.storyEngineMemory?.currentCampEvent ?? null,
|
||||
currentSceneName: gameState.currentScenePreset?.name ?? null,
|
||||
}),
|
||||
[
|
||||
gameState.chapterState,
|
||||
gameState.currentScenePreset?.id,
|
||||
gameState.currentScenePreset?.name,
|
||||
gameState.quests,
|
||||
gameState.storyEngineMemory?.currentCampEvent,
|
||||
gameState.storyEngineMemory?.currentChapter,
|
||||
gameState.storyEngineMemory?.currentJourneyBeat,
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective,
|
||||
gameState.worldType,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSnapshot = createGoalPulseSnapshot(
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
);
|
||||
const previousSnapshot = previousGoalPulseSnapshotRef.current;
|
||||
|
||||
if (!previousSnapshot) {
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPulse = deriveGoalPulseEvent({
|
||||
previous: previousSnapshot,
|
||||
quests: gameState.quests,
|
||||
goalStack: runtimeGoalStack,
|
||||
});
|
||||
if (nextPulse) {
|
||||
setGoalPulse(nextPulse);
|
||||
}
|
||||
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
}, [gameState.quests, runtimeGoalStack]);
|
||||
|
||||
const dismissGoalPulse = useCallback(() => {
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
const resetGoalPulseTracking = useCallback(() => {
|
||||
previousGoalPulseSnapshotRef.current = null;
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runtimeGoalStack,
|
||||
goalUi: {
|
||||
goalStack: runtimeGoalStack,
|
||||
pulse: goalPulse,
|
||||
dismissPulse: dismissGoalPulse,
|
||||
} satisfies GoalFlowUi,
|
||||
resetGoalPulseTracking,
|
||||
};
|
||||
}
|
||||
52
src/hooks/rpg-runtime-story/index.ts
Normal file
52
src/hooks/rpg-runtime-story/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice,
|
||||
resumeRpgRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
export {
|
||||
useRpgRuntimeInteractionFlow,
|
||||
createRpgRuntimeInteractionUiResetter,
|
||||
type RpgRuntimeInteractionFlowResult,
|
||||
type UseRpgRuntimeInteractionFlowParams,
|
||||
} from './useRpgRuntimeInteractionFlow';
|
||||
export {
|
||||
useRpgRuntimeNpcInteraction,
|
||||
type RpgRuntimeNpcInteractionResult,
|
||||
type UseRpgRuntimeNpcInteractionParams,
|
||||
} from './useRpgRuntimeNpcInteraction';
|
||||
export {
|
||||
useRpgRuntimeStory,
|
||||
type BattleRewardSummary,
|
||||
type BattleRewardUi,
|
||||
type CharacterChatModalState,
|
||||
type CharacterChatTarget,
|
||||
type CharacterChatUi,
|
||||
type GiftModalState,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type RecruitModalState,
|
||||
type RpgRuntimeStoryResult,
|
||||
type StoryGenerationNpcUi,
|
||||
type TradeModalState,
|
||||
type UseRpgRuntimeStoryParams,
|
||||
} from './useRpgRuntimeStory';
|
||||
export {
|
||||
useRpgRuntimeStoryController,
|
||||
type RpgRuntimeStoryControllerResult,
|
||||
type UseRpgRuntimeStoryControllerParams,
|
||||
} from './useRpgRuntimeStoryController';
|
||||
export {
|
||||
useRpgRuntimeStoryFlow,
|
||||
type RpgRuntimeStoryFlowResult,
|
||||
type UseRpgRuntimeStoryFlowParams,
|
||||
} from './useRpgRuntimeStoryFlow';
|
||||
export {
|
||||
createRpgRuntimeStoryUiResetter,
|
||||
useRpgRuntimeStoryState,
|
||||
type RpgRuntimeStoryStateResult,
|
||||
type UseRpgRuntimeStoryStateParams,
|
||||
} from './useRpgRuntimeStoryState';
|
||||
196
src/hooks/rpg-runtime-story/inventoryActions.ts
Normal file
196
src/hooks/rpg-runtime-story/inventoryActions.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
INVENTORY_USE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryInventoryActions({
|
||||
gameState,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
};
|
||||
}) {
|
||||
const {
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
!character ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
setAiError(error instanceof Error ? error.message : '背包动作执行失败');
|
||||
if (!currentStory) {
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, character));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: INVENTORY_USE_FUNCTION.id,
|
||||
actionText: `使用${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const equipInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
actionText: `装备${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
payload: { slotId: slot },
|
||||
});
|
||||
};
|
||||
|
||||
const craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_DISMANTLE_FUNCTION.id,
|
||||
actionText: `拆解${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const reforgeItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
} satisfies InventoryFlowUi,
|
||||
};
|
||||
}
|
||||
1888
src/hooks/rpg-runtime-story/npcEncounterActions.test.ts
Normal file
1888
src/hooks/rpg-runtime-story/npcEncounterActions.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
773
src/hooks/rpg-runtime-story/npcInteraction.ts
Normal file
773
src/hooks/rpg-runtime-story/npcInteraction.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type StoryNpcInteractionRuntime = {
|
||||
currentStory: StoryMoment | null;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
|
||||
return [
|
||||
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
|
||||
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
|
||||
releaseLine,
|
||||
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function normalizeRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
dialogueText: string,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
|
||||
const defaultLines = [
|
||||
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
|
||||
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
|
||||
releaseLine,
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
const firstDefaultLine = defaultLines[0];
|
||||
if (firstDefaultLine) {
|
||||
workingLines.unshift(firstDefaultLine);
|
||||
}
|
||||
}
|
||||
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
}
|
||||
}
|
||||
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
workingLines[workingLines.length - 1] = acceptanceLine;
|
||||
}
|
||||
|
||||
const compactLines = workingLines.slice(0, 5);
|
||||
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
|
||||
compactLines.push(acceptanceLine);
|
||||
}
|
||||
|
||||
return compactLines.slice(0, 6).join('\n');
|
||||
}
|
||||
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity?: number,
|
||||
) => InventoryItem;
|
||||
runtime: StoryNpcInteractionRuntime;
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
|
||||
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
|
||||
};
|
||||
|
||||
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
|
||||
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
|
||||
|
||||
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
|
||||
};
|
||||
|
||||
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
return getTradeNpcItem(state, modal)?.quantity ?? 0;
|
||||
}
|
||||
|
||||
return getTradePlayerItem(state, modal)?.quantity ?? 0;
|
||||
};
|
||||
|
||||
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
|
||||
const maxQuantity = getTradeMaxQuantity(state, modal);
|
||||
if (maxQuantity <= 0) return 1;
|
||||
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 resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
releasedNpcId?: string | null;
|
||||
preludeText?: string | null;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: 'npc_recruit',
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: 'recruit',
|
||||
},
|
||||
},
|
||||
payload: {
|
||||
...(params.releasedNpcId
|
||||
? {
|
||||
releaseNpcId: params.releasedNpcId,
|
||||
}
|
||||
: {}),
|
||||
...(params.preludeText
|
||||
? {
|
||||
preludeText: params.preludeText,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecruitmentSequence = async (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
releasedNpcId?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return;
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const provisionalState = {
|
||||
...gameState,
|
||||
};
|
||||
const recruitPromptSummary = releasedCompanionName
|
||||
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
|
||||
: '如果对方答应加入,你们将立刻结伴同行。';
|
||||
|
||||
setRecruitModal(null);
|
||||
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 streamNpcRecruitDialogue(
|
||||
gameState.worldType!,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
releasedNpcId,
|
||||
preludeText: finalDialogueText,
|
||||
});
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const currentNpcState = getResolvedNpcState(gameState, encounter);
|
||||
const npcState = syncNpcTradeInventory(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState,
|
||||
);
|
||||
|
||||
if (
|
||||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||||
|| npcState !== currentNpcState
|
||||
) {
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
|
||||
setTradeModal(
|
||||
buildNpcTradeModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
const selectedItemId = getPreferredGiftItemId(
|
||||
gameState.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
setTradeModal(null);
|
||||
setGiftModal(null);
|
||||
setRecruitModal(null);
|
||||
};
|
||||
|
||||
const resolveServerNpcAction = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
functionId: string;
|
||||
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: params.action,
|
||||
},
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmTrade = () => {
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = tradeModal.encounter;
|
||||
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
|
||||
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
|
||||
const totalPrice = unitPrice * quantity;
|
||||
|
||||
if (tradeModal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(gameState, tradeModal);
|
||||
if (!npcItem || quantity <= 0) return;
|
||||
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
itemId: npcItem.id,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(gameState, tradeModal);
|
||||
if (!playerItem || quantity <= 0) return;
|
||||
if (playerItem.quantity < quantity) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
itemId: playerItem.id,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmGift = () => {
|
||||
if (!giftModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = giftModal.encounter;
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
setGiftModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
npcUi: {
|
||||
tradeModal,
|
||||
giftModal,
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
|
||||
}
|
||||
: current),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
confirmTrade,
|
||||
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
void startRecruitmentSequence(
|
||||
recruitModal.encounter,
|
||||
recruitModal.actionText,
|
||||
recruitModal.selectedReleaseNpcId,
|
||||
);
|
||||
},
|
||||
} satisfies StoryGenerationNpcUi,
|
||||
openTradeModal,
|
||||
openGiftModal,
|
||||
openRecruitModal,
|
||||
startRecruitmentSequence,
|
||||
clearNpcInteractionUi,
|
||||
};
|
||||
}
|
||||
790
src/hooks/rpg-runtime-story/progressionActions.ts
Normal file
790
src/hooks/rpg-runtime-story/progressionActions.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
acceptQuest,
|
||||
buildChapterQuestForScene,
|
||||
getChapterQuestForScene,
|
||||
} from '../../data/questFlow';
|
||||
import { resolveSceneChapterBlueprint } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { resolveCurrentActState } from '../../services/storyEngine/actPlanner';
|
||||
import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack';
|
||||
import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner';
|
||||
import {
|
||||
advanceCampaignState,
|
||||
resolveCampaignState,
|
||||
} from '../../services/storyEngine/campaignDirector';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import {
|
||||
applyCompanionReactionToStance,
|
||||
buildCompanionReactionBatch,
|
||||
} from '../../services/storyEngine/companionReactionDirector';
|
||||
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
|
||||
import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
|
||||
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
|
||||
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
|
||||
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
|
||||
import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex';
|
||||
import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks';
|
||||
import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport';
|
||||
import {
|
||||
recordReplaySeed,
|
||||
replayNarrativeRun,
|
||||
} from '../../services/storyEngine/narrativeRegressionReplay';
|
||||
import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry';
|
||||
import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler';
|
||||
import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab';
|
||||
import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest';
|
||||
import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport';
|
||||
import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
collectStorySignals,
|
||||
resolveSignalsToThreadUpdates,
|
||||
} from '../../services/storyEngine/threadSignalRouter';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
applyWorldMutationsToGameState,
|
||||
resolveWorldMutations,
|
||||
} from '../../services/storyEngine/worldMutationRouter';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
function hydrateStoryEngineMemory(state: GameState): GameState {
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
) ??
|
||||
state.customWorldProfile.playableNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack ??
|
||||
buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph ??
|
||||
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
const npcState =
|
||||
state.npcStates[
|
||||
state.currentEncounter.id ?? state.currentEncounter.npcName
|
||||
];
|
||||
const activeThreadIds =
|
||||
storyEngineMemory.activeThreadIds.length > 0
|
||||
? storyEngineMemory.activeThreadIds
|
||||
: narrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage:
|
||||
npcState?.affinity != null
|
||||
? npcState.affinity < 15
|
||||
? 'guarded'
|
||||
: npcState.affinity < 45
|
||||
? 'partial'
|
||||
: npcState.affinity < 75
|
||||
? 'honest'
|
||||
: 'deep'
|
||||
: 'guarded',
|
||||
isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true,
|
||||
seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
discoveredFactIds: dedupeStrings(
|
||||
[
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...visibilitySlice.sayableFactIds,
|
||||
],
|
||||
16,
|
||||
),
|
||||
activeThreadIds: dedupeStrings(
|
||||
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
|
||||
6,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
|
||||
const previousIds = new Set(
|
||||
previousState.playerInventory.map((item) => item.id),
|
||||
);
|
||||
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
|
||||
}
|
||||
|
||||
function ensureSceneChapterQuestState(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
}) {
|
||||
const storyEngineMemory =
|
||||
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const scene = params.nextState.currentScenePreset;
|
||||
if (
|
||||
params.nextState.currentScene !== 'Story' ||
|
||||
!params.nextState.worldType ||
|
||||
!scene?.id
|
||||
) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const openedSceneChapterIds = dedupeStrings(
|
||||
[...(storyEngineMemory.openedSceneChapterIds ?? [])],
|
||||
64,
|
||||
);
|
||||
if (openedSceneChapterIds.includes(scene.id)) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextMemory = {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
const existingChapterQuest = getChapterQuestForScene(
|
||||
params.nextState.quests,
|
||||
scene.id,
|
||||
);
|
||||
if (existingChapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const sceneChapter = resolveSceneChapterBlueprint(
|
||||
params.nextState.customWorldProfile,
|
||||
scene.id,
|
||||
);
|
||||
const sceneChapterContext = sceneChapter
|
||||
? {
|
||||
sceneTaskDescription: sceneChapter.sceneTaskDescription,
|
||||
actEventDescriptions: sceneChapter.acts
|
||||
.map((act) => act.eventDescription)
|
||||
.filter(Boolean),
|
||||
primaryNpcName:
|
||||
params.nextState.customWorldProfile?.storyNpcs.find(
|
||||
(npc) => npc.id === sceneChapter.acts[0]?.primaryNpcId,
|
||||
)?.name ?? sceneChapter.acts[0]?.primaryNpcId ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const chapterQuest = buildChapterQuestForScene({
|
||||
scene,
|
||||
worldType: params.nextState.worldType,
|
||||
sceneChapterContext,
|
||||
context: {
|
||||
worldType: params.nextState.worldType,
|
||||
actState: params.nextState.storyEngineMemory?.actState ?? null,
|
||||
recentStoryMoments: params.nextState.storyHistory.slice(-6),
|
||||
playerCharacter: params.nextState.playerCharacter,
|
||||
playerProgression: params.nextState.playerProgression ?? null,
|
||||
},
|
||||
});
|
||||
if (!chapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
quests: acceptQuest(params.nextState.quests, chapterQuest),
|
||||
};
|
||||
}
|
||||
|
||||
function applyStoryEngineEchoes(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
lastFunctionId?: string | null;
|
||||
}) {
|
||||
const hydratedState = hydrateStoryEngineMemory(params.nextState);
|
||||
const contracts = hydratedState.customWorldProfile
|
||||
? (hydratedState.customWorldProfile.threadContracts ??
|
||||
buildThreadContractsFromProfile(hydratedState.customWorldProfile))
|
||||
: [];
|
||||
const newItems = findNewInventoryItems(params.previousState, hydratedState);
|
||||
const signals = collectStorySignals({
|
||||
prevState: params.previousState,
|
||||
nextState: hydratedState,
|
||||
actionText: params.actionText,
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
rewardItems: newItems,
|
||||
});
|
||||
const stateWithSignals = resolveSignalsToThreadUpdates({
|
||||
state: hydratedState,
|
||||
signals,
|
||||
contracts,
|
||||
});
|
||||
const stateWithSceneChapter = ensureSceneChapterQuestState({
|
||||
previousState: params.previousState,
|
||||
nextState: stateWithSignals,
|
||||
});
|
||||
const reactions = buildCompanionReactionBatch({
|
||||
state: stateWithSceneChapter,
|
||||
signals,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const stateWithReactions = applyCompanionReactionToStance({
|
||||
state: stateWithSceneChapter,
|
||||
reactions,
|
||||
});
|
||||
const storyEngineMemory =
|
||||
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
stateWithReactions.chapterState ??
|
||||
storyEngineMemory.currentChapter ??
|
||||
null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: stateWithReactions,
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...stateWithReactions,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
},
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state: stateWithReactions,
|
||||
reactions,
|
||||
}),
|
||||
});
|
||||
const campEvent = evaluateCampEventOpportunity({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const worldMutations = resolveWorldMutations({
|
||||
state: stateWithReactions,
|
||||
signals,
|
||||
chapterState,
|
||||
});
|
||||
const stateWithMutations = applyWorldMutationsToGameState({
|
||||
state: stateWithReactions,
|
||||
mutations: worldMutations,
|
||||
});
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const chronicle = appendChronicleEntries({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
worldMutations,
|
||||
reactions,
|
||||
signals,
|
||||
campEvent,
|
||||
setpieceDirective,
|
||||
});
|
||||
const factionTensionStates = buildFactionTensionState(
|
||||
stateWithMutations.customWorldProfile,
|
||||
storyEngineMemory,
|
||||
);
|
||||
const actState = resolveCurrentActState({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
});
|
||||
const campaignState = advanceCampaignState({
|
||||
previous:
|
||||
storyEngineMemory.campaignState ??
|
||||
stateWithMutations.campaignState ??
|
||||
null,
|
||||
next: resolveCampaignState({
|
||||
state: stateWithMutations,
|
||||
actState,
|
||||
}),
|
||||
});
|
||||
const consequenceLedger = appendConsequenceRecord({
|
||||
existing: storyEngineMemory.consequenceLedger,
|
||||
signals,
|
||||
reactions,
|
||||
worldMutations,
|
||||
campEvent,
|
||||
});
|
||||
const authorialConstraintPack = buildAuthorialConstraintPack({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
});
|
||||
const compiledPacks = stateWithMutations.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
})
|
||||
: null;
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
|
||||
compiledPacks?.scenarioPack ??
|
||||
null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
const playerStyleProfile = updatePlayerStyleProfileFromAction({
|
||||
current: storyEngineMemory.playerStyleProfile,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const companionResolutions = resolveAllCompanionResolutions({
|
||||
state: stateWithMutations,
|
||||
arcStates: companionArcStates,
|
||||
ledger: consequenceLedger,
|
||||
reactions,
|
||||
});
|
||||
const endingState =
|
||||
actState?.status === 'finale' || actState?.status === 'resolved'
|
||||
? resolveEndingState({
|
||||
state: stateWithMutations,
|
||||
companionResolutions,
|
||||
factionTensionStates,
|
||||
})
|
||||
: (storyEngineMemory.endingState ?? null);
|
||||
const epilogueSummary = endingState
|
||||
? buildEpilogueSummary({
|
||||
endingState,
|
||||
companionResolutions,
|
||||
})
|
||||
: null;
|
||||
const currentJourneyBeatId =
|
||||
journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
|
||||
const branchBudgetStatus = evaluateBranchBudget({
|
||||
consequenceLedger,
|
||||
authorialConstraintPack,
|
||||
endingFamilyCount: endingState ? 1 : 0,
|
||||
});
|
||||
const baseMemoryForQa = {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
currentJourneyBeatId,
|
||||
currentJourneyBeat: journeyBeat,
|
||||
companionArcStates,
|
||||
worldMutations,
|
||||
chronicle,
|
||||
factionTensionStates,
|
||||
currentCampEvent: campEvent,
|
||||
currentSetpieceDirective: setpieceDirective,
|
||||
campaignState,
|
||||
actState,
|
||||
consequenceLedger,
|
||||
companionResolutions,
|
||||
endingState,
|
||||
authorialConstraintPack,
|
||||
branchBudgetStatus,
|
||||
playerStyleProfile,
|
||||
};
|
||||
const consistencyIssues = runNarrativeConsistencyChecks({
|
||||
memory: baseMemoryForQa,
|
||||
threadContracts: contracts,
|
||||
branchBudgetStatus,
|
||||
});
|
||||
const narrativeQaReport = buildNarrativeQaReport({
|
||||
issues: consistencyIssues,
|
||||
});
|
||||
const simulationRunResults =
|
||||
activeScenarioPack && activeCampaignPack
|
||||
? runPlaythroughMatrix({
|
||||
scenarioPackId: activeScenarioPack.id,
|
||||
campaignPack: activeCampaignPack,
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
seeds: ['baseline', 'companion', 'explore'],
|
||||
})
|
||||
: [];
|
||||
const replaySummary = simulationRunResults[0]
|
||||
? replayNarrativeRun({
|
||||
recordedSeed: recordReplaySeed({
|
||||
seed: simulationRunResults[0].seed,
|
||||
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
|
||||
}),
|
||||
result: simulationRunResults[0],
|
||||
}).summary
|
||||
: null;
|
||||
const releaseGateReport = buildReleaseGateReport({
|
||||
qaReport: narrativeQaReport,
|
||||
simulationResults: simulationRunResults,
|
||||
unresolvedThreadCount:
|
||||
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
|
||||
});
|
||||
const saveMigrationManifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const telemetrySnapshot = captureNarrativeTelemetry({
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
qaReport: narrativeQaReport,
|
||||
});
|
||||
const contentDiffReport = buildContentDiffReport({
|
||||
previousProfile: params.previousState.customWorldProfile,
|
||||
nextProfile: stateWithMutations.customWorldProfile,
|
||||
previousCampaignPack: null,
|
||||
nextCampaignPack: activeCampaignPack,
|
||||
});
|
||||
const narrativeCodex = buildNarrativeCodex({
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
},
|
||||
});
|
||||
const continueDigest =
|
||||
buildContinueGameDigest({
|
||||
state: {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
narrativeCodex,
|
||||
saveMigrationManifest,
|
||||
},
|
||||
},
|
||||
}) +
|
||||
[
|
||||
epilogueSummary,
|
||||
replaySummary,
|
||||
telemetrySnapshot.summary,
|
||||
contentDiffReport.summary,
|
||||
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
activeScenarioPackId:
|
||||
activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
continueGameDigest: continueDigest,
|
||||
narrativeQaReport,
|
||||
narrativeCodex,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
saveMigrationManifest,
|
||||
recentCompanionReactions: [
|
||||
...(storyEngineMemory.recentCompanionReactions ?? []),
|
||||
...reactions,
|
||||
].slice(-6),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: GameState['storyHistory'];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
export function appendStoryHistory(
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
): GameState['storyHistory'] {
|
||||
return [
|
||||
...state.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
}
|
||||
|
||||
export function createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const commitGeneratedState: CommitGeneratedState = async (
|
||||
nextState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(stateWithHistory, character, resultText),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry =
|
||||
async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
setGameState(entryState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (hasEncounterEntity(resolvedState)) {
|
||||
const runTicks = Math.max(
|
||||
1,
|
||||
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
|
||||
);
|
||||
const tickDurationMs = Math.max(
|
||||
1,
|
||||
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
|
||||
);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
setGameState(
|
||||
interpolateEncounterTransitionState(
|
||||
entryState,
|
||||
resolvedState,
|
||||
progress,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, tickDurationMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(stateWithHistory, character, resultText),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
};
|
||||
}
|
||||
140
src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts
Normal file
140
src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
*/
|
||||
export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText: response.presentation.storyText,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
export async function resumeServerRuntimeStory(
|
||||
snapshot: HydratedSavedGameSnapshot,
|
||||
) {
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(snapshot);
|
||||
const shouldRefreshFromServer =
|
||||
hydratedSnapshot.gameState.currentScene === 'Story' &&
|
||||
Boolean(hydratedSnapshot.gameState.worldType) &&
|
||||
Boolean(hydratedSnapshot.gameState.playerCharacter);
|
||||
|
||||
if (!shouldRefreshFromServer) {
|
||||
return {
|
||||
hydratedSnapshot,
|
||||
nextStory: hydratedSnapshot.currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
fallbackGameState: hydratedSnapshot.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
|
||||
return {
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
nextStory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveServerRuntimeChoice(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'> &
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadRpgRuntimeOptionCatalogParams = Parameters<
|
||||
typeof loadServerRuntimeOptionCatalog
|
||||
>[0];
|
||||
export type ResolveRpgRuntimeChoiceParams = Parameters<
|
||||
typeof resolveServerRuntimeChoice
|
||||
>[0];
|
||||
|
||||
export const loadRpgRuntimeOptionCatalog = loadServerRuntimeOptionCatalog;
|
||||
export const resumeRpgRuntimeStory = resumeServerRuntimeStory;
|
||||
export const resolveRpgRuntimeChoice = resolveServerRuntimeChoice;
|
||||
642
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
Normal file
642
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getRuntimeStoryStateMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<
|
||||
typeof import('../../services/rpg-runtime/rpgRuntimeStoryClient')
|
||||
>(
|
||||
'../../services/rpg-runtime/rpgRuntimeStoryClient',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
});
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { WorldType } from '../../types';
|
||||
import {
|
||||
loadServerRuntimeOptionCatalog,
|
||||
resolveServerRuntimeChoice,
|
||||
resumeServerRuntimeStory,
|
||||
} from './runtimeStoryCoordinator';
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createRuntimeNpcBattleSnapshot(
|
||||
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
|
||||
) {
|
||||
return {
|
||||
version: 8,
|
||||
savedAt: '2026-04-14T00:00:00.000Z',
|
||||
bottomTab: 'adventure' as const,
|
||||
currentStory: createStory('战斗中的服务端故事'),
|
||||
gameState: {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
currentScene: 'Story',
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '拦路的刀客',
|
||||
context: '断桥口',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-bandit',
|
||||
name: '断桥匪首',
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
description: '拦路的刀客',
|
||||
},
|
||||
],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 42,
|
||||
playerMaxHp: 50,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-bandit': {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as unknown as GameState,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-opponent',
|
||||
action: 'chat',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const hydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
} as GameState,
|
||||
currentStory: createStory('快照中的故事'),
|
||||
bottomTab: 'adventure',
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 96,
|
||||
maxHp: 100,
|
||||
mana: 18,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '关系已有变化',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
option,
|
||||
targetId: 'npc-opponent',
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '快照中的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
const serverHydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('服务端快照故事'),
|
||||
bottomTab: 'character' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端恢复后的故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverHydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '服务端恢复后的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Home',
|
||||
worldType: null,
|
||||
playerCharacter: null,
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'adventure' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
|
||||
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
|
||||
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-bandit',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_probe_pressure',
|
||||
actionText: '稳步试探',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawBattleSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_probe_pressure',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 7,
|
||||
});
|
||||
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
|
||||
runtimeActionVersion: 8,
|
||||
playerHp: 39,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-bandit',
|
||||
name: '断桥匪首',
|
||||
hp: 14,
|
||||
maxHp: 32,
|
||||
description: '拦路的刀客',
|
||||
},
|
||||
] as unknown as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 39,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_guard_break',
|
||||
actionText: '破架重击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '断桥匪首还在步步逼近。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawServerBattleSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
hp: 14,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory).not.toBeNull();
|
||||
expect(result.nextStory?.options[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'battle_guard_break',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
|
||||
resumeRpgRuntimeStory as resumeServerRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
249
src/hooks/rpg-runtime-story/sessionActions.test.ts
Normal file
249
src/hooks/rpg-runtime-story/sessionActions.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
acknowledgeQuestCompletionState,
|
||||
applyQuestRewardClaim,
|
||||
} from './sessionActions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(id: string, name: string, quantity = 1): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
|
||||
return {
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-trader',
|
||||
issuerNpcName: 'Trader Lin',
|
||||
sceneId: 'scene-1',
|
||||
chapterId: 'chapter:scene:scene-1',
|
||||
title: 'Deliver the cache',
|
||||
description: 'Deliver the cache safely.',
|
||||
summary: 'Help Trader Lin recover the cache.',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'cache',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status,
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 12,
|
||||
items: [createInventoryItem('reward-herb', 'Reward Herb', 2)],
|
||||
},
|
||||
rewardText: 'Trader Lin rewards you for the delivery.',
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const encounter = {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc' as const,
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 5,
|
||||
playerInventory: [createInventoryItem('starter-potion', 'Starter Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
affinity: 4,
|
||||
},
|
||||
},
|
||||
quests: [createQuest('completed')],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sessionActions', () => {
|
||||
it('marks quest completion notifications without changing unrelated quest state', () => {
|
||||
const nextState = acknowledgeQuestCompletionState(createBaseState(), 'quest-1');
|
||||
|
||||
expect(nextState.quests[0]?.completionNotified).toBe(true);
|
||||
expect(nextState.quests[0]?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns null when trying to claim a reward for a quest that is not completed', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
quests: [createQuest('active')],
|
||||
};
|
||||
|
||||
expect(applyQuestRewardClaim(state, 'quest-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
|
||||
const rewardClaim = applyQuestRewardClaim(createBaseState(), 'quest-1');
|
||||
|
||||
expect(rewardClaim).not.toBeNull();
|
||||
if (!rewardClaim) {
|
||||
throw new Error('Expected quest reward claim state');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.quests[0]?.status).toBe('turned_in');
|
||||
expect(rewardClaim.nextState.playerCurrency).toBe(17);
|
||||
expect(rewardClaim.nextState.playerInventory.find((item) => item.id === 'reward-herb')?.quantity).toBe(2);
|
||||
expect(rewardClaim.nextState.npcStates['npc-trader']?.affinity).toBe(7);
|
||||
expect(rewardClaim).toHaveProperty('handoff');
|
||||
});
|
||||
|
||||
it('refreshes chapter state after a chapter quest is turned in', () => {
|
||||
const baseState = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '断桥边风声未散。',
|
||||
imageSrc: '/scene-1.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
chapterState: {
|
||||
id: 'chapter:scene:scene-1',
|
||||
title: '断桥旧哨·高潮',
|
||||
theme: '回报遗迹调查',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax' as const,
|
||||
chapterSummary: '当前章节已逼近最后收束。',
|
||||
sceneId: 'scene-1',
|
||||
chapterQuestId: 'quest-1',
|
||||
},
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
inferredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
openedSceneChapterIds: ['scene-1'],
|
||||
recentSignalIds: [],
|
||||
recentCompanionReactions: [],
|
||||
currentChapter: {
|
||||
id: 'chapter:scene:scene-1',
|
||||
title: '断桥旧哨·高潮',
|
||||
theme: '回报遗迹调查',
|
||||
primaryThreadIds: [],
|
||||
stage: 'climax' as const,
|
||||
chapterSummary: '当前章节已逼近最后收束。',
|
||||
sceneId: 'scene-1',
|
||||
chapterQuestId: 'quest-1',
|
||||
},
|
||||
currentJourneyBeatId: null,
|
||||
currentJourneyBeat: null,
|
||||
companionArcStates: [],
|
||||
worldMutations: [],
|
||||
chronicle: [],
|
||||
factionTensionStates: [],
|
||||
currentCampEvent: null,
|
||||
currentSetpieceDirective: null,
|
||||
continueGameDigest: null,
|
||||
campaignState: null,
|
||||
actState: null,
|
||||
consequenceLedger: [],
|
||||
companionResolutions: [],
|
||||
endingState: null,
|
||||
authorialConstraintPack: null,
|
||||
branchBudgetStatus: null,
|
||||
narrativeQaReport: null,
|
||||
narrativeCodex: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const rewardClaim = applyQuestRewardClaim(baseState, 'quest-1');
|
||||
expect(rewardClaim).not.toBeNull();
|
||||
if (!rewardClaim) {
|
||||
throw new Error('Expected reward claim result');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
|
||||
});
|
||||
});
|
||||
176
src/hooks/rpg-runtime-story/sessionActions.ts
Normal file
176
src/hooks/rpg-runtime-story/sessionActions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
findQuestById,
|
||||
markQuestCompletionNotified,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
GameState,
|
||||
StoryMoment,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function acknowledgeQuestCompletionState(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): GameState {
|
||||
return {
|
||||
...state,
|
||||
quests: markQuestCompletionNotified(state.quests, questId),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyQuestRewardClaim(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): {
|
||||
nextState: GameState;
|
||||
handoff: ReturnType<typeof buildGoalHandoffFromState>;
|
||||
} | null {
|
||||
const quest = findQuestById(state.quests, questId);
|
||||
if (!quest || (quest.status !== 'completed' && quest.status !== 'ready_to_turn_in')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
|
||||
npcStates: issuerNpcState
|
||||
? {
|
||||
...state.npcStates,
|
||||
[quest.issuerNpcId]: {
|
||||
...issuerNpcState,
|
||||
affinity: issuerNpcState.affinity + quest.reward.affinityBonus,
|
||||
},
|
||||
}
|
||||
: state.npcStates,
|
||||
}, quest.reward.items);
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
nextState.chapterState
|
||||
?? nextState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: nextState,
|
||||
}),
|
||||
});
|
||||
const storyEngineMemory =
|
||||
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const synchronizedNextState: GameState = {
|
||||
...nextState,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
nextState: synchronizedNextState,
|
||||
handoff: buildGoalHandoffFromState(synchronizedNextState),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStorySessionActions({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
clearStoryRuntimeUi: () => void;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const acknowledgeQuestCompletion = (questId: string) => {
|
||||
setGameState(currentState => acknowledgeQuestCompletionState(currentState, questId));
|
||||
};
|
||||
|
||||
const claimQuestReward = (questId: string) => {
|
||||
const rewardClaim = applyQuestRewardClaim(gameState, questId);
|
||||
if (!rewardClaim) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGameState(rewardClaim.nextState);
|
||||
return {
|
||||
questId,
|
||||
handoff: rewardClaim.handoff,
|
||||
};
|
||||
};
|
||||
|
||||
const resetStoryState = () => {
|
||||
setCurrentStory(null);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const hydrateStoryState = (story: StoryMoment | null) => {
|
||||
setCurrentStory(story);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const travelToSceneFromMap = (sceneId: string) => {
|
||||
if (!gameState.playerCharacter || isLoading || gameState.inBattle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(gameState, sceneId);
|
||||
if (!travelResolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.travelResultText,
|
||||
),
|
||||
);
|
||||
|
||||
void commitGeneratedState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.actionText,
|
||||
travelResolution.travelResultText,
|
||||
'idle_travel_next_scene',
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
};
|
||||
}
|
||||
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
430
src/hooks/rpg-runtime-story/storyChoiceContinuation.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type HandleNpcBattleConversationContinuation = (params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) => boolean;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
const baseChoiceState =
|
||||
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
|
||||
!params.gameState.npcInteractionActive &&
|
||||
!params.option.interaction
|
||||
? {
|
||||
...params.gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: params.gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([
|
||||
actionPromise,
|
||||
responsePromise,
|
||||
]);
|
||||
|
||||
if (actionResult.status === 'rejected') {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory
|
||||
? resolvedChoice.afterSequence
|
||||
: actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar' &&
|
||||
nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
params.buildNpcStory(
|
||||
nextState,
|
||||
params.character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
if (
|
||||
nextState.currentEncounter &&
|
||||
params.handleNpcBattleConversationContinuation({
|
||||
nextState,
|
||||
encounter: nextState.currentEncounter,
|
||||
character: params.character,
|
||||
actionText: params.option.actionText,
|
||||
resultText: victory.resultText,
|
||||
battleMode: baseChoiceState.currentNpcBattleMode!,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
history: nextHistory,
|
||||
choice: params.option.actionText,
|
||||
lastFunctionId: params.option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error(
|
||||
'Failed to continue npc battle resolution story:',
|
||||
storyError,
|
||||
);
|
||||
params.setAiError(
|
||||
storyError instanceof Error
|
||||
? storyError.message
|
||||
: '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
nextState,
|
||||
params.character,
|
||||
victory.resultText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
recoveredState,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(fallbackState, params.character),
|
||||
);
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
134
src/hooks/rpg-runtime-story/storyChoiceCoordinator.test.ts
Normal file
134
src/hooks/rpg-runtime-story/storyChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
_encounter: GameState['currentEncounter'],
|
||||
): _encounter is Encounter => false;
|
||||
|
||||
describe('storyChoiceCoordinator', () => {
|
||||
it('builds one config object for createStoryChoiceActions from runtime controller and support', () => {
|
||||
const runtimeController = {
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn(),
|
||||
buildFallbackStoryForState: vi.fn(),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(),
|
||||
getCampCompanionTravelScene: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
};
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryChoiceCoordinatorConfig({
|
||||
gameState: createState(),
|
||||
currentStory: createStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(() => []),
|
||||
runtimeController: runtimeController as never,
|
||||
runtimeSupport: runtimeSupport as never,
|
||||
enterNpcInteraction: vi.fn(),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
buildStoryContextFromState: runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState: runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState: runtimeController.getAvailableOptionsForState,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
void createCharacter();
|
||||
});
|
||||
});
|
||||
174
src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts
Normal file
174
src/hooks/rpg-runtime-story/storyChoiceCoordinator.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
|
||||
export type ChoiceRuntimeController = {
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ChoiceRuntimeSupport = Pick<
|
||||
StoryRuntimeSupport,
|
||||
| 'buildNpcStory'
|
||||
| 'handleNpcBattleConversationContinuation'
|
||||
| 'updateQuestLog'
|
||||
| 'updateRuntimeStats'
|
||||
>;
|
||||
|
||||
export type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController;
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createStoryChoiceCoordinatorConfig(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
return {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward: params.setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
buildStoryContextFromState:
|
||||
params.runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState:
|
||||
params.runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: params.runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState:
|
||||
params.runtimeController.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
buildNpcStory: params.runtimeSupport.buildNpcStory,
|
||||
handleNpcBattleConversationContinuation:
|
||||
params.runtimeSupport.handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: params.runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
params.runtimeController.getCampCompanionTravelScene,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
};
|
||||
}
|
||||
425
src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
Normal file
425
src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
||||
'../../data/hostileNpcPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
rollHostileNpcLoot: rollHostileNpcLootMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyChoiceRuntime', () => {
|
||||
beforeEach(() => {
|
||||
rollHostileNpcLootMock.mockReset();
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
||||
const options = buildReasonedOptionCatalog([
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_help'),
|
||||
]);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_chat', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'chat',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_trade', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_gift', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'gift',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape and victory context text for local battle resolution', () => {
|
||||
const baseState = createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState,
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'escape',
|
||||
projectedBattleReward: null,
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('你已成功逃脱');
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState: {
|
||||
...baseState,
|
||||
currentBattleNpcId: null,
|
||||
},
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'battle',
|
||||
projectedBattleReward: {
|
||||
id: 'reward-1',
|
||||
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
||||
items: [
|
||||
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
||||
],
|
||||
},
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('战利品:狼牙。');
|
||||
});
|
||||
|
||||
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([
|
||||
{
|
||||
id: 'loot-1',
|
||||
category: '材料',
|
||||
name: '狼牙',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
||||
expect(reward?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: '狼牙',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setBattleReward = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 3,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: null,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(setAiError).toHaveBeenCalledWith('boom');
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'fallback',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('plays server battle presentation before committing the hydrated snapshot', async () => {
|
||||
const gameState = createState({
|
||||
inBattle: true,
|
||||
playerHp: 30,
|
||||
playerMana: 10,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf',
|
||||
name: '山狼',
|
||||
action: '逼近',
|
||||
description: '山狼',
|
||||
animation: 'idle',
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 18,
|
||||
maxHp: 18,
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 26,
|
||||
sceneHostileNpcs: [],
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
presentation: {
|
||||
battle: {
|
||||
targetId: 'wolf',
|
||||
damageDealt: 18,
|
||||
damageTaken: 4,
|
||||
outcome: 'victory',
|
||||
},
|
||||
resultText: '山狼被你压制下去。',
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: createStory('当前故事'),
|
||||
option: createOption('battle_attack_basic'),
|
||||
character: createCharacter(),
|
||||
setBattleReward: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: vi.fn() as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
animationState: 'idle',
|
||||
playerHp: 26,
|
||||
sceneHostileNpcs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'wolf',
|
||||
hp: 0,
|
||||
animation: 'die',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
});
|
||||
427
src/hooks/rpg-runtime-story/storyChoiceRuntime.ts
Normal file
427
src/hooks/rpg-runtime-story/storyChoiceRuntime.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: 'battle' | 'escape' | 'idle';
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
(
|
||||
option.interaction?.kind === 'npc' ||
|
||||
!option.interaction
|
||||
) &&
|
||||
(
|
||||
option.functionId === 'npc_chat' ||
|
||||
option.functionId === 'npc_trade' ||
|
||||
option.functionId === 'npc_gift'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: 'battle' | 'escape' | 'idle',
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape' ||
|
||||
!state.worldType ||
|
||||
state.currentBattleNpcId ||
|
||||
!state.inBattle ||
|
||||
afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(
|
||||
(hostileNpc) =>
|
||||
!nextHostileNpcs.some(
|
||||
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const targetScene = params.getCampCompanionTravelScene(
|
||||
params.gameState,
|
||||
params.character,
|
||||
);
|
||||
if (!targetScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
|
||||
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
|
||||
? params.gameState.currentEncounter.npcName
|
||||
: params.fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
);
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(
|
||||
resolvedState,
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
|
||||
params.setIsLoading(true);
|
||||
params.setGameState(travelRunState);
|
||||
await sleep(params.turnVisualMs);
|
||||
|
||||
await params.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
params.character,
|
||||
params.option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
params.option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
turnVisualMs?: number;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
payload: params.option.runtimePayload,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
await playServerBattlePresentation({
|
||||
baseState: params.gameState,
|
||||
finalState: hydratedSnapshot.gameState,
|
||||
option: params.option,
|
||||
response,
|
||||
setGameState: params.setGameState,
|
||||
turnVisualMs: params.turnVisualMs ?? 820,
|
||||
});
|
||||
}
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve runtime action on the server:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '运行时动作执行失败',
|
||||
);
|
||||
if (!params.currentStory) {
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
params.gameState,
|
||||
params.character,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getServerBattlePlayerAnimation(option: StoryOption) {
|
||||
if (option.functionId === 'battle_escape_breakout') return AnimationState.RUN;
|
||||
if (option.functionId === 'battle_recover_breath') return AnimationState.IDLE;
|
||||
if (option.functionId === 'inventory_use') return AnimationState.ACQUIRE;
|
||||
return option.visuals?.playerAnimation ?? AnimationState.ATTACK;
|
||||
}
|
||||
|
||||
async function playServerBattlePresentation(params: {
|
||||
baseState: GameState;
|
||||
finalState: GameState;
|
||||
option: StoryOption;
|
||||
response: Awaited<ReturnType<typeof resolveRpgRuntimeChoice>>['response'];
|
||||
setGameState: (state: GameState) => void;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const battle = params.response.presentation.battle;
|
||||
if (!battle || !params.baseState.inBattle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (battle.outcome === 'escaped') {
|
||||
params.setGameState({
|
||||
...params.baseState,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: true,
|
||||
activeCombatEffects: [],
|
||||
});
|
||||
await sleep(Math.max(220, Math.round(params.turnVisualMs * 0.6)));
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = battle.targetId ?? params.baseState.sceneHostileNpcs[0]?.id ?? null;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecoveryOrItem =
|
||||
params.option.functionId === 'battle_recover_breath' ||
|
||||
params.option.functionId === 'inventory_use';
|
||||
const actingState: GameState = {
|
||||
...params.baseState,
|
||||
animationState: getServerBattlePlayerAnimation(params.option),
|
||||
playerActionMode: isRecoveryOrItem ? 'idle' : 'melee',
|
||||
activeCombatEffects: [],
|
||||
sceneHostileNpcs: params.baseState.sceneHostileNpcs.map((hostileNpc) =>
|
||||
hostileNpc.id === targetId
|
||||
? {
|
||||
...hostileNpc,
|
||||
animation: isRecoveryOrItem ? ('move' as const) : ('attack' as const),
|
||||
action: params.response.presentation.resultText || hostileNpc.action,
|
||||
}
|
||||
: hostileNpc,
|
||||
),
|
||||
};
|
||||
params.setGameState(actingState);
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.55)));
|
||||
|
||||
const finalTarget = params.finalState.sceneHostileNpcs.find(
|
||||
(hostileNpc) => hostileNpc.id === targetId,
|
||||
);
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(!finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
playerMana: params.finalState.playerMana,
|
||||
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
|
||||
activeBuildBuffs: params.finalState.activeBuildBuffs,
|
||||
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
|
||||
if (hostileNpc.id !== targetId) return hostileNpc;
|
||||
return {
|
||||
...hostileNpc,
|
||||
hp: finalTarget?.hp ?? (targetDefeated ? 0 : hostileNpc.hp),
|
||||
animation: targetDefeated ? ('die' as const) : hostileNpc.animation,
|
||||
characterAnimation: targetDefeated ? AnimationState.DIE : hostileNpc.characterAnimation,
|
||||
};
|
||||
}),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
});
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
|
||||
}
|
||||
625
src/hooks/rpg-runtime-story/storyContextBuilder.ts
Normal file
625
src/hooks/rpg-runtime-story/storyContextBuilder.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
describeNpcAffinityInWords,
|
||||
getNpcConversationDirective,
|
||||
isNpcFirstMeaningfulContact,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
|
||||
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
|
||||
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
|
||||
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import type { GameState } from '../../types';
|
||||
import { getCharacterChatRecord } from './characterChat';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildPartyRelationshipNotes(state: GameState) {
|
||||
const lines: string[] = [];
|
||||
const seenCharacterIds = new Set<string>();
|
||||
|
||||
const appendNote = (characterId: string, roleLabel: string) => {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
lines.push(
|
||||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||||
);
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferConversationSituation(
|
||||
state: GameState,
|
||||
extras: Pick<
|
||||
StoryContextBuilderExtras,
|
||||
'lastFunctionId' | 'openingCampDialogue'
|
||||
>,
|
||||
) {
|
||||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||||
return 'camp_first_contact' as const;
|
||||
if (
|
||||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||||
extras.openingCampDialogue?.trim()
|
||||
) {
|
||||
return 'camp_followup' as const;
|
||||
}
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||||
return 'private_followup' as const;
|
||||
return 'first_contact_cautious' as const;
|
||||
}
|
||||
|
||||
function inferConversationPressure(
|
||||
state: GameState,
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||||
if (
|
||||
situation === 'post_battle_breath' ||
|
||||
situation === 'shared_danger_coordination'
|
||||
)
|
||||
return 'medium' as const;
|
||||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||||
return 'low' as const;
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
function describeConversationSituation(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
function describeConversationTalkPriority(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(state: GameState) {
|
||||
const encounter = state.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(
|
||||
state: GameState,
|
||||
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
|
||||
) {
|
||||
if (state.storyEngineMemory?.activeThreadIds?.length) {
|
||||
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
if (encounterNarrativeProfile?.relatedThreadIds.length) {
|
||||
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
export function buildStoryContextFromState(
|
||||
state: GameState,
|
||||
extras: StoryContextBuilderExtras = {},
|
||||
): StoryGenerationContext {
|
||||
const conversationSituation = inferConversationSituation(state, extras);
|
||||
const conversationPressure = inferConversationPressure(
|
||||
state,
|
||||
conversationSituation,
|
||||
);
|
||||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||||
const encounterNpcState =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const isFirstMeaningfulContact =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||||
: false;
|
||||
})()
|
||||
: false;
|
||||
const firstContactRelationStance = (() => {
|
||||
if (
|
||||
!isFirstMeaningfulContact ||
|
||||
!state.currentEncounter ||
|
||||
state.currentEncounter.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||||
if (
|
||||
stance === 'guarded' ||
|
||||
stance === 'neutral' ||
|
||||
stance === 'cooperative' ||
|
||||
stance === 'bonded'
|
||||
) {
|
||||
return stance;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const encounterAffinityText =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||||
recruited: encounterNpcState.recruited,
|
||||
})
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||||
const sceneMutationDescription = [
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const observeSignsSceneDescription =
|
||||
extras.observeSignsRequested && state.worldType
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const knowledgeFacts =
|
||||
state.customWorldProfile?.knowledgeFacts
|
||||
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
|
||||
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
|
||||
const activeThreadIds = resolveActiveThreadIds(
|
||||
{
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
} as GameState,
|
||||
encounterNarrativeProfile,
|
||||
);
|
||||
const visibilitySlice =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const relevantFacts = knowledgeFacts.filter((fact) =>
|
||||
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|
||||
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
return relevantFacts.length > 0
|
||||
? buildVisibilitySliceFromFacts({
|
||||
facts: relevantFacts,
|
||||
discoveredFactIds: [
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...(encounterNpcState?.revealedFacts ?? []),
|
||||
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
|
||||
(chapterId) =>
|
||||
relevantFacts.find((fact) =>
|
||||
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
|
||||
)?.id ?? '',
|
||||
),
|
||||
],
|
||||
activeThreadIds,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
})
|
||||
: buildEncounterVisibilitySlice({
|
||||
narrativeProfile: encounterNarrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const sceneNarrativeDirective = buildSceneNarrativeDirective({
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
|
||||
activeThreadIds,
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
affinity: encounterNpcState?.affinity ?? null,
|
||||
});
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
} as GameState,
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state,
|
||||
reactions: storyEngineMemory.recentCompanionReactions,
|
||||
}),
|
||||
});
|
||||
const currentCampEvent = evaluateCampEventOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
|
||||
const recentChronicleSummary = buildChronicleSummary({
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
companionArcStates,
|
||||
},
|
||||
} as GameState);
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
sceneDescription: observeSignsSceneDescription,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
: null,
|
||||
encounterKind: state.currentEncounter?.kind ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
narrativeProfile: state.currentEncounter.narrativeProfile,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
firstContactRelationStance,
|
||||
conversationSituation,
|
||||
conversationPressure,
|
||||
recentSharedEvent:
|
||||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective,
|
||||
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
activeCampaignPack,
|
||||
encounterNarrativeProfile,
|
||||
knowledgeFacts,
|
||||
activeThreadIds,
|
||||
companionArcStates,
|
||||
companionResolutions: storyEngineMemory.companionResolutions ?? [],
|
||||
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
|
||||
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
|
||||
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
|
||||
recentCarrierEchoes: buildRecentCarrierEchoes(state),
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
},
|
||||
profile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
});
|
||||
}
|
||||
164
src/hooks/rpg-runtime-story/storyEncounterState.test.ts
Normal file
164
src/hooks/rpg-runtime-story/storyEncounterState.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
} from '../../types';
|
||||
import { createStoryStateResolvers } from './storyEncounterState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
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,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createNpcEncounter(
|
||||
overrides: Partial<Encounter> = {},
|
||||
): Encounter {
|
||||
return {
|
||||
id: 'npc-guard',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '守在路口的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
...overrides,
|
||||
} as Encounter;
|
||||
}
|
||||
|
||||
describe('storyEncounterState', () => {
|
||||
it('uses preview talk options for regular npc encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter(),
|
||||
});
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses normal npc story options after the npc interaction has started', () => {
|
||||
const character = createCharacter();
|
||||
const npcStory: StoryMoment = {
|
||||
text: '普通 NPC 正常对话',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter(),
|
||||
npcInteractionActive: true,
|
||||
});
|
||||
const buildNpcStory = vi.fn(() => npcStory);
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual(
|
||||
npcStory.options,
|
||||
);
|
||||
expect(buildNpcStory).toHaveBeenCalledWith(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const { buildFallbackStoryForState } = createStoryStateResolvers({
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const story = buildFallbackStoryForState(state, character, '手动兜底文本');
|
||||
|
||||
expect(story.text).toBe('手动兜底文本');
|
||||
expect(story.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
197
src/hooks/rpg-runtime-story/storyEncounterState.ts
Normal file
197
src/hooks/rpg-runtime-story/storyEncounterState.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../../data/stateFunctions';
|
||||
import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { buildFallbackStoryMoment } from '../combatStoryUtils';
|
||||
|
||||
type EncounterStoryBuilder = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function buildNpcPreviewStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
if (!state.worldType) {
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter)],
|
||||
};
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: false,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: [],
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
|
||||
.filter((functionId) => functionId !== 'idle_call_out')
|
||||
.map((functionId) => resolveFunctionOption(functionId, functionContext))
|
||||
.filter((option): option is StoryOption => Boolean(option));
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedSceneHostileNpcs(state: GameState) {
|
||||
return state.sceneHostileNpcs;
|
||||
}
|
||||
|
||||
export function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||||
}
|
||||
|
||||
export function isInitialCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function isRegularNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
|
||||
}
|
||||
|
||||
export function isTreasureEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEncounterStory(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
fallbackText?: string;
|
||||
}) {
|
||||
const { state, character, fallbackText } = params;
|
||||
|
||||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
if (!state.npcInteractionActive) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return buildTreasureStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createStoryStateResolvers(params: {
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
}) {
|
||||
const getAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) =>
|
||||
resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
})?.options ?? null;
|
||||
|
||||
const buildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const resolvedStory = resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
fallbackText,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
});
|
||||
if (resolvedStory) {
|
||||
return resolvedStory;
|
||||
}
|
||||
|
||||
const fallback = buildFallbackStoryMoment(state, character);
|
||||
return fallbackText
|
||||
? {
|
||||
...fallback,
|
||||
text: fallbackText,
|
||||
}
|
||||
: fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
};
|
||||
}
|
||||
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
328
src/hooks/rpg-runtime-story/storyGenerationState.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
description: 'A quiet camp.',
|
||||
imageSrc: '/camp.png',
|
||||
connectedSceneIds: ['scene-2'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
connectedSceneIds: ['scene-1'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/scenePresets', () => ({
|
||||
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
||||
scenes.find(scene => scene.id === sceneId) ?? null,
|
||||
getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [],
|
||||
getSceneHostileNpcs: () => [],
|
||||
getScenePresetsByWorld: () => scenes,
|
||||
getWorldCampScenePreset: () => scenes[0] ?? null,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CompanionState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildMapTravelResolution,
|
||||
resolveNpcInteractionDecision,
|
||||
} from './storyGenerationState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(
|
||||
id: string,
|
||||
name: string,
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
}
|
||||
|
||||
function createCompanion(npcId: string): CompanionState {
|
||||
return {
|
||||
npcId,
|
||||
characterId: `character-${npcId}`,
|
||||
joinedAtAffinity: 10,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
mana: 5,
|
||||
maxMana: 5,
|
||||
skillCooldowns: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: scenes[0] ?? null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 10,
|
||||
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.WUXIA),
|
||||
inventory: [createInventoryItem('npc-herb', 'Herb')],
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createInteractionOption(action: Extract<NonNullable<StoryOption['interaction']>, { kind: 'npc' }>['action']): StoryOption {
|
||||
return {
|
||||
functionId: `npc_${action}`,
|
||||
actionText: action,
|
||||
text: action,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-trader',
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyGenerationState', () => {
|
||||
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
createBaseState(),
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('trade_modal');
|
||||
if (decision.kind !== 'trade_modal') {
|
||||
throw new Error('Expected trade modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedNpcItemId).toBe('npc-herb');
|
||||
expect(decision.modal.selectedPlayerItemId).toBe('player-potion');
|
||||
expect(decision.modal.selectedQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('skips zero-quantity player items when opening the trade modal', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
{
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('player-herb', 'Herb'),
|
||||
],
|
||||
},
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('trade_modal');
|
||||
if (decision.kind !== 'trade_modal') {
|
||||
throw new Error('Expected trade modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedPlayerItemId).toBe('player-herb');
|
||||
});
|
||||
|
||||
it('forces a recruit replacement modal when the active party is full', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)),
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('recruit'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('recruit_modal');
|
||||
if (decision.kind !== 'recruit_modal') {
|
||||
throw new Error('Expected recruit modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
||||
});
|
||||
|
||||
it('opens the gift modal with the preferred gift candidate selected', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('gift_modal');
|
||||
if (decision.kind !== 'gift_modal') {
|
||||
throw new Error('Expected gift modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedItemId).toBe('jade-token');
|
||||
});
|
||||
|
||||
it('does not open the gift modal when there are no gift candidates', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [],
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('none');
|
||||
});
|
||||
|
||||
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
const sourceScene = scenes[0];
|
||||
const targetScene = scenes[1]!;
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: sourceScene ?? null,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'battle-npc',
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
sparReturnEncounter: createEncounter(),
|
||||
};
|
||||
|
||||
const resolution = buildMapTravelResolution(state, targetScene.id);
|
||||
|
||||
expect(resolution).not.toBeNull();
|
||||
if (!resolution) {
|
||||
throw new Error('Expected map travel resolution');
|
||||
}
|
||||
|
||||
expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id);
|
||||
expect(resolution.nextState.npcInteractionActive).toBe(false);
|
||||
expect(resolution.nextState.inBattle).toBe(false);
|
||||
expect(resolution.nextState.currentBattleNpcId).toBeNull();
|
||||
expect(resolution.nextState.currentNpcBattleMode).toBeNull();
|
||||
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
174
src/hooks/rpg-runtime-story/storyGenerationState.ts
Normal file
174
src/hooks/rpg-runtime-story/storyGenerationState.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_TRADE_FUNCTION,
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
applyQuestProgressFromSceneReached,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getPreferredGiftItemId,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
export type NpcInteractionDecision =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'trade_modal'; modal: TradeModalState }
|
||||
| { kind: 'gift_modal'; modal: GiftModalState }
|
||||
| { kind: 'recruit_modal'; modal: RecruitModalState }
|
||||
| { kind: 'recruit_immediate' };
|
||||
|
||||
export type MapTravelResolution = {
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
travelResultText: string;
|
||||
};
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNpcInteractionDecision(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
): NpcInteractionDecision {
|
||||
if (
|
||||
!state.playerCharacter ||
|
||||
!option.interaction ||
|
||||
!isNpcEncounter(state.currentEncounter)
|
||||
) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const encounter = state.currentEncounter;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
|
||||
switch (option.functionId) {
|
||||
case NPC_TRADE_FUNCTION.id:
|
||||
return {
|
||||
kind: 'trade_modal',
|
||||
modal: buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
{
|
||||
const selectedGiftItemId = getPreferredGiftItemId(
|
||||
state.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedGiftItemId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
selectedGiftItemId,
|
||||
),
|
||||
};
|
||||
}
|
||||
case NPC_RECRUIT_FUNCTION.id:
|
||||
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
|
||||
return {
|
||||
kind: 'recruit_modal',
|
||||
modal: buildNpcRecruitModalState(state, encounter, option.actionText),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'recruit_immediate' };
|
||||
default:
|
||||
return { kind: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMapTravelResolution(
|
||||
state: GameState,
|
||||
sceneId: string,
|
||||
): MapTravelResolution | null {
|
||||
if (!state.worldType || !state.playerCharacter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetScene = getScenePresetById(state.worldType, sceneId);
|
||||
if (!targetScene || targetScene.id === state.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextState = ensureSceneEncounterPreview({
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
|
||||
scenesTraveled: 1,
|
||||
}),
|
||||
quests: applyQuestProgressFromSceneReached(state.quests, targetScene.id),
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
});
|
||||
const travelResultText = `你离开${state.currentScenePreset?.name ?? '当前位置'},前往${targetScene.name}。`;
|
||||
|
||||
return {
|
||||
nextState,
|
||||
actionText: `前往${targetScene.name}`,
|
||||
travelResultText,
|
||||
};
|
||||
}
|
||||
155
src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts
Normal file
155
src/hooks/rpg-runtime-story/storyInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyInteractionCoordinator', () => {
|
||||
it('builds shared interaction configs for treasure, inventory and npc flows', () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const buildFallbackStoryForState = vi.fn();
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildDialogueStoryMoment = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getTypewriterDelay = (_char: string) => 90 as const;
|
||||
const commitGeneratedState = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = vi.fn();
|
||||
const appendHistory = vi.fn();
|
||||
const buildOpeningCampChatContext = vi.fn();
|
||||
const sortOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const buildContinueAdventureOption = vi.fn(() => createOption('continue'));
|
||||
const sanitizeOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
getNpcEncounterKey: vi.fn(),
|
||||
getResolvedNpcState: vi.fn(),
|
||||
updateNpcState: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
|
||||
expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime);
|
||||
expect(config.treasureFlow).toEqual({
|
||||
gameState,
|
||||
runtime: config.inventoryFlow.runtime,
|
||||
});
|
||||
expect(config.npcInteractionFlow).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: expect.objectContaining({
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(config.npcEncounterActions).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
sanitizeOptions,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts
Normal file
136
src/hooks/rpg-runtime-story/storyInteractionCoordinator.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
currentStory: StoryMoment | null;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: RpgRuntimeStoryControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: RpgRuntimeStoryControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: RpgRuntimeStoryControllerResult['getAvailableOptionsForState'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: RpgRuntimeStoryControllerResult['getTypewriterDelay'];
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
commitGeneratedState: RpgRuntimeStoryControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: RpgRuntimeStoryControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: RpgRuntimeStoryControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: RpgRuntimeStoryControllerResult['buildOpeningCampChatContext'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
sanitizeOptions: (
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) => StoryOption[];
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
};
|
||||
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
};
|
||||
|
||||
return {
|
||||
treasureFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
inventoryFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
npcInteractionFlow: {
|
||||
gameState: params.gameState,
|
||||
setGameState: params.setGameState,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory: params.appendHistory,
|
||||
buildOpeningCampChatContext: params.buildOpeningCampChatContext,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
sanitizeOptions: params.sanitizeOptions,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision: params.resolveNpcInteractionDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryInteractionCoordinatorConfig = ReturnType<
|
||||
typeof createStoryInteractionCoordinatorConfig
|
||||
>;
|
||||
149
src/hooks/rpg-runtime-story/storyPresentation.test.ts
Normal file
149
src/hooks/rpg-runtime-story/storyPresentation.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import { buildStoryFromResponse } from './storyPresentation';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
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,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyPresentation', () => {
|
||||
it('keeps provided available options when the AI response omits them', () => {
|
||||
const availableOptions = [createOption('npc_help', '请求援手')];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('服务端返回正文'),
|
||||
availableOptions,
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回正文');
|
||||
expect(story.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated response options before padding local fallbacks', () => {
|
||||
const duplicatedOptions = [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('需要本地归一化', duplicatedOptions),
|
||||
availableOptions: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
story.options.filter(
|
||||
(option) =>
|
||||
option.functionId === 'npc_chat' &&
|
||||
option.actionText === '继续交谈',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(story.options.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
245
src/hooks/rpg-runtime-story/storyPresentation.ts
Normal file
245
src/hooks/rpg-runtime-story/storyPresentation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
function dedupeStoryOptions(options: StoryOption[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||||
if (seen.has(identity)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
const specialChars = [
|
||||
'\\',
|
||||
'^',
|
||||
'$',
|
||||
'*',
|
||||
'+',
|
||||
'?',
|
||||
'.',
|
||||
'(',
|
||||
')',
|
||||
'|',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
return specialChars.reduce(
|
||||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeStoryOptions(
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) {
|
||||
const normalizedOptions = dedupeStoryOptions(
|
||||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||||
);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return buildFallbackStoryMoment(state, character).options;
|
||||
}
|
||||
|
||||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(
|
||||
dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryFromResponse(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
response: StoryMoment;
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) {
|
||||
return {
|
||||
text: params.response.text,
|
||||
options: resolveStoryResponseOptions({
|
||||
responseOptions: params.response.options,
|
||||
availableOptions: params.availableOptions,
|
||||
optionCatalog: params.optionCatalog ?? null,
|
||||
getSanitizedOptions: () =>
|
||||
sanitizeStoryOptions(
|
||||
params.response.options,
|
||||
params.character,
|
||||
params.state,
|
||||
),
|
||||
}),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export function parseDialogueTurns(
|
||||
text: string,
|
||||
npcName: string,
|
||||
): StoryDialogueTurn[] {
|
||||
const turns: StoryDialogueTurn[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch) {
|
||||
const rawSpeakerName = namedSpeakerMatch[1];
|
||||
const rawSpeakerText = namedSpeakerMatch[2];
|
||||
if (!rawSpeakerName || !rawSpeakerText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||||
const speakerText = rawSpeakerText.trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turns.length > 0) {
|
||||
const lastTurnIndex = turns.length - 1;
|
||||
const lastTurn = turns[lastTurnIndex];
|
||||
if (lastTurn) {
|
||||
turns[lastTurnIndex] = {
|
||||
...lastTurn,
|
||||
text: lastTurn.text + line,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter((turn) => turn.text.length > 0);
|
||||
}
|
||||
|
||||
export function buildDialogueStoryMoment(
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(text, npcName),
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
return parseDialogueTurns(text, npcName).length >= 2;
|
||||
}
|
||||
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!?!?]/u.test(char)) {
|
||||
return 240;
|
||||
}
|
||||
if (/[,、;;:]/u.test(char)) {
|
||||
return 150;
|
||||
}
|
||||
if (/\s/u.test(char)) {
|
||||
return 45;
|
||||
}
|
||||
return 90;
|
||||
}
|
||||
192
src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts
Normal file
192
src/hooks/rpg-runtime-story/storyRequestCoordinator.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
generateStoryForStateWithCoordinator,
|
||||
resolveStoryRequestOptions,
|
||||
} from './storyRequestCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createOption('npc_help', '请求援手')]);
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
expect(result.availableOptions).toBeNull();
|
||||
expect(result.optionCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps explicit option catalogs without reloading server runtime options', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const optionCatalog = [createOption('npc_help', '请求援手')];
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi.fn();
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled();
|
||||
expect(getAvailableOptionsForState).not.toHaveBeenCalled();
|
||||
expect(result.optionCatalog).toBe(optionCatalog);
|
||||
expect(result.availableOptions).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to local available options when server runtime catalog refresh fails during续写', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const history = [createStory('上一轮剧情')];
|
||||
const localOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const getAvailableOptionsForState = vi.fn(() => localOptions);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'inn_room',
|
||||
sceneName: '客栈内室',
|
||||
sceneDescription: '屋里安静得只剩风声。',
|
||||
pendingSceneEncounter: false,
|
||||
lastFunctionId: extras?.lastFunctionId ?? null,
|
||||
}) as StoryGenerationContext,
|
||||
);
|
||||
const buildStoryFromResponse = vi.fn(
|
||||
(
|
||||
_state: GameState,
|
||||
_character: Character,
|
||||
response: StoryMoment,
|
||||
) => response,
|
||||
);
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn().mockResolvedValue({
|
||||
storyText: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('server option catalog failed'));
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
|
||||
const result = await generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1);
|
||||
expect(requestInitialStory).not.toHaveBeenCalled();
|
||||
expect(requestNextStep).toHaveBeenCalledWith(
|
||||
'WUXIA',
|
||||
character,
|
||||
[],
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
availableOptions: localOptions,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
text: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/hooks/rpg-runtime-story/storyRequestCoordinator.ts
Normal file
196
src/hooks/rpg-runtime-story/storyRequestCoordinator.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseRpgRuntimeServerOptions } from '../../services/rpg-runtime';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { loadRpgRuntimeOptionCatalog } from '.';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[];
|
||||
|
||||
type RequestInitialStory = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type RequestNextStep = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type LoadRuntimeOptionCatalog = typeof loadRpgRuntimeOptionCatalog;
|
||||
|
||||
export type ResolvedStoryRequestOptions = {
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog: StoryOption[] | null;
|
||||
};
|
||||
|
||||
export async function resolveStoryRequestOptions(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
currentStory: StoryMoment | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
let optionCatalog =
|
||||
params.optionCatalog && params.optionCatalog.length > 0
|
||||
? params.optionCatalog
|
||||
: null;
|
||||
let availableOptions = optionCatalog
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseRpgRuntimeServerOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverOptionCatalog = await (
|
||||
params.loadRuntimeOptionCatalog ?? loadRpgRuntimeOptionCatalog
|
||||
)({
|
||||
gameState: params.state,
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
|
||||
if (serverOptionCatalog && serverOptionCatalog.length > 0) {
|
||||
optionCatalog = serverOptionCatalog;
|
||||
availableOptions = null;
|
||||
}
|
||||
} catch (error) {
|
||||
params.onServerOptionCatalogLoadError?.(error);
|
||||
}
|
||||
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
export function buildAiStoryRequestOptions(
|
||||
options: ResolvedStoryRequestOptions,
|
||||
) {
|
||||
if (options.availableOptions) {
|
||||
return {
|
||||
availableOptions: options.availableOptions,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.optionCatalog) {
|
||||
return {
|
||||
optionCatalog: options.optionCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function generateStoryForStateWithCoordinator(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
currentStory: StoryMoment | null;
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
if (!params.state.worldType) {
|
||||
throw new Error(
|
||||
'The current world is not initialized, so story generation cannot continue.',
|
||||
);
|
||||
}
|
||||
const worldType = params.state.worldType;
|
||||
|
||||
const resolvedOptions = await resolveStoryRequestOptions({
|
||||
state: params.state,
|
||||
character: params.character,
|
||||
currentStory: params.currentStory,
|
||||
optionCatalog: params.optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
const requestOptions = buildAiStoryRequestOptions(resolvedOptions);
|
||||
const monsters = params.getStoryGenerationHostileNpcs(params.state);
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
context,
|
||||
requestOptions,
|
||||
)
|
||||
: await params.requestInitialStory(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return params.buildStoryFromResponse(
|
||||
params.state,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
resolvedOptions.availableOptions,
|
||||
resolvedOptions.optionCatalog,
|
||||
);
|
||||
}
|
||||
136
src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts
Normal file
136
src/hooks/rpg-runtime-story/storyRequestRuntime.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
|
||||
const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({
|
||||
generateStoryForStateWithCoordinatorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./storyRequestCoordinator', () => ({
|
||||
generateStoryForStateWithCoordinator:
|
||||
generateStoryForStateWithCoordinatorMock,
|
||||
}));
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
backstory: '长年行走江湖。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestRuntime', () => {
|
||||
it('forwards runtime request dependencies and currentStory into the coordinator', async () => {
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildStoryFromResponse = vi.fn();
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn();
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const history = [createStory('上一轮剧情')];
|
||||
|
||||
generateStoryForStateWithCoordinatorMock.mockResolvedValue({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
|
||||
const generateStoryForState = createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
const result = await generateStoryForState({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
});
|
||||
|
||||
expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/hooks/rpg-runtime-story/storyRequestRuntime.ts
Normal file
69
src/hooks/rpg-runtime-story/storyRequestRuntime.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { GenerateStoryForState } from './progressionActions';
|
||||
import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator';
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type BuildStoryContextFromState = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryContextFromState'];
|
||||
|
||||
type BuildStoryFromResponse = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryFromResponse'];
|
||||
|
||||
type GetStoryGenerationHostileNpcs = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['getStoryGenerationHostileNpcs'];
|
||||
|
||||
type RequestInitialStory = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestInitialStory'];
|
||||
|
||||
type RequestNextStep = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestNextStep'];
|
||||
|
||||
export function createGenerateStoryForState(params: {
|
||||
currentStory: StoryMoment | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}): GenerateStoryForState {
|
||||
return async ({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
}) =>
|
||||
generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory: params.currentStory,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.buildStoryFromResponse,
|
||||
requestInitialStory: params.requestInitialStory,
|
||||
requestNextStep: params.requestNextStep,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
}
|
||||
184
src/hooks/rpg-runtime-story/storyResponseOptions.test.ts
Normal file
184
src/hooks/rpg-runtime-story/storyResponseOptions.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type StoryOption } from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText: string,
|
||||
priority = 0,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyResponseOptions', () => {
|
||||
it('keeps rewritten actionText when camp companion follow-up uses available options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '先聊聊营地安排', 3),
|
||||
createOption('npc_gift', '把旧礼物递给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才的话继续问下去', 3),
|
||||
createOption('npc_gift', '把刚挑好的礼物正式交给你', 2),
|
||||
createOption('camp_travel_home_scene', '前往云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'顺着你刚才的话继续问下去',
|
||||
'把刚挑好的礼物正式交给你',
|
||||
'前往云河渡',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interaction metadata when AI rewrites provided npc options', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点', 1),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_chat', '顺着你刚才那句提醒继续追问', 3),
|
||||
createOption('camp_travel_home_scene', '先回云河渡', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着你刚才那句提醒继续追问',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to available options when the response omits them entirely', () => {
|
||||
const availableOptions = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('camp_travel_home_scene', '前往山门', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
availableOptions,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('available options fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'前往山门',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 3, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_help', '请求援手', 2, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
}),
|
||||
createOption('npc_trade', '看看能交换什么', 1, {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'trade',
|
||||
}),
|
||||
];
|
||||
const responseOptions = [
|
||||
createOption('npc_help', '顺着刚才的话请他搭把手', 3),
|
||||
createOption('npc_chat', '追问他刚才为什么突然沉默', 2),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog branch should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '顺着刚才的话请他搭把手',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'help',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '追问他刚才为什么突然沉默',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-camp',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => {
|
||||
const optionCatalog = [
|
||||
createOption('npc_chat', '继续交谈', 2),
|
||||
createOption('npc_trade', '看看能交换什么', 1),
|
||||
];
|
||||
|
||||
const resolved = resolveStoryResponseOptions({
|
||||
responseOptions: [],
|
||||
optionCatalog,
|
||||
getSanitizedOptions: () => {
|
||||
throw new Error('option catalog fallback should not sanitize');
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.map((option) => option.actionText)).toEqual([
|
||||
'继续交谈',
|
||||
'看看能交换什么',
|
||||
]);
|
||||
});
|
||||
});
|
||||
126
src/hooks/rpg-runtime-story/storyResponseOptions.ts
Normal file
126
src/hooks/rpg-runtime-story/storyResponseOptions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryOption } from '../../types';
|
||||
|
||||
type ResolveStoryResponseOptionsParams = {
|
||||
responseOptions: StoryOption[];
|
||||
availableOptions?: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getSanitizedOptions: () => StoryOption[];
|
||||
};
|
||||
|
||||
function cloneStoryOption(option: StoryOption): StoryOption {
|
||||
return {
|
||||
...option,
|
||||
visuals: {
|
||||
...option.visuals,
|
||||
monsterChanges: option.visuals.monsterChanges.map((change) => ({
|
||||
...change,
|
||||
})),
|
||||
},
|
||||
interaction: option.interaction ? { ...option.interaction } : undefined,
|
||||
goalAffordance: option.goalAffordance
|
||||
? { ...option.goalAffordance }
|
||||
: option.goalAffordance,
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteOptionsFromBaseOptions(
|
||||
responseOptions: StoryOption[],
|
||||
baseOptions: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return baseOptions.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
const consumedOptions = new Set<StoryOption>();
|
||||
|
||||
baseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved: StoryOption[] = [];
|
||||
|
||||
responseOptions.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) return;
|
||||
|
||||
consumedOptions.add(matchedOption);
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
resolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
});
|
||||
|
||||
if (resolved.length === baseOptions.length) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const remainingOptions = baseOptions.filter(
|
||||
(option) => !consumedOptions.has(option),
|
||||
);
|
||||
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
|
||||
}
|
||||
|
||||
function rewriteOptionsFromCatalog(
|
||||
responseOptions: StoryOption[],
|
||||
optionCatalog: StoryOption[],
|
||||
) {
|
||||
if (responseOptions.length === 0) {
|
||||
return optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
const optionBuckets = new Map<string, StoryOption[]>();
|
||||
|
||||
optionCatalog.forEach((option) => {
|
||||
const bucket = optionBuckets.get(option.functionId) ?? [];
|
||||
bucket.push(option);
|
||||
optionBuckets.set(option.functionId, bucket);
|
||||
});
|
||||
|
||||
const resolved = responseOptions.reduce<StoryOption[]>((nextResolved, option) => {
|
||||
const bucket = optionBuckets.get(option.functionId);
|
||||
const matchedOption = bucket?.shift();
|
||||
if (!matchedOption) {
|
||||
return nextResolved;
|
||||
}
|
||||
|
||||
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
|
||||
nextResolved.push({
|
||||
...cloneStoryOption(matchedOption),
|
||||
actionText: rewrittenText,
|
||||
text: rewrittenText || matchedOption.text || matchedOption.actionText,
|
||||
});
|
||||
return nextResolved;
|
||||
}, []);
|
||||
|
||||
return resolved.length > 0
|
||||
? resolved
|
||||
: optionCatalog.map(cloneStoryOption);
|
||||
}
|
||||
|
||||
export function resolveStoryResponseOptions({
|
||||
responseOptions,
|
||||
availableOptions = null,
|
||||
optionCatalog = null,
|
||||
getSanitizedOptions,
|
||||
}: ResolveStoryResponseOptionsParams) {
|
||||
if (availableOptions) {
|
||||
return sortStoryOptionsByPriority(
|
||||
rewriteOptionsFromBaseOptions(responseOptions, availableOptions),
|
||||
);
|
||||
}
|
||||
|
||||
if (optionCatalog) {
|
||||
return sortStoryOptionsByPriority(
|
||||
rewriteOptionsFromCatalog(responseOptions, optionCatalog),
|
||||
);
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(getSanitizedOptions());
|
||||
}
|
||||
123
src/hooks/rpg-runtime-story/storyRuntimeSupport.test.ts
Normal file
123
src/hooks/rpg-runtime-story/storyRuntimeSupport.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
cloneInventoryItemForOwner,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
} from './storyRuntimeSupport';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 1,
|
||||
playerMaxHp: 1,
|
||||
playerMana: 1,
|
||||
playerMaxMana: 1,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyRuntimeSupport', () => {
|
||||
it('preserves identity-sensitive inventory items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'artifact-1',
|
||||
category: '饰品',
|
||||
name: '旧日秘匣',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
seedKey: 'artifact-seed',
|
||||
},
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc:artifact-1:2',
|
||||
quantity: 2,
|
||||
runtimeMetadata: expect.objectContaining({
|
||||
seedKey: 'artifact-seed:npc',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 3,
|
||||
rarity: 'common',
|
||||
tags: ['healing'],
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'player')).toEqual(
|
||||
expect.objectContaining({
|
||||
id: `player:${encodeURIComponent('消耗品-回气散')}`,
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates quest logs and runtime stats without keeping that logic in the main hook', () => {
|
||||
const initialState = createGameState();
|
||||
const withQuest = updateQuestLog(initialState, () => [
|
||||
{
|
||||
id: 'quest-1',
|
||||
},
|
||||
] as GameState['quests']);
|
||||
const withStats = updateRuntimeStats(withQuest, {
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
|
||||
expect(withStats.quests).toEqual([{ id: 'quest-1' }]);
|
||||
expect(withStats.runtimeStats.itemsUsed).toBe(2);
|
||||
expect(withStats.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/storyRuntimeSupport.ts
Normal file
136
src/hooks/rpg-runtime-story/storyRuntimeSupport.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
NpcBattleMode,
|
||||
} from '../../types';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
export function cloneInventoryItemForOwner(
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity = 1,
|
||||
) {
|
||||
const preserveIdentity = Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.buildProfile ||
|
||||
item.equipmentSlotId ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: preserveIdentity
|
||||
? `${owner}:${item.id}:${quantity}`
|
||||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||||
quantity,
|
||||
runtimeMetadata: item.runtimeMetadata
|
||||
? {
|
||||
...item.runtimeMetadata,
|
||||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||||
}
|
||||
: item.runtimeMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) {
|
||||
return buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState: getResolvedNpcState(state, encounter),
|
||||
playerCharacter: character,
|
||||
playerInventory: state.playerInventory,
|
||||
activeQuests: state.quests,
|
||||
scene: state.currentScenePreset,
|
||||
partySize: state.companions.length,
|
||||
overrideText,
|
||||
worldType: state.worldType,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: ReturnType<typeof getResolvedNpcState>,
|
||||
) => ReturnType<typeof getResolvedNpcState>,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
|
||||
syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: updater(getResolvedNpcState(state, encounter)),
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateQuestLog(
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
quests: updater(state.quests),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRuntimeStats(
|
||||
state: GameState,
|
||||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||||
};
|
||||
}
|
||||
|
||||
export const storyRuntimeSupport = {
|
||||
cloneInventoryItemForOwner,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
buildNpcStory,
|
||||
handleNpcBattleConversationContinuation: (_params: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
character: Character;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
battleMode: NpcBattleMode;
|
||||
}) => false,
|
||||
updateNpcState,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
};
|
||||
|
||||
export type StoryRuntimeSupport = typeof storyRuntimeSupport;
|
||||
105
src/hooks/rpg-runtime-story/uiTypes.ts
Normal file
105
src/hooks/rpg-runtime-story/uiTypes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
|
||||
export type TradeModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
introText: string | null;
|
||||
mode: 'buy' | 'sell';
|
||||
selectedNpcItemId: string | null;
|
||||
selectedPlayerItemId: string | null;
|
||||
selectedQuantity: number;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export interface StoryGenerationNpcUi {
|
||||
tradeModal: TradeModalState | null;
|
||||
giftModal: GiftModalState | null;
|
||||
recruitModal: RecruitModalState | null;
|
||||
setTradeMode: (mode: 'buy' | 'sell') => void;
|
||||
selectTradeNpcItem: (itemId: string) => void;
|
||||
selectTradePlayerItem: (itemId: string) => void;
|
||||
setTradeQuantity: (quantity: number) => void;
|
||||
closeTradeModal: () => void;
|
||||
confirmTrade: () => void;
|
||||
selectGiftItem: (itemId: string) => void;
|
||||
closeGiftModal: () => void;
|
||||
confirmGift: () => void;
|
||||
selectRecruitRelease: (npcId: string) => void;
|
||||
closeRecruitModal: () => void;
|
||||
confirmRecruit: () => void;
|
||||
}
|
||||
|
||||
export interface InventoryFlowUi {
|
||||
useInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
equipInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
|
||||
forgeRecipes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
}>;
|
||||
craftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
dismantleItem: (itemId: string) => Promise<boolean>;
|
||||
reforgeItem: (itemId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface QuestFlowUi {
|
||||
acknowledgeQuestCompletion: (questId: string) => void;
|
||||
claimQuestReward: (questId: string) => {
|
||||
questId: string;
|
||||
handoff: GoalHandoff | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => boolean;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
export interface GoalFlowUi {
|
||||
goalStack: GoalStackState;
|
||||
pulse: GoalPulseEvent | null;
|
||||
dismissPulse: () => void;
|
||||
}
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
id: string;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string }>;
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
export interface BattleRewardUi {
|
||||
reward: BattleRewardSummary | null;
|
||||
dismiss: () => void;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRpgRuntimeInteractionUiResetter } from './useRpgRuntimeInteractionFlow';
|
||||
|
||||
describe('useRpgRuntimeInteractionFlow helpers', () => {
|
||||
it('clears interaction ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryInteractionUi = createRpgRuntimeInteractionUiResetter({
|
||||
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
|
||||
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
|
||||
});
|
||||
|
||||
clearStoryInteractionUi();
|
||||
|
||||
expect(calls).toEqual(['choice', 'npc']);
|
||||
});
|
||||
});
|
||||
289
src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts
Normal file
289
src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { useTreasureFlow } from '../useTreasureFlow';
|
||||
import { useStoryInventoryActions } from './inventoryActions';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type {
|
||||
ChoiceRuntimeController,
|
||||
ChoiceRuntimeSupport,
|
||||
StoryChoiceCoordinatorParams,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { createRpgRuntimeNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
|
||||
type RpgRuntimeInteractionFlowParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryInteractionUi(params: {
|
||||
clearStoryChoiceUi: () => void;
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG runtime 交互分发层。
|
||||
* 统一串起宝箱、背包、NPC 交互与 story choice 的正式分发。
|
||||
*/
|
||||
export function useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: RpgRuntimeInteractionFlowParams) {
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
const { inventoryUi } = useStoryInventoryActions(
|
||||
interactionConfig.inventoryFlow,
|
||||
);
|
||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||
interactionConfig.npcInteractionFlow,
|
||||
);
|
||||
const {
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
reopenNpcChatAfterBattle,
|
||||
handleNpcChatTurn,
|
||||
exitNpcChat,
|
||||
replacePendingNpcQuestOffer,
|
||||
abandonPendingNpcQuestOffer,
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createRpgRuntimeNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||||
enterNpcInteraction(
|
||||
gameState.currentEncounter,
|
||||
`与${gameState.currentEncounter.npcName}搭话`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
enterNpcInteraction,
|
||||
gameState.currentEncounter,
|
||||
gameState.inBattle,
|
||||
gameState.npcInteractionActive,
|
||||
isLoading,
|
||||
isNpcEncounter,
|
||||
]);
|
||||
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
currentStory: interactionConfig.npcEncounterActions.currentStory,
|
||||
buildStoryContextFromState:
|
||||
interactionConfig.npcEncounterActions.buildStoryContextFromState,
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) =>
|
||||
buildStoryFromResponse(
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
),
|
||||
buildFallbackStoryForState:
|
||||
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
|
||||
generateStoryForState: async (params) =>
|
||||
interactionConfig.npcEncounterActions.generateStoryForState(params),
|
||||
getAvailableOptionsForState:
|
||||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||||
getCampCompanionTravelScene: (state, character) =>
|
||||
getCampCompanionTravelScene(state, character),
|
||||
commitGeneratedStateWithEncounterEntry: async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
);
|
||||
},
|
||||
};
|
||||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||||
...runtimeSupport,
|
||||
handleNpcBattleConversationContinuation: ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}) =>
|
||||
reopenNpcChatAfterBattle({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
battleMode,
|
||||
}),
|
||||
};
|
||||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||||
useStoryChoiceCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState: interactionConfig.npcEncounterActions.setGameState,
|
||||
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
|
||||
setAiError: interactionConfig.npcEncounterActions.setAiError,
|
||||
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs:
|
||||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController: choiceRuntimeController,
|
||||
runtimeSupport: choiceRuntimeSupport,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
|
||||
buildContinueAdventureOption:
|
||||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
|
||||
const clearStoryInteractionUi = useCallback(
|
||||
createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi,
|
||||
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
|
||||
}),
|
||||
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput: (input: string) => {
|
||||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleNpcChatTurn(encounter, input);
|
||||
return true;
|
||||
},
|
||||
refreshNpcChatOptions: () => {
|
||||
const story = interactionConfig.npcEncounterActions.currentStory;
|
||||
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
|
||||
if (!story?.npcChatState || !story.options.length || !encounter || encounter.kind !== 'npc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [firstOption, ...restOptions] = story.options;
|
||||
if (!firstOption || restOptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
interactionConfig.npcEncounterActions.setCurrentStory({
|
||||
...story,
|
||||
options: [...restOptions, firstOption],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi: {
|
||||
replacePendingOffer: replacePendingNpcQuestOffer,
|
||||
abandonPendingOffer: abandonPendingNpcQuestOffer,
|
||||
acceptPendingOffer: acceptPendingNpcQuestOffer,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeInteractionFlowParams = Parameters<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>[0];
|
||||
export type RpgRuntimeInteractionFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeInteractionUiResetter =
|
||||
createClearStoryInteractionUi;
|
||||
2165
src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
Normal file
2165
src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
Normal file
File diff suppressed because it is too large
Load Diff
153
src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts
Normal file
153
src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
buildContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isContinueAdventureOption,
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { useCharacterChatFlow } from './characterChat';
|
||||
import { buildStoryContextFromState } from './storyContextBuilder';
|
||||
import {
|
||||
getResolvedSceneHostileNpcs,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isNpcEncounter,
|
||||
isRegularNpcEncounter,
|
||||
} from './storyEncounterState';
|
||||
import {
|
||||
resolveNpcInteractionDecision,
|
||||
} from './storyGenerationState';
|
||||
import { storyRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { BattleRewardUi, QuestFlowUi } from './uiTypes';
|
||||
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
|
||||
import { useRpgRuntimeStoryFlow } from './useRpgRuntimeStoryFlow';
|
||||
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
|
||||
const FALLBACK_COMPANION_NAME = '同伴';
|
||||
|
||||
export type {
|
||||
CharacterChatModalState,
|
||||
CharacterChatTarget,
|
||||
CharacterChatUi,
|
||||
} from './characterChat';
|
||||
export type {
|
||||
BattleRewardSummary,
|
||||
BattleRewardUi,
|
||||
GiftModalState,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
/**
|
||||
* RPG runtime story 顶层装配入口。
|
||||
* 这里负责收口角色聊天、story controller 与 story flow 三层能力,
|
||||
* 让运行态主链直接消费 RPG 域命名,不再保留旧 story hook 入口。
|
||||
*/
|
||||
export function useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
}) {
|
||||
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
const runtimeController = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport: storyRuntimeSupport,
|
||||
sortOptions: sortStoryOptionsByPriority,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
|
||||
fallbackCompanionName: FALLBACK_COMPANION_NAME,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory: runtimeController.currentStory,
|
||||
isLoading: runtimeController.isLoading,
|
||||
aiError: runtimeController.aiError,
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi: battleRewardUi satisfies BattleRewardUi,
|
||||
questUi: questUi satisfies QuestFlowUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryParams = Parameters<typeof useRpgRuntimeStory>[0];
|
||||
export type RpgRuntimeStoryResult = ReturnType<typeof useRpgRuntimeStory>;
|
||||
137
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts
Normal file
137
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
} from './storyEncounterState';
|
||||
import {
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: StoryContextBuilderExtras,
|
||||
) => StoryGenerationContext;
|
||||
|
||||
/**
|
||||
* RPG runtime story controller。
|
||||
* 统一管理当前故事、AI 请求状态和生成后的状态提交。
|
||||
*/
|
||||
export function useRpgRuntimeStoryController(params: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
}) {
|
||||
const { gameState, setGameState, buildStoryContextFromState } = params;
|
||||
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
createStoryStateResolvers({
|
||||
buildNpcStory,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog: StoryOption[] | null = null,
|
||||
) =>
|
||||
buildStoryFromResponseFromPresentation({
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStoryForState = useMemo(
|
||||
() =>
|
||||
createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory: generateInitialStory,
|
||||
requestNextStep: generateNextStep,
|
||||
onServerOptionCatalogLoadError: (error) => {
|
||||
console.warn(
|
||||
'[useRpgRuntimeStory] failed to load server runtime option catalog',
|
||||
error,
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
],
|
||||
);
|
||||
|
||||
const appendHistory = useCallback(appendStoryHistory, []);
|
||||
|
||||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||||
createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
aiError,
|
||||
setAiError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure: null,
|
||||
startOpeningAdventure: async () => undefined,
|
||||
resetPreparedOpeningAdventure: () => undefined,
|
||||
buildStoryContextFromState,
|
||||
buildDialogueStoryMoment,
|
||||
getTypewriterDelay,
|
||||
getCampCompanionTravelScene: () => null,
|
||||
buildOpeningCampChatContext: () => ({}),
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
buildStoryFromResponse,
|
||||
generateStoryForState,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryControllerParams = Parameters<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>[0];
|
||||
export type RpgRuntimeStoryControllerResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>;
|
||||
203
src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts
Normal file
203
src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Character, Encounter, GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import { sanitizeStoryOptions } from './storyPresentation';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { useRpgRuntimeInteractionFlow } from './useRpgRuntimeInteractionFlow';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
import { useRpgRuntimeStoryState } from './useRpgRuntimeStoryState';
|
||||
import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
type RpgRuntimeStoryFlowParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: RpgRuntimeStoryControllerResult;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
clearCharacterChatModal: () => void;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG runtime story 主编排层。
|
||||
* 这里把 option 展示、正式交互分发和 story/session 状态动作收束成稳定出口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: RpgRuntimeStoryFlowParams) {
|
||||
const {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
isLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getTypewriterDelay,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions: sanitizeStoryOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
} = useStoryGoalOptionCoordinator({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
|
||||
useRpgRuntimeStoryState({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
resetPreparedOpeningAdventure,
|
||||
clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi,
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
handleNpcChatInput,
|
||||
refreshNpcChatOptions,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryFlowParams = Parameters<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>[0];
|
||||
export type RpgRuntimeStoryFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>;
|
||||
28
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts
Normal file
28
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRpgRuntimeStoryUiResetter } from './useRpgRuntimeStoryState';
|
||||
|
||||
describe('useRpgRuntimeStoryState helpers', () => {
|
||||
it('clears story runtime ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryRuntimeUi = createRpgRuntimeStoryUiResetter({
|
||||
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
|
||||
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
|
||||
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
|
||||
setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)),
|
||||
resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')),
|
||||
clearCharacterChatModal: vi.fn(() => calls.push('chat')),
|
||||
});
|
||||
|
||||
clearStoryRuntimeUi();
|
||||
|
||||
expect(calls).toEqual([
|
||||
'goal-option',
|
||||
'interaction',
|
||||
'ai:null',
|
||||
'loading:false',
|
||||
'opening',
|
||||
'chat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
103
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts
Normal file
103
src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { StoryMoment, GameState, Character } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { createStorySessionActions } from './sessionActions';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function createClearStoryRuntimeUi(params: {
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(false);
|
||||
params.resetPreparedOpeningAdventure();
|
||||
params.clearCharacterChatModal();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG runtime story 状态层。
|
||||
* 负责 story reset、hydration、地图跳转,以及 quest 领取/确认 UI 的收口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryState(params: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
const clearStoryRuntimeUi = useCallback(
|
||||
createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: params.clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi: params.clearStoryInteractionUi,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure,
|
||||
clearCharacterChatModal: params.clearCharacterChatModal,
|
||||
}),
|
||||
[
|
||||
params.clearCharacterChatModal,
|
||||
params.clearStoryGoalOptionUi,
|
||||
params.clearStoryInteractionUi,
|
||||
params.resetPreparedOpeningAdventure,
|
||||
params.setAiError,
|
||||
params.setIsLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
} = createStorySessionActions({
|
||||
gameState: params.gameState,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
},
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
clearStoryRuntimeUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryStateParams = Parameters<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>[0];
|
||||
export type RpgRuntimeStoryStateResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeStoryUiResetter = createClearStoryRuntimeUi;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
|
||||
|
||||
describe('useStoryChoiceCoordinator helpers', () => {
|
||||
it('clears choice ui by dismissing battle reward', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryChoiceUi = createClearStoryChoiceUi({
|
||||
clearBattleReward: vi.fn(() => calls.push('battle')),
|
||||
});
|
||||
|
||||
clearStoryChoiceUi();
|
||||
|
||||
expect(calls).toEqual(['battle']);
|
||||
});
|
||||
});
|
||||
136
src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts
Normal file
136
src/hooks/rpg-runtime-story/useStoryChoiceCoordinator.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
import {
|
||||
createStoryChoiceCoordinatorConfig,
|
||||
type ChoiceRuntimeController,
|
||||
type ChoiceRuntimeSupport,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
|
||||
type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Parameters<typeof createStoryChoiceActions>[0]['setGameState'];
|
||||
setCurrentStory: Parameters<
|
||||
typeof createStoryChoiceActions
|
||||
>[0]['setCurrentStory'];
|
||||
setAiError: Parameters<typeof createStoryChoiceActions>[0]['setAiError'];
|
||||
setIsLoading: Parameters<typeof createStoryChoiceActions>[0]['setIsLoading'];
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController & {
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: Parameters<
|
||||
typeof createStoryChoiceCoordinatorConfig
|
||||
>[0]['finalizeNpcBattleResult'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryChoiceUi(params: {
|
||||
clearBattleReward: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearBattleReward();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryChoiceCoordinator(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions(
|
||||
createStoryChoiceCoordinatorConfig({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.runtimeController.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
runtimeController: params.runtimeController,
|
||||
runtimeSupport: params.runtimeSupport,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearStoryChoiceUi = useCallback(
|
||||
createClearStoryChoiceUi({
|
||||
clearBattleReward: () => setBattleReward(null),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi: {
|
||||
reward: battleReward,
|
||||
dismiss: () => setBattleReward(null),
|
||||
},
|
||||
clearStoryChoiceUi,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
describe('useStoryGoalOptionCoordinator helpers', () => {
|
||||
it('clears story goal and option ui together', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({
|
||||
resetStoryOptions: vi.fn(() => calls.push('options')),
|
||||
resetGoalPulseTracking: vi.fn(() => calls.push('goal')),
|
||||
});
|
||||
|
||||
clearStoryGoalOptionUi();
|
||||
|
||||
expect(calls).toEqual(['options', 'goal']);
|
||||
});
|
||||
});
|
||||
50
src/hooks/rpg-runtime-story/useStoryGoalOptionCoordinator.ts
Normal file
50
src/hooks/rpg-runtime-story/useStoryGoalOptionCoordinator.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { useStoryOptions } from '../useStoryOptions';
|
||||
import { useStoryGoalFlow } from './goalFlow';
|
||||
|
||||
export function createClearStoryGoalOptionUi(params: {
|
||||
resetStoryOptions: () => void;
|
||||
resetGoalPulseTracking: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.resetStoryOptions();
|
||||
params.resetGoalPulseTracking();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalOptionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow(
|
||||
params.gameState,
|
||||
);
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
} = useStoryOptions(params.currentStory, runtimeGoalStack);
|
||||
|
||||
const clearStoryGoalOptionUi = useCallback(
|
||||
createClearStoryGoalOptionUi({
|
||||
resetStoryOptions,
|
||||
resetGoalPulseTracking,
|
||||
}),
|
||||
[resetGoalPulseTracking, resetStoryOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalOptionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalOptionCoordinator
|
||||
>;
|
||||
12
src/hooks/rpg-session/index.ts
Normal file
12
src/hooks/rpg-session/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
export type { BottomTab } from './rpgSessionTypes';
|
||||
export {
|
||||
type RpgRuntimeSessionResult,
|
||||
useRpgRuntimeSession,
|
||||
} from './useRpgRuntimeSession';
|
||||
export {
|
||||
type RpgSessionPersistenceResult,
|
||||
type UseRpgSessionPersistenceParams,
|
||||
useRpgSessionPersistence,
|
||||
} from './useRpgSessionPersistence';
|
||||
export type { BottomTab as RpgBottomTab } from './rpgSessionTypes';
|
||||
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { BottomTab } from '../../types/navigation';
|
||||
|
||||
export type { BottomTab };
|
||||
192
src/hooks/rpg-session/useRpgRuntimeSession.ts
Normal file
192
src/hooks/rpg-session/useRpgRuntimeSession.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||||
import { useCombatFlow } from '../useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../useNpcInteractionFlow';
|
||||
import { useRpgRuntimeStory } from '../rpg-runtime-story/useRpgRuntimeStory';
|
||||
import { useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
import { useRpgSessionPersistence } from './useRpgSessionPersistence';
|
||||
|
||||
/**
|
||||
* RPG 主运行态装配器真实实现。
|
||||
* 工作包 C 起主链改为组合 `rpg-session` 下的 bootstrap / persistence 新入口。
|
||||
*/
|
||||
export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
const authUi = useAuthUi();
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect: selectCustomWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useRpgSessionBootstrap();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId: authUi?.user?.id ?? null,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||||
resetStoryState: storyFlow.resetStoryState,
|
||||
});
|
||||
|
||||
useBackgroundMusic({
|
||||
active: Boolean(
|
||||
gameState.playerCharacter && gameState.currentScene === 'Story',
|
||||
),
|
||||
volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState((currentState) => {
|
||||
if (
|
||||
!currentState.playerCharacter ||
|
||||
currentState.currentScene !== 'Story'
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return syncGameStatePlayTime(currentState);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
void persistence.continueSavedGame(snapshot);
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
void persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleBenchCompanion = (npcId: string) => {
|
||||
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
|
||||
};
|
||||
|
||||
const handleActivateRosterCompanion = (
|
||||
npcId: string,
|
||||
swapNpcId?: string | null,
|
||||
) => {
|
||||
setGameState((currentState) =>
|
||||
activateRosterCompanion(currentState, npcId, swapNpcId),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
aiError: storyFlow.aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
},
|
||||
story: {
|
||||
displayedOptions: storyFlow.displayedOptions,
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleNpcChatInput: storyFlow.handleNpcChatInput,
|
||||
refreshNpcChatOptions: storyFlow.refreshNpcChatOptions,
|
||||
exitNpcChat: storyFlow.exitNpcChat,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
|
||||
goalUi: storyFlow.goalUi,
|
||||
},
|
||||
entry: {
|
||||
hasSavedGame: persistence.hasSavedGame,
|
||||
savedSnapshot: persistence.savedSnapshot,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
},
|
||||
companions: {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion: handleBenchCompanion,
|
||||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||||
},
|
||||
audio: {
|
||||
musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME,
|
||||
onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgRuntimeSessionResult = ReturnType<typeof useRpgRuntimeSession>;
|
||||
413
src/hooks/rpg-session/useRpgSessionBootstrap.ts
Normal file
413
src/hooks/rpg-session/useRpgSessionBootstrap.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldRuntimeCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../../data/equipmentEffects';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildInitialPlayerInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
function mergeStarterInventoryItems<
|
||||
T extends { category: string; name: string },
|
||||
>(explicitItems: T[], fallbackItems: T[]) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...explicitItems, ...fallbackItems].forEach((item) => {
|
||||
merged.set(`${item.category}:${item.name}`, item);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function normalizeExplicitStarterCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferExplicitStarterSlot(category: string) {
|
||||
const normalized = normalizeExplicitStarterCategory(category);
|
||||
if (normalized === '武器') return 'weapon' as const;
|
||||
if (normalized === '护甲') return 'armor' as const;
|
||||
if (
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitCustomWorldRoleStarterState(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
) {
|
||||
const role =
|
||||
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
||||
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
||||
null;
|
||||
|
||||
const inventory = role
|
||||
? role.initialItems.map((item, index) => {
|
||||
const category = normalizeExplicitStarterCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferExplicitStarterSlot(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: 'discovery' as const,
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc' as const,
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
||||
inventory.forEach((item) => {
|
||||
const slot = item.equipmentSlotId;
|
||||
if (!slot || equipment[slot]) {
|
||||
return;
|
||||
}
|
||||
equipment[slot] = item;
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
equipment,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
): Encounter | null {
|
||||
if (!worldType) return null;
|
||||
|
||||
const campScenePreset =
|
||||
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
||||
const npcCandidates = (campScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
||||
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
||||
if (npcCandidates.length === 0) return null;
|
||||
|
||||
const npc =
|
||||
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
||||
if (!npc) return null;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: createInitialGameRuntimeStats(),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Selection',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: null,
|
||||
activeCampaignPackId: null,
|
||||
characterChats: {},
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_BASE_MAX_HP,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 0,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
*/
|
||||
export function useRpgSessionBootstrap() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createInitialGameState(),
|
||||
);
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile
|
||||
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
|
||||
: null,
|
||||
);
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
const resetGame = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
|
||||
const resolvedWorldType = WorldType.CUSTOM;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState((prev) =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
activeBuildBuffs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: Character) => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset = resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(
|
||||
resolvedWorldType,
|
||||
character,
|
||||
);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(
|
||||
character,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const explicitStarterItems =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildExplicitCustomWorldRoleStarterState(
|
||||
resolvedCustomWorldProfile!,
|
||||
character,
|
||||
)
|
||||
: null;
|
||||
const mergedStarterEquipment = {
|
||||
weapon:
|
||||
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
||||
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
||||
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
||||
};
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
|
||||
return ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
348
src/hooks/rpg-session/useRpgSessionPersistence.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.currentScene === 'Story' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
Boolean(gameState.playerCharacter) &&
|
||||
story?.streaming !== true
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
if (bottomTab === 'character' || bottomTab === 'inventory') {
|
||||
return bottomTab;
|
||||
}
|
||||
|
||||
return 'adventure';
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgSessionPersistenceParams = {
|
||||
authenticatedUserId: string | null;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: (state: GameState) => void;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
hydrateStoryState: (story: StoryMoment | null) => void;
|
||||
resetStoryState: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG session persistence 主实现。
|
||||
* 工作包 C 起由新域 hook 负责自动存档、继续游戏恢复与运行态 story 恢复刷新。
|
||||
*/
|
||||
export function useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: UseRpgSessionPersistenceParams) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] =
|
||||
useState<HydratedSavedGameSnapshot | null>(null);
|
||||
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
|
||||
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
|
||||
const [persistenceError, setPersistenceError] = useState<string | null>(null);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const saveControllerRef = useRef<AbortController | null>(null);
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}, []);
|
||||
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
if (!authenticatedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
abortActiveSave();
|
||||
|
||||
const requestId = saveRequestIdRef.current + 1;
|
||||
saveRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
saveControllerRef.current = controller;
|
||||
setIsPersistingSnapshot(true);
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
const snapshot = await rpgSnapshotClient.putSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : '远端存档同步失败';
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setPersistenceError(message);
|
||||
}
|
||||
console.warn(`[useRpgSessionPersistence] ${params.logLabel}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
if (saveControllerRef.current === controller) {
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortActiveSave, authenticatedUserId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
hydrateControllerRef.current = null;
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
setIsHydratingSnapshot(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
void rpgSnapshotClient
|
||||
.getSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(Boolean(snapshot));
|
||||
setPersistenceError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端存档失败';
|
||||
setPersistenceError(message);
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to load remote snapshot',
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSnapshot(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist =
|
||||
!isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]);
|
||||
|
||||
const saveCurrentGame = useCallback(
|
||||
async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
|
||||
return Boolean(snapshot);
|
||||
},
|
||||
[bottomTab, currentStory, gameState, persistSnapshot],
|
||||
);
|
||||
|
||||
const clearSavedGame = useCallback(async () => {
|
||||
abortActiveSave();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
setPersistenceError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rpgSnapshotClient.deleteSnapshot();
|
||||
setPersistenceError(null);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to delete remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
}, [abortActiveSave, authenticatedUserId]);
|
||||
|
||||
const continueSavedGame = useCallback(
|
||||
async (snapshotOverride?: HydratedSavedGameSnapshot | null) => {
|
||||
if (!authenticatedUserId && !snapshotOverride) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
snapshotOverride ??
|
||||
savedSnapshot ??
|
||||
(await rpgSnapshotClient.getSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useRpgSessionPersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
},
|
||||
[
|
||||
authenticatedUserId,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
isHydratingSnapshot,
|
||||
isPersistingSnapshot,
|
||||
persistenceError,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionPersistenceResult = ReturnType<
|
||||
typeof useRpgSessionPersistence
|
||||
>;
|
||||
163
src/hooks/runtimeAuthGuards.test.tsx
Normal file
163
src/hooks/runtimeAuthGuards.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { readSavedSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import { useRpgSessionPersistence } from './rpg-session';
|
||||
import { useGameSettings } from './useGameSettings';
|
||||
|
||||
const storageMocks = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
putSettings: vi.fn(),
|
||||
getSaveSnapshot: vi.fn(),
|
||||
putSaveSnapshot: vi.fn(),
|
||||
deleteSaveSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/rpg-entry', () => ({
|
||||
getRpgProfileSettings: storageMocks.getSettings,
|
||||
putRpgProfileSettings: storageMocks.putSettings,
|
||||
}));
|
||||
|
||||
vi.mock('../services/rpg-runtime', () => ({
|
||||
rpgSnapshotClient: {
|
||||
getSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSnapshot: storageMocks.putSaveSnapshot,
|
||||
deleteSnapshot: storageMocks.deleteSaveSnapshot,
|
||||
},
|
||||
}));
|
||||
|
||||
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
|
||||
const settings = useGameSettings(authenticatedUserId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
settings.setMusicVolume(0.6);
|
||||
}}
|
||||
>
|
||||
设置音量
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersistenceHarness({
|
||||
authenticatedUserId,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
}) {
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState: {} as GameState,
|
||||
bottomTab: 'adventure' as BottomTab,
|
||||
currentStory: null as StoryMoment | null,
|
||||
isLoading: false,
|
||||
setGameState: () => {},
|
||||
setBottomTab: () => {},
|
||||
hydrateStoryState: () => {},
|
||||
resetStoryState: () => {},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
|
||||
<div data-testid="hydrating">
|
||||
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
window.localStorage.clear();
|
||||
storageMocks.getSettings.mockResolvedValue({
|
||||
musicVolume: 0.42,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
storageMocks.putSettings.mockResolvedValue({
|
||||
musicVolume: 0.6,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
storageMocks.getSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.putSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.deleteSaveSnapshot.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => {
|
||||
window.localStorage.setItem(
|
||||
'tavernrealms.settings.v1',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
musicVolume: 0.33,
|
||||
platformTheme: 'dark',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<SettingsHarness authenticatedUserId={null} />);
|
||||
|
||||
expect(screen.getByTestId('music-volume').textContent).toBe('0.33');
|
||||
expect(storageMocks.getSettings).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '设置音量' }).click();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSettings).not.toHaveBeenCalled();
|
||||
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
|
||||
});
|
||||
|
||||
test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => {
|
||||
storageMocks.getSettings.mockResolvedValue({
|
||||
musicVolume: 0.8,
|
||||
platformTheme: 'dark',
|
||||
});
|
||||
|
||||
render(<SettingsHarness authenticatedUserId="user-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storageMocks.getSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.getByTestId('music-volume').textContent).toBe('0.80');
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '设置音量' }).click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSettings).toHaveBeenCalledTimes(1);
|
||||
expect(storageMocks.putSettings).toHaveBeenCalledWith(
|
||||
{ musicVolume: 0.6, platformTheme: 'dark' },
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
|
||||
});
|
||||
|
||||
test('unauthenticated runtime skips remote snapshot hydration', async () => {
|
||||
render(<PersistenceHarness authenticatedUserId={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('hydrating').textContent).toBe('no');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
||||
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
233
src/hooks/useBackgroundMusic.ts
Normal file
233
src/hooks/useBackgroundMusic.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
type AudioWindow = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
function scheduleTone(
|
||||
context: AudioContext,
|
||||
destination: GainNode,
|
||||
frequency: number,
|
||||
startTime: number,
|
||||
duration: number,
|
||||
options: {
|
||||
gain: number;
|
||||
type: OscillatorType;
|
||||
attack?: number;
|
||||
release?: number;
|
||||
detune?: number;
|
||||
},
|
||||
) {
|
||||
if (context.state === 'closed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const oscillator = context.createOscillator();
|
||||
const gainNode = context.createGain();
|
||||
const attack = options.attack ?? 0.05;
|
||||
const release = options.release ?? Math.max(0.18, duration * 0.5);
|
||||
const peakGain = options.gain;
|
||||
const releaseStart = Math.max(startTime + attack, startTime + duration - release);
|
||||
|
||||
oscillator.type = options.type;
|
||||
oscillator.frequency.setValueAtTime(frequency, startTime);
|
||||
oscillator.detune.setValueAtTime(options.detune ?? 0, startTime);
|
||||
|
||||
gainNode.gain.setValueAtTime(0.0001, startTime);
|
||||
gainNode.gain.linearRampToValueAtTime(peakGain, startTime + attack);
|
||||
gainNode.gain.setValueAtTime(peakGain, releaseStart);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(destination);
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + duration + 0.02);
|
||||
}
|
||||
|
||||
function scheduleChord(context: AudioContext, destination: GainNode, notes: number[], startTime: number) {
|
||||
notes.forEach((frequency, index) => {
|
||||
scheduleTone(context, destination, frequency, startTime, 2.45, {
|
||||
gain: index === 0 ? 0.028 : 0.022,
|
||||
type: index === 0 ? 'triangle' : 'sine',
|
||||
attack: 0.12,
|
||||
release: 1.4,
|
||||
detune: index === 1 ? -4 : index === 2 ? 4 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAccent(context: AudioContext, destination: GainNode, frequency: number, startTime: number) {
|
||||
scheduleTone(context, destination, frequency, startTime, 0.32, {
|
||||
gain: 0.032,
|
||||
type: 'triangle',
|
||||
attack: 0.01,
|
||||
release: 0.18,
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleBass(context: AudioContext, destination: GainNode, frequency: number, startTime: number) {
|
||||
scheduleTone(context, destination, frequency, startTime, 1.9, {
|
||||
gain: 0.024,
|
||||
type: 'sine',
|
||||
attack: 0.02,
|
||||
release: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackgroundMusic({
|
||||
active,
|
||||
volume,
|
||||
}: {
|
||||
active: boolean;
|
||||
volume: number;
|
||||
}) {
|
||||
const contextRef = useRef<AudioContext | null>(null);
|
||||
const masterGainRef = useRef<GainNode | null>(null);
|
||||
const loopTimerRef = useRef<number | null>(null);
|
||||
const stepRef = useRef(0);
|
||||
const activeRef = useRef(active);
|
||||
const volumeRef = useRef(volume);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current !== null) {
|
||||
window.clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureAudioGraph = useCallback(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const AudioContextCtor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext;
|
||||
if (!AudioContextCtor) return null;
|
||||
|
||||
if (contextRef.current?.state === 'closed') {
|
||||
contextRef.current = null;
|
||||
masterGainRef.current = null;
|
||||
}
|
||||
|
||||
if (!contextRef.current) {
|
||||
contextRef.current = new AudioContextCtor();
|
||||
}
|
||||
|
||||
if (!masterGainRef.current) {
|
||||
const masterGain = contextRef.current.createGain();
|
||||
masterGain.gain.value = 0.0001;
|
||||
masterGain.connect(contextRef.current.destination);
|
||||
masterGainRef.current = masterGain;
|
||||
}
|
||||
|
||||
return {
|
||||
context: contextRef.current,
|
||||
masterGain: masterGainRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleLoop = useCallback(() => {
|
||||
const graph = ensureAudioGraph();
|
||||
if (!graph || !activeRef.current || volumeRef.current <= 0) {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
const { context, masterGain } = graph;
|
||||
if (context.state === 'closed') {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
const progression = [
|
||||
[220, 277.18, 329.63],
|
||||
[246.94, 311.13, 369.99],
|
||||
[196, 246.94, 293.66],
|
||||
[174.61, 220, 261.63],
|
||||
];
|
||||
const chord = progression[stepRef.current % progression.length] ?? progression[0];
|
||||
if (!chord) {
|
||||
return;
|
||||
}
|
||||
const [bassNote, midNote, topNote] = chord;
|
||||
if (bassNote === undefined || midNote === undefined || topNote === undefined) {
|
||||
return;
|
||||
}
|
||||
const startTime = context.currentTime + 0.08;
|
||||
|
||||
scheduleChord(context, masterGain, chord, startTime);
|
||||
scheduleBass(context, masterGain, bassNote / 2, startTime);
|
||||
scheduleAccent(context, masterGain, topNote * 2, startTime + 0.24);
|
||||
scheduleAccent(context, masterGain, midNote * 2, startTime + 1.12);
|
||||
|
||||
stepRef.current += 1;
|
||||
loopTimerRef.current = window.setTimeout(scheduleLoop, 2200);
|
||||
}, [ensureAudioGraph, stopLoop]);
|
||||
|
||||
const updateMasterVolume = useCallback((graph?: { context: AudioContext; masterGain: GainNode } | null) => {
|
||||
const audioGraph = graph ?? ensureAudioGraph();
|
||||
if (!audioGraph) return;
|
||||
|
||||
const targetGain = activeRef.current && volumeRef.current > 0
|
||||
? Math.max(0.0001, volumeRef.current * 0.18)
|
||||
: 0.0001;
|
||||
|
||||
audioGraph.masterGain.gain.cancelScheduledValues(audioGraph.context.currentTime);
|
||||
audioGraph.masterGain.gain.linearRampToValueAtTime(targetGain, audioGraph.context.currentTime + 0.24);
|
||||
}, [ensureAudioGraph]);
|
||||
|
||||
const startPlayback = useCallback(async () => {
|
||||
const graph = ensureAudioGraph();
|
||||
if (!graph || !activeRef.current || volumeRef.current <= 0) return;
|
||||
|
||||
if (graph.context.state === 'suspended') {
|
||||
await graph.context.resume();
|
||||
}
|
||||
|
||||
updateMasterVolume(graph);
|
||||
|
||||
if (loopTimerRef.current === null) {
|
||||
scheduleLoop();
|
||||
}
|
||||
}, [ensureAudioGraph, scheduleLoop, updateMasterVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
volumeRef.current = volume;
|
||||
|
||||
if (!active || volume <= 0) {
|
||||
updateMasterVolume();
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
void startPlayback();
|
||||
|
||||
const handleUserGesture = () => {
|
||||
void startPlayback();
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handleUserGesture);
|
||||
window.addEventListener('keydown', handleUserGesture);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handleUserGesture);
|
||||
window.removeEventListener('keydown', handleUserGesture);
|
||||
};
|
||||
}, [active, startPlayback, stopLoop, updateMasterVolume, volume]);
|
||||
|
||||
useEffect(() => () => {
|
||||
stopLoop();
|
||||
|
||||
if (masterGainRef.current && contextRef.current) {
|
||||
masterGainRef.current.gain.cancelScheduledValues(contextRef.current.currentTime);
|
||||
masterGainRef.current.gain.value = 0.0001;
|
||||
}
|
||||
|
||||
const context = contextRef.current;
|
||||
contextRef.current = null;
|
||||
masterGainRef.current = null;
|
||||
|
||||
if (context && context.state !== 'closed') {
|
||||
void context.close();
|
||||
}
|
||||
}, [stopLoop]);
|
||||
}
|
||||
67
src/hooks/useCombatFlow.ts
Normal file
67
src/hooks/useCombatFlow.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryOption,
|
||||
} from '../types';
|
||||
import {
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
buildBattlePlan as buildBattlePlanFromEngine,
|
||||
} from './combat/battlePlan';
|
||||
import { createCombatPlayback } from './combat/playback';
|
||||
import type { EscapePlaybackSync } from './combat/escapeFlow';
|
||||
import {
|
||||
buildResolvedChoiceState as buildResolvedChoiceStateFromEngine,
|
||||
type ResolvedChoiceState,
|
||||
} from './combat/resolvedChoice';
|
||||
export { buildSkillEffects } from './combat/skillEffects';
|
||||
export type { ResolvedChoiceState } from './combat/resolvedChoice';
|
||||
|
||||
const TOTAL_SEQUENCE_MS = 6000;
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const RESET_STAGE_MS = 260;
|
||||
const MIN_TURN_COUNT = 6;
|
||||
|
||||
export type ResolvedChoicePlaybackSync = EscapePlaybackSync;
|
||||
void applyRecoveryEffectToState;
|
||||
|
||||
export function useCombatFlow({
|
||||
setGameState,
|
||||
}: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
}) {
|
||||
const buildBattlePlan = (state: GameState, option: StoryOption, character: Character): BattlePlan =>
|
||||
buildBattlePlanFromEngine({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
totalSequenceMs: TOTAL_SEQUENCE_MS,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
resetStageMs: RESET_STAGE_MS,
|
||||
minTurnCount: MIN_TURN_COUNT,
|
||||
});
|
||||
|
||||
const buildResolvedChoiceState = (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
): ResolvedChoiceState => buildResolvedChoiceStateFromEngine({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
buildBattlePlan,
|
||||
});
|
||||
|
||||
const { playResolvedChoice } = createCombatPlayback({
|
||||
setGameState,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
resetStageMs: RESET_STAGE_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
};
|
||||
}
|
||||
381
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
381
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useMemo } from 'react';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import { WorldType } from '../types';
|
||||
import { useRpgSessionBootstrap } from './rpg-session';
|
||||
|
||||
function buildBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
privateChatUnlockAffinity: 40,
|
||||
chapters: [
|
||||
{
|
||||
id: `${label}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${label}先只肯说表面的来意。`,
|
||||
content: `${label}表面上只愿意谈当前局势。`,
|
||||
contextSnippet: `${label}表面上还在收着话。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${label}背后还有一段旧伤。`,
|
||||
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
|
||||
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${label}真正想追的不是表面那件事。`,
|
||||
content: `${label}真正挂着的是旧案里还没结的那条线。`,
|
||||
contextSnippet: `${label}真正执念指向旧案深处。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${label}手里还压着最后一张牌。`,
|
||||
content: `${label}手里还握着能直接证明真相的关键证据。`,
|
||||
contextSnippet: `${label}最后的底牌足以改写局势。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildSavedProfile() {
|
||||
const profile = normalizeCustomWorldProfileRecord({
|
||||
id: 'saved-runtime-profile',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '回潮群岛',
|
||||
subtitle: '旧灯塔与断续潮路',
|
||||
summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船夜与封航记录被改动的真相。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['封航争夺', '沉船真相'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '回潮群岛',
|
||||
settingSummary: '潮雾旧航路',
|
||||
tone: '压抑',
|
||||
conflictCore: '沉船真相',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
description: '最熟悉旧潮路的人。',
|
||||
backstory: '他在沉船夜里带着半支船队逃出过假航灯。',
|
||||
personality: '表面沉稳,心里一直在算退路。',
|
||||
motivation: '想赶在封航前查清真相。',
|
||||
combatStyle: '借潮路换位,先拉扯再压近。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧友', '沉船旧案'],
|
||||
tags: ['潮路', '引路'],
|
||||
backstoryReveal: buildBackstoryReveal('沈砺'),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-playable-1',
|
||||
name: '潮行引路',
|
||||
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'skill-playable-2',
|
||||
name: '回雾折返',
|
||||
summary: '借海雾遮住身位,再从侧线拉开。',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'skill-playable-3',
|
||||
name: '旧图定标',
|
||||
summary: '用旧潮图锁定退路和突入口。',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-playable-1',
|
||||
name: '旧潮短刃',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '专门在湿滑甲板上近身换位用的短刃。',
|
||||
tags: ['潮路', '近战'],
|
||||
},
|
||||
{
|
||||
id: 'item-playable-2',
|
||||
name: '雾盐药包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '压住寒潮后遗症的随身药包。',
|
||||
tags: ['补给'],
|
||||
},
|
||||
{
|
||||
id: 'item-playable-3',
|
||||
name: '旧潮图残页',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '足够指向沉船夜另一条线的残页。',
|
||||
tags: ['线索', '真相'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
description: '夜里巡灯与封锁禁航区的人。',
|
||||
backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。',
|
||||
personality: '冷静克制,但提到旧灯册会明显变调。',
|
||||
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
|
||||
combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。',
|
||||
initialAffinity: 8,
|
||||
relationshipHooks: ['禁航记录', '灯塔值夜'],
|
||||
tags: ['守灯会', '灯塔'],
|
||||
backstoryReveal: buildBackstoryReveal('顾潮音'),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-story-1',
|
||||
name: '夜潮灯语',
|
||||
summary: '借灯语与潮声干扰对方判断。',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'skill-story-2',
|
||||
name: '禁航暗潮',
|
||||
summary: '封住错误航线,把人逼回她熟悉的区域。',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'skill-story-3',
|
||||
name: '回声巡线',
|
||||
summary: '借塔顶回声迅速锁定异动方向。',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-story-1',
|
||||
name: '值夜灯尺',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '兼作警械和测灯尺的长柄器具。',
|
||||
tags: ['守灯会'],
|
||||
},
|
||||
],
|
||||
narrativeProfile: {
|
||||
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
|
||||
visibleLine: '她表面上只是在守灯和封线。',
|
||||
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
|
||||
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
|
||||
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
|
||||
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
|
||||
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
|
||||
relatedThreadIds: ['thread-visible-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['原始灯册', '封灯令'],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-2',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿着旧潮阶继续前压到雾栈尽头。',
|
||||
},
|
||||
],
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-1',
|
||||
title: '潮痕',
|
||||
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
|
||||
linkedFactIds: ['fact-1'],
|
||||
linkedThreadIds: ['thread-visible-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'landmark-2',
|
||||
name: '雾栈尽头',
|
||||
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
|
||||
sceneNpcIds: [],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-1',
|
||||
relativePosition: 'back',
|
||||
summary: '退回灯塔还能重新整理路线。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
scenarioPackId: 'scenario-pack:tide',
|
||||
campaignPackId: 'campaign-pack:tide',
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('failed to build saved custom world profile');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
function readSnapshot() {
|
||||
const raw = screen.getByTestId('state-snapshot').textContent ?? '{}';
|
||||
return JSON.parse(raw) as {
|
||||
worldType: string | null;
|
||||
currentScene: string;
|
||||
profileName: string | null;
|
||||
activeScenarioPackId: string | null;
|
||||
activeCampaignPackId: string | null;
|
||||
currentScenePresetId: string | null;
|
||||
currentScenePresetName: string | null;
|
||||
currentSceneConnectedIds: string[];
|
||||
firstLandmarkResidueTitle: string | null;
|
||||
playerCharacterName: string | null;
|
||||
playerInventoryNames: string[];
|
||||
playerEquipment: {
|
||||
weapon: string | null;
|
||||
armor: string | null;
|
||||
relic: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function GameFlowHarness() {
|
||||
const profile = useMemo(() => buildSavedProfile(), []);
|
||||
const playableCharacters = useMemo(
|
||||
() => buildCustomWorldPlayableCharacters(profile),
|
||||
[profile],
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
|
||||
useRpgSessionBootstrap();
|
||||
|
||||
const snapshot = {
|
||||
worldType: gameState.worldType,
|
||||
currentScene: gameState.currentScene,
|
||||
profileName: gameState.customWorldProfile?.name ?? null,
|
||||
activeScenarioPackId: gameState.activeScenarioPackId ?? null,
|
||||
activeCampaignPackId: gameState.activeCampaignPackId ?? null,
|
||||
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
|
||||
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
|
||||
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
|
||||
firstLandmarkResidueTitle:
|
||||
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
||||
?.title ?? null,
|
||||
playerCharacterName: gameState.playerCharacter?.name ?? null,
|
||||
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
|
||||
playerEquipment: {
|
||||
weapon: gameState.playerEquipment.weapon?.name ?? null,
|
||||
armor: gameState.playerEquipment.armor?.name ?? null,
|
||||
relic: gameState.playerEquipment.relic?.name ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCustomWorldSelect(profile)}
|
||||
>
|
||||
选择世界
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedCharacter) {
|
||||
handleCharacterSelect(selectedCharacter);
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认角色
|
||||
</button>
|
||||
<pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
setRuntimeCharacterOverrides(null);
|
||||
});
|
||||
|
||||
test('saved custom world result settings flow into game state after entering the world', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<GameFlowHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '选择世界' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
|
||||
});
|
||||
|
||||
expect(readSnapshot().profileName).toBe('回潮群岛');
|
||||
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
|
||||
expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所');
|
||||
expect(readSnapshot().currentSceneConnectedIds).toContain(
|
||||
'custom-scene-landmark-1',
|
||||
);
|
||||
expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕');
|
||||
expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide');
|
||||
expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认角色' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().currentScene).toBe('Story');
|
||||
});
|
||||
|
||||
expect(readSnapshot().playerCharacterName).toBe('沈砺');
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
|
||||
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
||||
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
|
||||
});
|
||||
210
src/hooks/useGameSettings.ts
Normal file
210
src/hooks/useGameSettings.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {
|
||||
clampVolume,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
normalizePlatformTheme,
|
||||
readSavedSettings,
|
||||
writeSavedSettings,
|
||||
} from '../persistence/gameSettingsStorage';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import {
|
||||
getRpgProfileSettings,
|
||||
putRpgProfileSettings,
|
||||
} from '../services/rpg-entry';
|
||||
|
||||
const SETTINGS_SYNC_DELAY_MS = 180;
|
||||
|
||||
export function useGameSettings(authenticatedUserId: string | null = null) {
|
||||
const [musicVolume, setMusicVolumeState] = useState(
|
||||
() => readSavedSettings().musicVolume,
|
||||
);
|
||||
const [platformTheme, setPlatformThemeState] = useState(
|
||||
() => readSavedSettings().platformTheme,
|
||||
);
|
||||
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
|
||||
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
|
||||
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
|
||||
const currentVolumeRef = useRef(readSavedSettings().musicVolume);
|
||||
const lastSyncedThemeRef = useRef(readSavedSettings().platformTheme);
|
||||
const currentThemeRef = useRef(readSavedSettings().platformTheme);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const persistControllerRef = useRef<AbortController | null>(null);
|
||||
const persistRequestIdRef = useRef(0);
|
||||
const [isRemoteSyncReady, setIsRemoteSyncReady] = useState(false);
|
||||
|
||||
const abortActivePersist = useCallback(() => {
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
currentVolumeRef.current = musicVolume;
|
||||
currentThemeRef.current = platformTheme;
|
||||
writeSavedSettings({ musicVolume, platformTheme });
|
||||
}, [musicVolume, platformTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
hydrateControllerRef.current = null;
|
||||
abortActivePersist();
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
lastSyncedVolumeRef.current = currentVolumeRef.current;
|
||||
lastSyncedThemeRef.current = currentThemeRef.current;
|
||||
setSettingsError(null);
|
||||
setIsHydratingSettings(false);
|
||||
setHasHydratedSettings(true);
|
||||
setIsRemoteSyncReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsRemoteSyncReady(false);
|
||||
setHasHydratedSettings(false);
|
||||
setIsHydratingSettings(true);
|
||||
|
||||
void getRpgProfileSettings({ signal: controller.signal })
|
||||
.then((settings) => {
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
lastSyncedThemeRef.current = nextPlatformTheme;
|
||||
setMusicVolumeState(nextVolume);
|
||||
setPlatformThemeState(nextPlatformTheme);
|
||||
setSettingsError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
lastSyncedVolumeRef.current = currentVolumeRef.current;
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端设置失败';
|
||||
setSettingsError(message);
|
||||
console.warn('[useGameSettings] failed to load remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSettings(false);
|
||||
setHasHydratedSettings(true);
|
||||
setIsRemoteSyncReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [abortActivePersist, authenticatedUserId]);
|
||||
|
||||
useEffect(() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticatedUserId || !hasHydratedSettings || !isRemoteSyncReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
lastSyncedVolumeRef.current === musicVolume
|
||||
&& lastSyncedThemeRef.current === platformTheme
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortActivePersist();
|
||||
|
||||
const requestId = persistRequestIdRef.current + 1;
|
||||
persistRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
persistControllerRef.current = controller;
|
||||
setIsPersistingSettings(true);
|
||||
setSettingsError(null);
|
||||
|
||||
void putRpgProfileSettings(
|
||||
{
|
||||
musicVolume,
|
||||
platformTheme,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((settings) => {
|
||||
if (persistRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
const nextPlatformTheme = normalizePlatformTheme(
|
||||
settings.platformTheme,
|
||||
);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
lastSyncedThemeRef.current = nextPlatformTheme;
|
||||
setMusicVolumeState((currentValue) =>
|
||||
currentValue === nextVolume ? currentValue : nextVolume,
|
||||
);
|
||||
setPlatformThemeState((currentValue) =>
|
||||
currentValue === nextPlatformTheme
|
||||
? currentValue
|
||||
: nextPlatformTheme,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '保存远端设置失败';
|
||||
if (persistRequestIdRef.current === requestId) {
|
||||
setSettingsError(message);
|
||||
}
|
||||
console.warn('[useGameSettings] failed to persist remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (persistControllerRef.current === controller) {
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}
|
||||
});
|
||||
}, SETTINGS_SYNC_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [
|
||||
abortActivePersist,
|
||||
authenticatedUserId,
|
||||
hasHydratedSettings,
|
||||
isRemoteSyncReady,
|
||||
musicVolume,
|
||||
platformTheme,
|
||||
]);
|
||||
|
||||
const setMusicVolume = useCallback((value: number) => {
|
||||
setMusicVolumeState(clampVolume(value));
|
||||
}, []);
|
||||
|
||||
const setPlatformTheme = useCallback((value: 'light' | 'dark') => {
|
||||
setPlatformThemeState(normalizePlatformTheme(value));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
musicVolume,
|
||||
setMusicVolume,
|
||||
platformTheme,
|
||||
setPlatformTheme,
|
||||
hasHydratedSettings,
|
||||
isHydratingSettings,
|
||||
isPersistingSettings,
|
||||
settingsError,
|
||||
};
|
||||
}
|
||||
209
src/hooks/useNpcInteractionFlow.test.ts
Normal file
209
src/hooks/useNpcInteractionFlow.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {getCharacterById} from '../data/characterPresets';
|
||||
import {AnimationState, type Character, type GameState,WorldType} from '../types';
|
||||
import {buildCompanionRenderStatesForGameState} from './useNpcInteractionFlow';
|
||||
|
||||
vi.mock('../data/characterPresets', () => ({
|
||||
getCharacterById: vi.fn(),
|
||||
}));
|
||||
|
||||
function createTestCharacter(id: string, name: string): Character {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: '测试同伴',
|
||||
description: '用于测试的角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/test-avatar.png',
|
||||
portrait: '/test-portrait.png',
|
||||
assetFolder: 'test-character',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [
|
||||
{
|
||||
id: 'basic-strike',
|
||||
name: '试探一击',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 0,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter('player', '主角'),
|
||||
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,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildCompanionRenderStatesForGameState', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCharacterById).mockReset();
|
||||
});
|
||||
|
||||
it('builds render states from the provided transition snapshot', () => {
|
||||
const companionCharacter = createTestCharacter('companion-a', '阿青');
|
||||
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
|
||||
if (characterId === companionCharacter.id || characterId === 'player') {
|
||||
return companionCharacter;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const transitionSnapshot: GameState = {
|
||||
...createBaseState(),
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.ATTACK,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-aqing',
|
||||
characterId: companionCharacter.id,
|
||||
joinedAtAffinity: 10,
|
||||
hp: 36,
|
||||
maxHp: 48,
|
||||
mana: 12,
|
||||
maxMana: 18,
|
||||
skillCooldowns: {basicStrike: 1},
|
||||
offsetX: 14,
|
||||
offsetY: -6,
|
||||
transitionMs: 90,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: transitionSnapshot,
|
||||
presentationByNpcId: {
|
||||
'npc-aqing': {
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
entryOffsetX: 28,
|
||||
entryOffsetY: 12,
|
||||
transitionMs: 240,
|
||||
recruitToken: 42,
|
||||
},
|
||||
},
|
||||
observeFacingByNpcId: {
|
||||
'npc-aqing': 'right',
|
||||
},
|
||||
});
|
||||
|
||||
expect(renderStates).toHaveLength(1);
|
||||
expect(renderStates[0]).toMatchObject({
|
||||
npcId: 'npc-aqing',
|
||||
character: companionCharacter,
|
||||
hp: 36,
|
||||
maxHp: 48,
|
||||
mana: 12,
|
||||
maxMana: 18,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
slot: 'upper',
|
||||
facing: 'right',
|
||||
entryOffsetX: 42,
|
||||
entryOffsetY: 6,
|
||||
transitionMs: 240,
|
||||
recruitToken: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets callers render a visible snapshot even if the live state already changed', () => {
|
||||
const companionCharacter = createTestCharacter('companion-b', '小舟');
|
||||
vi.mocked(getCharacterById).mockImplementation((characterId: string) => {
|
||||
if (characterId === companionCharacter.id || characterId === 'player') {
|
||||
return companionCharacter;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const visibleSnapshot: GameState = {
|
||||
...createBaseState(),
|
||||
scrollWorld: true,
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-xiaozhou',
|
||||
characterId: companionCharacter.id,
|
||||
joinedAtAffinity: 18,
|
||||
hp: 30,
|
||||
maxHp: 30,
|
||||
mana: 9,
|
||||
maxMana: 12,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const liveState: GameState = {
|
||||
...createBaseState(),
|
||||
companions: [],
|
||||
};
|
||||
|
||||
const visibleRenderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: visibleSnapshot,
|
||||
});
|
||||
const liveRenderStates = buildCompanionRenderStatesForGameState({
|
||||
gameState: liveState,
|
||||
});
|
||||
|
||||
expect(visibleRenderStates).toHaveLength(1);
|
||||
expect(visibleRenderStates[0]?.animationState).toBe(AnimationState.RUN);
|
||||
expect(liveRenderStates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
281
src/hooks/useNpcInteractionFlow.ts
Normal file
281
src/hooks/useNpcInteractionFlow.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import { AnimationState, CompanionRenderState, GameState } from '../types';
|
||||
|
||||
type CompanionRecruitPresentation = {
|
||||
animationState: AnimationState;
|
||||
entryOffsetX: number;
|
||||
entryOffsetY: number;
|
||||
transitionMs: number;
|
||||
recruitToken: number;
|
||||
};
|
||||
|
||||
type CompanionPresentationMap = Record<string, CompanionRecruitPresentation>;
|
||||
type CompanionObserveFacingMap = Record<string, 'left' | 'right'>;
|
||||
|
||||
const RECRUIT_ENTRY_OFFSET_X = 148;
|
||||
const RECRUIT_ENTRY_OFFSET_Y = 10;
|
||||
const MIN_RECRUIT_PHASE_MS = 180;
|
||||
const OBSERVE_MIN_PAUSE_MS = 500;
|
||||
const OBSERVE_MAX_PAUSE_MS = 2000;
|
||||
|
||||
function randomFacing() {
|
||||
return Math.random() > 0.5 ? 'left' as const : 'right' as const;
|
||||
}
|
||||
|
||||
function randomObservePause() {
|
||||
return Math.round(OBSERVE_MIN_PAUSE_MS + Math.random() * (OBSERVE_MAX_PAUSE_MS - OBSERVE_MIN_PAUSE_MS));
|
||||
}
|
||||
|
||||
export function buildCompanionRenderStatesForGameState(params: {
|
||||
gameState: GameState;
|
||||
presentationByNpcId?: CompanionPresentationMap;
|
||||
observeFacingByNpcId?: CompanionObserveFacingMap;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
presentationByNpcId = {},
|
||||
observeFacingByNpcId = {},
|
||||
} = params;
|
||||
|
||||
return (gameState.companions ?? [])
|
||||
.map((companion, index) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
|
||||
const presentation = presentationByNpcId[companion.npcId];
|
||||
|
||||
return {
|
||||
npcId: companion.npcId,
|
||||
character,
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
skillCooldowns: companion.skillCooldowns,
|
||||
animationState: presentation?.animationState ?? (
|
||||
companion.hp <= 0
|
||||
? companion.animationState ?? AnimationState.DIE
|
||||
: gameState.scrollWorld
|
||||
? AnimationState.RUN
|
||||
: gameState.inBattle
|
||||
? companion.animationState ?? AnimationState.IDLE
|
||||
: gameState.animationState
|
||||
),
|
||||
actionMode: companion.actionMode ?? 'idle',
|
||||
slot: index % 2 === 0 ? 'upper' : 'lower',
|
||||
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
|
||||
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
|
||||
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
|
||||
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
|
||||
recruitToken: presentation?.recruitToken,
|
||||
} satisfies CompanionRenderState;
|
||||
})
|
||||
.filter(Boolean) as CompanionRenderState[];
|
||||
}
|
||||
|
||||
export function useNpcInteractionFlow(gameState: GameState) {
|
||||
const [presentationByNpcId, setPresentationByNpcId] = useState<Record<string, CompanionRecruitPresentation>>({});
|
||||
const [observeFacingByNpcId, setObserveFacingByNpcId] = useState<Record<string, 'left' | 'right'>>({});
|
||||
const previousCompanionIdsRef = useRef<string[]>([]);
|
||||
const timerIdsRef = useRef<number[]>([]);
|
||||
const observeTimerIdsRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
|
||||
timerIdsRef.current = [];
|
||||
(Object.values(observeTimerIdsRef.current) as number[]).forEach(timerId => window.clearTimeout(timerId));
|
||||
observeTimerIdsRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentCompanions = gameState.companions ?? [];
|
||||
const previousIds = new Set(previousCompanionIdsRef.current);
|
||||
const currentIds = currentCompanions.map(companion => companion.npcId);
|
||||
const currentIdSet = new Set(currentIds);
|
||||
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
Object.keys(next).forEach(npcId => {
|
||||
if (!currentIdSet.has(npcId)) {
|
||||
delete next[npcId];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
currentCompanions.forEach(companion => {
|
||||
if (previousIds.has(companion.npcId)) return;
|
||||
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return;
|
||||
|
||||
const hasDash = Boolean(character.animationMap?.[AnimationState.DASH]);
|
||||
const hasAcquire = Boolean(character.animationMap?.[AnimationState.ACQUIRE]);
|
||||
const dashDuration = hasDash
|
||||
? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.DASH))
|
||||
: 0;
|
||||
const acquireDuration = hasAcquire
|
||||
? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE))
|
||||
: 0;
|
||||
const recruitToken = Date.now() + Math.random();
|
||||
|
||||
if (hasDash) {
|
||||
setPresentationByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
animationState: AnimationState.DASH,
|
||||
entryOffsetX: RECRUIT_ENTRY_OFFSET_X,
|
||||
entryOffsetY: RECRUIT_ENTRY_OFFSET_Y,
|
||||
transitionMs: 0,
|
||||
recruitToken,
|
||||
},
|
||||
}));
|
||||
|
||||
const launchTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const existing = current[companion.npcId];
|
||||
if (!existing) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
...existing,
|
||||
entryOffsetX: 0,
|
||||
entryOffsetY: 0,
|
||||
transitionMs: dashDuration,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, 16);
|
||||
timerIdsRef.current.push(launchTimer);
|
||||
|
||||
const afterDashTimer = window.setTimeout(() => {
|
||||
if (!hasAcquire) {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPresentationByNpcId(current => {
|
||||
const existing = current[companion.npcId];
|
||||
if (!existing) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
...existing,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
transitionMs: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, dashDuration + 24);
|
||||
timerIdsRef.current.push(afterDashTimer);
|
||||
|
||||
if (hasAcquire) {
|
||||
const clearTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
}, dashDuration + acquireDuration + 56);
|
||||
timerIdsRef.current.push(clearTimer);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAcquire) {
|
||||
setPresentationByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
entryOffsetX: 0,
|
||||
entryOffsetY: 0,
|
||||
transitionMs: 0,
|
||||
recruitToken,
|
||||
},
|
||||
}));
|
||||
|
||||
const clearTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
}, acquireDuration + 40);
|
||||
timerIdsRef.current.push(clearTimer);
|
||||
}
|
||||
});
|
||||
|
||||
previousCompanionIdsRef.current = currentIds;
|
||||
}, [gameState.companions]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentCompanions = gameState.companions ?? [];
|
||||
const currentIds = new Set(currentCompanions.map(companion => companion.npcId));
|
||||
const isObserveActive = gameState.ambientIdleMode === 'observe_signs';
|
||||
|
||||
const clearObserveTimer = (npcId: string) => {
|
||||
const timerId = observeTimerIdsRef.current[npcId];
|
||||
if (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
delete observeTimerIdsRef.current[npcId];
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(observeTimerIdsRef.current).forEach(npcId => {
|
||||
if (!isObserveActive || !currentIds.has(npcId)) {
|
||||
clearObserveTimer(npcId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isObserveActive) {
|
||||
setObserveFacingByNpcId({});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleNextObserveTurn = (npcId: string) => {
|
||||
clearObserveTimer(npcId);
|
||||
observeTimerIdsRef.current[npcId] = window.setTimeout(() => {
|
||||
setObserveFacingByNpcId(current => ({
|
||||
...current,
|
||||
[npcId]: randomFacing(),
|
||||
}));
|
||||
scheduleNextObserveTurn(npcId);
|
||||
}, randomObservePause());
|
||||
};
|
||||
|
||||
currentCompanions.forEach(companion => {
|
||||
if (observeTimerIdsRef.current[companion.npcId]) return;
|
||||
|
||||
setObserveFacingByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: randomFacing(),
|
||||
}));
|
||||
scheduleNextObserveTurn(companion.npcId);
|
||||
});
|
||||
}, [gameState.ambientIdleMode, gameState.companions]);
|
||||
|
||||
const buildCompanionRenderStates = useCallback((state: GameState) => buildCompanionRenderStatesForGameState({
|
||||
gameState: state,
|
||||
presentationByNpcId,
|
||||
observeFacingByNpcId,
|
||||
}), [observeFacingByNpcId, presentationByNpcId]);
|
||||
|
||||
const companionRenderStates = buildCompanionRenderStates(gameState);
|
||||
|
||||
return {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
};
|
||||
}
|
||||
108
src/hooks/useResolvedAssetReadUrl.test.tsx
Normal file
108
src/hooks/useResolvedAssetReadUrl.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ResolvedAssetImage } from '../components/ResolvedAssetImage';
|
||||
import { clearStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||
import { clearSignedAssetReadUrlCache } from '../services/assetReadUrlService';
|
||||
|
||||
describe('useResolvedAssetReadUrl', () => {
|
||||
beforeEach(() => {
|
||||
clearSignedAssetReadUrlCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
test('generated 私有资源签名完成前不会把裸路径写入 img', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
|
||||
const image = await screen.findByRole('img', { name: '候选图' });
|
||||
expect(image.getAttribute('src')).toBe('https://signed.example.com/puzzle.png');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: '对象不存在',
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
65
src/hooks/useResolvedAssetReadUrl.ts
Normal file
65
src/hooks/useResolvedAssetReadUrl.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
isGeneratedLegacyPath,
|
||||
resolveAssetReadUrl,
|
||||
} from '../services/assetReadUrlService';
|
||||
|
||||
type UseResolvedAssetReadUrlOptions = {
|
||||
enabled?: boolean;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
export function useResolvedAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: UseResolvedAssetReadUrlOptions = {},
|
||||
) {
|
||||
const enabled = options.enabled !== false;
|
||||
const normalizedSource = source?.trim() ?? '';
|
||||
const shouldResolve =
|
||||
enabled && Boolean(normalizedSource) && isGeneratedLegacyPath(normalizedSource);
|
||||
const [resolvedUrl, setResolvedUrl] = useState(
|
||||
shouldResolve ? '' : normalizedSource,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedSource) {
|
||||
setResolvedUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldResolve) {
|
||||
setResolvedUrl(normalizedSource);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// 生成资源通常是 OSS 私有对象;签名 URL 未就绪前不能把裸 generated 路径交给 img 触发无鉴权 GET。
|
||||
setResolvedUrl('');
|
||||
|
||||
void resolveAssetReadUrl(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
})
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) {
|
||||
setResolvedUrl(nextUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// 签名失败时保持空 src,避免继续请求无签名的私有对象兼容路径。
|
||||
setResolvedUrl('');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedSource, options.expireSeconds, shouldResolve]);
|
||||
|
||||
return {
|
||||
resolvedUrl,
|
||||
isResolving: shouldResolve && !resolvedUrl,
|
||||
shouldResolve,
|
||||
};
|
||||
}
|
||||
112
src/hooks/useStoryOptions.ts
Normal file
112
src/hooks/useStoryOptions.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { sortStoryOptionsByPriority } from '../data/stateFunctions';
|
||||
import { annotateStoryOptionsWithGoalAffordance } from '../services/storyEngine/goalDirector';
|
||||
import type { GoalStackState, StoryMoment } from '../types';
|
||||
|
||||
const OPTION_PAGE_SIZE = 3;
|
||||
|
||||
export function useStoryOptions(
|
||||
currentStory: StoryMoment | null,
|
||||
goalStack?: GoalStackState | null,
|
||||
) {
|
||||
const [optionWindowStart, setOptionWindowStart] = useState(0);
|
||||
|
||||
const activeOptionPool = useMemo(() => {
|
||||
if (!currentStory) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(
|
||||
annotateStoryOptionsWithGoalAffordance(
|
||||
currentStory.options,
|
||||
goalStack,
|
||||
),
|
||||
);
|
||||
}, [currentStory, goalStack]);
|
||||
|
||||
const optionPoolSignature = useMemo(
|
||||
() =>
|
||||
activeOptionPool
|
||||
.map((option) =>
|
||||
[
|
||||
option.functionId,
|
||||
option.actionText,
|
||||
option.text ?? '',
|
||||
option.goalAffordance?.goalId ?? '',
|
||||
option.goalAffordance?.relation ?? '',
|
||||
].join('::'),
|
||||
)
|
||||
.join('||'),
|
||||
[activeOptionPool],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOptionWindowStart(0);
|
||||
}, [currentStory, optionPoolSignature]);
|
||||
|
||||
const displayedOptions = useMemo(
|
||||
() => {
|
||||
const windowOptions = activeOptionPool.slice(
|
||||
optionWindowStart,
|
||||
optionWindowStart + OPTION_PAGE_SIZE,
|
||||
);
|
||||
if (
|
||||
windowOptions.some(
|
||||
(option) => option.goalAffordance?.relation === 'advance',
|
||||
)
|
||||
) {
|
||||
return windowOptions;
|
||||
}
|
||||
|
||||
const pinnedAdvanceOption =
|
||||
activeOptionPool.find(
|
||||
(option) => option.goalAffordance?.relation === 'advance',
|
||||
) ?? null;
|
||||
if (!pinnedAdvanceOption) {
|
||||
return windowOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
pinnedAdvanceOption,
|
||||
...windowOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!(
|
||||
option.functionId === pinnedAdvanceOption.functionId
|
||||
&& option.actionText === pinnedAdvanceOption.actionText
|
||||
),
|
||||
)
|
||||
.slice(0, OPTION_PAGE_SIZE - 1),
|
||||
];
|
||||
},
|
||||
[activeOptionPool, optionWindowStart],
|
||||
);
|
||||
|
||||
const canRefreshOptions = activeOptionPool.length > OPTION_PAGE_SIZE;
|
||||
|
||||
const handleRefreshOptions = useCallback(() => {
|
||||
if (activeOptionPool.length <= OPTION_PAGE_SIZE) return;
|
||||
|
||||
const nextStart = optionWindowStart + OPTION_PAGE_SIZE;
|
||||
if (nextStart < activeOptionPool.length) {
|
||||
setOptionWindowStart(nextStart);
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionWindowStart(0);
|
||||
}, [activeOptionPool.length, optionWindowStart]);
|
||||
|
||||
const resetStoryOptions = useCallback(() => {
|
||||
setOptionWindowStart(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeOptionPool,
|
||||
displayedOptions,
|
||||
optionWindowStart,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
};
|
||||
}
|
||||
75
src/hooks/useTreasureFlow.ts
Normal file
75
src/hooks/useTreasureFlow.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { resolveRpgRuntimeChoice } from './rpg-runtime-story';
|
||||
import { Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
export function useTreasureFlow({
|
||||
gameState,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
};
|
||||
}) {
|
||||
const handleTreasureInteraction = useCallback(
|
||||
async (option: StoryOption) => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
option.interaction?.kind !== 'treasure' ||
|
||||
gameState.currentEncounter?.kind !== 'treasure'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } =
|
||||
await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
runtime.setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to resolve treasure runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '宝藏动作执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
gameState,
|
||||
gameState.playerCharacter,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[gameState, runtime],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTreasureInteraction,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user