1
This commit is contained in:
854
server-node/src/modules/story/runtimeSession.ts
Normal file
854
server-node/src/modules/story/runtimeSession.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
import type {
|
||||
RuntimeStoryEncounterViewModel,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryViewModel,
|
||||
Task5RuntimeOptionScope,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type StoryHistoryRole = 'action' | 'result';
|
||||
|
||||
type FunctionDefinition = {
|
||||
actionText: string;
|
||||
detailText: string;
|
||||
scope: Task5RuntimeOptionScope;
|
||||
};
|
||||
|
||||
export type RuntimeStoryHistoryEntry = {
|
||||
text: string;
|
||||
historyRole: StoryHistoryRole;
|
||||
};
|
||||
|
||||
export type RuntimeNpcState = {
|
||||
affinity: number;
|
||||
chattedCount: number;
|
||||
helpUsed: boolean;
|
||||
giftsGiven: number;
|
||||
inventory: unknown[];
|
||||
recruited: boolean;
|
||||
firstMeaningfulContactResolved: boolean;
|
||||
relationState: JsonRecord | null;
|
||||
stanceProfile: JsonRecord | null;
|
||||
tradeStockSignature?: string | null;
|
||||
revealedFacts?: string[];
|
||||
knownAttributeRumors?: string[];
|
||||
seenBackstoryChapterIds?: string[];
|
||||
};
|
||||
|
||||
export type RuntimeEncounter = {
|
||||
id: string;
|
||||
kind: 'npc' | 'treasure';
|
||||
npcName: string;
|
||||
npcDescription: string;
|
||||
context: string;
|
||||
hostile: boolean;
|
||||
characterId: string | null;
|
||||
monsterPresetId: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeHostileNpc = {
|
||||
id: string;
|
||||
name: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type RuntimeCompanion = {
|
||||
npcId: string;
|
||||
characterId: string;
|
||||
joinedAtAffinity: number;
|
||||
};
|
||||
|
||||
export type RuntimeSession = {
|
||||
sessionId: string;
|
||||
runtimeVersion: number;
|
||||
snapshotBottomTab: string;
|
||||
rawGameState: JsonRecord;
|
||||
worldType: string | null;
|
||||
storyHistory: RuntimeStoryHistoryEntry[];
|
||||
currentEncounter: RuntimeEncounter | null;
|
||||
npcInteractionActive: boolean;
|
||||
sceneHostileNpcs: RuntimeHostileNpc[];
|
||||
inBattle: boolean;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
npcStates: Record<string, RuntimeNpcState>;
|
||||
companions: RuntimeCompanion[];
|
||||
currentNpcBattleMode: 'fight' | 'spar' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
|
||||
};
|
||||
|
||||
export const MAX_TASK5_COMPANIONS = 2;
|
||||
|
||||
const STORY_FUNCTION_IDS = new Set<string>([
|
||||
'story_continue_adventure',
|
||||
'story_opening_camp_dialogue',
|
||||
'camp_travel_home_scene',
|
||||
'idle_call_out',
|
||||
'idle_explore_forward',
|
||||
'idle_observe_signs',
|
||||
'idle_rest_focus',
|
||||
'idle_travel_next_scene',
|
||||
]);
|
||||
|
||||
const COMBAT_FUNCTION_IDS = new Set<string>([
|
||||
'battle_all_in_crush',
|
||||
'battle_escape_breakout',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_guard_break',
|
||||
'battle_probe_pressure',
|
||||
'battle_recover_breath',
|
||||
]);
|
||||
|
||||
const NPC_FUNCTION_IDS = new Set<string>([
|
||||
'npc_chat',
|
||||
'npc_fight',
|
||||
'npc_help',
|
||||
'npc_leave',
|
||||
'npc_preview_talk',
|
||||
'npc_recruit',
|
||||
'npc_spar',
|
||||
]);
|
||||
|
||||
const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK6_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
|
||||
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([
|
||||
]);
|
||||
|
||||
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
|
||||
story_continue_adventure: {
|
||||
actionText: '继续推进冒险',
|
||||
detailText: '让后端基于当前快照继续推进当前故事状态。',
|
||||
scope: 'story',
|
||||
},
|
||||
story_opening_camp_dialogue: {
|
||||
actionText: '交换开场判断',
|
||||
detailText: '把当前营地里的第一次正式对话切进服务端交互态。',
|
||||
scope: 'story',
|
||||
},
|
||||
camp_travel_home_scene: {
|
||||
actionText: '返回营地',
|
||||
detailText: '结束当前遭遇,把流程带回安全的营地状态。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_call_out: {
|
||||
actionText: '主动出声试探',
|
||||
detailText: '对前路喊话,逼迫附近的动静更快浮出水面。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_explore_forward: {
|
||||
actionText: '继续向前探索',
|
||||
detailText: '继续沿当前路径深入,把新遭遇交给后端推进。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_observe_signs: {
|
||||
actionText: '观察周围迹象',
|
||||
detailText: '先读环境,再决定下一轮要不要靠近或出手。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_rest_focus: {
|
||||
actionText: '原地调息',
|
||||
detailText: '恢复少量生命与灵力,稳住下一轮节奏。',
|
||||
scope: 'story',
|
||||
},
|
||||
idle_travel_next_scene: {
|
||||
actionText: '前往相邻场景',
|
||||
detailText: '收束当前遭遇并切往下一段场景流程。',
|
||||
scope: 'story',
|
||||
},
|
||||
battle_all_in_crush: {
|
||||
actionText: '正面强压',
|
||||
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_escape_breakout: {
|
||||
actionText: '强行脱离战斗',
|
||||
detailText: '打断当前战斗,把状态切回探索或脱身结果。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_feint_step: {
|
||||
actionText: '虚晃切步',
|
||||
detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_finisher_window: {
|
||||
actionText: '抓破绽终结',
|
||||
detailText: '对残血目标有额外收益,适合收尾。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_guard_break: {
|
||||
actionText: '破架重击',
|
||||
detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_probe_pressure: {
|
||||
actionText: '稳步试探',
|
||||
detailText: '低风险压迫,兼顾伤害和节奏控制。',
|
||||
scope: 'combat',
|
||||
},
|
||||
battle_recover_breath: {
|
||||
actionText: '边守边调息',
|
||||
detailText: '优先回稳资源,但仍可能吃到轻量反击。',
|
||||
scope: 'combat',
|
||||
},
|
||||
npc_chat: {
|
||||
actionText: '继续交谈',
|
||||
detailText: '围绕当前话题延续对话,推进好感与关系判断。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_fight: {
|
||||
actionText: '与对方战斗',
|
||||
detailText: '把当前 NPC 交互直接切进正式战斗结算。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_help: {
|
||||
actionText: '请求援手',
|
||||
detailText: '向当前 NPC 请求一次性支援,恢复部分状态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_leave: {
|
||||
actionText: '离开当前角色',
|
||||
detailText: '结束当前 NPC 交互,重新回到探索态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_preview_talk: {
|
||||
actionText: '转向眼前角色',
|
||||
detailText: '从遭遇预览切进正式 NPC 互动菜单。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_recruit: {
|
||||
actionText: '邀请加入队伍',
|
||||
detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_spar: {
|
||||
actionText: '点到为止切磋',
|
||||
detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_trade: {
|
||||
actionText: '交易',
|
||||
detailText: '查看库存并执行买入或卖出。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_gift: {
|
||||
actionText: '赠送礼物',
|
||||
detailText: '把背包里的物品正式交给当前角色。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_quest_accept: {
|
||||
actionText: '接下委托',
|
||||
detailText: '把当前角色的委托正式收进任务日志。',
|
||||
scope: 'npc',
|
||||
},
|
||||
npc_quest_turn_in: {
|
||||
actionText: '交付委托',
|
||||
detailText: '向当前角色结算已经完成的委托奖励。',
|
||||
scope: 'npc',
|
||||
},
|
||||
treasure_secure: {
|
||||
actionText: '直接收取',
|
||||
detailText: '不再拖延,直接把眼前最关键的收获带走。',
|
||||
scope: 'story',
|
||||
},
|
||||
treasure_inspect: {
|
||||
actionText: '仔细检查',
|
||||
detailText: '多花些时间拆开机关、痕迹和伪装。',
|
||||
scope: 'story',
|
||||
},
|
||||
treasure_leave: {
|
||||
actionText: '先记下位置',
|
||||
detailText: '暂时不碰它,只把异常位置和痕迹记住。',
|
||||
scope: 'story',
|
||||
},
|
||||
};
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback = false) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function normalizeStoryHistory(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => {
|
||||
const rawEntry = isObject(entry) ? entry : {};
|
||||
const historyRole =
|
||||
rawEntry.historyRole === 'action' ? 'action' : 'result';
|
||||
|
||||
return {
|
||||
text: readString(rawEntry.text),
|
||||
historyRole,
|
||||
} satisfies RuntimeStoryHistoryEntry;
|
||||
})
|
||||
.filter((entry) => entry.text);
|
||||
}
|
||||
|
||||
function normalizeNpcState(value: unknown): RuntimeNpcState {
|
||||
const rawState = isObject(value) ? value : {};
|
||||
|
||||
return {
|
||||
affinity: Math.round(readNumber(rawState.affinity, 0)),
|
||||
chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))),
|
||||
helpUsed: readBoolean(rawState.helpUsed),
|
||||
giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))),
|
||||
inventory: cloneJson(readArray(rawState.inventory)),
|
||||
recruited: readBoolean(rawState.recruited),
|
||||
firstMeaningfulContactResolved: readBoolean(
|
||||
rawState.firstMeaningfulContactResolved,
|
||||
),
|
||||
tradeStockSignature: readString(rawState.tradeStockSignature) || null,
|
||||
relationState: isObject(rawState.relationState)
|
||||
? cloneJson(rawState.relationState)
|
||||
: null,
|
||||
stanceProfile: isObject(rawState.stanceProfile)
|
||||
? cloneJson(rawState.stanceProfile)
|
||||
: null,
|
||||
revealedFacts: readArray(rawState.revealedFacts).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEncounter(value: unknown): RuntimeEncounter | null {
|
||||
const rawEncounter = isObject(value) ? value : null;
|
||||
if (!rawEncounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc';
|
||||
const npcName = readString(rawEncounter.npcName);
|
||||
if (!npcName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: readString(rawEncounter.id, npcName),
|
||||
kind,
|
||||
npcName,
|
||||
npcDescription: readString(rawEncounter.npcDescription),
|
||||
context: readString(rawEncounter.context),
|
||||
hostile:
|
||||
readBoolean(rawEncounter.hostile) ||
|
||||
Boolean(readString(rawEncounter.monsterPresetId)),
|
||||
characterId: readString(rawEncounter.characterId) || null,
|
||||
monsterPresetId: readString(rawEncounter.monsterPresetId) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
|
||||
const rawNpc = isObject(value) ? value : null;
|
||||
if (!rawNpc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = readString(rawNpc.id);
|
||||
const name = readString(rawNpc.name, id);
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1)));
|
||||
const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp))));
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
hp,
|
||||
maxHp,
|
||||
description: readString(rawNpc.description),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCompanion(value: unknown): RuntimeCompanion | null {
|
||||
const rawCompanion = isObject(value) ? value : null;
|
||||
if (!rawCompanion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcId = readString(rawCompanion.npcId);
|
||||
if (!npcId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
npcId,
|
||||
characterId: readString(rawCompanion.characterId),
|
||||
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNpcStates(value: unknown) {
|
||||
const rawStates = isObject(value) ? value : {};
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]),
|
||||
) as Record<string, RuntimeNpcState>;
|
||||
}
|
||||
|
||||
function normalizeCompanions(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeCompanion(entry))
|
||||
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeHostileNpcs(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeHostileNpc(entry))
|
||||
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
|
||||
}
|
||||
|
||||
export function getEncounterKey(encounter: RuntimeEncounter) {
|
||||
return encounter.id || encounter.npcName;
|
||||
}
|
||||
|
||||
export function loadRuntimeSession(
|
||||
snapshot: SavedSnapshot,
|
||||
requestedSessionId: string,
|
||||
): RuntimeSession {
|
||||
const rawGameState = isObject(snapshot.gameState)
|
||||
? cloneJson(snapshot.gameState)
|
||||
: {};
|
||||
const currentEncounter = normalizeEncounter(rawGameState.currentEncounter);
|
||||
const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs);
|
||||
const inBattle =
|
||||
readBoolean(rawGameState.inBattle) &&
|
||||
sceneHostileNpcs.some((npc) => npc.hp > 0);
|
||||
|
||||
return {
|
||||
sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId),
|
||||
runtimeVersion: Math.max(
|
||||
0,
|
||||
Math.round(readNumber(rawGameState.runtimeActionVersion, 0)),
|
||||
),
|
||||
snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'),
|
||||
rawGameState,
|
||||
worldType: readString(rawGameState.worldType) || null,
|
||||
storyHistory: normalizeStoryHistory(rawGameState.storyHistory),
|
||||
currentEncounter,
|
||||
npcInteractionActive: readBoolean(rawGameState.npcInteractionActive),
|
||||
sceneHostileNpcs,
|
||||
inBattle,
|
||||
playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))),
|
||||
playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))),
|
||||
playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))),
|
||||
playerMaxMana: Math.max(
|
||||
1,
|
||||
Math.round(readNumber(rawGameState.playerMaxMana, 1)),
|
||||
),
|
||||
npcStates: normalizeNpcStates(rawGameState.npcStates),
|
||||
companions: normalizeCompanions(rawGameState.companions),
|
||||
currentNpcBattleMode:
|
||||
rawGameState.currentNpcBattleMode === 'fight' ||
|
||||
rawGameState.currentNpcBattleMode === 'spar'
|
||||
? rawGameState.currentNpcBattleMode
|
||||
: null,
|
||||
currentNpcBattleOutcome:
|
||||
rawGameState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
rawGameState.currentNpcBattleOutcome === 'spar_complete'
|
||||
? rawGameState.currentNpcBattleOutcome
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isStoryFunctionId(functionId: string) {
|
||||
return STORY_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isCombatFunctionId(functionId: string) {
|
||||
return COMBAT_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isNpcFunctionId(functionId: string) {
|
||||
return NPC_FUNCTION_IDS.has(functionId);
|
||||
}
|
||||
|
||||
export function isTask5FunctionId(functionId: string) {
|
||||
return (
|
||||
isStoryFunctionId(functionId) ||
|
||||
isCombatFunctionId(functionId) ||
|
||||
isNpcFunctionId(functionId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isTask6RuntimeFunctionId(functionId: string) {
|
||||
return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function getEncounterNpcState(session: RuntimeSession) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = getEncounterKey(session.currentEncounter);
|
||||
return (
|
||||
session.npcStates[key] ?? {
|
||||
affinity: 0,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
firstMeaningfulContactResolved: false,
|
||||
relationState: null,
|
||||
stanceProfile: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setEncounterNpcState(
|
||||
session: RuntimeSession,
|
||||
npcState: RuntimeNpcState,
|
||||
) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return;
|
||||
}
|
||||
|
||||
session.npcStates[getEncounterKey(session.currentEncounter)] = npcState;
|
||||
}
|
||||
|
||||
function buildOptionView(
|
||||
functionId: string,
|
||||
overrides: Partial<RuntimeStoryOptionView> = {},
|
||||
): RuntimeStoryOptionView {
|
||||
const definition = FUNCTION_DEFINITIONS[functionId];
|
||||
if (!definition) {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
detailText: '',
|
||||
scope: 'story',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
functionId,
|
||||
actionText: definition.actionText,
|
||||
detailText: definition.detailText,
|
||||
scope: definition.scope,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type RuntimeQuestPreview = {
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] {
|
||||
return readArray(session.rawGameState.quests)
|
||||
.map((quest) => {
|
||||
const rawQuest = isObject(quest) ? quest : {};
|
||||
const id = readString(rawQuest.id);
|
||||
const issuerNpcId = readString(rawQuest.issuerNpcId);
|
||||
const status = readString(rawQuest.status);
|
||||
|
||||
if (!id || !issuerNpcId || !status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
issuerNpcId,
|
||||
status,
|
||||
} satisfies RuntimeQuestPreview;
|
||||
})
|
||||
.filter((quest): quest is RuntimeQuestPreview => Boolean(quest));
|
||||
}
|
||||
|
||||
function getActiveEncounterQuest(session: RuntimeSession) {
|
||||
if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readQuestPreviews(session).find(
|
||||
(quest) =>
|
||||
quest.issuerNpcId === session.currentEncounter?.id &&
|
||||
quest.status !== 'turned_in',
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hasGiftablePlayerInventory(session: RuntimeSession) {
|
||||
return readArray(session.rawGameState.playerInventory).some((item) => {
|
||||
const rawItem = isObject(item) ? item : {};
|
||||
return readNumber(rawItem.quantity, 0) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildAvailableOptions(session: RuntimeSession) {
|
||||
if (session.inBattle) {
|
||||
return [
|
||||
'battle_probe_pressure',
|
||||
'battle_guard_break',
|
||||
'battle_feint_step',
|
||||
'battle_finisher_window',
|
||||
'battle_all_in_crush',
|
||||
'battle_recover_breath',
|
||||
'battle_escape_breakout',
|
||||
].map((functionId) => buildOptionView(functionId));
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'npc') {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
if (session.currentEncounter.hostile) {
|
||||
return [
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
if (!session.npcInteractionActive) {
|
||||
return [
|
||||
buildOptionView('npc_preview_talk'),
|
||||
buildOptionView('npc_fight'),
|
||||
buildOptionView('npc_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
const activeQuest = getActiveEncounterQuest(session);
|
||||
const options = [
|
||||
buildOptionView('npc_chat'),
|
||||
buildOptionView('npc_help', npcState?.helpUsed
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '当前 NPC 的一次性援手已经用完了。',
|
||||
}
|
||||
: {}),
|
||||
buildOptionView('npc_spar'),
|
||||
buildOptionView('npc_fight'),
|
||||
];
|
||||
|
||||
if ((npcState?.inventory?.length ?? 0) > 0) {
|
||||
options.push(buildOptionView('npc_trade'));
|
||||
}
|
||||
|
||||
if (hasGiftablePlayerInventory(session)) {
|
||||
options.push(buildOptionView('npc_gift'));
|
||||
}
|
||||
|
||||
if (
|
||||
activeQuest &&
|
||||
(activeQuest.status === 'completed' ||
|
||||
activeQuest.status === 'ready_to_turn_in')
|
||||
) {
|
||||
options.push(buildOptionView('npc_quest_turn_in'));
|
||||
} else if (!activeQuest) {
|
||||
options.push(buildOptionView('npc_quest_accept'));
|
||||
}
|
||||
|
||||
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
|
||||
options.push(
|
||||
buildOptionView(
|
||||
'npc_recruit',
|
||||
session.companions.length >= MAX_TASK5_COMPANIONS
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。',
|
||||
}
|
||||
: {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
options.push(buildOptionView('npc_leave'));
|
||||
return options;
|
||||
}
|
||||
|
||||
if (session.currentEncounter?.kind === 'treasure') {
|
||||
return [
|
||||
buildOptionView('treasure_secure'),
|
||||
buildOptionView('treasure_inspect'),
|
||||
buildOptionView('treasure_leave'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'idle_observe_signs',
|
||||
'idle_call_out',
|
||||
'idle_rest_focus',
|
||||
'idle_explore_forward',
|
||||
'idle_travel_next_scene',
|
||||
'story_continue_adventure',
|
||||
].map((functionId) => buildOptionView(functionId));
|
||||
}
|
||||
|
||||
function buildEncounterViewModel(
|
||||
session: RuntimeSession,
|
||||
): RuntimeStoryEncounterViewModel | null {
|
||||
if (!session.currentEncounter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const npcState = getEncounterNpcState(session);
|
||||
return {
|
||||
id: session.currentEncounter.id,
|
||||
kind: session.currentEncounter.kind,
|
||||
npcName: session.currentEncounter.npcName,
|
||||
hostile: session.currentEncounter.hostile,
|
||||
affinity: npcState?.affinity,
|
||||
recruited: npcState?.recruited,
|
||||
interactionActive: session.npcInteractionActive,
|
||||
battleMode: session.currentNpcBattleMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRuntimeViewModel(
|
||||
session: RuntimeSession,
|
||||
options = buildAvailableOptions(session),
|
||||
): RuntimeStoryViewModel {
|
||||
return {
|
||||
player: {
|
||||
hp: session.playerHp,
|
||||
maxHp: session.playerMaxHp,
|
||||
mana: session.playerMana,
|
||||
maxMana: session.playerMaxMana,
|
||||
},
|
||||
encounter: buildEncounterViewModel(session),
|
||||
companions: session.companions.map((companion) => ({
|
||||
npcId: companion.npcId,
|
||||
characterId: companion.characterId || undefined,
|
||||
joinedAtAffinity: companion.joinedAtAffinity,
|
||||
})),
|
||||
availableOptions: options,
|
||||
status: {
|
||||
inBattle: session.inBattle,
|
||||
npcInteractionActive: session.npcInteractionActive,
|
||||
currentNpcBattleMode: session.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: session.currentNpcBattleOutcome,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStoryHistory(
|
||||
session: RuntimeSession,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) {
|
||||
session.storyHistory.push(
|
||||
{
|
||||
text: actionText,
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: resultText,
|
||||
historyRole: 'result',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildLegacyCurrentStory(
|
||||
storyText: string,
|
||||
options: RuntimeStoryOptionView[],
|
||||
) {
|
||||
return {
|
||||
text: storyText,
|
||||
options: options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncRawGameState(session: RuntimeSession) {
|
||||
session.rawGameState.runtimeSessionId = session.sessionId;
|
||||
session.rawGameState.runtimeActionVersion = session.runtimeVersion;
|
||||
session.rawGameState.storyHistory = cloneJson(session.storyHistory);
|
||||
session.rawGameState.currentEncounter = session.currentEncounter
|
||||
? cloneJson(session.currentEncounter)
|
||||
: null;
|
||||
session.rawGameState.npcInteractionActive = session.npcInteractionActive;
|
||||
session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs);
|
||||
session.rawGameState.inBattle = session.inBattle;
|
||||
session.rawGameState.playerHp = session.playerHp;
|
||||
session.rawGameState.playerMaxHp = session.playerMaxHp;
|
||||
session.rawGameState.playerMana = session.playerMana;
|
||||
session.rawGameState.playerMaxMana = session.playerMaxMana;
|
||||
session.rawGameState.npcStates = cloneJson(session.npcStates);
|
||||
session.rawGameState.companions = cloneJson(session.companions);
|
||||
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
|
||||
session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome;
|
||||
session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null;
|
||||
session.rawGameState.activeCombatEffects = [];
|
||||
session.rawGameState.playerActionMode = 'idle';
|
||||
session.rawGameState.scrollWorld = false;
|
||||
session.rawGameState.animationState = 'idle';
|
||||
}
|
||||
|
||||
export function replaceRuntimeSessionRawGameState(
|
||||
session: RuntimeSession,
|
||||
nextGameState: JsonRecord,
|
||||
) {
|
||||
session.rawGameState = cloneJson(nextGameState);
|
||||
const refreshed = loadRuntimeSession(
|
||||
{
|
||||
version: 2,
|
||||
savedAt: '',
|
||||
bottomTab: session.snapshotBottomTab,
|
||||
gameState: session.rawGameState,
|
||||
currentStory: null,
|
||||
},
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
session.worldType = refreshed.worldType;
|
||||
session.storyHistory = refreshed.storyHistory;
|
||||
session.currentEncounter = refreshed.currentEncounter;
|
||||
session.npcInteractionActive = refreshed.npcInteractionActive;
|
||||
session.sceneHostileNpcs = refreshed.sceneHostileNpcs;
|
||||
session.inBattle = refreshed.inBattle;
|
||||
session.playerHp = refreshed.playerHp;
|
||||
session.playerMaxHp = refreshed.playerMaxHp;
|
||||
session.playerMana = refreshed.playerMana;
|
||||
session.playerMaxMana = refreshed.playerMaxMana;
|
||||
session.npcStates = refreshed.npcStates;
|
||||
session.companions = refreshed.companions;
|
||||
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
|
||||
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
|
||||
}
|
||||
Reference in New Issue
Block a user