@@ -3,6 +3,13 @@ import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
incrementGameRuntimeStats,
|
||||
isInventoryItemUsable,
|
||||
removeInventoryItem,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../../bridges/legacyInventoryRuntimeBridge.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
@@ -32,6 +39,9 @@ type CombatActionConfig = {
|
||||
tags: string[];
|
||||
durationTurns: number;
|
||||
}>;
|
||||
consumedItemId?: string | null;
|
||||
usedItem?: RuntimeCombatInventoryItem | null;
|
||||
itemEffect?: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>> | null;
|
||||
};
|
||||
|
||||
export type CombatResolution = {
|
||||
@@ -50,6 +60,13 @@ const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
|
||||
'battle_finisher_window',
|
||||
]);
|
||||
|
||||
type RuntimeCombatInventoryItem = Parameters<
|
||||
typeof resolveInventoryItemUseEffect
|
||||
>[0] & {
|
||||
id: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -58,10 +75,57 @@ function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function readNumber(value: unknown, fallback = 0) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function getAliveTarget(session: RuntimeSession) {
|
||||
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
|
||||
}
|
||||
|
||||
function getCombatInventoryItem(
|
||||
session: RuntimeSession,
|
||||
itemId: string,
|
||||
): RuntimeCombatInventoryItem | null {
|
||||
const rawItem = readArray(session.rawGameState.playerInventory).find(
|
||||
(candidate) => isObject(candidate) && readString(candidate.id) === itemId,
|
||||
);
|
||||
if (!rawItem || !isObject(rawItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = readString(rawItem.name, itemId);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rarity = readString(rawItem.rarity, 'common');
|
||||
const normalizedRarity =
|
||||
rarity === 'legendary' ||
|
||||
rarity === 'epic' ||
|
||||
rarity === 'rare' ||
|
||||
rarity === 'uncommon'
|
||||
? rarity
|
||||
: 'common';
|
||||
|
||||
return {
|
||||
id: itemId,
|
||||
name,
|
||||
quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))),
|
||||
rarity: normalizedRarity,
|
||||
tags: readArray(rawItem.tags).filter(
|
||||
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
|
||||
),
|
||||
useProfile: isObject(rawItem.useProfile)
|
||||
? (rawItem.useProfile as RuntimeCombatInventoryItem['useProfile'])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function applySparAffinityReward(session: RuntimeSession) {
|
||||
const npcState = getEncounterNpcState(session);
|
||||
const encounter = session.currentEncounter;
|
||||
@@ -210,6 +274,53 @@ function resolveCombatActionConfig(params: {
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
if (functionId === 'inventory_use') {
|
||||
const character = getPlayerCharacter(session);
|
||||
if (!character) {
|
||||
throw conflict('缺少玩家角色,无法结算战斗物品动作');
|
||||
}
|
||||
|
||||
const itemId = readString(isObject(payload) ? payload.itemId : '');
|
||||
if (!itemId) {
|
||||
throw conflict('inventory_use 缺少 itemId');
|
||||
}
|
||||
|
||||
const item = getCombatInventoryItem(session, itemId);
|
||||
if (!item || item.quantity <= 0) {
|
||||
throw conflict('未找到可用于战斗结算的物品');
|
||||
}
|
||||
|
||||
if (!isInventoryItemUsable(item)) {
|
||||
throw conflict(`${item.name} 当前不可在战斗中直接使用`);
|
||||
}
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(item, character);
|
||||
if (
|
||||
!effect ||
|
||||
((effect.hpRestore ?? 0) <= 0 &&
|
||||
(effect.manaRestore ?? 0) <= 0 &&
|
||||
(effect.cooldownReduction ?? 0) <= 0 &&
|
||||
(effect.buildBuffs?.length ?? 0) <= 0)
|
||||
) {
|
||||
throw conflict(`${item.name} 当前没有可直接结算的战斗效果`);
|
||||
}
|
||||
|
||||
return {
|
||||
actionText: `使用${item.name}`,
|
||||
manaCost: 0,
|
||||
baseDamage: 0,
|
||||
counterMultiplier: 0.72,
|
||||
heal: effect.hpRestore,
|
||||
manaRestore: effect.manaRestore,
|
||||
cooldownBonus: effect.cooldownReduction,
|
||||
selectedSkillId: null,
|
||||
buildBuffs: effect.buildBuffs,
|
||||
consumedItemId: item.id,
|
||||
usedItem: item,
|
||||
itemEffect: effect,
|
||||
} satisfies CombatActionConfig;
|
||||
}
|
||||
|
||||
throw conflict(`暂不支持的战斗动作:${functionId}`);
|
||||
}
|
||||
|
||||
@@ -304,6 +415,25 @@ export function resolveCombatAction(
|
||||
}
|
||||
session.rawGameState.playerSkillCooldowns = nextCooldowns;
|
||||
|
||||
if (action.consumedItemId) {
|
||||
session.rawGameState.playerInventory = removeInventoryItem(
|
||||
session.rawGameState.playerInventory as Parameters<typeof removeInventoryItem>[0],
|
||||
action.consumedItemId,
|
||||
1,
|
||||
);
|
||||
session.rawGameState.runtimeStats = incrementGameRuntimeStats(
|
||||
(isObject(session.rawGameState.runtimeStats)
|
||||
? session.rawGameState.runtimeStats
|
||||
: {
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
}) as Parameters<typeof incrementGameRuntimeStats>[0],
|
||||
{ itemsUsed: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action.buildBuffs?.length) {
|
||||
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
|
||||
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
|
||||
@@ -354,7 +484,10 @@ export function resolveCombatAction(
|
||||
patches.push(affinityPatch);
|
||||
}
|
||||
outcome = 'spar_complete';
|
||||
resultText = `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
|
||||
resultText =
|
||||
params.functionId === 'inventory_use' && action.usedItem
|
||||
? `你刚用下${action.usedItem.name}稳住一口气,但${target.name}还是把你逼到了极限,这场切磋点到为止。`
|
||||
: `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
|
||||
} else if (!isSpar && session.playerHp <= 0) {
|
||||
session.playerHp = 0;
|
||||
session.inBattle = false;
|
||||
@@ -363,9 +496,21 @@ export function resolveCombatAction(
|
||||
session.npcInteractionActive = false;
|
||||
session.currentEncounter = null;
|
||||
outcome = 'escaped';
|
||||
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
|
||||
resultText =
|
||||
params.functionId === 'inventory_use' && action.usedItem
|
||||
? `你刚把${action.usedItem.name}用下去,却还是被${target.name}压到失去战斗能力,这轮正面冲突只能先断开。`
|
||||
: `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
|
||||
} else if (params.functionId === 'battle_recover_breath') {
|
||||
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`;
|
||||
} else if (
|
||||
params.functionId === 'inventory_use' &&
|
||||
action.usedItem &&
|
||||
action.itemEffect
|
||||
) {
|
||||
resultText = `${buildInventoryUseResultText(
|
||||
action.usedItem,
|
||||
action.itemEffect,
|
||||
).replace(/。$/u, '')},但${target.name}仍在持续逼近。`;
|
||||
} else if (params.functionId === 'battle_use_skill') {
|
||||
resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`;
|
||||
} else {
|
||||
|
||||
@@ -225,6 +225,43 @@ export interface CustomWorldSceneConnection {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type SceneActStage =
|
||||
| 'opening'
|
||||
| 'expansion'
|
||||
| 'turning_point'
|
||||
| 'climax'
|
||||
| 'aftermath';
|
||||
|
||||
export type SceneActAdvanceRule =
|
||||
| 'after_primary_contact'
|
||||
| 'after_active_step_complete'
|
||||
| 'after_chapter_resolution';
|
||||
|
||||
export interface SceneActBlueprint {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
stageCoverage: SceneActStage[];
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
primaryNpcId: string;
|
||||
linkedThreadIds: string[];
|
||||
advanceRule: SceneActAdvanceRule;
|
||||
actGoal: string;
|
||||
transitionHook: string;
|
||||
}
|
||||
|
||||
export interface SceneChapterBlueprint {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
linkedThreadIds: string[];
|
||||
linkedLandmarkIds: string[];
|
||||
acts: SceneActBlueprint[];
|
||||
}
|
||||
|
||||
export interface CustomWorldCampScene {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -323,6 +360,7 @@ export interface CustomWorldProfile {
|
||||
storyGraph?: WorldStoryGraph | null;
|
||||
knowledgeFacts?: Array<Record<string, unknown>> | null;
|
||||
threadContracts?: Array<Record<string, unknown>> | null;
|
||||
sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
|
||||
anchorContent?: Record<string, unknown> | null;
|
||||
creatorIntent?: CustomWorldCreatorIntent | null;
|
||||
anchorPack?: CustomWorldAnchorPack | null;
|
||||
|
||||
63
server-node/src/modules/progression/levelBenchmarks.ts
Normal file
63
server-node/src/modules/progression/levelBenchmarks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface LevelBenchmark {
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
cumulativeXpRequired: number;
|
||||
referenceStrength: number;
|
||||
baseHp: number;
|
||||
baseMana: number;
|
||||
baselineDamageScale: number;
|
||||
}
|
||||
|
||||
export const MAX_PLAYER_LEVEL = 20;
|
||||
|
||||
function clampLevel(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
function roundMetric(value: number, digits = 3) {
|
||||
return Number(value.toFixed(digits));
|
||||
}
|
||||
|
||||
function computeXpToNextLevel(level: number) {
|
||||
const scale = Math.max(0, level - 1);
|
||||
return 60 + 20 * scale + 8 * scale * scale;
|
||||
}
|
||||
|
||||
function buildLevelBenchmarks(maxLevel: number) {
|
||||
const benchmarks: LevelBenchmark[] = [];
|
||||
let cumulativeXpRequired = 0;
|
||||
|
||||
for (let level = 1; level <= maxLevel; level += 1) {
|
||||
const scale = level - 1;
|
||||
const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level);
|
||||
|
||||
benchmarks.push({
|
||||
level,
|
||||
xpToNextLevel,
|
||||
cumulativeXpRequired,
|
||||
referenceStrength: 100 + 16 * scale + 6 * scale * scale,
|
||||
baseHp: 180 + 24 * scale + 10 * scale * scale,
|
||||
baseMana: 80 + 14 * scale + 6 * scale * scale,
|
||||
baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale),
|
||||
});
|
||||
|
||||
cumulativeXpRequired += xpToNextLevel;
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL);
|
||||
const LEVEL_BENCHMARKS_BY_LEVEL = new Map(
|
||||
LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]),
|
||||
);
|
||||
|
||||
export function getLevelBenchmark(level: number) {
|
||||
return (
|
||||
LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createInitialPlayerProgressionState,
|
||||
grantPlayerExperience,
|
||||
normalizePlayerProgressionState,
|
||||
} from './playerProgressionService.js';
|
||||
|
||||
test('player progression starts at level 1 with the first upgrade threshold', () => {
|
||||
const initialState = createInitialPlayerProgressionState();
|
||||
|
||||
assert.deepEqual(initialState, {
|
||||
level: 1,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 0,
|
||||
xpToNextLevel: 60,
|
||||
pendingLevelUps: 0,
|
||||
lastGrantedSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('grantPlayerExperience upgrades level state from quest rewards', () => {
|
||||
const result = grantPlayerExperience(
|
||||
{
|
||||
level: 1,
|
||||
currentLevelXp: 50,
|
||||
totalXp: 50,
|
||||
xpToNextLevel: 60,
|
||||
},
|
||||
40,
|
||||
{
|
||||
source: 'quest',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.grantedXp, 40);
|
||||
assert.equal(result.previousLevel, 1);
|
||||
assert.equal(result.nextLevel, 2);
|
||||
assert.equal(result.levelUps, 1);
|
||||
assert.equal(result.state.level, 2);
|
||||
assert.equal(result.state.currentLevelXp, 30);
|
||||
assert.equal(result.state.totalXp, 90);
|
||||
assert.equal(result.state.xpToNextLevel, 88);
|
||||
assert.equal(result.state.lastGrantedSource, 'quest');
|
||||
});
|
||||
|
||||
test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => {
|
||||
const normalized = normalizePlayerProgressionState({
|
||||
level: 3,
|
||||
currentLevelXp: 15,
|
||||
});
|
||||
|
||||
assert.equal(normalized.level, 3);
|
||||
assert.equal(normalized.currentLevelXp, 15);
|
||||
assert.equal(normalized.totalXp, 163);
|
||||
assert.equal(normalized.xpToNextLevel, 132);
|
||||
});
|
||||
192
server-node/src/modules/progression/playerProgressionService.ts
Normal file
192
server-node/src/modules/progression/playerProgressionService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc';
|
||||
|
||||
export interface PlayerProgressionState {
|
||||
level: number;
|
||||
currentLevelXp: number;
|
||||
totalXp: number;
|
||||
xpToNextLevel: number;
|
||||
pendingLevelUps?: number;
|
||||
lastGrantedSource?: PlayerProgressionGrantSource | null;
|
||||
}
|
||||
|
||||
export interface PlayerExperienceGrantResult {
|
||||
state: PlayerProgressionState;
|
||||
grantedXp: number;
|
||||
previousLevel: number;
|
||||
nextLevel: number;
|
||||
levelUps: number;
|
||||
leveledUp: boolean;
|
||||
reachedMaxLevel: boolean;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function clampNonNegativeInteger(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
function clampLevel(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value)));
|
||||
}
|
||||
|
||||
function normalizeLastGrantedSource(value: unknown) {
|
||||
return value === 'quest' || value === 'hostile_npc' ? value : null;
|
||||
}
|
||||
|
||||
function resolveLevelFromTotalXp(totalXp: number) {
|
||||
let resolvedLevel = 1;
|
||||
|
||||
for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) {
|
||||
if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) {
|
||||
break;
|
||||
}
|
||||
|
||||
resolvedLevel = level;
|
||||
}
|
||||
|
||||
return resolvedLevel;
|
||||
}
|
||||
|
||||
function buildProgressionStateFromTotalXp(
|
||||
totalXp: number,
|
||||
lastGrantedSource: PlayerProgressionGrantSource | null = null,
|
||||
): PlayerProgressionState {
|
||||
const normalizedTotalXp = clampNonNegativeInteger(totalXp);
|
||||
const level = resolveLevelFromTotalXp(normalizedTotalXp);
|
||||
const benchmark = getLevelBenchmark(level);
|
||||
|
||||
if (level >= MAX_PLAYER_LEVEL) {
|
||||
return {
|
||||
level,
|
||||
currentLevelXp: 0,
|
||||
totalXp: normalizedTotalXp,
|
||||
xpToNextLevel: 0,
|
||||
pendingLevelUps: 0,
|
||||
lastGrantedSource,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
level,
|
||||
currentLevelXp: Math.max(
|
||||
0,
|
||||
normalizedTotalXp - benchmark.cumulativeXpRequired,
|
||||
),
|
||||
totalXp: normalizedTotalXp,
|
||||
xpToNextLevel: benchmark.xpToNextLevel,
|
||||
pendingLevelUps: 0,
|
||||
lastGrantedSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialPlayerProgressionState(): PlayerProgressionState {
|
||||
return buildProgressionStateFromTotalXp(0);
|
||||
}
|
||||
|
||||
export function normalizePlayerProgressionState(
|
||||
value: unknown,
|
||||
): PlayerProgressionState {
|
||||
if (!isRecord(value)) {
|
||||
return createInitialPlayerProgressionState();
|
||||
}
|
||||
|
||||
const explicitLevel = clampLevel(value.level);
|
||||
const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp);
|
||||
const totalXp = clampNonNegativeInteger(value.totalXp);
|
||||
const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0;
|
||||
const derivedTotalXp =
|
||||
totalXp > 0 || !hasExplicitProgress
|
||||
? totalXp
|
||||
: getLevelBenchmark(explicitLevel).cumulativeXpRequired +
|
||||
Math.min(
|
||||
explicitCurrentLevelXp,
|
||||
getLevelBenchmark(explicitLevel).xpToNextLevel,
|
||||
);
|
||||
|
||||
return {
|
||||
...buildProgressionStateFromTotalXp(
|
||||
derivedTotalXp,
|
||||
normalizeLastGrantedSource(value.lastGrantedSource),
|
||||
),
|
||||
pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps),
|
||||
};
|
||||
}
|
||||
|
||||
export function grantPlayerExperience(
|
||||
value: unknown,
|
||||
amount: number,
|
||||
options: {
|
||||
source: PlayerProgressionGrantSource;
|
||||
},
|
||||
): PlayerExperienceGrantResult {
|
||||
const currentState = normalizePlayerProgressionState(value);
|
||||
const grantedXp = clampNonNegativeInteger(amount);
|
||||
|
||||
if (grantedXp <= 0) {
|
||||
return {
|
||||
state: {
|
||||
...currentState,
|
||||
pendingLevelUps: 0,
|
||||
},
|
||||
grantedXp: 0,
|
||||
previousLevel: currentState.level,
|
||||
nextLevel: currentState.level,
|
||||
levelUps: 0,
|
||||
leveledUp: false,
|
||||
reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = buildProgressionStateFromTotalXp(
|
||||
currentState.totalXp + grantedXp,
|
||||
options.source,
|
||||
);
|
||||
const levelUps = Math.max(0, nextState.level - currentState.level);
|
||||
|
||||
return {
|
||||
state: {
|
||||
...nextState,
|
||||
pendingLevelUps: 0,
|
||||
},
|
||||
grantedXp,
|
||||
previousLevel: currentState.level,
|
||||
nextLevel: nextState.level,
|
||||
levelUps,
|
||||
leveledUp: levelUps > 0,
|
||||
reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExperienceGrantResultText(
|
||||
result: PlayerExperienceGrantResult,
|
||||
) {
|
||||
if (result.grantedXp <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const parts = [`经验 +${result.grantedXp}`];
|
||||
|
||||
if (result.leveledUp) {
|
||||
parts.push(
|
||||
result.levelUps > 1
|
||||
? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}`
|
||||
: `升至 Lv.${result.nextLevel}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `${parts.join(',')}。`;
|
||||
}
|
||||
@@ -3,10 +3,16 @@ import {
|
||||
normalizeQuestLogEntries,
|
||||
} from '../../bridges/legacyQuestProgressBridge.js';
|
||||
|
||||
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number];
|
||||
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[1];
|
||||
export type QuestLogEntry = Parameters<
|
||||
typeof normalizeQuestLogEntries
|
||||
>[0][number];
|
||||
export type QuestProgressSignal = Parameters<
|
||||
typeof applyQuestProgressSignal
|
||||
>[1];
|
||||
|
||||
type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in';
|
||||
type QuestMutationFailureCode =
|
||||
| 'quest_not_found'
|
||||
| 'quest_not_ready_to_turn_in';
|
||||
|
||||
export type QuestMutationFailure = {
|
||||
ok: false;
|
||||
@@ -61,7 +67,9 @@ function buildSuccess(
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] {
|
||||
export function normalizeQuestEntries(
|
||||
quests: QuestLogEntry[],
|
||||
): QuestLogEntry[] {
|
||||
return normalizeQuestLogEntries(quests);
|
||||
}
|
||||
|
||||
@@ -116,10 +124,7 @@ export function getQuestForIssuer(
|
||||
);
|
||||
}
|
||||
|
||||
export function acceptQuest(
|
||||
quests: QuestLogEntry[],
|
||||
quest: QuestLogEntry,
|
||||
) {
|
||||
export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
|
||||
const normalizedQuests = normalizeQuestEntries(quests);
|
||||
if (findQuestById(normalizedQuests, quest.id)) {
|
||||
return normalizedQuests;
|
||||
@@ -136,17 +141,26 @@ export function buildQuestAcceptResultText(quest: QuestLogEntry) {
|
||||
}`;
|
||||
}
|
||||
|
||||
export function buildQuestTurnInResultText(quest: QuestLogEntry) {
|
||||
export function buildQuestTurnInResultText(
|
||||
quest: QuestLogEntry,
|
||||
options: {
|
||||
experienceText?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const normalizedQuest = normalizeQuestEntries([quest])[0]!;
|
||||
const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、');
|
||||
const itemText =
|
||||
normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给';
|
||||
const intelText = normalizedQuest.reward.intel?.rumorText
|
||||
? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}`
|
||||
: '';
|
||||
const storyHintText = normalizedQuest.reward.storyHint
|
||||
? ` ${normalizedQuest.reward.storyHint}`
|
||||
: '';
|
||||
const experienceText = options.experienceText?.trim()
|
||||
? ` ${options.experienceText.trim()}`
|
||||
: '';
|
||||
|
||||
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`;
|
||||
return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和${itemText}${intelText}。${experienceText}${storyHintText}`;
|
||||
}
|
||||
|
||||
export function isQuestReadyToClaim(quest: QuestLogEntry) {
|
||||
@@ -154,10 +168,7 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) {
|
||||
return status === 'ready_to_turn_in' || status === 'completed';
|
||||
}
|
||||
|
||||
export function markQuestTurnedIn(
|
||||
quests: QuestLogEntry[],
|
||||
questId: string,
|
||||
) {
|
||||
export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.map((quest) =>
|
||||
quest.id === questId
|
||||
? normalizeQuestEntries([
|
||||
|
||||
@@ -2,6 +2,10 @@ import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import {
|
||||
buildExperienceGrantResultText,
|
||||
grantPlayerExperience,
|
||||
} from '../progression/playerProgressionService.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
appendStoryEngineCarrierMemory,
|
||||
@@ -37,7 +41,9 @@ type QuestStoryResolution = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
|
||||
type RuntimeQuestLogEntry = NonNullable<ReturnType<typeof buildQuestForEncounter>>;
|
||||
type RuntimeQuestLogEntry = NonNullable<
|
||||
ReturnType<typeof buildQuestForEncounter>
|
||||
>;
|
||||
type RuntimeNpcState = Parameters<
|
||||
typeof markNpcFirstMeaningfulContactResolved
|
||||
>[0];
|
||||
@@ -159,7 +165,8 @@ function resolveQuestAcceptAction(
|
||||
session: RuntimeSession,
|
||||
currentStory?: unknown,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
|
||||
const { state, encounter, npcKey, npcState } =
|
||||
ensureEncounterQuestContext(session);
|
||||
const quests = Array.isArray(state.quests) ? state.quests : [];
|
||||
const existingQuest = getQuestForIssuer(quests, npcKey);
|
||||
if (existingQuest) {
|
||||
@@ -174,6 +181,14 @@ function resolveQuestAcceptAction(
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
context: {
|
||||
worldType: state.worldType,
|
||||
recentStoryMoments: Array.isArray(state.storyHistory)
|
||||
? state.storyHistory.slice(-6)
|
||||
: [],
|
||||
playerCharacter: state.playerCharacter ?? null,
|
||||
playerProgression: state.playerProgression ?? null,
|
||||
},
|
||||
currentQuests: quests.map((item) => ({
|
||||
id: item.id,
|
||||
issuerNpcId: item.issuerNpcId,
|
||||
@@ -214,7 +229,8 @@ function resolveQuestTurnInAction(
|
||||
session: RuntimeSession,
|
||||
request: RuntimeStoryActionRequest,
|
||||
): QuestStoryResolution {
|
||||
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session);
|
||||
const { state, encounter, npcKey, npcState } =
|
||||
ensureEncounterQuestContext(session);
|
||||
const quests = Array.isArray(state.quests) ? state.quests : [];
|
||||
const questId = readQuestId(request);
|
||||
const quest =
|
||||
@@ -235,11 +251,22 @@ function resolveQuestTurnInAction(
|
||||
}
|
||||
|
||||
const nextAffinity = npcState.affinity + quest.reward.affinityBonus;
|
||||
const experienceGrant = grantPlayerExperience(
|
||||
state.playerProgression,
|
||||
quest.reward.experience ?? 0,
|
||||
{
|
||||
source: 'quest',
|
||||
},
|
||||
);
|
||||
let nextState = {
|
||||
...state,
|
||||
quests: turnInResult.nextQuests,
|
||||
playerProgression: experienceGrant.state,
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
|
||||
playerInventory: addInventoryItems(
|
||||
state.playerInventory,
|
||||
quest.reward.items,
|
||||
),
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[npcKey]: {
|
||||
@@ -258,7 +285,9 @@ function resolveQuestTurnInAction(
|
||||
|
||||
return {
|
||||
actionText: `向${encounter.npcName}交付委托`,
|
||||
resultText: buildQuestTurnInResultText(quest),
|
||||
resultText: buildQuestTurnInResultText(quest, {
|
||||
experienceText: buildExperienceGrantResultText(experienceGrant),
|
||||
}),
|
||||
patches: [
|
||||
{
|
||||
type: 'npc_affinity_changed',
|
||||
|
||||
@@ -38,6 +38,7 @@ export type QuestRewardItem = {
|
||||
export type QuestReward = {
|
||||
affinityBonus: number;
|
||||
currency: number;
|
||||
experience?: number;
|
||||
items: QuestRewardItem[];
|
||||
intel?: {
|
||||
rumorText: string;
|
||||
@@ -150,7 +151,11 @@ export type QuestOpportunity = {
|
||||
};
|
||||
|
||||
export type QuestProgressSignal =
|
||||
| { kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string }
|
||||
| {
|
||||
kind: 'hostile_npc_defeated';
|
||||
sceneId?: string | null;
|
||||
hostileNpcId: string;
|
||||
}
|
||||
| { kind: 'treasure_inspected'; sceneId?: string | null }
|
||||
| { kind: 'npc_spar_completed'; npcId: string }
|
||||
| { kind: 'npc_talk_completed'; npcId: string }
|
||||
@@ -189,6 +194,12 @@ export type QuestGenerationContext = {
|
||||
name?: string;
|
||||
title?: string;
|
||||
} | null;
|
||||
playerProgression?: {
|
||||
level?: number;
|
||||
currentLevelXp?: number;
|
||||
totalXp?: number;
|
||||
xpToNextLevel?: number;
|
||||
} | null;
|
||||
playerHp?: number;
|
||||
playerMaxHp?: number;
|
||||
playerMana?: number;
|
||||
@@ -254,6 +265,7 @@ type RuntimeStateLike = {
|
||||
currentScenePreset?: RuntimeSceneLike | null;
|
||||
storyHistory: Array<{ text: string }>;
|
||||
playerCharacter?: QuestGenerationContext['playerCharacter'];
|
||||
playerProgression?: QuestGenerationContext['playerProgression'];
|
||||
playerHp?: number;
|
||||
playerMaxHp?: number;
|
||||
playerMana?: number;
|
||||
@@ -267,7 +279,11 @@ type RuntimeStateLike = {
|
||||
};
|
||||
|
||||
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed'];
|
||||
const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired'];
|
||||
const TERMINAL_QUEST_STATUSES: QuestStatus[] = [
|
||||
'turned_in',
|
||||
'failed',
|
||||
'expired',
|
||||
];
|
||||
|
||||
function clampProgress(progress: number | undefined, requiredCount: number) {
|
||||
return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0)));
|
||||
@@ -341,7 +357,8 @@ function getScenePrimaryThreat(
|
||||
}
|
||||
|
||||
const hostileNpc =
|
||||
scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null;
|
||||
scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ??
|
||||
null;
|
||||
if (hostileNpc) {
|
||||
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
|
||||
return {
|
||||
@@ -434,13 +451,71 @@ function buildRewardItems(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function computeXpToNextLevel(level: number) {
|
||||
const scale = Math.max(0, level - 1);
|
||||
return 60 + 20 * scale + 8 * scale * scale;
|
||||
}
|
||||
|
||||
function resolveQuestTargetLevel(context?: QuestGenerationContext) {
|
||||
const level = context?.playerProgression?.level;
|
||||
if (typeof level !== 'number' || !Number.isFinite(level)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor(level));
|
||||
}
|
||||
|
||||
function resolveQuestStepCountMultiplier(stepCount: number) {
|
||||
if (stepCount <= 1) {
|
||||
return 0.85;
|
||||
}
|
||||
|
||||
if (stepCount === 2) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1.12;
|
||||
}
|
||||
|
||||
function resolveQuestNarrativeXpMultiplier(narrativeType: QuestNarrativeType) {
|
||||
return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1;
|
||||
}
|
||||
|
||||
function resolveQuestUrgencyXpMultiplier(urgency: QuestUrgency) {
|
||||
return urgency === 'high' ? 1.05 : 1;
|
||||
}
|
||||
|
||||
function buildQuestExperienceReward(params: {
|
||||
context?: QuestGenerationContext;
|
||||
narrativeType: QuestNarrativeType;
|
||||
urgency: QuestUrgency;
|
||||
stepCount: number;
|
||||
}) {
|
||||
const baseQuestXp =
|
||||
computeXpToNextLevel(resolveQuestTargetLevel(params.context)) * 0.45;
|
||||
|
||||
return Math.max(
|
||||
5,
|
||||
Math.round(
|
||||
(baseQuestXp *
|
||||
resolveQuestStepCountMultiplier(params.stepCount) *
|
||||
resolveQuestNarrativeXpMultiplier(params.narrativeType) *
|
||||
resolveQuestUrgencyXpMultiplier(params.urgency)) /
|
||||
5,
|
||||
) * 5,
|
||||
);
|
||||
}
|
||||
|
||||
function buildQuestReward(params: {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
worldType: string | null | undefined;
|
||||
rewardTheme: QuestRewardTheme;
|
||||
narrativeType: QuestNarrativeType;
|
||||
urgency: QuestUrgency;
|
||||
stepCount: number;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
context?: QuestGenerationContext;
|
||||
}) {
|
||||
const baseCurrency =
|
||||
params.rewardTheme === 'intel'
|
||||
@@ -453,10 +528,17 @@ function buildQuestReward(params: {
|
||||
|
||||
const reward: QuestReward = {
|
||||
affinityBonus:
|
||||
params.narrativeType === 'relationship' || params.narrativeType === 'trial'
|
||||
params.narrativeType === 'relationship' ||
|
||||
params.narrativeType === 'trial'
|
||||
? 14
|
||||
: 12,
|
||||
currency: baseCurrency,
|
||||
experience: buildQuestExperienceReward({
|
||||
context: params.context,
|
||||
narrativeType: params.narrativeType,
|
||||
urgency: params.urgency,
|
||||
stepCount: params.stepCount,
|
||||
}),
|
||||
items: buildRewardItems(params),
|
||||
storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`,
|
||||
};
|
||||
@@ -479,10 +561,12 @@ function buildRewardText(
|
||||
) {
|
||||
const itemText =
|
||||
reward.items.map((item) => item.name).join('、') || '当前局势相关的补给';
|
||||
const experienceText =
|
||||
(reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : '';
|
||||
const intelText = reward.intel?.rumorText
|
||||
? `,以及情报“${reward.intel.rumorText}”`
|
||||
: '';
|
||||
return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(
|
||||
return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency(
|
||||
reward.currency,
|
||||
worldType,
|
||||
)}、${itemText}${intelText}。`;
|
||||
@@ -522,7 +606,7 @@ function buildPrimaryQuestStep(params: {
|
||||
: [threat.kind];
|
||||
const chosenKind = preferredKinds.includes(threat.kind)
|
||||
? threat.kind
|
||||
: preferredKinds[0] ?? threat.kind;
|
||||
: (preferredKinds[0] ?? threat.kind);
|
||||
|
||||
if (chosenKind === 'inspect_treasure' && scene) {
|
||||
return {
|
||||
@@ -606,7 +690,9 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10);
|
||||
return fallbackTitle.length <= 12
|
||||
? fallbackTitle
|
||||
: fallbackTitle.slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||||
@@ -618,7 +704,8 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||||
Math.max(1, Math.round(step.requiredCount ?? 1)),
|
||||
),
|
||||
}));
|
||||
const activeStep = steps.find((step) => step.progress < step.requiredCount) ?? null;
|
||||
const activeStep =
|
||||
steps.find((step) => step.progress < step.requiredCount) ?? null;
|
||||
const terminal = isTerminalStatus(quest.status);
|
||||
const rewardReady = !terminal && !activeStep ? 'completed' : quest.status;
|
||||
|
||||
@@ -626,10 +713,21 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||||
...quest,
|
||||
title: normalizeQuestTitle(quest.title, quest.title),
|
||||
summary: quest.summary.trim() || quest.description.trim(),
|
||||
progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0,
|
||||
objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null),
|
||||
progress:
|
||||
activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0,
|
||||
objective: deriveObjectiveFromStep(
|
||||
activeStep ?? steps[steps.length - 1] ?? null,
|
||||
),
|
||||
status: terminal ? quest.status : rewardReady,
|
||||
completionNotified: quest.completionNotified ?? false,
|
||||
reward: {
|
||||
affinityBonus: Math.round(quest.reward.affinityBonus ?? 0),
|
||||
currency: Math.max(0, Math.round(quest.reward.currency ?? 0)),
|
||||
experience: Math.max(0, Math.round(quest.reward.experience ?? 0)),
|
||||
items: quest.reward.items ?? [],
|
||||
intel: quest.reward.intel,
|
||||
storyHint: quest.reward.storyHint,
|
||||
},
|
||||
rewardText: quest.rewardText.trim(),
|
||||
steps,
|
||||
activeStepId: activeStep?.id ?? null,
|
||||
@@ -659,7 +757,9 @@ function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) {
|
||||
case 'npc_talk_completed':
|
||||
return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId;
|
||||
case 'scene_reached':
|
||||
return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId;
|
||||
return (
|
||||
step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId
|
||||
);
|
||||
case 'item_delivered':
|
||||
return (
|
||||
step.kind === 'deliver_item' &&
|
||||
@@ -701,7 +801,8 @@ export function buildQuestGenerationContextFromState(params: {
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, {
|
||||
recruited: issuerState?.recruited,
|
||||
}),
|
||||
activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [],
|
||||
activeThreadIds:
|
||||
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [],
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount:
|
||||
state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
@@ -710,6 +811,7 @@ export function buildQuestGenerationContextFromState(params: {
|
||||
.map((npc) => npc.monsterPresetId ?? npc.id),
|
||||
recentStoryMoments: state.storyHistory.slice(-6),
|
||||
playerCharacter: state.playerCharacter ?? null,
|
||||
playerProgression: state.playerProgression ?? null,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
@@ -731,15 +833,21 @@ export function findQuestById(quests: QuestLogEntry[], questId: string) {
|
||||
return quests.find((quest) => quest.id === questId) ?? null;
|
||||
}
|
||||
|
||||
export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) {
|
||||
export function getQuestForIssuer(
|
||||
quests: QuestLogEntry[],
|
||||
issuerNpcId: string,
|
||||
) {
|
||||
return (
|
||||
normalizeQuestLogEntries(quests).find(
|
||||
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||||
(quest) =>
|
||||
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity {
|
||||
export function evaluateQuestOpportunity(
|
||||
params: QuestPreviewRequest,
|
||||
): QuestOpportunity {
|
||||
const { issuerNpcId, scene, currentQuests = [] } = params;
|
||||
if (!scene) {
|
||||
return {
|
||||
@@ -750,7 +858,8 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo
|
||||
|
||||
if (
|
||||
currentQuests.some(
|
||||
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||||
(quest) =>
|
||||
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
|
||||
)
|
||||
) {
|
||||
return {
|
||||
@@ -888,7 +997,10 @@ export function compileQuestIntentToQuest(
|
||||
worldType: params.worldType,
|
||||
rewardTheme: intent.rewardTheme,
|
||||
narrativeType: intent.narrativeType,
|
||||
urgency: intent.urgency,
|
||||
stepCount: steps.length,
|
||||
scene: params.scene,
|
||||
context: params.context,
|
||||
});
|
||||
const rewardText = buildRewardText(reward, params.worldType);
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor
|
||||
rewardText: '完成后可领取测试奖励。',
|
||||
reward: {
|
||||
currency: 10,
|
||||
experience: 0,
|
||||
items: [],
|
||||
},
|
||||
steps: [
|
||||
@@ -128,6 +129,8 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor
|
||||
assert.equal(snapshot.gameState.playerMaxMana, 95);
|
||||
assert.equal(snapshot.gameState.playerMana, 22);
|
||||
assert.equal(snapshot.gameState.playerCurrency, 160);
|
||||
assert.equal(snapshot.gameState.playerProgression.level, 1);
|
||||
assert.equal(snapshot.gameState.playerProgression.totalXp, 0);
|
||||
assert.deepEqual(snapshot.gameState.roster, []);
|
||||
assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []);
|
||||
assert.equal(
|
||||
@@ -200,7 +203,16 @@ test('runtime snapshot hydration backfills starter loadout when legacy saves omi
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.gameState.playerMaxHp, 208);
|
||||
assert.equal(snapshot.gameState.playerMaxMana, 1009);
|
||||
assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon');
|
||||
assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor');
|
||||
assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic');
|
||||
assert.equal(
|
||||
snapshot.gameState.playerEquipment.weapon?.id,
|
||||
'starter:hero:weapon',
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.gameState.playerEquipment.armor?.id,
|
||||
'starter:hero:armor',
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.gameState.playerEquipment.relic?.id,
|
||||
'starter:hero:relic',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { jsonClone } from '../../http.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js';
|
||||
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
|
||||
import {
|
||||
createEmptyEquipmentLoadout,
|
||||
@@ -61,9 +62,7 @@ function clampNonNegativeInteger(value: unknown) {
|
||||
}
|
||||
|
||||
function normalizeBottomTab(value: unknown) {
|
||||
return value === 'character' || value === 'inventory'
|
||||
? value
|
||||
: 'adventure';
|
||||
return value === 'character' || value === 'inventory' ? value : 'adventure';
|
||||
}
|
||||
|
||||
function buildSaveMigrationManifest() {
|
||||
@@ -135,9 +134,7 @@ function normalizeRuntimeStats(
|
||||
? Math.max(0, rawStats.playTimeMs)
|
||||
: 0,
|
||||
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
|
||||
hostileNpcsDefeated: clampNonNegativeInteger(
|
||||
rawStats.hostileNpcsDefeated,
|
||||
),
|
||||
hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated),
|
||||
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
|
||||
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
|
||||
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
|
||||
@@ -146,28 +143,30 @@ function normalizeRuntimeStats(
|
||||
|
||||
function normalizeCharacterChats(value: unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
|
||||
const rawRecord = isRecord(record) ? record : {};
|
||||
Object.entries(isRecord(value) ? value : {}).map(
|
||||
([characterId, record]) => {
|
||||
const rawRecord = isRecord(record) ? record : {};
|
||||
|
||||
return [
|
||||
characterId,
|
||||
{
|
||||
history: readArray(rawRecord.history)
|
||||
.filter(
|
||||
(turn) =>
|
||||
isRecord(turn) &&
|
||||
typeof turn.text === 'string' &&
|
||||
(turn.speaker === 'player' || turn.speaker === 'character'),
|
||||
)
|
||||
.map((turn) => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
})),
|
||||
summary: readString(rawRecord.summary),
|
||||
updatedAt: readString(rawRecord.updatedAt) || null,
|
||||
},
|
||||
];
|
||||
}),
|
||||
return [
|
||||
characterId,
|
||||
{
|
||||
history: readArray(rawRecord.history)
|
||||
.filter(
|
||||
(turn) =>
|
||||
isRecord(turn) &&
|
||||
typeof turn.text === 'string' &&
|
||||
(turn.speaker === 'player' || turn.speaker === 'character'),
|
||||
)
|
||||
.map((turn) => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
})),
|
||||
summary: readString(rawRecord.summary),
|
||||
updatedAt: readString(rawRecord.updatedAt) || null,
|
||||
},
|
||||
];
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,14 +193,18 @@ function dedupeCompanions(value: unknown) {
|
||||
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeCompanionState(entry))
|
||||
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
|
||||
if (!entry || seenNpcIds.has(entry.npcId)) {
|
||||
return false;
|
||||
}
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
|
||||
if (!entry || seenNpcIds.has(entry.npcId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenNpcIds.add(entry.npcId);
|
||||
return true;
|
||||
});
|
||||
seenNpcIds.add(entry.npcId);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRoster(
|
||||
@@ -258,9 +261,8 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
|
||||
)
|
||||
? (
|
||||
(
|
||||
(
|
||||
customWorldProfile.ownedSettingLayers as JsonRecord
|
||||
).ruleProfile as JsonRecord
|
||||
(customWorldProfile.ownedSettingLayers as JsonRecord)
|
||||
.ruleProfile as JsonRecord
|
||||
).economyProfile as JsonRecord
|
||||
).initialCurrency
|
||||
: undefined,
|
||||
@@ -270,7 +272,9 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
|
||||
return Math.max(0, Math.round(customWorldInitialCurrency));
|
||||
}
|
||||
|
||||
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
|
||||
return readString(gameState.worldType).toUpperCase() === 'XIANXIA'
|
||||
? 140
|
||||
: 160;
|
||||
}
|
||||
|
||||
function normalizeEquipmentLoadout(value: unknown) {
|
||||
@@ -319,7 +323,9 @@ function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
|
||||
function getLegacyCharacterEquipment(
|
||||
character: JsonRecord,
|
||||
): LegacyCharacterEquipmentItem[] {
|
||||
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
|
||||
'sword-princess': [
|
||||
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
|
||||
@@ -495,7 +501,9 @@ function normalizeGameState(gameState: unknown) {
|
||||
);
|
||||
const resolvedEquipment =
|
||||
normalizeEquipmentLoadout(rawState.playerEquipment) ??
|
||||
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
|
||||
(playerCharacter
|
||||
? buildLegacyStarterEquipmentLoadout(playerCharacter)
|
||||
: null);
|
||||
const baseResourceProfile = playerCharacter
|
||||
? buildCharacterResourceProfile(playerCharacter)
|
||||
: null;
|
||||
@@ -512,14 +520,18 @@ function normalizeGameState(gameState: unknown) {
|
||||
const normalizedCommonState = {
|
||||
...rawStateWithoutEquipment,
|
||||
customWorldProfile:
|
||||
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
|
||||
? rawState.customWorldProfile ?? null
|
||||
isRecord(rawState.customWorldProfile) ||
|
||||
rawState.customWorldProfile === null
|
||||
? (rawState.customWorldProfile ?? null)
|
||||
: null,
|
||||
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
rawState.playerCharacter && rawState.currentScene === 'Story',
|
||||
),
|
||||
}),
|
||||
playerProgression: normalizePlayerProgressionState(
|
||||
rawState.playerProgression,
|
||||
),
|
||||
storyEngineMemory,
|
||||
chapterState:
|
||||
rawState.chapterState ??
|
||||
@@ -530,7 +542,7 @@ function normalizeGameState(gameState: unknown) {
|
||||
rawState.campaignState ??
|
||||
(isRecord(storyEngineMemory.campaignState)
|
||||
? storyEngineMemory.campaignState
|
||||
: storyEngineMemory.campaignState ?? null),
|
||||
: (storyEngineMemory.campaignState ?? null)),
|
||||
activeScenarioPackId:
|
||||
readString(rawState.activeScenarioPackId) ||
|
||||
readString(
|
||||
@@ -623,7 +635,9 @@ function normalizeGameState(gameState: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
|
||||
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(
|
||||
snapshot: T,
|
||||
) {
|
||||
return {
|
||||
...snapshot,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
|
||||
@@ -165,6 +165,7 @@ const COMBAT_FUNCTION_IDS = new Set<string>([
|
||||
'battle_guard_break',
|
||||
'battle_probe_pressure',
|
||||
'battle_recover_breath',
|
||||
'inventory_use',
|
||||
]);
|
||||
|
||||
const NPC_FUNCTION_IDS = new Set<string>([
|
||||
|
||||
@@ -985,6 +985,193 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve inventory_use as a single ongoing combat turn', async () => {
|
||||
await withTestServer('combat-use-item', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(baseUrl, 'story_combat_item', 'secret123');
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_bandit_01',
|
||||
npcName: '断桥匪首',
|
||||
npcDescription: '手提短刀的拦路匪徒',
|
||||
context: '桥口劫匪',
|
||||
hostile: true,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc_bandit_01',
|
||||
name: '断桥匪首',
|
||||
hp: 80,
|
||||
maxHp: 80,
|
||||
description: '桥口劫匪',
|
||||
},
|
||||
],
|
||||
inBattle: true,
|
||||
playerHp: 20,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 4,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {
|
||||
slash: 2,
|
||||
},
|
||||
activeBuildBuffs: [],
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'focus-tonic',
|
||||
category: '消耗品',
|
||||
name: '凝神灵液',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['mana', 'healing'],
|
||||
useProfile: {
|
||||
hpRestore: 12,
|
||||
manaRestore: 6,
|
||||
cooldownReduction: 1,
|
||||
buildBuffs: [
|
||||
{
|
||||
id: 'focus-tonic:buff',
|
||||
sourceType: 'item',
|
||||
sourceId: 'focus-tonic',
|
||||
name: '凝神增益',
|
||||
tags: ['快剑'],
|
||||
durationTurns: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
affinity: -12,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'inventory_use',
|
||||
payload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
serverVersion: number;
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: number;
|
||||
mana: number;
|
||||
};
|
||||
status: {
|
||||
inBattle: boolean;
|
||||
};
|
||||
availableOptions: Array<{
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload?: {
|
||||
skillId?: string;
|
||||
itemId?: string;
|
||||
};
|
||||
disabled?: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
presentation: {
|
||||
resultText: string;
|
||||
storyText: string;
|
||||
battle: {
|
||||
outcome: string;
|
||||
damageTaken: number;
|
||||
} | null;
|
||||
};
|
||||
snapshot: {
|
||||
gameState: {
|
||||
playerHp: number;
|
||||
playerMana: number;
|
||||
playerSkillCooldowns: Record<string, number>;
|
||||
runtimeStats: {
|
||||
itemsUsed: number;
|
||||
};
|
||||
playerInventory: unknown[];
|
||||
activeBuildBuffs: Array<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
patches: Array<{
|
||||
type: string;
|
||||
functionId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.serverVersion, 1);
|
||||
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
|
||||
assert.equal(payload.presentation.battle?.damageTaken, 8);
|
||||
assert.equal(
|
||||
payload.presentation.storyText,
|
||||
payload.presentation.resultText,
|
||||
);
|
||||
assert.match(payload.presentation.storyText, /凝神灵液/u);
|
||||
assert.equal(payload.viewModel.status.inBattle, true);
|
||||
assert.equal(payload.viewModel.player.hp, 24);
|
||||
assert.equal(payload.viewModel.player.mana, 10);
|
||||
assert.equal(payload.snapshot.gameState.playerHp, 24);
|
||||
assert.equal(payload.snapshot.gameState.playerMana, 10);
|
||||
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 0);
|
||||
assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1);
|
||||
assert.deepEqual(payload.snapshot.gameState.playerInventory, []);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.activeBuildBuffs[0]?.id,
|
||||
'focus-tonic:buff',
|
||||
);
|
||||
assert.ok(
|
||||
payload.patches.some(
|
||||
(patch) =>
|
||||
patch.type === 'battle_resolved' &&
|
||||
patch.functionId === 'inventory_use',
|
||||
),
|
||||
);
|
||||
|
||||
const inventoryOption = payload.viewModel.availableOptions.find(
|
||||
(option) => option.functionId === 'inventory_use',
|
||||
);
|
||||
assert.ok(inventoryOption);
|
||||
assert.equal(inventoryOption.disabled, true);
|
||||
assert.match(inventoryOption.reason ?? '', /暂无可用物品/u);
|
||||
|
||||
const skillOption = payload.viewModel.availableOptions.find(
|
||||
(option) =>
|
||||
option.functionId === 'battle_use_skill' &&
|
||||
option.payload?.skillId === 'slash',
|
||||
);
|
||||
assert.ok(skillOption);
|
||||
assert.equal(skillOption.actionText, '试锋斩');
|
||||
assert.equal(skillOption.disabled, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime story actions resolve inventory_use and persist updated resources', async () => {
|
||||
await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
@@ -1418,116 +1605,121 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests
|
||||
});
|
||||
|
||||
test('runtime story actions accept pending npc quest offers from saved chat state', async () => {
|
||||
await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_q_accept_pending',
|
||||
'secret123',
|
||||
);
|
||||
const seededQuest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout_01',
|
||||
issuerNpcName: '巡路人',
|
||||
roleText: '巡路人',
|
||||
scene: QUEST_BATTLE_SCENE,
|
||||
worldType: 'WUXIA',
|
||||
currentQuests: [],
|
||||
});
|
||||
assert.ok(seededQuest);
|
||||
const pendingQuest = {
|
||||
...seededQuest,
|
||||
id: 'quest-pending-offer',
|
||||
};
|
||||
await withTestServer(
|
||||
'task6-quest-accept-pending-offer',
|
||||
async ({ baseUrl }) => {
|
||||
const entry = await authEntry(
|
||||
baseUrl,
|
||||
'story_q_accept_pending',
|
||||
'secret123',
|
||||
);
|
||||
const seededQuest = buildQuestForEncounter({
|
||||
issuerNpcId: 'npc_scout_01',
|
||||
issuerNpcName: '巡路人',
|
||||
roleText: '巡路人',
|
||||
scene: QUEST_BATTLE_SCENE,
|
||||
worldType: 'WUXIA',
|
||||
currentQuests: [],
|
||||
});
|
||||
assert.ok(seededQuest);
|
||||
const pendingQuest = {
|
||||
...seededQuest,
|
||||
id: 'quest-pending-offer',
|
||||
};
|
||||
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_scout_01',
|
||||
npcName: '巡路人',
|
||||
npcDescription: '熟悉桥口风向的探子',
|
||||
context: '巡路人',
|
||||
characterId: 'scout-quest',
|
||||
},
|
||||
currentScenePreset: QUEST_BATTLE_SCENE,
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
npc_scout_01: {
|
||||
affinity: 16,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
await putSnapshot(
|
||||
baseUrl,
|
||||
entry.token,
|
||||
createTask6GameState({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc_scout_01',
|
||||
npcName: '巡路人',
|
||||
npcDescription: '熟悉桥口风向的探子',
|
||||
context: '巡路人',
|
||||
characterId: 'scout-quest',
|
||||
},
|
||||
},
|
||||
}),
|
||||
createPendingQuestOfferCurrentStory(pendingQuest),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_quest_accept',
|
||||
currentScenePreset: QUEST_BATTLE_SCENE,
|
||||
npcInteractionActive: true,
|
||||
npcStates: {
|
||||
npc_scout_01: {
|
||||
affinity: 16,
|
||||
chattedCount: 0,
|
||||
helpUsed: false,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
|
||||
};
|
||||
currentStory: {
|
||||
displayMode?: string;
|
||||
options?: Array<{ actionText?: string }>;
|
||||
dialogue?: Array<{ speaker?: string; text?: string }>;
|
||||
npcChatState?: {
|
||||
pendingQuestOffer?: unknown;
|
||||
createPendingQuestOfferCurrentStory(pendingQuest),
|
||||
);
|
||||
|
||||
const response = await httpRequest(
|
||||
`${baseUrl}/api/runtime/story/actions/resolve`,
|
||||
withBearer(entry.token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 0,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_quest_accept',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const payload = (await response.json()) as {
|
||||
snapshot: {
|
||||
gameState: {
|
||||
quests: Array<{ id: string; issuerNpcId: string; status: string }>;
|
||||
};
|
||||
currentStory: {
|
||||
displayMode?: string;
|
||||
options?: Array<{ actionText?: string }>;
|
||||
dialogue?: Array<{ speaker?: string; text?: string }>;
|
||||
npcChatState?: {
|
||||
pendingQuestOffer?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.quests.length, 1);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.id,
|
||||
'quest-pending-offer',
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.issuerNpcId,
|
||||
'npc_scout_01',
|
||||
);
|
||||
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.snapshot.currentStory.options?.map((option) => option.actionText),
|
||||
[
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
|
||||
'这件事我愿意接下,你把关键要点交给我。',
|
||||
);
|
||||
assert.match(
|
||||
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
|
||||
/那就拜托你了。/u,
|
||||
);
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.snapshot.gameState.quests.length, 1);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.id,
|
||||
'quest-pending-offer',
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.gameState.quests[0]?.issuerNpcId,
|
||||
'npc_scout_01',
|
||||
);
|
||||
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
payload.snapshot.currentStory.options?.map(
|
||||
(option) => option.actionText,
|
||||
),
|
||||
[
|
||||
'这件事里你最担心哪一步',
|
||||
'我回来时你最想先知道什么',
|
||||
'除了这份委托,你还想提醒我什么',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
payload.snapshot.currentStory.dialogue?.at(-2)?.text,
|
||||
'这件事我愿意接下,你把关键要点交给我。',
|
||||
);
|
||||
assert.match(
|
||||
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
|
||||
/那就拜托你了。/u,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('runtime story actions progress quests from combat victories and npc turn-ins', async () => {
|
||||
@@ -1678,6 +1870,10 @@ test('runtime story actions progress quests from combat victories and npc turn-i
|
||||
gameState: {
|
||||
quests: Array<{ status: string }>;
|
||||
playerCurrency: number;
|
||||
playerProgression: {
|
||||
level: number;
|
||||
totalXp: number;
|
||||
};
|
||||
playerInventory: Array<{ name: string }>;
|
||||
npcStates: {
|
||||
npc_bandit_01: {
|
||||
@@ -1694,6 +1890,8 @@ test('runtime story actions progress quests from combat victories and npc turn-i
|
||||
'turned_in',
|
||||
);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerProgression.totalXp > 0);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerProgression.level >= 1);
|
||||
assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0);
|
||||
assert.ok(
|
||||
turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6,
|
||||
|
||||
@@ -572,13 +572,8 @@ function normalizeStatusPatch(session: RuntimeSession) {
|
||||
}
|
||||
|
||||
function shouldGenerateReasonedCombatStory(
|
||||
functionId: string,
|
||||
resolution: StoryResolution,
|
||||
) {
|
||||
if (!isCombatFunctionId(functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const outcome = resolution.battle?.outcome;
|
||||
return (
|
||||
outcome === 'victory' ||
|
||||
@@ -919,7 +914,9 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
const previousEncounter = session.currentEncounter
|
||||
? { ...session.currentEncounter }
|
||||
: null;
|
||||
if (isCombatFunctionId(functionId)) {
|
||||
const shouldResolveAsCombat =
|
||||
functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId);
|
||||
if (shouldResolveAsCombat) {
|
||||
resolution = resolveCombatAction(session, {
|
||||
functionId,
|
||||
payload: isObject(params.request.action.payload)
|
||||
@@ -1003,7 +1000,7 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
}
|
||||
} else if (
|
||||
params.llmClient &&
|
||||
shouldGenerateReasonedCombatStory(functionId, resolution)
|
||||
shouldGenerateReasonedCombatStory(resolution)
|
||||
) {
|
||||
try {
|
||||
const generatedPayload = await generateReasonedStoryPayload({
|
||||
|
||||
@@ -31,6 +31,26 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
chattedCount: 1,
|
||||
recruited: false,
|
||||
},
|
||||
questOfferContext: {
|
||||
state: {
|
||||
currentScenePreset: {
|
||||
id: 'scene-inn',
|
||||
},
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
},
|
||||
turnCount: 2,
|
||||
},
|
||||
chatDirective: {
|
||||
sceneActId: 'scene-inn-act-1',
|
||||
turnLimit: 5,
|
||||
remainingTurns: 3,
|
||||
limitReason: 'negative_affinity',
|
||||
closingMode: 'free',
|
||||
forceExitAfterTurn: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(payload.character.name, '沈行');
|
||||
@@ -40,4 +60,7 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
]);
|
||||
assert.equal(payload.questOfferContext?.turnCount, 2);
|
||||
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
|
||||
assert.equal(payload.chatDirective?.remainingTurns, 3);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,21 @@ const baseNpcChatSchema = z.object({
|
||||
context: jsonObjectSchema,
|
||||
});
|
||||
|
||||
const npcChatDirectiveSchema = z.object({
|
||||
sceneActId: z.string().trim().min(1).nullable().optional(),
|
||||
turnLimit: z.number().int().nonnegative().nullable().optional(),
|
||||
remainingTurns: z.number().int().nonnegative().nullable().optional(),
|
||||
limitReason: z.enum(['negative_affinity']).nullable().optional(),
|
||||
closingMode: z.enum(['free', 'foreshadow_close']).nullable().optional(),
|
||||
forceExitAfterTurn: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const npcChatQuestOfferContextSchema = z.object({
|
||||
state: jsonObjectSchema,
|
||||
encounter: jsonObjectSchema,
|
||||
turnCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
|
||||
conversationSummary: z.string().optional().default(''),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
@@ -59,6 +74,8 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
dialogue: z.array(jsonObjectSchema).optional(),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(),
|
||||
chatDirective: npcChatDirectiveSchema.nullable().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.character && !value.player) {
|
||||
|
||||
@@ -45,6 +45,7 @@ function resolveCardTitle(
|
||||
draftProfile.landmarks.find((entry) => entry.id === cardId)?.name ||
|
||||
draftProfile.threads.find((entry) => entry.id === cardId)?.title ||
|
||||
draftProfile.chapters.find((entry) => entry.id === cardId)?.title ||
|
||||
draftProfile.sceneChapters.find((entry) => entry.id === cardId)?.title ||
|
||||
(draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') ||
|
||||
'当前卡片'
|
||||
);
|
||||
|
||||
211
server-node/src/services/customWorldAgentDraftCompiler.test.ts
Normal file
211
server-node/src/services/customWorldAgentDraftCompiler.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { updateDraftCardSections } from './customWorldAgentDraftEditService.js';
|
||||
import {
|
||||
CustomWorldAgentDraftCompiler,
|
||||
normalizeFoundationDraftProfile,
|
||||
} from './customWorldAgentDraftCompiler.js';
|
||||
|
||||
function createSceneChapterDraftProfile() {
|
||||
return {
|
||||
name: '雾港列岛',
|
||||
summary: '潮雾、旧航道和失序港口缠在一起的海岛世界。',
|
||||
tone: '冷峻、克制、带着海盐和旧铁锈味道。',
|
||||
playerGoal: '先在失序的港口里站稳,再找出谁在提前布网。',
|
||||
coreConflicts: ['旧航道解释权正在被重新争夺'],
|
||||
iconicElements: ['潮雾钟声', '盐火灯塔'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'npc-lin',
|
||||
name: '林潮',
|
||||
title: '守潮人',
|
||||
role: '码头引路人',
|
||||
publicIdentity: '码头上最懂回潮时间的人。',
|
||||
publicMask: '码头上最懂回潮时间的人。',
|
||||
currentPressure: '必须决定今晚要不要帮玩家进港。',
|
||||
hiddenHook: '他知道第一批被转移的货不是普通货。',
|
||||
relationToPlayer: '对玩家保持试探,但还愿意给一次机会。',
|
||||
threadIds: ['thread-smuggling'],
|
||||
summary: '他像向导,也像仍在权衡站位的守门人。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc-yan',
|
||||
name: '晏九',
|
||||
title: '黑市中间人',
|
||||
role: '封锁码头的人',
|
||||
publicIdentity: '他负责把不该上岸的东西挡在潮线外。',
|
||||
publicMask: '他负责把不该上岸的东西挡在潮线外。',
|
||||
currentPressure: '必须让今晚的码头保持沉默。',
|
||||
hiddenHook: '他已经替更大的势力提前清过一次场。',
|
||||
relationToPlayer: '对玩家带着明显敌意,但又不想立刻翻脸。',
|
||||
threadIds: ['thread-smuggling'],
|
||||
summary: '他像威胁,也像握着下一跳线索的人。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-docks',
|
||||
name: '潮汐码头',
|
||||
description: '涨潮时会吞没半条旧栈桥的码头。',
|
||||
purpose: '承接玩家和封锁者的第一次正式碰撞。',
|
||||
mood: '潮声压低,空气里有明显不欢迎的意味。',
|
||||
importance: '这里是玩家第一章必须破开的门槛。',
|
||||
secret: '今晚靠岸的货和旧航道失踪案有关。',
|
||||
dangerLevel: '中高',
|
||||
imageSrc: '/images/scene/docks-base.webp',
|
||||
characterIds: ['npc-lin', 'npc-yan'],
|
||||
threadIds: ['thread-smuggling'],
|
||||
summary: '这里不是背景,而是第一章真正开始收紧的地方。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-smuggling',
|
||||
title: '失踪货船去哪了',
|
||||
type: 'main',
|
||||
conflictType: '明线',
|
||||
conflict: '有人在重写旧航道的夜间进出规则。',
|
||||
stakes: '如果玩家跟不上这条线,整个港口都会先把他排除在外。',
|
||||
characterIds: ['npc-lin', 'npc-yan'],
|
||||
landmarkIds: ['landmark-docks'],
|
||||
summary: '旧航道的解释权正在被重新洗牌。',
|
||||
},
|
||||
],
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-docks',
|
||||
title: '码头开场',
|
||||
openingEvent: '一艘不该靠岸的船提前抵达潮线外。',
|
||||
playerGoal: '先确认谁在码头上拥有发言权。',
|
||||
characterIds: ['npc-lin', 'npc-yan'],
|
||||
landmarkIds: ['landmark-docks'],
|
||||
understandingShift: '玩家会意识到这不是简单的港口封锁。',
|
||||
summary: '码头上的第一次碰撞会直接决定后续节奏。',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-docks',
|
||||
sceneId: 'landmark-docks',
|
||||
sceneName: '潮汐码头',
|
||||
title: '潮汐码头章节',
|
||||
summary: '玩家会在这里完成试探、逼问和第一次局部收束。',
|
||||
linkedThreadIds: ['thread-smuggling'],
|
||||
linkedLandmarkIds: ['landmark-docks'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-docks-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '玩家刚抵达时,林潮先决定要不要放行。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/images/scene/docks-act-1.webp',
|
||||
encounterNpcIds: ['npc-lin', 'npc-yan'],
|
||||
primaryNpcId: 'npc-lin',
|
||||
linkedThreadIds: ['thread-smuggling'],
|
||||
actGoal: '先让玩家拿到码头里的第一句真话。',
|
||||
transitionHook: '确认站位后,真正的封锁者会压上来。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: 'act-docks-2',
|
||||
title: '封锁加压',
|
||||
summary: '晏九开始把玩家往更危险的方向逼。',
|
||||
stageCoverage: ['turning_point', 'climax', 'aftermath'],
|
||||
backgroundImageSrc: '/images/scene/docks-act-2.webp',
|
||||
encounterNpcIds: ['npc-yan', 'npc-lin'],
|
||||
primaryNpcId: 'npc-yan',
|
||||
linkedThreadIds: ['thread-smuggling'],
|
||||
actGoal: '把矛盾推向必须接住的下一跳。',
|
||||
transitionHook: '第 2 幕收束时必须把下一步追踪方向抛出来。',
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('draft compiler compiles scene chapter cards with act-level editable sections', () => {
|
||||
const draftProfile = createSceneChapterDraftProfile();
|
||||
const compiler = new CustomWorldAgentDraftCompiler();
|
||||
|
||||
const draftCards = compiler.compileDraftCards(draftProfile);
|
||||
const sceneChapterCard = draftCards.find((entry) => entry.kind === 'scene_chapter');
|
||||
const detail = compiler.getDraftCardDetail(draftProfile, 'scene-chapter-docks');
|
||||
|
||||
assert.ok(sceneChapterCard);
|
||||
assert.equal(sceneChapterCard?.title, '潮汐码头章节');
|
||||
assert.match(sceneChapterCard?.subtitle ?? '', /2 幕/u);
|
||||
assert.ok(detail);
|
||||
assert.equal(detail?.kind, 'scene_chapter');
|
||||
assert.ok(detail?.editableSectionIds.includes('title'));
|
||||
assert.ok(detail?.editableSectionIds.includes('act:act-docks-1:title'));
|
||||
assert.ok(
|
||||
detail?.sections.some(
|
||||
(section) =>
|
||||
section.id === 'act:act-docks-1:backgroundImageSrc' &&
|
||||
section.value === '/images/scene/docks-act-1.webp',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
detail?.sections.some(
|
||||
(section) =>
|
||||
section.id === 'act:act-docks-2:primaryNpcId' &&
|
||||
section.value.includes('晏九'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('updateDraftCardSections rewrites scene chapter act NPC order and primary npc', () => {
|
||||
const updatedDraftProfile = updateDraftCardSections({
|
||||
draftProfile: JSON.parse(JSON.stringify(createSceneChapterDraftProfile())),
|
||||
cardId: 'scene-chapter-docks',
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'title',
|
||||
value: '潮汐码头对峙章',
|
||||
},
|
||||
{
|
||||
sectionId: 'act:act-docks-1:title',
|
||||
value: '封港前夜',
|
||||
},
|
||||
{
|
||||
sectionId: 'act:act-docks-1:backgroundImageSrc',
|
||||
value: '/images/scene/docks-act-1-night.webp',
|
||||
},
|
||||
{
|
||||
sectionId: 'act:act-docks-1:encounterNpcIds',
|
||||
value: '晏九\n林潮',
|
||||
},
|
||||
{
|
||||
sectionId: 'act:act-docks-1:transitionHook',
|
||||
value: '第 1 幕最后要把玩家逼到必须继续追的方向上。',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const normalized = normalizeFoundationDraftProfile(updatedDraftProfile);
|
||||
const updatedSceneChapter = normalized?.sceneChapters.find(
|
||||
(entry) => entry.id === 'scene-chapter-docks',
|
||||
);
|
||||
const updatedAct = updatedSceneChapter?.acts.find((entry) => entry.id === 'act-docks-1');
|
||||
|
||||
assert.ok(updatedSceneChapter);
|
||||
assert.ok(updatedAct);
|
||||
assert.equal(updatedSceneChapter?.title, '潮汐码头对峙章');
|
||||
assert.equal(updatedAct?.title, '封港前夜');
|
||||
assert.equal(
|
||||
updatedAct?.backgroundImageSrc,
|
||||
'/images/scene/docks-act-1-night.webp',
|
||||
);
|
||||
assert.deepEqual(updatedAct?.encounterNpcIds, ['npc-yan', 'npc-lin']);
|
||||
assert.equal(updatedAct?.primaryNpcId, 'npc-yan');
|
||||
assert.equal(
|
||||
updatedAct?.transitionHook,
|
||||
'第 1 幕最后要把玩家逼到必须继续追的方向上。',
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
CustomWorldFoundationDraftFaction,
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneAct,
|
||||
CustomWorldFoundationDraftSceneChapter,
|
||||
CustomWorldFoundationDraftThread,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
@@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [
|
||||
'dangerLevel',
|
||||
] as const;
|
||||
|
||||
const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [
|
||||
'title',
|
||||
'summary',
|
||||
] as const;
|
||||
|
||||
const SCENE_ACT_STAGE_ORDER = [
|
||||
'opening',
|
||||
'expansion',
|
||||
'turning_point',
|
||||
'climax',
|
||||
'aftermath',
|
||||
] as const;
|
||||
|
||||
const SCENE_ACT_STAGE_LABELS: Record<
|
||||
CustomWorldFoundationDraftSceneAct['stageCoverage'][number],
|
||||
string
|
||||
> = {
|
||||
opening: '开场',
|
||||
expansion: '铺展',
|
||||
turning_point: '转折',
|
||||
climax: '高潮',
|
||||
aftermath: '余波',
|
||||
};
|
||||
|
||||
const SCENE_ACT_ADVANCE_RULE_LABELS: Record<
|
||||
CustomWorldFoundationDraftSceneAct['advanceRule'],
|
||||
string
|
||||
> = {
|
||||
after_primary_contact: '主角色首次有效接触后推进',
|
||||
after_active_step_complete: '当前主动步骤完成后推进',
|
||||
after_chapter_resolution: '章节进入收束后推进',
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -101,6 +136,28 @@ function toStringArray(value: unknown, maxCount = 8) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCharacterSkills(value: unknown, fallbackName: string) {
|
||||
const skills = toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || `skill-${index + 1}`,
|
||||
name: toText(item.name) || `技能${index + 1}`,
|
||||
actionPreviewConfig: toRecord(item.actionPreviewConfig),
|
||||
}))
|
||||
.filter((item) => Boolean(item.id));
|
||||
|
||||
if (skills.length > 0) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: `${clampText(fallbackName, 10) || '角色'}招牌动作`,
|
||||
actionPreviewConfig: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
@@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) {
|
||||
if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS];
|
||||
if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS];
|
||||
if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS];
|
||||
if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS];
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveSceneChapterEditableSectionIds(
|
||||
sceneChapter: CustomWorldFoundationDraftSceneChapter,
|
||||
) {
|
||||
return [
|
||||
...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS,
|
||||
...sceneChapter.acts.flatMap((act) => [
|
||||
`act:${act.id}:title`,
|
||||
`act:${act.id}:summary`,
|
||||
`act:${act.id}:backgroundImageSrc`,
|
||||
`act:${act.id}:encounterNpcIds`,
|
||||
`act:${act.id}:actGoal`,
|
||||
`act:${act.id}:transitionHook`,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveSceneActStageCoverageLabel(
|
||||
stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'],
|
||||
) {
|
||||
return stageCoverage
|
||||
.map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage)
|
||||
.join('、');
|
||||
}
|
||||
|
||||
function resolveSceneActAdvanceRuleLabel(
|
||||
advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'],
|
||||
) {
|
||||
return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule;
|
||||
}
|
||||
|
||||
function normalizeFaction(
|
||||
value: unknown,
|
||||
index: number,
|
||||
@@ -243,6 +331,7 @@ function normalizeCharacter(
|
||||
].join(';'),
|
||||
120,
|
||||
),
|
||||
skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'),
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
generatedVisualAssetId: toText(record.generatedVisualAssetId) || null,
|
||||
generatedAnimationSetId: toText(record.generatedAnimationSetId) || null,
|
||||
@@ -287,6 +376,7 @@ function normalizeLandmark(
|
||||
importance: secret || '玩家第一次抵达就会意识到它不只是背景',
|
||||
secret: secret || '玩家第一次抵达就会意识到它不只是背景',
|
||||
dangerLevel: dangerLevel || '中',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
characterIds: toStringArray(record.characterIds, 8),
|
||||
threadIds: toStringArray(record.threadIds, 8),
|
||||
summary:
|
||||
@@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
|
||||
description: description || '玩家暂时还能整顿情报和喘口气的地方',
|
||||
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
|
||||
imageSrc: toText(record.imageSrc) || null,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
@@ -422,6 +513,342 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] =>
|
||||
SCENE_ACT_STAGE_ORDER.includes(
|
||||
entry as (typeof SCENE_ACT_STAGE_ORDER)[number],
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
|
||||
if (actCount <= 2) {
|
||||
return index === 0
|
||||
? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'])
|
||||
: (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']);
|
||||
}
|
||||
|
||||
if (actCount === 3) {
|
||||
if (index === 0) {
|
||||
return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
if (index === 1) {
|
||||
return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
|
||||
if (actCount === 4) {
|
||||
if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage'];
|
||||
}
|
||||
|
||||
return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]];
|
||||
}
|
||||
|
||||
function normalizeSceneAct(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallback: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
linkedThreadIds: string[];
|
||||
actCount: number;
|
||||
},
|
||||
): CustomWorldFoundationDraftSceneAct | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = toText(record.title);
|
||||
const summary = toText(record.summary);
|
||||
const encounterNpcIds = toStringArray(
|
||||
record.encounterNpcIds,
|
||||
Math.max(1, fallback.encounterNpcIds.length || 8),
|
||||
);
|
||||
const stageCoverage = normalizeStageCoverage(record.stageCoverage);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedEncounterNpcIds =
|
||||
encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds;
|
||||
const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || '';
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(record.id) ||
|
||||
createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index),
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
[
|
||||
title || `第 ${index + 1} 幕`,
|
||||
toText(record.actGoal) || '这一幕仍需继续精修',
|
||||
].join(';'),
|
||||
120,
|
||||
),
|
||||
stageCoverage:
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: buildFallbackSceneActStageCoverage(index, fallback.actCount),
|
||||
backgroundImageSrc:
|
||||
toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null,
|
||||
backgroundAssetId: toText(record.backgroundAssetId) || null,
|
||||
encounterNpcIds: resolvedEncounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds:
|
||||
toStringArray(record.linkedThreadIds, 8).length > 0
|
||||
? toStringArray(record.linkedThreadIds, 8)
|
||||
: fallback.linkedThreadIds,
|
||||
actGoal:
|
||||
toText(record.actGoal) ||
|
||||
(index === 0
|
||||
? `先在${fallback.sceneName}接住开场 lead`
|
||||
: index === fallback.actCount - 1
|
||||
? `把${fallback.sceneName}这一章收住`
|
||||
: `继续逼近${fallback.sceneName}的核心压力`),
|
||||
transitionHook:
|
||||
toText(record.transitionHook) ||
|
||||
(index === fallback.actCount - 1
|
||||
? '这一幕结束后会把问题推向下一跳。'
|
||||
: '完成当前推进后,局势会进入下一幕。'),
|
||||
advanceRule:
|
||||
toText(record.advanceRule) === 'after_primary_contact' ||
|
||||
toText(record.advanceRule) === 'after_active_step_complete' ||
|
||||
toText(record.advanceRule) === 'after_chapter_resolution'
|
||||
? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule'])
|
||||
: index === 0
|
||||
? 'after_primary_contact'
|
||||
: index === fallback.actCount - 1
|
||||
? 'after_chapter_resolution'
|
||||
: 'after_active_step_complete',
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSceneActs(params: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
sceneSummary: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
linkedThreadIds: string[];
|
||||
}) {
|
||||
const actCount = 3;
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${params.sceneId}-act-1`,
|
||||
title: `初见 ${params.sceneName}`,
|
||||
summary: clampText(
|
||||
`玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(0, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `先在${params.sceneName}接住开场 lead`,
|
||||
transitionHook: '和主角色完成首次有效接触后,局势会继续加压。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: `${params.sceneId}-act-2`,
|
||||
title: `${params.sceneName}承压`,
|
||||
summary: clampText(
|
||||
`玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(1, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `继续逼近${params.sceneName}的核心压力`,
|
||||
transitionHook: '完成当前主动 step 后,这一章会转向收束。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
{
|
||||
id: `${params.sceneId}-act-3`,
|
||||
title: `${params.sceneName}收束`,
|
||||
summary: clampText(
|
||||
`这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`,
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(2, actCount),
|
||||
backgroundImageSrc: params.backgroundImageSrc || null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: params.encounterNpcIds,
|
||||
primaryNpcId: params.encounterNpcIds[0] || '',
|
||||
linkedThreadIds: params.linkedThreadIds,
|
||||
actGoal: `把${params.sceneName}这一章收住`,
|
||||
transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。',
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
},
|
||||
] satisfies CustomWorldFoundationDraftSceneAct[];
|
||||
}
|
||||
|
||||
function normalizeSceneChapter(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallback: {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
sceneSummary: string;
|
||||
linkedThreadIds: string[];
|
||||
linkedLandmarkIds: string[];
|
||||
backgroundImageSrc?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
},
|
||||
): CustomWorldFoundationDraftSceneChapter | null {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sceneId = toText(record.sceneId) || fallback.sceneId;
|
||||
const sceneName = toText(record.sceneName) || fallback.sceneName;
|
||||
const title = toText(record.title);
|
||||
const summary = toText(record.summary);
|
||||
const actsInput = Array.isArray(record.acts) ? record.acts : [];
|
||||
const actCount = Math.min(5, Math.max(2, actsInput.length || 3));
|
||||
const linkedThreadIds =
|
||||
toStringArray(record.linkedThreadIds, 8).length > 0
|
||||
? toStringArray(record.linkedThreadIds, 8)
|
||||
: fallback.linkedThreadIds;
|
||||
const linkedLandmarkIds =
|
||||
toStringArray(record.linkedLandmarkIds, 8).length > 0
|
||||
? toStringArray(record.linkedLandmarkIds, 8)
|
||||
: fallback.linkedLandmarkIds;
|
||||
|
||||
const acts = actsInput
|
||||
.map((entry, actIndex) =>
|
||||
normalizeSceneAct(entry, actIndex, {
|
||||
sceneId,
|
||||
sceneName,
|
||||
backgroundImageSrc: fallback.backgroundImageSrc,
|
||||
encounterNpcIds: fallback.encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
actCount,
|
||||
}),
|
||||
)
|
||||
.filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
id: toText(record.id) || createId('scene-chapter', sceneName || title, index),
|
||||
sceneId,
|
||||
sceneName,
|
||||
title: title || `${sceneName}章节`,
|
||||
summary:
|
||||
summary ||
|
||||
clampText(
|
||||
[
|
||||
sceneName,
|
||||
fallback.sceneSummary || '这一章的场景节拍仍可继续收紧',
|
||||
].join(':'),
|
||||
140,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds,
|
||||
acts: acts.length >= 2 ? acts : buildFallbackSceneActs({
|
||||
sceneId,
|
||||
sceneName,
|
||||
sceneSummary: fallback.sceneSummary,
|
||||
backgroundImageSrc: fallback.backgroundImageSrc,
|
||||
encounterNpcIds: fallback.encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSceneChapters(params: {
|
||||
landmarks: CustomWorldFoundationDraftLandmark[];
|
||||
characters: CustomWorldFoundationDraftCharacter[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
chapters: CustomWorldFoundationDraftChapter[];
|
||||
}) {
|
||||
const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id);
|
||||
|
||||
return params.landmarks.map((landmark, index) => {
|
||||
const matchingChapter =
|
||||
params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null;
|
||||
const encounterNpcIds =
|
||||
landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds;
|
||||
const linkedThreadIds =
|
||||
landmark.threadIds.length > 0
|
||||
? landmark.threadIds
|
||||
: params.threads
|
||||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||||
.map((thread) => thread.id)
|
||||
.slice(0, 4);
|
||||
|
||||
return {
|
||||
id: `scene-chapter-${landmark.id}`,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
title: matchingChapter?.title || `${landmark.name}章节`,
|
||||
summary:
|
||||
matchingChapter?.summary ||
|
||||
clampText(
|
||||
[landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
140,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds: [landmark.id],
|
||||
acts: buildFallbackSceneActs({
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
sceneSummary: landmark.summary,
|
||||
backgroundImageSrc: landmark.imageSrc || null,
|
||||
encounterNpcIds,
|
||||
linkedThreadIds,
|
||||
}),
|
||||
} satisfies CustomWorldFoundationDraftSceneChapter;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) {
|
||||
const record = toRecord(item);
|
||||
const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8);
|
||||
return {
|
||||
sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`,
|
||||
sceneName:
|
||||
toText(record?.sceneName) ||
|
||||
toText(record?.title) ||
|
||||
`场景章节 ${index + 1}`,
|
||||
sceneSummary:
|
||||
toText(record?.summary) ||
|
||||
'这一章仍可继续精修场景幕结构。',
|
||||
linkedThreadIds: toStringArray(record?.linkedThreadIds, 8),
|
||||
linkedLandmarkIds,
|
||||
backgroundImageSrc: toText(record?.backgroundImageSrc) || null,
|
||||
encounterNpcIds: toStringArray(record?.encounterNpcIds, 8),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFoundationDraftProfile(
|
||||
value: unknown,
|
||||
): CustomWorldFoundationDraftProfile | null {
|
||||
@@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile(
|
||||
Boolean(item),
|
||||
),
|
||||
);
|
||||
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
|
||||
const explicitSceneChapters = toRecordArray(record.sceneChapters)
|
||||
.map((item, index) =>
|
||||
normalizeSceneChapter(
|
||||
item,
|
||||
index,
|
||||
resolveSceneChapterFallbackFromRecord(item, index),
|
||||
),
|
||||
)
|
||||
.filter((item): item is CustomWorldFoundationDraftSceneChapter =>
|
||||
Boolean(item),
|
||||
);
|
||||
const sceneChapters = dedupeById(
|
||||
explicitSceneChapters.length > 0
|
||||
? explicitSceneChapters
|
||||
: buildFallbackSceneChapters({
|
||||
landmarks,
|
||||
characters: mergedCharacters,
|
||||
threads,
|
||||
chapters,
|
||||
})
|
||||
);
|
||||
const camp = normalizeCamp(record.camp);
|
||||
const hasStructuredFoundationContent =
|
||||
playableNpcs.length > 0 ||
|
||||
@@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile(
|
||||
factions.length > 0 ||
|
||||
threads.length > 0 ||
|
||||
chapters.length > 0 ||
|
||||
sceneChapters.length > 0 ||
|
||||
Boolean(camp);
|
||||
|
||||
if (!hasStructuredFoundationContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
|
||||
const coreConflicts = toStringArray(record.coreConflicts, 6);
|
||||
|
||||
return {
|
||||
@@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile(
|
||||
factions,
|
||||
threads,
|
||||
chapters,
|
||||
sceneChapters,
|
||||
worldHook: toText(record.worldHook) || name || summary,
|
||||
playerPremise: toText(record.playerPremise),
|
||||
openingSituation: toText(record.openingSituation),
|
||||
@@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildSceneChapterWarnings(params: {
|
||||
sceneChapter: CustomWorldFoundationDraftSceneChapter;
|
||||
characterById: Map<string, CustomWorldFoundationDraftCharacter>;
|
||||
threadById: Map<string, CustomWorldFoundationDraftThread>;
|
||||
landmarkById: Map<string, CustomWorldFoundationDraftLandmark>;
|
||||
}) {
|
||||
const { sceneChapter, characterById, threadById, landmarkById } = params;
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (sceneChapter.acts.length < 2) {
|
||||
warnings.push('这个场景章节至少需要 2 幕。');
|
||||
}
|
||||
if (sceneChapter.acts.length > 5) {
|
||||
warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。');
|
||||
}
|
||||
|
||||
const linkedLandmarks = sceneChapter.linkedLandmarkIds
|
||||
.map((id) => landmarkById.get(id))
|
||||
.filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry));
|
||||
|
||||
sceneChapter.acts.forEach((act, index) => {
|
||||
const actLabel = `第 ${index + 1} 幕`;
|
||||
const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId;
|
||||
const actThreadIds =
|
||||
act.linkedThreadIds.length > 0
|
||||
? act.linkedThreadIds
|
||||
: sceneChapter.linkedThreadIds;
|
||||
|
||||
if (!act.backgroundImageSrc && !act.backgroundAssetId) {
|
||||
warnings.push(`${actLabel}还没有绑定背景图。`);
|
||||
}
|
||||
if (act.encounterNpcIds.length === 0) {
|
||||
warnings.push(`${actLabel}还没有配置相遇 NPC。`);
|
||||
}
|
||||
if (!primaryNpcId) {
|
||||
warnings.push(`${actLabel}缺少主角色。`);
|
||||
}
|
||||
if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) {
|
||||
warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`);
|
||||
}
|
||||
if (actThreadIds.length === 0) {
|
||||
warnings.push(`${actLabel}还没有挂到明确线程。`);
|
||||
}
|
||||
|
||||
const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id));
|
||||
if (unresolvedNpcIds.length > 0) {
|
||||
warnings.push(
|
||||
`${actLabel}存在未进入当前世界角色池的 NPC:${unresolvedNpcIds
|
||||
.slice(0, 3)
|
||||
.join('、')}。`,
|
||||
);
|
||||
}
|
||||
|
||||
const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id));
|
||||
if (unresolvedThreadIds.length > 0) {
|
||||
warnings.push(
|
||||
`${actLabel}存在未绑定的线程引用:${unresolvedThreadIds
|
||||
.slice(0, 3)
|
||||
.join('、')}。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryNpcId && characterById.has(primaryNpcId)) {
|
||||
const linkedToLandmark = linkedLandmarks.some((landmark) =>
|
||||
landmark.characterIds.includes(primaryNpcId),
|
||||
);
|
||||
const linkedToThread = actThreadIds.some((threadId) =>
|
||||
threadById.get(threadId)?.characterIds.includes(primaryNpcId),
|
||||
);
|
||||
if (!linkedToLandmark && !linkedToThread) {
|
||||
warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function buildCampWarnings() {
|
||||
return [] as string[];
|
||||
}
|
||||
@@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac
|
||||
generatedVisualAssetId: character.generatedVisualAssetId,
|
||||
generatedAnimationSetId: character.generatedAnimationSetId,
|
||||
animationMap: character.animationMap,
|
||||
skills: character.skills ?? [],
|
||||
},
|
||||
roleKind: 'story',
|
||||
});
|
||||
@@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler {
|
||||
...profile.landmarks.map((entry) => entry.id),
|
||||
...profile.threads.map((entry) => entry.id),
|
||||
...profile.chapters.map((entry) => entry.id),
|
||||
...profile.sceneChapters.map((entry) => entry.id),
|
||||
].slice(0, 12),
|
||||
sections: [
|
||||
buildSection('title', '标题', profile.name),
|
||||
@@ -1025,6 +1554,129 @@ export class CustomWorldAgentDraftCompiler {
|
||||
});
|
||||
});
|
||||
|
||||
profile.sceneChapters.forEach((sceneChapter) => {
|
||||
const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))];
|
||||
const readyBackgroundCount = sceneChapter.acts.filter(
|
||||
(act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId),
|
||||
).length;
|
||||
const warnings = buildSceneChapterWarnings({
|
||||
sceneChapter,
|
||||
characterById,
|
||||
threadById,
|
||||
landmarkById,
|
||||
});
|
||||
|
||||
pushCard({
|
||||
id: sceneChapter.id,
|
||||
kind: 'scene_chapter',
|
||||
title: sceneChapter.title,
|
||||
subtitle: clampText(
|
||||
`${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`,
|
||||
40,
|
||||
),
|
||||
summary: sceneChapter.summary,
|
||||
linkedIds: [
|
||||
...sceneChapter.linkedLandmarkIds,
|
||||
...sceneChapter.linkedThreadIds,
|
||||
...uniqueNpcIds,
|
||||
].slice(0, 12),
|
||||
sections: [
|
||||
buildSection('sceneName', '所属场景', sceneChapter.sceneName),
|
||||
buildSection('title', '场景章节标题', sceneChapter.title),
|
||||
buildSection('summary', '场景章节摘要', sceneChapter.summary),
|
||||
buildSection(
|
||||
'actOverview',
|
||||
'幕结构总览',
|
||||
sceneChapter.acts
|
||||
.map((act, index) => {
|
||||
const primaryNpcName =
|
||||
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
|
||||
'待补主角色';
|
||||
const supportNpcNames =
|
||||
resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC';
|
||||
return [
|
||||
`第 ${index + 1} 幕|${act.title}`,
|
||||
`主角色:${primaryNpcName}`,
|
||||
`辅助 NPC:${supportNpcNames}`,
|
||||
`目标:${act.actGoal}`,
|
||||
`过渡:${act.transitionHook}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n'),
|
||||
),
|
||||
buildSection(
|
||||
'linkedLandmarkIds',
|
||||
'关联地点',
|
||||
resolveLandmarkNames(sceneChapter.linkedLandmarkIds),
|
||||
),
|
||||
buildSection(
|
||||
'linkedThreadIds',
|
||||
'关联线程',
|
||||
resolveThreadTitles(sceneChapter.linkedThreadIds),
|
||||
),
|
||||
...sceneChapter.acts.flatMap((act, index) => {
|
||||
const actLabel = `第 ${index + 1} 幕`;
|
||||
const encounterNpcValue =
|
||||
resolveCharacterNames(act.encounterNpcIds) ||
|
||||
act.encounterNpcIds.join('、');
|
||||
const primaryNpcValue =
|
||||
resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) ||
|
||||
act.encounterNpcIds[0] ||
|
||||
act.primaryNpcId;
|
||||
const actThreadTitles =
|
||||
resolveThreadTitles(
|
||||
act.linkedThreadIds.length > 0
|
||||
? act.linkedThreadIds
|
||||
: sceneChapter.linkedThreadIds,
|
||||
) || '待补线程挂钩';
|
||||
|
||||
return [
|
||||
buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title),
|
||||
buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary),
|
||||
buildSection(
|
||||
`act:${act.id}:backgroundImageSrc`,
|
||||
`${actLabel}背景图`,
|
||||
act.backgroundImageSrc || act.backgroundAssetId || '',
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:encounterNpcIds`,
|
||||
`${actLabel}相遇 NPC`,
|
||||
encounterNpcValue,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:primaryNpcId`,
|
||||
`${actLabel}主角色`,
|
||||
primaryNpcValue,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:stageCoverage`,
|
||||
`${actLabel}阶段覆盖`,
|
||||
resolveSceneActStageCoverageLabel(act.stageCoverage),
|
||||
),
|
||||
buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal),
|
||||
buildSection(
|
||||
`act:${act.id}:transitionHook`,
|
||||
`${actLabel}过渡钩子`,
|
||||
act.transitionHook,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:linkedThreadIds`,
|
||||
`${actLabel}关联线程`,
|
||||
actThreadTitles,
|
||||
),
|
||||
buildSection(
|
||||
`act:${act.id}:advanceRule`,
|
||||
`${actLabel}推进规则`,
|
||||
resolveSceneActAdvanceRuleLabel(act.advanceRule),
|
||||
),
|
||||
];
|
||||
}),
|
||||
],
|
||||
editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter),
|
||||
warningMessages: warnings,
|
||||
});
|
||||
});
|
||||
|
||||
return cards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const EDITABLE_SECTION_IDS = {
|
||||
thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
|
||||
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
|
||||
camp: new Set(['name', 'description', 'dangerLevel']),
|
||||
sceneChapter: new Set(['title', 'summary']),
|
||||
} as const;
|
||||
|
||||
function normalizePatches(sections: DraftSectionPatch[]) {
|
||||
@@ -52,6 +53,17 @@ function parseStringList(value: string) {
|
||||
return [...new Set(value.split(/[\n;;]+/u).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function parseReferenceList(value: string) {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[\n,,、;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveThreadType(value: string) {
|
||||
if (value.includes('暗') || value.toLowerCase() === 'hidden') {
|
||||
return 'hidden' as const;
|
||||
@@ -60,6 +72,61 @@ function resolveThreadType(value: string) {
|
||||
return 'main' as const;
|
||||
}
|
||||
|
||||
function parseSceneActSectionId(sectionId: string) {
|
||||
const match = sectionId.match(
|
||||
/^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
actId: match[1],
|
||||
field: match[2] as
|
||||
| 'title'
|
||||
| 'summary'
|
||||
| 'backgroundImageSrc'
|
||||
| 'encounterNpcIds'
|
||||
| 'actGoal'
|
||||
| 'transitionHook',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCharacterIdByReference(
|
||||
value: string,
|
||||
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
|
||||
) {
|
||||
const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs];
|
||||
return (
|
||||
characters.find((entry) => entry.id === value)?.id ||
|
||||
characters.find((entry) => entry.name === value)?.id ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function parseEncounterNpcIds(
|
||||
value: string,
|
||||
draftProfile: NonNullable<ReturnType<typeof normalizeFoundationDraftProfile>>,
|
||||
) {
|
||||
const references = parseReferenceList(value);
|
||||
if (references.length === 0) {
|
||||
throw badRequest('scene act requires at least one encounter NPC');
|
||||
}
|
||||
|
||||
const unresolvedReferences = references.filter(
|
||||
(reference) => !resolveCharacterIdByReference(reference, draftProfile),
|
||||
);
|
||||
if (unresolvedReferences.length > 0) {
|
||||
throw badRequest(
|
||||
`unknown scene act NPC reference: ${unresolvedReferences.join('、')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return references.map((reference) =>
|
||||
resolveCharacterIdByReference(reference, draftProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
if (!draftProfile) {
|
||||
@@ -293,6 +360,70 @@ export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
|
||||
return draftProfile as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sceneChapter = draftProfile.sceneChapters.find(
|
||||
(entry) => entry.id === params.cardId,
|
||||
);
|
||||
if (sceneChapter) {
|
||||
patches.forEach(({ sectionId, value }) => {
|
||||
if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) {
|
||||
if (sectionId === 'title') {
|
||||
sceneChapter.title = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionId === 'summary') {
|
||||
sceneChapter.summary = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedSceneActSection = parseSceneActSectionId(sectionId);
|
||||
if (!parsedSceneActSection) {
|
||||
throw badRequest(`section ${sectionId} is not editable for scene_chapter`);
|
||||
}
|
||||
|
||||
const targetAct = sceneChapter.acts.find(
|
||||
(entry) => entry.id === parsedSceneActSection.actId,
|
||||
);
|
||||
if (!targetAct) {
|
||||
throw notFound(`scene act ${parsedSceneActSection.actId} not found`);
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'title') {
|
||||
targetAct.title = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'summary') {
|
||||
targetAct.summary = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'backgroundImageSrc') {
|
||||
targetAct.backgroundImageSrc = value || null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'encounterNpcIds') {
|
||||
const encounterNpcIds = parseEncounterNpcIds(value, draftProfile);
|
||||
targetAct.encounterNpcIds = encounterNpcIds;
|
||||
targetAct.primaryNpcId = encounterNpcIds[0] || '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'actGoal') {
|
||||
targetAct.actGoal = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSceneActSection.field === 'transitionHook') {
|
||||
targetAct.transitionHook = value;
|
||||
}
|
||||
});
|
||||
|
||||
return draftProfile as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (draftProfile.camp?.id === params.cardId) {
|
||||
patches.forEach(({ sectionId, value }) => {
|
||||
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {
|
||||
|
||||
@@ -119,12 +119,16 @@ async function createObjectRefiningSession(
|
||||
seedText: '一个被潮雾切开的列岛世界。',
|
||||
});
|
||||
|
||||
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
|
||||
clientMessageId: 'phase5-ready-1',
|
||||
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
});
|
||||
const message1 = await orchestrator.submitMessage(
|
||||
userId,
|
||||
createdSession.sessionId,
|
||||
{
|
||||
clientMessageId: 'phase5-ready-1',
|
||||
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
@@ -132,12 +136,16 @@ async function createObjectRefiningSession(
|
||||
message1.operation.operationId,
|
||||
);
|
||||
|
||||
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
|
||||
clientMessageId: 'phase5-ready-2',
|
||||
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
});
|
||||
const message2 = await orchestrator.submitMessage(
|
||||
userId,
|
||||
createdSession.sessionId,
|
||||
{
|
||||
clientMessageId: 'phase5-ready-2',
|
||||
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
},
|
||||
);
|
||||
await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
@@ -194,7 +202,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'visual_refining');
|
||||
@@ -216,7 +227,9 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
});
|
||||
const userId = 'user-phase5-sync-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
const characterCard = session.draftCards.find((card) => card.kind === 'character');
|
||||
const characterCard = session.draftCards.find(
|
||||
(card) => card.kind === 'character',
|
||||
);
|
||||
|
||||
assert.ok(characterCard);
|
||||
|
||||
@@ -255,33 +268,48 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
session.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find(
|
||||
(entry) => entry.id === characterCard!.id,
|
||||
const snapshot = await orchestrator.getSessionSnapshot(
|
||||
userId,
|
||||
session.sessionId,
|
||||
);
|
||||
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile);
|
||||
const syncedRole = [
|
||||
...(profile?.playableNpcs ?? []),
|
||||
...(profile?.storyNpcs ?? []),
|
||||
].find((entry) => entry.id === characterCard!.id);
|
||||
const syncedCard = snapshot?.draftCards.find(
|
||||
(card) => card.id === characterCard!.id,
|
||||
);
|
||||
const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id);
|
||||
const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
|
||||
(entry) => entry.roleId === characterCard!.id,
|
||||
);
|
||||
const latestRecord = await sessionStore.get(userId, session.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png');
|
||||
assert.equal(
|
||||
syncedRole?.imageSrc,
|
||||
'/generated/characters/shenli-portrait.png',
|
||||
);
|
||||
assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1');
|
||||
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
|
||||
assert.equal(
|
||||
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle
|
||||
?.basePath,
|
||||
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)
|
||||
?.idle?.basePath,
|
||||
'/generated/characters/shenli/idle',
|
||||
);
|
||||
assert.equal(syncedAssetSummary?.status, 'complete');
|
||||
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪');
|
||||
assert.ok(syncedCard?.subtitle.includes('动作已就绪'));
|
||||
const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? [];
|
||||
assert.ok(syncedSkillIds.length > 0);
|
||||
assert.equal(syncedAssetSummary?.status, 'animations_ready');
|
||||
assert.deepEqual(
|
||||
syncedAssetSummary?.missingAnimations,
|
||||
syncedSkillIds.map((skillId) => `skill:${skillId}`),
|
||||
);
|
||||
assert.equal(syncedCard?.assetStatusLabel, '动作补齐中');
|
||||
assert.ok(syncedCard?.subtitle.includes('动作补齐中'));
|
||||
assert.ok(
|
||||
snapshot?.messages.some(
|
||||
(message) =>
|
||||
message.kind === 'action_result' && message.text.includes('动作已就绪'),
|
||||
message.kind === 'action_result' && message.text.includes('动作补齐中'),
|
||||
),
|
||||
);
|
||||
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildRoleAssetSummary } from './customWorldAgentRoleAssetStateService.js';
|
||||
|
||||
test('role asset summary only requires run attack and configured skill actions', () => {
|
||||
const summary = buildRoleAssetSummary({
|
||||
role: {
|
||||
id: 'role-shenli',
|
||||
name: '沈砺',
|
||||
threadIds: ['thread-1'],
|
||||
imageSrc: '/generated/shenli/portrait.png',
|
||||
generatedVisualAssetId: 'visual-shenli',
|
||||
generatedAnimationSetId: 'animation-shenli',
|
||||
animationMap: {
|
||||
run: { basePath: '/generated/shenli/run' },
|
||||
attack: { basePath: '/generated/shenli/attack' },
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-tidelight',
|
||||
name: '潮灯斩',
|
||||
actionPreviewConfig: {
|
||||
basePath: '/generated/shenli/skill-tidelight',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
roleKind: 'playable',
|
||||
});
|
||||
|
||||
assert.equal(summary.status, 'complete');
|
||||
assert.deepEqual(summary.missingAnimations, []);
|
||||
});
|
||||
|
||||
test('role asset summary marks missing skill actions as required gaps', () => {
|
||||
const summary = buildRoleAssetSummary({
|
||||
role: {
|
||||
id: 'role-yunhe',
|
||||
name: '云禾',
|
||||
threadIds: [],
|
||||
imageSrc: '/generated/yunhe/portrait.png',
|
||||
generatedVisualAssetId: 'visual-yunhe',
|
||||
generatedAnimationSetId: 'animation-yunhe',
|
||||
animationMap: {
|
||||
run: { basePath: '/generated/yunhe/run' },
|
||||
attack: { basePath: '/generated/yunhe/attack' },
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-wave',
|
||||
name: '断潮步',
|
||||
actionPreviewConfig: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
roleKind: 'story',
|
||||
});
|
||||
|
||||
assert.equal(summary.status, 'animations_ready');
|
||||
assert.deepEqual(summary.missingAnimations, ['skill:skill-wave']);
|
||||
});
|
||||
|
||||
test('role asset summary treats idle and die as optional', () => {
|
||||
const summary = buildRoleAssetSummary({
|
||||
role: {
|
||||
id: 'role-lin',
|
||||
name: '林砂',
|
||||
threadIds: [],
|
||||
imageSrc: '/generated/lin/portrait.png',
|
||||
generatedVisualAssetId: 'visual-lin',
|
||||
generatedAnimationSetId: 'animation-lin',
|
||||
animationMap: {
|
||||
run: { basePath: '/generated/lin/run' },
|
||||
attack: { basePath: '/generated/lin/attack' },
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
roleKind: 'story',
|
||||
});
|
||||
|
||||
assert.equal(summary.status, 'complete');
|
||||
assert.deepEqual(summary.missingAnimations, []);
|
||||
});
|
||||
@@ -5,13 +5,13 @@ import type {
|
||||
CustomWorldRoleAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
|
||||
const CORE_ROLE_ANIMATION_KEYS = [
|
||||
'idle',
|
||||
'run',
|
||||
'attack',
|
||||
'hurt',
|
||||
'die',
|
||||
] as const;
|
||||
const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
|
||||
|
||||
type DraftRoleSkillRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
actionPreviewConfig?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type DraftRoleRecord = {
|
||||
id: string;
|
||||
@@ -21,6 +21,7 @@ type DraftRoleRecord = {
|
||||
generatedVisualAssetId?: string | null;
|
||||
generatedAnimationSetId?: string | null;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
skills: DraftRoleSkillRecord[];
|
||||
};
|
||||
|
||||
type DraftRoleKind = 'playable' | 'story';
|
||||
@@ -65,11 +66,8 @@ function toAnimationMap(value: unknown) {
|
||||
return toRecord(value);
|
||||
}
|
||||
|
||||
function hasAnimationSlot(
|
||||
animationMap: Record<string, unknown> | null | undefined,
|
||||
slot: string,
|
||||
) {
|
||||
const entry = toRecord(animationMap?.[slot]);
|
||||
function hasAnimationAsset(entryValue: unknown) {
|
||||
const entry = toRecord(entryValue);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
@@ -77,6 +75,41 @@ function hasAnimationSlot(
|
||||
return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath));
|
||||
}
|
||||
|
||||
function hasAnimationSlot(
|
||||
animationMap: Record<string, unknown> | null | undefined,
|
||||
slot: string,
|
||||
) {
|
||||
return hasAnimationAsset(animationMap?.[slot]);
|
||||
}
|
||||
|
||||
function normalizeRoleSkills(value: unknown, fallbackName = '角色') {
|
||||
const skills = toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || `skill-${index + 1}`,
|
||||
name: toText(item.name) || `技能${index + 1}`,
|
||||
actionPreviewConfig: toRecord(item.actionPreviewConfig),
|
||||
}))
|
||||
.filter((item) => Boolean(item.id));
|
||||
|
||||
if (skills.length > 0) {
|
||||
return skills;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: `${toText(fallbackName).slice(0, 10) || '角色'}招牌动作`,
|
||||
actionPreviewConfig: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function collectMissingSkillActions(role: DraftRoleRecord) {
|
||||
return role.skills
|
||||
.filter((skill) => !hasAnimationAsset(skill.actionPreviewConfig))
|
||||
.map((skill) => `skill:${skill.id}`);
|
||||
}
|
||||
|
||||
function resolvePriorityTier(
|
||||
role: DraftRoleRecord,
|
||||
roleKind: DraftRoleKind,
|
||||
@@ -127,6 +160,7 @@ function collectDraftRoles(profileInput: unknown) {
|
||||
generatedVisualAssetId: toText(item.generatedVisualAssetId) || null,
|
||||
generatedAnimationSetId: toText(item.generatedAnimationSetId) || null,
|
||||
animationMap: toAnimationMap(item.animationMap),
|
||||
skills: normalizeRoleSkills(item.skills, toText(item.role) || name),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -160,7 +194,9 @@ function collectDraftRoles(profileInput: unknown) {
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) {
|
||||
export function resolveRoleAssetStatusLabel(
|
||||
status: CustomWorldRoleAssetStatus,
|
||||
) {
|
||||
if (status === 'complete') {
|
||||
return '动作已就绪';
|
||||
}
|
||||
@@ -182,9 +218,12 @@ export function buildRoleAssetSummary(params: {
|
||||
}): CustomWorldRoleAssetSummary {
|
||||
const { role, roleKind } = params;
|
||||
const priorityTier = resolvePriorityTier(role, roleKind);
|
||||
const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter(
|
||||
(slot) => !hasAnimationSlot(role.animationMap, slot),
|
||||
);
|
||||
const missingAnimations = [
|
||||
...REQUIRED_ROLE_ANIMATION_KEYS.filter(
|
||||
(slot) => !hasAnimationSlot(role.animationMap, slot),
|
||||
),
|
||||
...collectMissingSkillActions(role),
|
||||
];
|
||||
const hasPortrait =
|
||||
Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId);
|
||||
const hasAnimationSet = Boolean(role.generatedAnimationSetId);
|
||||
@@ -210,10 +249,7 @@ export function buildRoleAssetSummary(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleAssetSummaryById(
|
||||
draftProfile: unknown,
|
||||
roleId: string,
|
||||
) {
|
||||
export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) {
|
||||
const roleEntry = collectDraftRoles(draftProfile).find(
|
||||
(entry) => entry.role.id === roleId,
|
||||
);
|
||||
@@ -281,8 +317,7 @@ export function mergeRoleAssetIntoDraftProfile(
|
||||
return touched;
|
||||
};
|
||||
|
||||
const touched =
|
||||
updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
|
||||
const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
|
||||
|
||||
if (!touched || !updatedRole) {
|
||||
throw new Error('目标角色不存在,无法同步角色资产。');
|
||||
|
||||
Reference in New Issue
Block a user