Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,7 +104,7 @@ function createBaseState(): GameState {
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -141,7 +141,7 @@ function createBaseState(): GameState {
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: scenes[0] ?? null,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
|
||||
@@ -142,7 +142,7 @@ export function buildMapTravelResolution(
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user