init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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);
});
});

View 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),
},
};
}

View 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');
});
});

View 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;
}

View 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,
};
}

View 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');
});
});

View 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;
}

View 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;
}

View 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}`;
}

View 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;

View 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;
}

View 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),
};
}

View 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 }),
);
});
});

View 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,
};
}

View 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,
};
}

View 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';

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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,
};
}

View 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;

View 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',
}),
);
});
});

View File

@@ -0,0 +1,7 @@
export {
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
resumeRpgRuntimeStory as resumeServerRuntimeStory,
type LoadRpgRuntimeOptionCatalogParams,
type ResolveRpgRuntimeChoiceParams,
} from './rpgRuntimeStoryGateway';

View 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');
});
});

View 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,
};
}

View 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);
}
}

View 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();
});
});

View 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,
};
}

View 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);
});
});

View 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)));
}

View 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,
});
}

View 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);
});
});

View 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,
};
}

View 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);
});
});

View 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,
};
}

View 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,
}),
);
});
});

View 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
>;

View 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);
});
});

View 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;
}

View 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', '顺势追问')],
});
});
});

View 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,
);
}

View 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: [],
});
});
});

View 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,
});
}

View 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([
'继续交谈',
'看看能交换什么',
]);
});
});

View 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());
}

View 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);
});
});

View 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;

View 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;
}

View File

@@ -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']);
});
});

View 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;

File diff suppressed because it is too large Load Diff

View 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>;

View 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
>;

View 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
>;

View 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',
]);
});
});

View 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;

View File

@@ -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']);
});
});

View 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,
};
}

View File

@@ -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']);
});
});

View 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
>;

View 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';

View File

@@ -0,0 +1,3 @@
import type { BottomTab } from '../../types/navigation';
export type { BottomTab };

View 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>;

View 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
>;

View 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
>;

View 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();
});

View 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]);
}

View 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,
};
}

View 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();
});

View 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,
};
}

View 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);
});
});

View 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,
};
}

View 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();
});
});

View 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,
};
}

View 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,
};
}

View 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,
};
}