Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -57,7 +57,7 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -121,11 +121,11 @@ describe('buildBattlePlan', () => {
expect(plan.turns).toEqual([]);
expect(plan.finalState.inBattle).toBe(false);
expect(plan.finalState.sceneMonsters).toEqual([]);
expect(plan.finalState.sceneHostileNpcs).toEqual([]);
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
});
it('reuses sceneHostileNpcs when npc battle entry has not synced sceneMonsters yet', () => {
it('builds a battle plan when npc battle entry already provides sceneHostileNpcs', () => {
const state = {
...createBaseState(),
currentBattleNpcId: 'npc-opponent',
@@ -169,7 +169,6 @@ describe('buildBattlePlan', () => {
});
expect(plan.turns.length).toBeGreaterThan(0);
expect(plan.preparedState.sceneMonsters).toHaveLength(1);
expect(plan.preparedState.sceneHostileNpcs).toHaveLength(1);
});
});

View File

@@ -16,9 +16,9 @@ import {
} from '../../data/characterPresets';
import { getEquipmentBonuses } from '../../data/equipmentEffects';
import {
getClosestMonster,
getClosestHostileNpc,
getFacingTowardPlayer,
settleMonsterAnimations,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import type {
@@ -27,7 +27,7 @@ import type {
CombatDelivery,
CompanionState,
GameState,
SceneMonster,
SceneHostileNpc,
StoryOption,
} from '../../types';
import {
@@ -209,7 +209,7 @@ function buildCombatTurnOrder(
});
});
state.sceneMonsters.forEach(monster => {
state.sceneHostileNpcs.forEach(monster => {
actorTimings.set(getCombatActorKey('monster', monster.id), {
actor: 'monster',
id: monster.id,
@@ -226,7 +226,7 @@ function buildCombatTurnOrder(
if (item.actor === 'companion') {
return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion));
}
return state.sceneMonsters.some(monster => monster.id === item.id && monster.hp > 0);
return state.sceneHostileNpcs.some(monster => monster.id === item.id && monster.hp > 0);
});
if (availableActors.length === 0) break;
@@ -278,7 +278,7 @@ function tickSkillCooldowns(character: Character, cooldowns: Record<string, numb
);
}
export function getFacingForPlayer(playerX: number, monster: SceneMonster | null) {
export function getFacingForPlayer(playerX: number, monster: SceneHostileNpc | null) {
if (!monster) return 'right' as const;
return monster.xMeters >= playerX ? 'right' : 'left';
}
@@ -295,8 +295,8 @@ export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: numb
: getMeleeStrikeX(attackerX, defenderX);
}
export function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
return settleMonsterAnimations(monsters).map(monster => ({
export function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
@@ -349,18 +349,12 @@ export function buildBattlePlan({
resetStageMs: number;
minTurnCount: number;
}): BattlePlan {
const resolvedSceneMonsters =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
const battleState: GameState = {
...state,
sceneMonsters: resolvedSceneMonsters,
sceneHostileNpcs: resolvedSceneMonsters,
};
const targetMonster = getClosestMonster(
const targetMonster = getClosestHostileNpc(
battleState.playerX,
battleState.sceneMonsters,
battleState.sceneHostileNpcs,
);
if (!targetMonster) {
return {
@@ -369,7 +363,6 @@ export function buildBattlePlan({
finalState: {
...battleState,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
companions: resetCompanionCombatPresentation(state.companions),
animationState: AnimationState.IDLE,
@@ -398,7 +391,7 @@ export function buildBattlePlan({
cooldowns: Record<string, number>;
}>();
battleState.sceneMonsters.forEach(monster => {
battleState.sceneHostileNpcs.forEach(monster => {
const npcCharacterId = monster.encounter?.characterId ?? null;
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
if (!npcCharacter) return;
@@ -413,12 +406,8 @@ export function buildBattlePlan({
let simulatedState: GameState = {
...applyRecoveryEffectToState(battleState, character, option.functionId),
companions: resetCompanionCombatPresentation(battleState.companions),
sceneMonsters: resetCombatPresentation(
battleState.sceneMonsters,
battleState.playerX,
),
sceneHostileNpcs: resetCombatPresentation(
battleState.sceneMonsters,
battleState.sceneHostileNpcs,
battleState.playerX,
),
activeCombatEffects: [],
@@ -429,7 +418,7 @@ export function buildBattlePlan({
const turns: BattlePlanStep[] = [];
for (const [turnIndex, turn] of turnOrder.entries()) {
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
const currentTarget = getClosestHostileNpc(simulatedState.playerX, simulatedState.sceneHostileNpcs);
if (!currentTarget) break;
if (turn.actor === 'player') {
@@ -465,7 +454,7 @@ export function buildBattlePlan({
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === currentTarget.id
? {
...monster,
@@ -479,7 +468,7 @@ export function buildBattlePlan({
const remainingMonsters = defeated
? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0))
: resolvedMonsters;
const nextTarget = getClosestMonster(originalPlayerX, remainingMonsters);
const nextTarget = getClosestHostileNpc(originalPlayerX, remainingMonsters);
simulatedState = {
...simulatedState,
@@ -494,7 +483,7 @@ export function buildBattlePlan({
activeCombatEffects: [],
playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost),
playerSkillCooldowns: appliedCooldowns,
sceneMonsters: remainingMonsters.map(monster => ({
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -537,7 +526,7 @@ export function buildBattlePlan({
if (!companionCharacter) continue;
const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId);
const targetMonster = getClosestMonster(companionX, simulatedState.sceneMonsters);
const targetMonster = getClosestHostileNpc(companionX, simulatedState.sceneHostileNpcs);
if (!targetMonster) break;
const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns);
@@ -584,7 +573,7 @@ export function buildBattlePlan({
const damage = isNpcSpar ? 1 : damageResult!.damage;
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
const resolvedMonsters = simulatedState.sceneHostileNpcs.map(monster =>
monster.id === targetMonster.id
? {
...monster,
@@ -609,7 +598,7 @@ export function buildBattlePlan({
skillCooldowns: appliedCooldowns,
}),
),
sceneMonsters: remainingMonsters.map(monster => ({
sceneHostileNpcs: remainingMonsters.map(monster => ({
...monster,
characterAnimation: undefined,
combatMode: undefined,
@@ -643,7 +632,7 @@ export function buildBattlePlan({
continue;
}
const actingMonster = simulatedState.sceneMonsters.find(monster => monster.id === turn.id && monster.hp > 0);
const actingMonster = simulatedState.sceneHostileNpcs.find(monster => monster.id === turn.id && monster.hp > 0);
if (!actingMonster) continue;
const randomTarget = chooseRandomPartyTarget(simulatedState);
@@ -698,7 +687,7 @@ export function buildBattlePlan({
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
@@ -753,7 +742,7 @@ export function buildBattlePlan({
simulatedState = {
...damagedState,
companions: resetCompanionCombatPresentation(damagedState.companions),
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
sceneHostileNpcs: simulatedState.sceneHostileNpcs.map(monster => ({
...monster,
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
animation: 'idle' as const,
@@ -800,12 +789,8 @@ export function buildBattlePlan({
scrollWorld: false,
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
? false
: simulatedState.sceneMonsters.length > 0,
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
sceneHostileNpcs: resetCombatPresentation(
simulatedState.sceneMonsters,
simulatedState.playerX,
),
: simulatedState.sceneHostileNpcs.length > 0,
sceneHostileNpcs: resetCombatPresentation(simulatedState.sceneHostileNpcs, simulatedState.playerX),
},
};
}

View File

@@ -11,7 +11,7 @@ import {
AnimationState,
type Character,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
WorldType,
} from '../../types';
@@ -43,7 +43,7 @@ function createCharacter(): Character {
};
}
function createMonster(): SceneMonster {
function createMonster(): SceneHostileNpc {
return {
id: 'wolf',
name: 'Wolf',
@@ -87,7 +87,7 @@ function createState(): GameState {
},
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [createMonster()],
sceneHostileNpcs: [createMonster()],
playerX: 0.2,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -2,13 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import {
getFacingTowardPlayer,
settleMonsterAnimations,
settleHostileNpcAnimations,
} from '../../data/hostileNpcs';
import { getFunctionEffect } from '../../data/stateFunctions';
import {
AnimationState,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
} from '../../types';
@@ -26,8 +26,8 @@ function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
return settleMonsterAnimations(monsters).map(monster => ({
function resetCombatPresentation(monsters: SceneHostileNpc[], playerX: number) {
return settleHostileNpcAnimations(monsters).map(monster => ({
...monster,
facing: getFacingTowardPlayer(monster.xMeters, playerX),
characterAnimation: undefined,
@@ -54,7 +54,7 @@ export function buildEscapeAfterSequence(
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneMonsters: resetCombatPresentation(state.sceneMonsters, escapePlayerX),
sceneHostileNpcs: resetCombatPresentation(state.sceneHostileNpcs, escapePlayerX),
playerX: escapePlayerX,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -105,7 +105,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: true,
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, currentState.playerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, currentState.playerX),
};
setGameState(currentState);
@@ -120,7 +120,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, nextPlayerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, nextPlayerX),
};
setGameState(currentState);
elapsedMs += ESCAPE_TICK_MS;
@@ -135,7 +135,7 @@ export async function playEscapeSequenceWithStorySync(params: {
playerActionMode: 'idle',
activeCombatEffects: [],
scrollWorld: false,
sceneMonsters: resetCombatPresentation(finalState.sceneMonsters, settlePlayerX),
sceneHostileNpcs: resetCombatPresentation(finalState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
await sleepMs(ESCAPE_TURN_PAUSE_MS);
@@ -143,7 +143,7 @@ export async function playEscapeSequenceWithStorySync(params: {
currentState = {
...currentState,
playerFacing: 'right',
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, settlePlayerX),
sceneHostileNpcs: resetCombatPresentation(currentState.sceneHostileNpcs, settlePlayerX),
};
setGameState(currentState);
return currentState;

View File

@@ -12,7 +12,7 @@ import {
getCharacterById,
} from '../../data/characterPresets';
import {
getClosestMonster,
getClosestHostileNpc,
getFacingTowardPlayer,
MONSTERS_BY_WORLD,
} from '../../data/hostileNpcs';
@@ -21,7 +21,7 @@ import {
type Character,
type CharacterSkillDefinition,
type GameState,
type SceneMonster,
type SceneHostileNpc,
type StoryOption,
type WorldType,
} from '../../types';
@@ -71,7 +71,7 @@ function getMonsterAnimationDurationMs(
return Math.max(turnVisualMs, Math.ceil((stepCount * 1000) / fps + 120));
}
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneMonster[]) {
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneHostileNpc[]) {
if (option.visuals.monsterChanges.length > 0) return option.visuals.monsterChanges;
const fallbackMonster = monsters[0];
@@ -198,7 +198,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
const currentTarget = currentState.sceneHostileNpcs.find(monster => monster.id === step.targetHostileNpcId);
if (!currentTarget) {
break;
}
@@ -259,7 +259,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
skill,
});
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
@@ -276,7 +276,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
@@ -289,7 +289,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
);
currentState = {
...currentState,
sceneMonsters: remainingMonsters,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
@@ -299,7 +299,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
break;
}
const nextTarget = getClosestMonster(step.originalPlayerX, currentState.sceneMonsters);
const nextTarget = getClosestHostileNpc(step.originalPlayerX, currentState.sceneHostileNpcs);
currentState = {
...currentState,
playerX: step.originalPlayerX,
@@ -323,7 +323,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
if (!companion || companion.hp <= 0) continue;
const companionCharacter = getCharacterById(companion.characterId);
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
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);
@@ -392,7 +392,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
setGameState(currentState);
await sleep(releaseDelay);
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
const resolvedMonsters = currentState.sceneHostileNpcs.map(monster =>
monster.id === step.targetHostileNpcId
? {
...monster,
@@ -409,7 +409,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: resolvedMonsters,
sceneHostileNpcs: resolvedMonsters,
inBattle: step.endsBattle ? false : currentState.inBattle,
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
};
@@ -422,7 +422,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
);
currentState = {
...currentState,
sceneMonsters: remainingMonsters,
sceneHostileNpcs: remainingMonsters,
inBattle: remainingMonsters.length > 0,
};
setGameState(currentState);
@@ -446,7 +446,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const actingMonster = currentState.sceneMonsters.find(monster => monster.id === step.monsterId);
const actingMonster = currentState.sceneHostileNpcs.find(monster => monster.id === step.monsterId);
if (!actingMonster) continue;
const npcCharacter = step.npcCharacterId ? getCharacterById(step.npcCharacterId) : null;
@@ -458,7 +458,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
? getSkillReleaseDelayMs(npcCharacter, npcSkill)
: getCharacterAnimationDurationMs(npcCharacter, casterAnimation);
const attackedMonsters = currentState.sceneMonsters.map(monster => {
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
@@ -481,7 +481,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
@@ -551,7 +551,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters.map(monster =>
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,
@@ -581,8 +581,8 @@ async function playBattleSequence(params: CombatPlaybackParams & {
continue;
}
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneMonsters);
const attackedMonsters = currentState.sceneMonsters.map(monster => {
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneHostileNpcs);
const attackedMonsters = currentState.sceneHostileNpcs.map(monster => {
if (monster.id !== step.monsterId) {
return {
...monster,
@@ -604,7 +604,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters,
sceneHostileNpcs: attackedMonsters,
companions: step.target === 'companion' && step.targetCompanionNpcId
? updateCompanionState(
currentState.companions,
@@ -657,7 +657,7 @@ async function playBattleSequence(params: CombatPlaybackParams & {
currentState = {
...currentState,
sceneMonsters: attackedMonsters.map(monster =>
sceneHostileNpcs: attackedMonsters.map(monster =>
monster.id === step.monsterId
? {
...monster,

View File

@@ -100,7 +100,7 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -180,7 +180,7 @@ describe('buildResolvedChoiceState', () => {
it('builds escape results without playback timing concerns', () => {
const state = {
...createBaseState(),
sceneMonsters: [
sceneHostileNpcs: [
{
id: 'monster-1',
name: 'Wolf',

View File

@@ -153,7 +153,7 @@ export function getFallbackOptionsForState(state: GameState, character: Characte
inBattle: state.inBattle,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
monsters: state.sceneMonsters,
monsters: state.sceneHostileNpcs,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -170,7 +170,7 @@ export function getFallbackOptionsForState(state: GameState, character: Characte
}
export function buildFallbackStoryMoment(state: GameState, character: Character): StoryMoment {
const primaryMonster = state.sceneMonsters.find(monster => monster.hp > 0) ?? state.sceneMonsters[0];
const primaryMonster = state.sceneHostileNpcs.find(monster => monster.hp > 0) ?? state.sceneHostileNpcs[0];
const text = state.inBattle && primaryMonster
? `${primaryMonster.name}${primaryMonster.action},战斗还没有结束。`
: `${state.currentScenePreset?.name ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;

View File

@@ -86,7 +86,7 @@ export function buildIdleAfterSequence(params: {
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -103,7 +103,7 @@ export function buildIdleAfterSequence(params: {
...createSceneCallOutEncounter(baseState),
} as GameState;
afterSequence = callOutState.sceneMonsters.length > 0 || callOutState.currentEncounter
afterSequence = callOutState.sceneHostileNpcs.length > 0 || callOutState.currentEncounter
? resolveSceneEncounterPreview(callOutState)
: baseState;
} else if (option.functionId === 'idle_explore_forward') {
@@ -121,7 +121,7 @@ export function buildIdleAfterSequence(params: {
currentScenePreset: nextScenePreset,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
@@ -143,7 +143,7 @@ export function buildIdleAfterSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentScenePreset: nextScenePreset,
playerX: 0,
playerFacing: 'right' as const,
@@ -229,7 +229,7 @@ export async function playIdleSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerFacing: 'right',
animationState: AnimationState.ACQUIRE,
playerActionMode: 'idle' as const,
@@ -272,7 +272,7 @@ export async function playIdleSequence(params: {
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerFacing: 'right',
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,

View File

@@ -62,7 +62,6 @@ function createBaseState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
@@ -140,7 +139,6 @@ describe('createStoryChoiceActions', () => {
const afterSequence = {
...state,
inBattle: false,
sceneMonsters: [],
sceneHostileNpcs: [],
currentNpcBattleOutcome: 'fight_victory' as const,
};
@@ -179,7 +177,7 @@ describe('createStoryChoiceActions', () => {
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
@@ -233,7 +231,7 @@ describe('createStoryChoiceActions', () => {
...createBaseState(),
currentBattleNpcId: null,
currentNpcBattleMode: null,
sceneMonsters: [
sceneHostileNpcs: [
{
id: 'wolf-1',
name: '山狼',
@@ -289,7 +287,7 @@ describe('createStoryChoiceActions', () => {
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneMonsters),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),

View File

@@ -18,6 +18,7 @@ import {
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import {
AnimationState,
@@ -95,7 +96,7 @@ function buildCombatResolutionContextText(params: {
afterSequence: GameState;
optionKind: ResolvedChoiceState['optionKind'];
projectedBattleReward: BattleRewardSummary | null;
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
}) {
const {
baseState,
@@ -138,7 +139,7 @@ function buildCombatResolutionContextText(params: {
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'],
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
return null;
@@ -230,8 +231,8 @@ export function createStoryChoiceActions({
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
@@ -273,7 +274,6 @@ export function createStoryChoiceActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -298,7 +298,6 @@ export function createStoryChoiceActions({
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -427,10 +426,10 @@ export function createStoryChoiceActions({
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
const projectedStateWithBattleReward = projectedBattleReward
? {
? appendStoryEngineCarrierMemory({
...projectedState,
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
}
} as GameState, projectedBattleReward.items)
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = getAvailableOptionsForState(
@@ -485,10 +484,10 @@ export function createStoryChoiceActions({
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
if (projectedBattleReward) {
afterSequence = {
afterSequence = appendStoryEngineCarrierMemory({
...afterSequence,
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
};
} as GameState, projectedBattleReward.items);
}
fallbackState = afterSequence;

View File

@@ -5,6 +5,7 @@ import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
@@ -12,6 +13,7 @@ import {
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
describeNpcAffinityInWords,
generateNpcHelpReward,
getChatAffinityOutcome,
getNpcLootItems,
@@ -40,6 +42,10 @@ import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {
appendStoryEngineCarrierMemory,
syncNpcNarrativeState,
} from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -92,13 +98,13 @@ type BuildStoryContextExtras = {
function buildCampCompanionChatResultText(
encounter: Encounter,
affinityGain: number,
_nextAffinity: number,
nextAffinity: number,
) {
const teamworkText =
affinityGain > 0
? 'You also feel a little more confident about how you will work together next.'
: 'You at least realign your rhythm for what comes next.';
return `${encounter.npcName}閸滃奔缍樻禍銈嗗床娴滃棔绔存潪顔藉厒濞夋洩绱?{describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
? '你也更能感觉到,下一步和对方并肩时会顺手一些。'
: '至少你们把接下来的节奏重新校准了一遍。';
return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
}
function isNpcEncounter(
@@ -169,7 +175,7 @@ export function createStoryNpcEncounterActions({
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
getAvailableOptionsForState: (
state: GameState,
@@ -232,10 +238,7 @@ export function createStoryNpcEncounterActions({
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
const activeBattleHostiles =
state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
const activeBattleHostiles = state.sceneHostileNpcs;
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
@@ -251,7 +254,6 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneMonsters: [],
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
@@ -259,6 +261,11 @@ export function createStoryNpcEncounterActions({
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_chat',
{ affinityGain: NPC_SPAR_AFFINITY_GAIN },
),
},
},
quests: progressedQuests,
@@ -303,7 +310,8 @@ export function createStoryNpcEncounterActions({
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
}
const nextState: GameState = incrementRuntimeStats(
const nextState: GameState = appendStoryEngineCarrierMemory(
incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
@@ -311,7 +319,6 @@ export function createStoryNpcEncounterActions({
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -339,6 +346,8 @@ export function createStoryNpcEncounterActions({
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
),
lootItems,
);
const lootText =
@@ -638,10 +647,14 @@ export function createStoryNpcEncounterActions({
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_help',
),
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
@@ -665,7 +678,7 @@ export function createStoryNpcEncounterActions({
),
)
: nextState.playerInventory,
};
} as GameState, reward.items);
await commitNpcChatState(
nextState,
@@ -705,10 +718,14 @@ export function createStoryNpcEncounterActions({
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_help',
),
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
@@ -732,7 +749,7 @@ export function createStoryNpcEncounterActions({
),
)
: nextState.playerInventory,
};
} as GameState, reward.items);
await commitNpcChatState(
nextState,
@@ -779,6 +796,11 @@ export function createStoryNpcEncounterActions({
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain },
),
};
},
);
@@ -838,8 +860,13 @@ export function createStoryNpcEncounterActions({
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
@@ -870,8 +897,13 @@ export function createStoryNpcEncounterActions({
acceptQuest(quests, fallbackQuest),
),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_quest_accept',
),
}),
),
{questsAccepted: 1},
);
@@ -896,19 +928,26 @@ export function createStoryNpcEncounterActions({
const quest = questId ? findQuestById(gameState.quests, questId) : null;
if (!quest || quest.status !== 'completed') return true;
const nextState = {
const nextState = appendStoryEngineCarrierMemory({
...updateQuestLog(gameState, (quests) =>
markQuestTurnedIn(quests, quest.id),
),
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
...syncNpcNarrativeState({
encounter,
npcState: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
},
customWorldProfile: gameState.customWorldProfile,
storyEngineMemory: gameState.storyEngineMemory,
}),
},
},
playerCurrency: gameState.playerCurrency + quest.reward.currency,
@@ -916,7 +955,7 @@ export function createStoryNpcEncounterActions({
gameState.playerInventory,
quest.reward.items,
),
};
} as GameState, quest.reward.items);
void commitGeneratedState(
nextState,
@@ -933,7 +972,6 @@ export function createStoryNpcEncounterActions({
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
@@ -981,7 +1019,6 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerFacing: 'right' as const,
@@ -1026,7 +1063,6 @@ export function createStoryNpcEncounterActions({
},
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [battleMonster],
sceneHostileNpcs: [battleMonster],
playerX: 0,
playerHp: sparPlayerMaxHp,

View File

@@ -22,6 +22,7 @@ import {
} from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcGiftCommitActionText,
buildNpcGiftResultText,
buildNpcRecruitResultText,
@@ -35,6 +36,10 @@ import {
} from '../../data/npcInteractions';
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
appendStoryEngineCarrierMemory,
syncNpcNarrativeState,
} from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
@@ -84,7 +89,7 @@ type StoryNpcInteractionRuntime = {
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
};
@@ -381,8 +386,20 @@ export function useStoryNpcInteractionFlow({
const nextNpcStates = {
...gameState.npcStates,
[recruitKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
...syncNpcNarrativeState({
encounter,
npcState: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
stanceProfile: applyStoryChoiceToStanceProfile(
npcState.stanceProfile,
'npc_recruit',
{ recruited: true },
),
},
customWorldProfile: gameState.customWorldProfile,
storyEngineMemory: gameState.storyEngineMemory,
}),
},
};
@@ -408,11 +425,10 @@ export function useStoryNpcInteractionFlow({
const nextState: GameState = {
...rosterState,
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
scrollWorld: false,
@@ -662,14 +678,14 @@ export function useStoryNpcInteractionFlow({
}),
);
nextState = {
nextState = appendStoryEngineCarrierMemory({
...nextState,
playerCurrency: nextState.playerCurrency - totalPrice,
playerInventory: addInventoryItems(
nextState.playerInventory,
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
),
};
} as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]);
setTradeModal(null);
void commitNpcReactionAndGenerate({
@@ -766,6 +782,11 @@ export function useStoryNpcInteractionFlow({
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: currentNpcState.giftsGiven + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_gift',
{ affinityGain },
),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(giftItem, 'npc')],

View File

@@ -157,7 +157,7 @@ export async function playOpeningAdventureSequence({
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
) => GameState['sceneHostileNpcs'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,

View File

@@ -8,6 +8,77 @@ import {
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 {
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,
@@ -20,6 +91,390 @@ 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 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 reactions = buildCompanionReactionBatch({
state: stateWithSignals,
signals,
actionText: params.actionText,
});
const stateWithReactions = applyCompanionReactionToStance({
state: stateWithSignals,
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;
@@ -79,26 +534,36 @@ export function createStoryProgressionActions({
actionText,
resultText,
lastFunctionId,
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
) => {
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,
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
@@ -134,22 +599,32 @@ export function createStoryProgressionActions({
}
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState,
nextState: {
...resolvedState,
storyHistory: nextHistory,
};
} as GameState,
actionText,
lastFunctionId,
});
setGameState(stateWithHistory);
try {
const nextStory = await generateStoryForState({
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
choice: actionText,
lastFunctionId,
});
const recoveredState = applyStoryEngineEchoes({
previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory),
actionText,
lastFunctionId,
});
const recoveredState = applyStoryReasoningRecovery(stateWithHistory);
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {

View File

@@ -104,7 +104,7 @@ function createBaseState(): GameState {
currentEncounter: encounter,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -9,6 +9,7 @@ import {
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import type {
GameState,
StoryMoment,
@@ -43,7 +44,7 @@ export function applyQuestRewardClaim(
const issuerNpcState = state.npcStates[quest.issuerNpcId];
return {
return appendStoryEngineCarrierMemory({
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
@@ -57,7 +58,7 @@ export function applyQuestRewardClaim(
},
}
: state.npcStates,
};
}, quest.reward.items);
}
export function createStorySessionActions({

View File

@@ -141,7 +141,7 @@ function createBaseState(): GameState {
currentEncounter: createEncounter(),
npcInteractionActive: false,
currentScenePreset: scenes[0] ?? null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -142,7 +142,7 @@ export function buildMapTravelResolution(
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,

View File

@@ -14,6 +14,7 @@ import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcIn
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, GameState, SceneNpc, WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
@@ -58,6 +59,11 @@ function createInitialGameState(): GameState {
runtimeStats: createInitialGameRuntimeStats(),
currentScene: 'Selection',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: null,
activeCampaignPackId: null,
characterChats: {},
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
@@ -65,7 +71,7 @@ function createInitialGameState(): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -128,9 +134,14 @@ export function useGameFlow() {
worldType: resolvedWorldType,
customWorldProfile,
currentScenePreset: initialScenePreset,
sceneMonsters: [],
sceneHostileNpcs: [],
currentEncounter: null,
npcInteractionActive: false,
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
playerActionMode: 'idle',
@@ -178,16 +189,21 @@ export function useGameFlow() {
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
currentScene: 'Story',
storyHistory: [],
characterChats: {},
currentScene: 'Story',
storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null,
campaignState: null,
activeScenarioPackId: gameState.customWorldProfile?.scenarioPackId ?? null,
activeCampaignPackId: gameState.customWorldProfile?.campaignPackId ?? null,
characterChats: {},
currentEncounter: initialEncounter,
npcInteractionActive: false,
currentScenePreset: initialScenePreset,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
animationState: AnimationState.IDLE,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',

View File

@@ -13,6 +13,11 @@ import { normalizeQuestLogEntries } from '../data/questFlow';
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage';
import {
applyStoryEngineMigration,
buildSaveMigrationManifest,
} from '../services/storyEngine/saveMigrationManifest';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { GameState, StoryMoment } from '../types';
import { BottomTab } from './useGameFlow';
@@ -47,15 +52,22 @@ function normalizeCharacterChats(gameState: GameState) {
}
function normalizeSavedGameState(gameState: GameState) {
const migrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5',
});
const migratedState = applyStoryEngineMigration({
state: gameState,
manifest: migrationManifest,
});
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && gameState.currentEncounter?.kind === 'treasure'
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure'
? ensureSceneEncounterPreview({
...gameState,
...migratedState,
currentEncounter: null,
sceneMonsters: [],
sceneHostileNpcs: [],
inBattle: false,
} as GameState)
: gameState;
: migratedState;
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
isActiveRun: Boolean(
normalizedEncounterState.playerCharacter &&
@@ -66,6 +78,25 @@ function normalizeSavedGameState(gameState: GameState) {
...normalizedEncounterState,
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
runtimeStats: normalizedRuntimeStats,
storyEngineMemory:
normalizedEncounterState.storyEngineMemory ??
createEmptyStoryEngineMemoryState(),
chapterState:
normalizedEncounterState.chapterState
?? normalizedEncounterState.storyEngineMemory?.currentChapter
?? null,
campaignState:
normalizedEncounterState.campaignState
?? normalizedEncounterState.storyEngineMemory?.campaignState
?? null,
activeScenarioPackId:
normalizedEncounterState.activeScenarioPackId
?? normalizedEncounterState.customWorldProfile?.scenarioPackId
?? null,
activeCampaignPackId:
normalizedEncounterState.activeCampaignPackId
?? normalizedEncounterState.customWorldProfile?.campaignPackId
?? null,
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
playerCurrency: typeof gameState.playerCurrency === 'number'
? gameState.playerCurrency

View File

@@ -1,4 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -43,6 +43,43 @@ import {
} from '../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../data/storyRecovery';
import { generateInitialStory, generateNextStep } from '../services/ai';
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 { syncNpcNarrativeState } from '../services/storyEngine/echoMemory';
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 {
Character,
Encounter,
@@ -285,6 +322,66 @@ function describeConversationTalkPriority(
}
}
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);
}
function buildStoryContextFromState(
state: GameState,
extras: {
@@ -361,10 +458,21 @@ function buildStoryContextFromState(
})()
: null;
const baseSceneDescription = state.currentScenePreset?.description ?? null;
const sceneMutationDescription = [
state.currentScenePreset?.mutationStateText
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
: null,
state.currentScenePreset?.currentPressureLevel
? `当前区域压力等级:${state.currentScenePreset.currentPressureLevel}`
: null,
]
.filter(Boolean)
.join('\n');
const observeSignsSceneDescription =
extras.observeSignsRequested && state.worldType
? [
baseSceneDescription,
sceneMutationDescription,
'Observed entity pool:',
buildSceneEntityCatalogText(
state.worldType,
@@ -373,9 +481,141 @@ function buildStoryContextFromState(
]
.filter(Boolean)
.join('\n')
: baseSceneDescription;
: [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 activeScenarioPack =
resolveScenarioPack(state.activeScenarioPackId)
?? compiledPacks?.scenarioPack
?? null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
return {
return applyAdaptiveTuningToPromptContext({
context: {
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
@@ -399,6 +639,7 @@ function buildStoryContextFromState(
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
@@ -416,10 +657,12 @@ function buildStoryContextFromState(
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,
@@ -433,6 +676,34 @@ function buildStoryContextFromState(
recentSharedEvent:
recentSharedEvent ?? describeConversationSituation(conversationSituation),
talkPriority: describeConversationTalkPriority(conversationSituation),
visibilitySlice,
sceneNarrativeDirective,
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
actState: storyEngineMemory.actState ?? null,
chapterState,
journeyBeat,
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 || buildChapterRecap({ state: { ...state, chapterState } as GameState }),
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
encounterRelationshipSummary: state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary || null
@@ -441,7 +712,9 @@ function buildStoryContextFromState(
customWorldProfile: state.customWorldProfile ?? null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
};
},
profile: storyEngineMemory.playerStyleProfile ?? null,
});
}
function buildNpcPreviewStory(
@@ -493,9 +766,7 @@ function getStoryGenerationHostileNpcs(state: GameState) {
}
function getResolvedSceneHostileNpcs(state: GameState) {
return state.sceneMonsters.length > 0
? state.sceneMonsters
: (state.sceneHostileNpcs ?? []);
return state.sceneHostileNpcs;
}
function sanitizeOptions(
@@ -1289,7 +1560,12 @@ export function useStoryGeneration({
npcStates: {
...state.npcStates,
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
updater(getResolvedNpcState(state, encounter)),
syncNpcNarrativeState({
encounter,
npcState: updater(getResolvedNpcState(state, encounter)),
customWorldProfile: state.customWorldProfile,
storyEngineMemory: state.storyEngineMemory,
}),
),
},
});
@@ -1527,7 +1803,6 @@ export function useStoryGeneration({
gameState.inBattle,
gameState.playerCharacter,
gameState.playerX,
gameState.sceneHostileNpcs,
gameState.worldType,
isLoading,
setGameState,

View File

@@ -6,6 +6,7 @@ import {
buildTreasureResultText,
resolveTreasureReward,
} from '../data/treasureInteractions';
import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory';
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
import type {CommitGeneratedState} from './generatedState';
@@ -51,11 +52,11 @@ export function useTreasureFlow({
? gameState
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
const nextState: GameState = {
const nextState: GameState = appendStoryEngineCarrierMemory({
...progressedState,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: progressedState.animationState,
@@ -80,7 +81,7 @@ export function useTreasureFlow({
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}, reward?.items ?? []);
void commitGeneratedState(
nextState,