Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -115,7 +115,7 @@ function buildGameState(
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
@@ -682,7 +682,7 @@ export function getCompanionBuildDamageBreakdown(
|
||||
}
|
||||
|
||||
export function getMonsterBuildDamageBreakdown(
|
||||
monster: SceneMonster,
|
||||
monster: SceneHostileNpc,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
@@ -866,7 +866,7 @@ export function resolveCompanionOutgoingDamageResult(
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamage(
|
||||
monster: SceneMonster,
|
||||
monster: SceneHostileNpc,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
@@ -887,7 +887,7 @@ export function resolveMonsterOutgoingDamage(
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamageResult(
|
||||
monster: SceneMonster,
|
||||
monster: SceneHostileNpc,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BuildTagCategory,
|
||||
BuildTagDefinition,
|
||||
Character,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
TimedBuildBuff,
|
||||
} from '../types';
|
||||
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
|
||||
@@ -256,7 +256,7 @@ function inferMonsterTagsFromText(source: string) {
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getSceneMonsterCombatTags(monster: SceneMonster) {
|
||||
export function getSceneMonsterCombatTags(monster: SceneHostileNpc) {
|
||||
if (monster.combatTags?.length) {
|
||||
return normalizeBuildTags(monster.combatTags, 3);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
normalizeCustomWorldLandmarks,
|
||||
type CustomWorldLandmarkDraft,
|
||||
} from './customWorldSceneGraph';
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
deriveCustomWorldLockStateFromIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
} from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
@@ -29,6 +32,10 @@ import {
|
||||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
} from './affinityLevels';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
import {
|
||||
type CustomWorldLandmarkDraft,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from './customWorldSceneGraph';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
@@ -592,6 +599,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
@@ -608,6 +617,29 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
anchorPack:
|
||||
value.anchorPack && typeof value.anchorPack === 'object'
|
||||
? (value.anchorPack as CustomWorldAnchorPack)
|
||||
: buildCustomWorldAnchorPackFromIntent(
|
||||
normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
),
|
||||
lockState:
|
||||
value.lockState && isRecord(value.lockState)
|
||||
? normalizeCustomWorldLockState(value.lockState)
|
||||
: deriveCustomWorldLockStateFromIntent(
|
||||
normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
),
|
||||
generationMode:
|
||||
value.generationMode === 'fast' || value.generationMode === 'full'
|
||||
? value.generationMode
|
||||
: 'full',
|
||||
generationStatus:
|
||||
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
|
||||
? value.generationStatus
|
||||
: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -153,16 +153,11 @@ export function validateMonsterOverrides(
|
||||
export function validateSceneOverrides(
|
||||
overrideMap: Record<string, ScenePresetOverride>,
|
||||
scenes: ScenePreset[],
|
||||
monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
|
||||
_monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
|
||||
const validSceneIds = new Set(scenes.map(scene => scene.id));
|
||||
const validMonsterIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((monstersByWorld[WorldType.WUXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.XIANXIA]: new Set((monstersByWorld[WorldType.XIANXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.CUSTOM]: new Set((monstersByWorld[WorldType.CUSTOM] ?? []).map(monster => monster.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([sceneId, override]) => {
|
||||
const scene = sceneById.get(sceneId);
|
||||
@@ -181,11 +176,6 @@ export function validateSceneOverrides(
|
||||
}
|
||||
});
|
||||
|
||||
(override.monsterIds ?? []).forEach(monsterId => {
|
||||
if (!validMonsterIdsByWorld[scene.worldType].has(monsterId)) {
|
||||
pushError(errors, `${sceneId} has invalid monsterId: ${monsterId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
|
||||
@@ -9,19 +9,19 @@ function lerp(start: number, end: number, progress: number) {
|
||||
return roundMeters(start + ((end - start) * progress));
|
||||
}
|
||||
|
||||
export function hasEncounterEntity(state: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>) {
|
||||
return state.sceneMonsters.length > 0 || Boolean(state.currentEncounter);
|
||||
export function hasEncounterEntity(state: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>) {
|
||||
return state.sceneHostileNpcs.length > 0 || Boolean(state.currentEncounter);
|
||||
}
|
||||
|
||||
export function buildEncounterEntryState(
|
||||
finalState: GameState,
|
||||
entryX: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const anchorX = getMonsterGroupAnchorX(finalState.sceneMonsters);
|
||||
if (finalState.sceneHostileNpcs.length > 0) {
|
||||
const anchorX = getMonsterGroupAnchorX(finalState.sceneHostileNpcs);
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
const offset = monster.xMeters - anchorX;
|
||||
const xMeters = roundMeters(entryX + offset);
|
||||
return {
|
||||
@@ -42,7 +42,7 @@ export function buildEncounterEntryState(
|
||||
...finalState.currentEncounter,
|
||||
xMeters: entryX,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,13 +51,13 @@ export function buildEncounterEntryState(
|
||||
|
||||
export function buildEncounterTransitionState(
|
||||
finalState: GameState,
|
||||
sourceState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
sourceState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const sourceById = new Map(sourceState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
if (finalState.sceneHostileNpcs.length > 0) {
|
||||
const sourceById = new Map(sourceState.sceneHostileNpcs.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
const sourceMonster = sourceById.get(monster.id);
|
||||
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
|
||||
return {
|
||||
@@ -78,7 +78,7 @@ export function buildEncounterTransitionState(
|
||||
...finalState.currentEncounter,
|
||||
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,15 +86,15 @@ export function buildEncounterTransitionState(
|
||||
}
|
||||
|
||||
export function interpolateEncounterTransitionState(
|
||||
startState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
startState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
|
||||
finalState: GameState,
|
||||
progress: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const startById = new Map(startState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
if (finalState.sceneHostileNpcs.length > 0) {
|
||||
const startById = new Map(startState.sceneHostileNpcs.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
const startMonster = startById.get(monster.id);
|
||||
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
|
||||
return {
|
||||
@@ -117,7 +117,7 @@ export function interpolateEncounterTransitionState(
|
||||
...finalState.currentEncounter,
|
||||
xMeters: lerp(startX, endX, progress),
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,9 @@ function createGameState(): GameState {
|
||||
name: '断碑古道',
|
||||
description: '阴气与碎骨混在旧路之间。',
|
||||
imageSrc: '/ruins.png',
|
||||
monsterIds: ['monster-03'],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
|
||||
@@ -998,7 +998,7 @@ export function rollHostileNpcLoot(
|
||||
npcDescription: `${monster.name}倒下后留下的战利痕迹。`,
|
||||
npcAvatar: '',
|
||||
context: state.currentScenePreset?.name ?? '战场余烬',
|
||||
hostileNpcPresetId: monster.id,
|
||||
monsterPresetId: monster.id,
|
||||
},
|
||||
});
|
||||
const directedReward = buildDirectedRuntimeReward(context, {
|
||||
|
||||
@@ -173,7 +173,6 @@ export function buildHostileNpcEncounter(
|
||||
return {
|
||||
id: `monster:${worldType}:${preset.id}`,
|
||||
kind: 'npc',
|
||||
hostileNpcPresetId: preset.id,
|
||||
monsterPresetId: preset.id,
|
||||
npcName: preset.name,
|
||||
npcDescription: preset.description,
|
||||
@@ -231,8 +230,6 @@ export function createSceneHostileNpc(
|
||||
};
|
||||
}
|
||||
|
||||
export const createSceneMonster = createSceneHostileNpc;
|
||||
|
||||
export function createSceneHostileNpcsFromIds(
|
||||
worldType: WorldType,
|
||||
hostileNpcIds: string[],
|
||||
@@ -260,21 +257,19 @@ export function createSceneHostileNpcsFromIds(
|
||||
.filter(Boolean) as SceneHostileNpc[];
|
||||
}
|
||||
|
||||
export const createSceneMonstersFromIds = createSceneHostileNpcsFromIds;
|
||||
|
||||
export function createSceneHostileNpcsFromEncounters(
|
||||
worldType: WorldType,
|
||||
encounters: Encounter[],
|
||||
playerX = PLAYER_BASE_X_METERS,
|
||||
): SceneHostileNpc[] {
|
||||
const hostileEncounters = encounters.filter(
|
||||
(encounter): encounter is Encounter & { hostileNpcPresetId: string } => Boolean(encounter.hostileNpcPresetId),
|
||||
(encounter): encounter is Encounter & { monsterPresetId: string } => Boolean(encounter.monsterPresetId),
|
||||
);
|
||||
if (hostileEncounters.length === 0) return [];
|
||||
|
||||
const baseMonsters = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
hostileEncounters.map(encounter => encounter.hostileNpcPresetId),
|
||||
hostileEncounters.map(encounter => encounter.monsterPresetId),
|
||||
playerX,
|
||||
);
|
||||
|
||||
@@ -295,8 +290,6 @@ export function createSceneHostileNpcsFromEncounters(
|
||||
});
|
||||
}
|
||||
|
||||
export const createSceneNpcMonstersFromEncounters = createSceneHostileNpcsFromEncounters;
|
||||
|
||||
export function getBaseSceneHostileNpcs(worldType: WorldType, playerX = PLAYER_BASE_X_METERS): SceneHostileNpc[] {
|
||||
const fallbackId = getHostileNpcPresetsByWorld(worldType)[0]?.id;
|
||||
return fallbackId ? createSceneHostileNpcsFromIds(worldType, [fallbackId], playerX) : [];
|
||||
@@ -312,8 +305,6 @@ export function getClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[
|
||||
return [...monsters].sort((a, b) => Math.abs(a.xMeters - playerX) - Math.abs(b.xMeters - playerX))[0];
|
||||
}
|
||||
|
||||
export const getClosestMonster = getClosestHostileNpc;
|
||||
|
||||
export function getHostileNpcDistance(playerX: number, monster: SceneHostileNpc) {
|
||||
return Math.abs(monster.xMeters - playerX);
|
||||
}
|
||||
@@ -371,8 +362,6 @@ export function settleHostileNpcAnimations(monsters: SceneHostileNpc[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export const settleMonsterAnimations = settleHostileNpcAnimations;
|
||||
|
||||
export function createFallbackOption(
|
||||
functionId: string,
|
||||
text: string,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {createSceneHostileNpcsFromIds, createSceneMonstersFromIds} from './hostileNpcs';
|
||||
@@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Character, Encounter, GameState, InventoryItem } from '../types';
|
||||
import { AnimationState, WorldType } from '../types';
|
||||
import {
|
||||
buildNpcHelpReward,
|
||||
buildGiftCandidateSummary,
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
buildNpcHelpReward,
|
||||
buildNpcTradeTransactionActionText,
|
||||
syncNpcTradeInventory,
|
||||
} from './npcInteractions';
|
||||
@@ -90,11 +90,9 @@ function createGameState(
|
||||
name: 'Camp',
|
||||
description: 'A temporary camp.',
|
||||
imageSrc: '/camp.png',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
@@ -154,7 +152,6 @@ describe('npcInteractions', () => {
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
@@ -179,7 +176,6 @@ describe('npcInteractions', () => {
|
||||
scene: {
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
NpcPersistentState,
|
||||
NpcWarmthStage,
|
||||
QuestLogEntry,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
ScenePresetInfo,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
@@ -142,6 +142,182 @@ const RARITY_LABELS: Record<ItemRarity, string> = {
|
||||
legendary: '传说',
|
||||
};
|
||||
|
||||
function clampStanceMetric(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeRecentStanceNotes(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).slice(-3)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function buildInitialStanceProfile(
|
||||
affinity: number,
|
||||
options: {
|
||||
recruited?: boolean;
|
||||
hostile?: boolean;
|
||||
roleText?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const recruitedBonus = options.recruited ? 14 : 0;
|
||||
const hostilePenalty = options.hostile ? 18 : 0;
|
||||
const roleText = options.roleText ?? '';
|
||||
const currentConflictTag =
|
||||
/旧案|调查|追查/u.test(roleText)
|
||||
? '旧案'
|
||||
: /守|卫|巡/u.test(roleText)
|
||||
? '守线'
|
||||
: /商|摊|军需/u.test(roleText)
|
||||
? '交易'
|
||||
: null;
|
||||
|
||||
return {
|
||||
trust: clampStanceMetric(42 + affinity * 0.55 + recruitedBonus - hostilePenalty),
|
||||
warmth: clampStanceMetric(36 + affinity * 0.5 + recruitedBonus),
|
||||
ideologicalFit: clampStanceMetric(48 + affinity * 0.25),
|
||||
fearOrGuard: clampStanceMetric(62 - affinity * 0.55 + hostilePenalty),
|
||||
loyalty: clampStanceMetric(24 + affinity * 0.35 + (options.recruited ? 26 : 0)),
|
||||
currentConflictTag,
|
||||
recentApprovals: [],
|
||||
recentDisapprovals: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyStoryChoiceToStanceProfile(
|
||||
stanceProfile: NpcPersistentState['stanceProfile'],
|
||||
action: 'npc_chat' | 'npc_help' | 'npc_gift' | 'npc_recruit' | 'npc_quest_accept',
|
||||
options: {
|
||||
affinityGain?: number;
|
||||
recruited?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const base =
|
||||
stanceProfile ??
|
||||
buildInitialStanceProfile(0, {
|
||||
recruited: options.recruited,
|
||||
});
|
||||
const affinityGain = options.affinityGain ?? 0;
|
||||
const approvalNotes = [...base.recentApprovals];
|
||||
const disapprovalNotes = [...base.recentDisapprovals];
|
||||
|
||||
const applyApproval = (note: string) => {
|
||||
approvalNotes.push(note);
|
||||
while (approvalNotes.length > 3) approvalNotes.shift();
|
||||
};
|
||||
const applyDisapproval = (note: string) => {
|
||||
disapprovalNotes.push(note);
|
||||
while (disapprovalNotes.length > 3) disapprovalNotes.shift();
|
||||
};
|
||||
|
||||
const next = {
|
||||
...base,
|
||||
trust: base.trust,
|
||||
warmth: base.warmth,
|
||||
ideologicalFit: base.ideologicalFit,
|
||||
fearOrGuard: base.fearOrGuard,
|
||||
loyalty: base.loyalty,
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'npc_chat':
|
||||
next.trust += 6 + affinityGain * 2;
|
||||
next.warmth += 4 + affinityGain * 2;
|
||||
next.fearOrGuard -= 5 + affinityGain;
|
||||
if (affinityGain >= 0) {
|
||||
applyApproval('你愿意先从眼前局势和试探开始说话。');
|
||||
} else {
|
||||
applyDisapproval('这轮交流没能真正对上节奏。');
|
||||
}
|
||||
break;
|
||||
case 'npc_help':
|
||||
next.trust += 12;
|
||||
next.warmth += 6;
|
||||
next.fearOrGuard -= 8;
|
||||
applyApproval('你在对方需要的时候搭了手。');
|
||||
break;
|
||||
case 'npc_gift':
|
||||
next.trust += 6 + affinityGain;
|
||||
next.warmth += 10 + affinityGain * 2;
|
||||
next.fearOrGuard -= 4;
|
||||
applyApproval('你给出的东西回应了对方眼下的处境。');
|
||||
break;
|
||||
case 'npc_recruit':
|
||||
next.trust += 8;
|
||||
next.warmth += 6;
|
||||
next.loyalty += 18;
|
||||
next.fearOrGuard -= 10;
|
||||
applyApproval('你正式把对方纳入了同行关系。');
|
||||
break;
|
||||
case 'npc_quest_accept':
|
||||
next.trust += 7;
|
||||
next.ideologicalFit += 5;
|
||||
next.loyalty += 4;
|
||||
applyApproval('你接住了对方主动交出来的事。');
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
trust: clampStanceMetric(next.trust),
|
||||
warmth: clampStanceMetric(next.warmth),
|
||||
ideologicalFit: clampStanceMetric(next.ideologicalFit),
|
||||
fearOrGuard: clampStanceMetric(next.fearOrGuard),
|
||||
loyalty: clampStanceMetric(next.loyalty),
|
||||
recentApprovals: approvalNotes,
|
||||
recentDisapprovals: disapprovalNotes,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStanceProfile(
|
||||
stanceProfile: NpcPersistentState['stanceProfile'],
|
||||
npcState: NpcPersistentState,
|
||||
) {
|
||||
if (!stanceProfile) {
|
||||
return buildInitialStanceProfile(npcState.affinity, {
|
||||
recruited: npcState.recruited,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
trust: clampStanceMetric(stanceProfile.trust ?? 40),
|
||||
warmth: clampStanceMetric(stanceProfile.warmth ?? 35),
|
||||
ideologicalFit: clampStanceMetric(stanceProfile.ideologicalFit ?? 45),
|
||||
fearOrGuard: clampStanceMetric(stanceProfile.fearOrGuard ?? 55),
|
||||
loyalty: clampStanceMetric(stanceProfile.loyalty ?? 20),
|
||||
currentConflictTag: stanceProfile.currentConflictTag ?? null,
|
||||
recentApprovals: normalizeRecentStanceNotes(stanceProfile.recentApprovals),
|
||||
recentDisapprovals: normalizeRecentStanceNotes(stanceProfile.recentDisapprovals),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeNpcNarrativePressure(
|
||||
encounter: Encounter,
|
||||
npcState: NpcPersistentState,
|
||||
) {
|
||||
const narrativeProfile = encounter.narrativeProfile;
|
||||
const guardedText =
|
||||
npcState.stanceProfile?.fearOrGuard && npcState.stanceProfile.fearOrGuard > 68
|
||||
? '对方明显绷着一口气,不愿先把主动权让出去。'
|
||||
: '对方把分寸拿得很紧,像是随时准备把话题拨回表层。';
|
||||
|
||||
if (!narrativeProfile) {
|
||||
return guardedText;
|
||||
}
|
||||
|
||||
return [
|
||||
narrativeProfile.immediatePressure || guardedText,
|
||||
narrativeProfile.contradiction
|
||||
? `话里还带着一点错位:${narrativeProfile.contradiction}`
|
||||
: null,
|
||||
narrativeProfile.reactionHooks[0]
|
||||
? `只要提到${narrativeProfile.reactionHooks[0]},对方就可能立刻变调。`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function makeItemId(prefix: string, category: string, name: string) {
|
||||
return `${prefix}:${encodeURIComponent(`${category}-${name}`)}`;
|
||||
}
|
||||
@@ -726,6 +902,7 @@ export function normalizeNpcPersistentState(
|
||||
seenBackstoryChapterIds: Array.isArray(npcState.seenBackstoryChapterIds)
|
||||
? npcState.seenBackstoryChapterIds.filter((fact): fact is string => typeof fact === 'string')
|
||||
: [],
|
||||
stanceProfile: normalizeStanceProfile(npcState.stanceProfile, npcState),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1374,6 +1551,11 @@ export function buildInitialNpcState(
|
||||
knownAttributeRumors: attributeRumors,
|
||||
firstMeaningfulContactResolved: false,
|
||||
seenBackstoryChapterIds: [],
|
||||
stanceProfile: buildInitialStanceProfile(initialAffinity, {
|
||||
recruited: false,
|
||||
hostile: Boolean(encounter.monsterPresetId) || initialAffinity < 0,
|
||||
roleText: encounter.context,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1644,7 +1826,7 @@ export function createNpcBattleMonster(
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
} satisfies SceneMonster;
|
||||
} satisfies SceneHostileNpc;
|
||||
}
|
||||
|
||||
const recruitCombatStats = recruitCharacter
|
||||
@@ -1695,7 +1877,7 @@ export function createNpcBattleMonster(
|
||||
...encounter,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
} satisfies SceneMonster;
|
||||
} satisfies SceneHostileNpc;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1716,7 +1898,7 @@ export function createNpcBattleMonster(
|
||||
...encounter,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
} satisfies SceneMonster;
|
||||
} satisfies SceneHostileNpc;
|
||||
}
|
||||
|
||||
export function getNpcLootItems(
|
||||
@@ -1753,7 +1935,7 @@ export function buildNpcEncounterStoryMoment({
|
||||
activeQuests: QuestLogEntry[];
|
||||
scene: Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'monsterIds' | 'npcs' | 'treasureHints'
|
||||
'id' | 'name' | 'npcs' | 'treasureHints'
|
||||
> | null;
|
||||
worldType: WorldType | null;
|
||||
partySize: number;
|
||||
@@ -1944,8 +2126,8 @@ export function buildNpcEncounterStoryMoment({
|
||||
overrideText ??
|
||||
(
|
||||
isNpcFirstMeaningfulContact(encounter, npcState)
|
||||
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
|
||||
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}。${getNpcActionText(encounter)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
|
||||
? `${buildNpcFirstContactStoryText(encounter, npcState, scene?.name)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
|
||||
: `${scene?.name ?? '当前地界'}里,你遇见了${encounter.npcName}。${getNpcActionText(encounter)} ${describeNpcNarrativePressure(encounter, npcState)} ${describeNpcAffinityInWords(encounter, npcState.affinity)}`
|
||||
),
|
||||
options: sortStoryOptionsByPriority(
|
||||
options,
|
||||
|
||||
@@ -14,10 +14,20 @@ const TEST_SCENE = {
|
||||
id: 'forest_path',
|
||||
name: 'Forest Path',
|
||||
description: 'A narrow trail with fresh claw marks.',
|
||||
monsterIds: ['wolf_alpha'],
|
||||
npcs: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'hostile-wolf-alpha',
|
||||
name: '狼王',
|
||||
description: 'A hostile wolf alpha.',
|
||||
avatar: '狼',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'wolf_alpha',
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
},
|
||||
],
|
||||
treasureHints: [],
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'monsterIds' | 'npcs' | 'treasureHints'>;
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
|
||||
|
||||
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep {
|
||||
const step = quest.steps?.find(item => item.id === stepId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import type {
|
||||
QuestCompilationRequest,
|
||||
QuestContract,
|
||||
@@ -7,7 +8,8 @@ import type {
|
||||
QuestProgressSignal,
|
||||
QuestSceneSnapshot,
|
||||
} from '../services/questTypes';
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import { buildNarrativeDocument } from '../services/storyEngine/documentCarrierCompiler';
|
||||
import { buildThreadContractsFromProfile } from '../services/storyEngine/threadContract';
|
||||
import {
|
||||
type CustomWorldProfile,
|
||||
type QuestLogEntry,
|
||||
@@ -171,13 +173,24 @@ function buildQuestReward(params: {
|
||||
fixedKinds: [...runtimeConfig.fixedKinds],
|
||||
fixedPermanence: [...runtimeConfig.fixedPermanence],
|
||||
});
|
||||
const threadContract = context?.customWorldProfile?.threadContracts?.find((contract) =>
|
||||
(context.activeThreadIds ?? []).includes(contract.threadId),
|
||||
) ?? null;
|
||||
const rewardItems = flattenDirectedRuntimeRewardItems(directedReward);
|
||||
const documentItem =
|
||||
rewardTheme === 'intel' && threadContract
|
||||
? buildNarrativeDocument({
|
||||
contract: threadContract,
|
||||
titleSeed: `${issuerNpcName}留下的调查简札`,
|
||||
})
|
||||
: null;
|
||||
|
||||
const reward: QuestReward = {
|
||||
affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12,
|
||||
currency: rewardTheme === 'intel'
|
||||
? (worldType === 'XIANXIA' ? 40 : 58)
|
||||
: (worldType === 'XIANXIA' ? 54 : 72),
|
||||
items: flattenDirectedRuntimeRewardItems(directedReward),
|
||||
items: documentItem ? [...rewardItems, documentItem] : rewardItems,
|
||||
storyHint: directedReward.storyHint,
|
||||
};
|
||||
|
||||
@@ -199,6 +212,34 @@ function buildRewardText(reward: QuestReward, worldType: WorldType | null) {
|
||||
return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`;
|
||||
}
|
||||
|
||||
function resolveQuestThreadContract(params: {
|
||||
context?: QuestGenerationContext;
|
||||
issuerNpcId: string;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
}) {
|
||||
const profile = params.context?.customWorldProfile;
|
||||
if (!profile?.storyGraph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contracts =
|
||||
profile.threadContracts && profile.threadContracts.length > 0
|
||||
? profile.threadContracts
|
||||
: buildThreadContractsFromProfile(profile);
|
||||
const activeThreadIds = params.context?.activeThreadIds ?? [];
|
||||
const contract = contracts.find((candidate) =>
|
||||
activeThreadIds.includes(candidate.threadId)
|
||||
|| candidate.issuerActorId === params.issuerNpcId
|
||||
|| candidate.steps.some((step) =>
|
||||
step.completionSignalIds.some((signalId) =>
|
||||
params.scene?.id ? signalId.includes(params.scene.id) : false,
|
||||
),
|
||||
),
|
||||
) ?? contracts[0] ?? null;
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey: string) {
|
||||
return `quest:${issuerNpcId}:${kind}:${targetKey}`;
|
||||
}
|
||||
@@ -226,7 +267,7 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
|
||||
|
||||
const hostileNpc = getSceneHostileNpcs(scene)[0] ?? null;
|
||||
if (hostileNpc) {
|
||||
const targetHostileNpcId = hostileNpc.hostileNpcPresetId ?? hostileNpc.monsterPresetId ?? hostileNpc.id;
|
||||
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
|
||||
const targetHostileNpcName = worldType
|
||||
? getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? hostileNpc.name ?? targetHostileNpcId
|
||||
: hostileNpc.name ?? targetHostileNpcId;
|
||||
@@ -240,19 +281,6 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackHostileNpcId = scene.hostileNpcIds?.[0] ?? scene.monsterIds?.[0];
|
||||
if (fallbackHostileNpcId) {
|
||||
return {
|
||||
kind: 'defeat_hostile_npc',
|
||||
targetHostileNpcId: fallbackHostileNpcId,
|
||||
targetHostileNpcName: worldType
|
||||
? getHostileNpcPresetById(worldType, fallbackHostileNpcId)?.name ?? fallbackHostileNpcId
|
||||
: fallbackHostileNpcId,
|
||||
targetSceneId: scene.id,
|
||||
suggestedThreatType: 'hostile_npc',
|
||||
};
|
||||
}
|
||||
|
||||
if ((scene.treasureHints?.length ?? 0) > 0) {
|
||||
return {
|
||||
kind: 'inspect_treasure',
|
||||
@@ -568,6 +596,12 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
|
||||
status,
|
||||
steps,
|
||||
activeStepId,
|
||||
actId: quest.actId ?? null,
|
||||
threadId: quest.threadId ?? null,
|
||||
contractId: quest.contractId ?? null,
|
||||
discoveredFactIds: quest.discoveredFactIds ?? [],
|
||||
relatedCarrierIds: quest.relatedCarrierIds ?? [],
|
||||
consequenceIds: quest.consequenceIds ?? [],
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -825,12 +859,20 @@ export function compileQuestIntentToQuest(
|
||||
},
|
||||
failPolicy: 'never',
|
||||
};
|
||||
const threadContract = resolveQuestThreadContract({
|
||||
context: params.context,
|
||||
issuerNpcId: params.issuerNpcId,
|
||||
scene: params.scene,
|
||||
});
|
||||
|
||||
return normalizeQuestLogEntry({
|
||||
id: contract.id,
|
||||
issuerNpcId: contract.issuerNpcId,
|
||||
issuerNpcName: contract.issuerNpcName,
|
||||
sceneId: contract.sceneId,
|
||||
actId: params.context?.actState?.id ?? null,
|
||||
threadId: threadContract?.threadId ?? null,
|
||||
contractId: threadContract?.id ?? null,
|
||||
title: contract.title,
|
||||
description: contract.description,
|
||||
summary: contract.summary,
|
||||
@@ -843,8 +885,11 @@ export function compileQuestIntentToQuest(
|
||||
narrativeBinding: contract.narrativeBinding,
|
||||
steps: contract.steps,
|
||||
activeStepId: contract.steps[0]?.id ?? null,
|
||||
visibleStage: 0,
|
||||
visibleStage: threadContract?.visibleStage ?? 0,
|
||||
hiddenFlags: [],
|
||||
discoveredFactIds: [],
|
||||
relatedCarrierIds: [],
|
||||
consequenceIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../services/storyEngine/actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
|
||||
import type {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
@@ -78,12 +84,81 @@ function derivePlayerBuildGaps(playerBuildTags: string[]) {
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveRelatedNpcNarrativeProfile(params: {
|
||||
customWorldProfile: GameState['customWorldProfile'];
|
||||
encounter: GameState['currentEncounter'];
|
||||
}) {
|
||||
const { customWorldProfile, encounter } = params;
|
||||
if (!customWorldProfile || !encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return encounter.narrativeProfile ?? null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
|
||||
const storyGraph =
|
||||
customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(params: {
|
||||
customWorldProfile: GameState['customWorldProfile'];
|
||||
relatedNpcNarrativeProfile: RuntimeItemGenerationContext['relatedNpcNarrativeProfile'];
|
||||
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
|
||||
}) {
|
||||
const threadSource = params.storyEngineMemory;
|
||||
if (Array.isArray(threadSource) && threadSource.length > 0) {
|
||||
return threadSource.slice(0, 4);
|
||||
}
|
||||
|
||||
if (
|
||||
threadSource &&
|
||||
!Array.isArray(threadSource) &&
|
||||
threadSource.activeThreadIds?.length
|
||||
) {
|
||||
return threadSource.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
|
||||
if (params.relatedNpcNarrativeProfile?.relatedThreadIds.length) {
|
||||
return params.relatedNpcNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
|
||||
if (!params.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
params.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(params.customWorldProfile);
|
||||
const storyGraph =
|
||||
params.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(params.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
function buildBaseRuntimeContext(params: {
|
||||
worldType: GameState['worldType'];
|
||||
customWorldProfile: GameState['customWorldProfile'];
|
||||
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||||
encounter: GameState['currentEncounter'];
|
||||
relatedNpcState: GameState['npcStates'][string] | null;
|
||||
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
|
||||
storyHistory: GameState['storyHistory'];
|
||||
playerCharacterId: string;
|
||||
playerBuildTags: string[];
|
||||
@@ -96,6 +171,7 @@ function buildBaseRuntimeContext(params: {
|
||||
scene,
|
||||
encounter,
|
||||
relatedNpcState,
|
||||
storyEngineMemory,
|
||||
storyHistory,
|
||||
playerCharacterId,
|
||||
playerBuildTags,
|
||||
@@ -103,6 +179,15 @@ function buildBaseRuntimeContext(params: {
|
||||
generationChannel,
|
||||
} = params;
|
||||
const recentStoryLines = buildRecentStoryLines(storyHistory);
|
||||
const relatedNpcNarrativeProfile = resolveRelatedNpcNarrativeProfile({
|
||||
customWorldProfile,
|
||||
encounter,
|
||||
});
|
||||
const activeThreadIds = resolveActiveThreadIds({
|
||||
customWorldProfile,
|
||||
relatedNpcNarrativeProfile,
|
||||
storyEngineMemory,
|
||||
});
|
||||
|
||||
return {
|
||||
worldType,
|
||||
@@ -117,9 +202,11 @@ function buildBaseRuntimeContext(params: {
|
||||
encounterNpcName: encounter?.npcName ?? null,
|
||||
encounterContextText: encounter?.context ?? null,
|
||||
relatedNpcState,
|
||||
relatedNpcNarrativeProfile,
|
||||
relatedScene: scene,
|
||||
recentStorySummary: buildRecentStorySummary(recentStoryLines),
|
||||
recentActions: recentStoryLines,
|
||||
activeThreadIds,
|
||||
playerCharacterId,
|
||||
playerBuildTags,
|
||||
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
|
||||
@@ -146,6 +233,7 @@ export function buildLooseRuntimeItemGenerationContext(params: {
|
||||
scene: params.scene ?? null,
|
||||
encounter: params.encounter ?? null,
|
||||
relatedNpcState: params.relatedNpcState ?? null,
|
||||
storyEngineMemory: params.customWorldProfile?.storyGraph?.visibleThreads.map((thread) => thread.id) ?? [],
|
||||
storyHistory: params.storyHistory ?? [],
|
||||
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
|
||||
playerBuildTags: params.playerBuildTags ?? [],
|
||||
@@ -180,6 +268,7 @@ export function buildRuntimeItemGenerationContext(params: {
|
||||
scene,
|
||||
encounter,
|
||||
relatedNpcState,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
storyHistory: state.storyHistory,
|
||||
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
|
||||
playerBuildTags,
|
||||
@@ -243,6 +332,7 @@ export function buildQuestRuntimeItemGenerationContext(params: {
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
},
|
||||
storyEngineMemory: context.activeThreadIds,
|
||||
storyHistory: context.recentStoryMoments ?? [],
|
||||
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
|
||||
playerBuildTags,
|
||||
|
||||
@@ -41,6 +41,9 @@ describe('runtime item director', () => {
|
||||
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('treasure');
|
||||
expect(reward.primaryItem?.runtimeMetadata?.relationAnchor?.type).toBe('npc');
|
||||
expect(reward.primaryItem?.name).not.toBe('未命名秘物');
|
||||
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.visibleClue).toBeTruthy();
|
||||
expect(reward.primaryItem?.runtimeMetadata?.storyFingerprint?.unresolvedQuestion).toBeTruthy();
|
||||
expect(reward.primaryItem?.description).toContain('适合当前局势里的临场构筑调整');
|
||||
});
|
||||
|
||||
it('keeps identity-sensitive runtime items separate when adding inventory', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
|
||||
import type {
|
||||
DirectedRuntimeReward,
|
||||
InventoryItem,
|
||||
@@ -8,7 +9,6 @@ import type {
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
|
||||
import {compileRuntimeItem} from './runtimeItemCompiler';
|
||||
import {
|
||||
applyRuntimeItemNarrative,
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
buildCarrierNarrativeDescription,
|
||||
buildCarrierNarrativeName,
|
||||
buildRuntimeItemStoryFingerprint,
|
||||
} from '../services/storyEngine/carrierNarrativeCompiler';
|
||||
import type {
|
||||
DirectedRuntimeReward,
|
||||
InventoryItem,
|
||||
@@ -8,10 +13,6 @@ import type {
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
|
||||
function pickFirst<T>(values: T[], fallback: T): T {
|
||||
return values[0] ?? fallback;
|
||||
}
|
||||
|
||||
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
|
||||
return (value ?? '')
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
|
||||
@@ -37,83 +38,29 @@ function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFunctionWord(item: InventoryItem, plan: RuntimeItemPlan, intent: RuntimeItemAiIntent) {
|
||||
const topTag = intent.desiredBuildTags[0] ?? plan.targetBuildDirection[0] ?? '';
|
||||
|
||||
if (plan.itemKind === 'consumable') {
|
||||
if (intent.desiredFunctionalBias.includes('heal')) return '灵露';
|
||||
if (intent.desiredFunctionalBias.includes('mana')) return '回气散';
|
||||
if (intent.desiredFunctionalBias.includes('cooldown')) return '压纹符';
|
||||
return '药包';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'material') {
|
||||
return topTag ? `${topTag}精粹` : '残材';
|
||||
}
|
||||
|
||||
if (plan.itemKind === 'quest') {
|
||||
return '信物';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'weapon') {
|
||||
if (topTag === '快剑' || topTag === '追击') return '短刃';
|
||||
if (topTag === '远射') return '短弓';
|
||||
if (topTag === '重击') return '战锤';
|
||||
return '兵刃';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'armor') {
|
||||
return topTag === '守御' ? '护甲' : '护符';
|
||||
}
|
||||
|
||||
if (item.equipmentSlotId === 'relic' || plan.itemKind === 'relic') {
|
||||
return topTag === '法力' ? '灵坠' : '护心佩';
|
||||
}
|
||||
|
||||
return pickFirst([
|
||||
sanitizeFragment(intent.shortNameSeed),
|
||||
topTag,
|
||||
item.category,
|
||||
].filter(Boolean), '秘物');
|
||||
}
|
||||
|
||||
function buildAnchorName(anchor: RuntimeRelationAnchor) {
|
||||
const label = resolveAnchorLabel(anchor);
|
||||
return sanitizeFragment(label, 4) || '旧誓';
|
||||
}
|
||||
|
||||
function buildRelationWord(anchor: RuntimeRelationAnchor, intent: RuntimeItemAiIntent) {
|
||||
const fromHook = sanitizeFragment(intent.relationHooks[0], 4);
|
||||
if (fromHook) return fromHook;
|
||||
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return sanitizeFragment(anchor.roleText, 4) || '旧识';
|
||||
case 'scene':
|
||||
return '遗痕';
|
||||
case 'monster':
|
||||
return '猎印';
|
||||
case 'quest':
|
||||
return '誓约';
|
||||
case 'faction':
|
||||
return '徽记';
|
||||
default:
|
||||
return '余烬';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeItemAiPromptInput(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
): RuntimeItemAiPromptInput {
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const activeThreadSummary = (context.activeThreadIds ?? [])
|
||||
.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
)
|
||||
.join('、');
|
||||
|
||||
return {
|
||||
worldSummary: context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
|
||||
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
|
||||
encounterSummary: [context.encounterNpcName, context.encounterContextText].filter(Boolean).join(' / '),
|
||||
relatedNpcSummary: context.relatedNpcState
|
||||
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
|
||||
: '暂无明确人物关系',
|
||||
relatedNpcSummary: context.relatedNpcNarrativeProfile
|
||||
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${context.relatedNpcNarrativeProfile.publicMask};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure}`
|
||||
: context.relatedNpcState
|
||||
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity}`
|
||||
: '暂无明确人物关系',
|
||||
recentStorySummary: context.recentStorySummary,
|
||||
activeThreadSummary,
|
||||
generationChannel: context.generationChannel,
|
||||
playerBuildDirection: context.playerBuildTags,
|
||||
playerBuildGaps: context.playerBuildGaps,
|
||||
@@ -165,6 +112,30 @@ export function buildRuntimeItemAiIntent(
|
||||
: context.playerBuildGaps.includes('survival_gap')
|
||||
? 'survival'
|
||||
: 'martial',
|
||||
visibleClue:
|
||||
context.relatedNpcNarrativeProfile?.visibleLine
|
||||
?? `${resolveAnchorLabel(plan.relationAnchor)}身上留下的旧痕`,
|
||||
witnessMark:
|
||||
context.relatedNpcNarrativeProfile?.debtOrBurden
|
||||
?? `${resolveAnchorLabel(plan.relationAnchor)}尚未散尽的使用痕`,
|
||||
unfinishedBusiness:
|
||||
context.relatedNpcNarrativeProfile?.contradiction
|
||||
?? `${resolveAnchorLabel(plan.relationAnchor)}背后还有没说完的问题`,
|
||||
hiddenHook:
|
||||
context.relatedNpcNarrativeProfile?.taboo
|
||||
?? `${resolveAnchorLabel(plan.relationAnchor)}为什么会在此刻重新出现`,
|
||||
reactionHooks: [
|
||||
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
|
||||
...(context.activeThreadIds ?? []),
|
||||
].slice(0, 4),
|
||||
namingPattern:
|
||||
plan.itemKind === 'quest'
|
||||
? 'quest_evidence'
|
||||
: plan.itemKind === 'material'
|
||||
? 'scene_relic'
|
||||
: plan.relationAnchor.type === 'npc'
|
||||
? 'npc_relic'
|
||||
: 'faction_issue',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,18 +145,23 @@ export function applyRuntimeItemNarrative(params: {
|
||||
plan: RuntimeItemPlan;
|
||||
intent: RuntimeItemAiIntent;
|
||||
}) {
|
||||
const {item, context, plan, intent} = params;
|
||||
const sourceWord = buildAnchorName(plan.relationAnchor);
|
||||
const relationWord = buildRelationWord(plan.relationAnchor, intent);
|
||||
const functionWord = resolveFunctionWord(item, plan, intent);
|
||||
const buildDirectionText = intent.desiredBuildTags.join('、') || context.playerBuildTags.join('、') || '均衡';
|
||||
const relationText = resolveAnchorLabel(plan.relationAnchor);
|
||||
const sourceReason = item.runtimeMetadata?.sourceReason ?? intent.reasonToAppear;
|
||||
const fingerprint = buildRuntimeItemStoryFingerprint(params);
|
||||
const runtimeMetadata =
|
||||
params.item.runtimeMetadata ?? {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: params.context.generationChannel,
|
||||
seedKey: `${params.context.generationChannel}:${params.item.id}`,
|
||||
sourceReason: params.intent.reasonToAppear,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
name: `${sourceWord}${relationWord}${functionWord}`,
|
||||
description: `${relationText}留下的${item.category}。${sourceReason} 它偏向 ${buildDirectionText} 方向,适合当前局势中的临场构筑调整。`,
|
||||
...params.item,
|
||||
name: buildCarrierNarrativeName(params),
|
||||
description: buildCarrierNarrativeDescription(params),
|
||||
runtimeMetadata: {
|
||||
...runtimeMetadata,
|
||||
storyFingerprint: fingerprint,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
@@ -202,7 +178,16 @@ export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward)
|
||||
}
|
||||
|
||||
export function buildRuntimeRewardStoryHint(reward: DirectedRuntimeReward) {
|
||||
const primaryName = reward.primaryItem?.name;
|
||||
if (!primaryName) return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
|
||||
return reward.storyHint ?? `这次得到的核心物件是 ${primaryName}。`;
|
||||
const primaryItem = reward.primaryItem;
|
||||
const fingerprint = primaryItem?.runtimeMetadata?.storyFingerprint;
|
||||
if (!primaryItem) {
|
||||
return reward.storyHint ?? '你得到了一件与当前局势相关的物品。';
|
||||
}
|
||||
if (reward.storyHint) {
|
||||
return reward.storyHint;
|
||||
}
|
||||
if (fingerprint) {
|
||||
return `${primaryItem.name}先露出的是“${fingerprint.visibleClue}”,但它背后还压着“${fingerprint.unresolvedQuestion}”。`;
|
||||
}
|
||||
return `这次得到的核心物件是 ${primaryItem.name}。`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneMonster } from './hostileNpcs';
|
||||
import { createSceneHostileNpc } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
hasAutoBattleSceneEncounter,
|
||||
@@ -78,11 +78,10 @@ function createBaseState(): GameState {
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
connectedSceneIds: [],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
@@ -133,8 +132,8 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBe('npc-trader');
|
||||
expect(resolved.currentNpcBattleMode).toBe('fight');
|
||||
expect(resolved.sceneMonsters).toHaveLength(1);
|
||||
expect(resolved.sceneMonsters[0]?.encounter?.npcName).toBe('Trader Lin');
|
||||
expect(resolved.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
|
||||
});
|
||||
|
||||
it('attaches npc encounter metadata to regular monsters', () => {
|
||||
@@ -143,7 +142,7 @@ describe('sceneEncounterPreviews', () => {
|
||||
throw new Error('Expected at least one monster preset');
|
||||
}
|
||||
|
||||
const monster = createSceneMonster(WorldType.WUXIA, monsterId);
|
||||
const monster = createSceneHostileNpc(WorldType.WUXIA, monsterId);
|
||||
|
||||
expect(monster).not.toBeNull();
|
||||
expect(monster?.encounter?.kind).toBe('npc');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
createSceneMonstersFromIds,
|
||||
createSceneNpcMonstersFromEncounters,
|
||||
createSceneHostileNpcsFromEncounters,
|
||||
createSceneHostileNpcsFromIds,
|
||||
getFacingTowardPlayer,
|
||||
getMonsterGroupAnchorX,
|
||||
pickEncounterMonsterIds,
|
||||
@@ -42,7 +42,7 @@ function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneMonsters: [
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight', {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
@@ -123,7 +123,7 @@ function buildHostileEncounterGroup(
|
||||
|
||||
const selectedHostiles = pickEncounterHostileNpcs(getAvailableHostileSceneNpcs(state));
|
||||
const hostileEncounters = selectedHostiles.map(npc => buildEncounterFromSceneNpc(npc));
|
||||
const hostileMonsters = createSceneNpcMonstersFromEncounters(
|
||||
const hostileMonsters = createSceneHostileNpcsFromEncounters(
|
||||
state.worldType,
|
||||
hostileEncounters,
|
||||
PLAYER_BASE_X_METERS,
|
||||
@@ -157,7 +157,7 @@ function buildFriendlyEncounter(npc: SceneNpc, xMeters: number) {
|
||||
function buildResolvedHostileBattleState(state: GameState, hostileEncounters: Encounter[]) {
|
||||
if (!state.worldType) return state;
|
||||
|
||||
const resolvedMonsters = createSceneNpcMonstersFromEncounters(
|
||||
const resolvedMonsters = createSceneHostileNpcsFromEncounters(
|
||||
state.worldType,
|
||||
hostileEncounters,
|
||||
PLAYER_BASE_X_METERS,
|
||||
@@ -175,7 +175,7 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
@@ -191,7 +191,7 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
|
||||
export function createSceneEncounterPreview(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -210,7 +210,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (!kind) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -219,7 +219,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
if (kind === 'hostile') {
|
||||
return {
|
||||
sceneMonsters: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
|
||||
sceneHostileNpcs: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -230,7 +230,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, PREVIEW_ENTITY_X_METERS) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -239,7 +239,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: treasureHint ? createTreasureEncounter(state, treasureHint) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -249,7 +249,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -269,7 +269,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (kind === 'hostile') {
|
||||
return {
|
||||
sceneMonsters: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
|
||||
sceneHostileNpcs: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -279,7 +279,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -289,7 +289,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (kind === 'treasure') {
|
||||
const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []);
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: treasureHint
|
||||
? {
|
||||
...createTreasureEncounter(state, treasureHint),
|
||||
@@ -302,7 +302,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
}
|
||||
|
||||
return {
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
@@ -312,7 +312,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
export function ensureSceneEncounterPreview(state: GameState): GameState {
|
||||
if (
|
||||
state.inBattle ||
|
||||
state.sceneMonsters.length > 0 ||
|
||||
state.sceneHostileNpcs.length > 0 ||
|
||||
state.currentEncounter ||
|
||||
!state.currentScenePreset ||
|
||||
!state.worldType
|
||||
@@ -337,8 +337,8 @@ export function hasAutoBattleSceneEncounter(state: GameState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.sceneMonsters.length > 0) {
|
||||
return state.sceneMonsters.some(monster => Boolean(monster.encounter?.monsterPresetId));
|
||||
if (state.sceneHostileNpcs.length > 0) {
|
||||
return state.sceneHostileNpcs.some(monster => Boolean(monster.encounter?.monsterPresetId));
|
||||
}
|
||||
|
||||
return state.currentEncounter?.kind === 'npc'
|
||||
@@ -352,12 +352,12 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
}
|
||||
|
||||
const previewState =
|
||||
state.sceneMonsters.length > 0 || state.currentEncounter
|
||||
state.sceneHostileNpcs.length > 0 || state.currentEncounter
|
||||
? state
|
||||
: ensureSceneEncounterPreview(state);
|
||||
|
||||
if (previewState.sceneMonsters.length > 0) {
|
||||
const hostileEncounters = previewState.sceneMonsters
|
||||
if (previewState.sceneHostileNpcs.length > 0) {
|
||||
const hostileEncounters = previewState.sceneHostileNpcs
|
||||
.map(monster => monster.encounter)
|
||||
.filter((encounter): encounter is Encounter => Boolean(encounter?.monsterPresetId));
|
||||
|
||||
@@ -365,9 +365,9 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
return buildResolvedHostileBattleState(previewState, hostileEncounters);
|
||||
}
|
||||
|
||||
const resolvedMonsters = createSceneMonstersFromIds(
|
||||
const resolvedMonsters = createSceneHostileNpcsFromIds(
|
||||
previewState.worldType ?? WorldType.WUXIA,
|
||||
previewState.sceneMonsters.map(monster => monster.id),
|
||||
previewState.sceneHostileNpcs.map(monster => monster.id),
|
||||
PLAYER_BASE_X_METERS,
|
||||
).map(monster => ({
|
||||
...monster,
|
||||
@@ -377,7 +377,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
|
||||
return {
|
||||
...previewState,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
sceneHostileNpcs: resolvedMonsters,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
@@ -405,7 +405,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
@@ -420,7 +420,7 @@ export function resolveSceneEncounterPreview(state: GameState): GameState {
|
||||
}
|
||||
|
||||
export function getPreviewEntityX(state: GameState) {
|
||||
return state.sceneMonsters.length > 0
|
||||
? getMonsterGroupAnchorX(state.sceneMonsters)
|
||||
return state.sceneHostileNpcs.length > 0
|
||||
? getMonsterGroupAnchorX(state.sceneHostileNpcs)
|
||||
: state.currentEncounter?.xMeters ?? PREVIEW_ENTITY_X_METERS;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||||
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../services/storyEngine/actorNarrativeProfile';
|
||||
import { buildSceneNarrativeResidues } from '../services/storyEngine/sceneResidueCompiler';
|
||||
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
|
||||
import {
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneConnectionInfo,
|
||||
SceneNpc,
|
||||
ScenePresetInfo,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
|
||||
@@ -31,9 +39,9 @@ export interface ScenePreset {
|
||||
forwardSceneId?: string;
|
||||
connectedSceneIds: string[];
|
||||
connections: SceneConnectionInfo[];
|
||||
monsterIds: string[];
|
||||
npcs: SceneNpc[];
|
||||
treasureHints: string[];
|
||||
narrativeResidues?: ScenePresetInfo['narrativeResidues'];
|
||||
}
|
||||
|
||||
export type ScenePresetOverride = Partial<Omit<ScenePreset, 'id' | 'worldType' | 'npcs'>>;
|
||||
@@ -79,7 +87,7 @@ type SceneTemplate = {
|
||||
name: string;
|
||||
description: string;
|
||||
worldType: WorldType;
|
||||
monsterIds: string[];
|
||||
hostileNpcPresetIds: string[];
|
||||
connectedSceneIds: string[];
|
||||
forwardSceneId?: string;
|
||||
treasureHints: string[];
|
||||
@@ -229,7 +237,6 @@ function buildHostileSceneNpc(sceneId: string, worldType: WorldType, monsterId:
|
||||
avatar: preset.name.slice(0, 1) || '敌',
|
||||
description: preset.description,
|
||||
gender: inferCustomNpcGender(`${sceneId}:${preset.id}`, preset.name),
|
||||
hostileNpcPresetId: preset.id,
|
||||
monsterPresetId: preset.id,
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
@@ -251,19 +258,25 @@ export function getSceneFriendlyNpcs(scene: { npcs?: SceneNpc[] } | null | undef
|
||||
return (scene?.npcs ?? []).filter(npc => !isHostileSceneNpc(npc));
|
||||
}
|
||||
|
||||
export function getSceneHostileNpcPresetIds(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||||
return [
|
||||
...new Set(
|
||||
getSceneHostileNpcs(scene)
|
||||
.map(npc => npc.monsterPresetId)
|
||||
.filter((monsterPresetId): monsterPresetId is string => Boolean(monsterPresetId)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildEncounterFromSceneNpc(
|
||||
npc: SceneNpc,
|
||||
xMeters?: number,
|
||||
): Encounter {
|
||||
const hostileNpcPresetId = npc.hostileNpcPresetId ?? npc.monsterPresetId;
|
||||
const monsterPresetId = npc.monsterPresetId ?? npc.hostileNpcPresetId;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
hostileNpcPresetId,
|
||||
monsterPresetId,
|
||||
monsterPresetId: npc.monsterPresetId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
@@ -285,6 +298,7 @@ export function buildEncounterFromSceneNpc(
|
||||
initialItems: npc.initialItems,
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,6 +307,13 @@ function buildCustomSceneNpc(
|
||||
profile: CustomWorldProfile,
|
||||
anchorWorldType: WorldType,
|
||||
): SceneNpc {
|
||||
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
|
||||
const storyGraph =
|
||||
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
);
|
||||
const monsterPreset =
|
||||
npc.initialAffinity < 0
|
||||
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
|
||||
@@ -324,24 +345,15 @@ function buildCustomSceneNpc(
|
||||
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
|
||||
description: [
|
||||
npc.description,
|
||||
npc.backstoryReveal.publicSummary
|
||||
? `公开背景:${npc.backstoryReveal.publicSummary}`
|
||||
: '',
|
||||
npc.motivation ? `动机:${npc.motivation}` : '',
|
||||
npc.skills.length > 0
|
||||
? `技能:${npc.skills.map((skill) => skill.name).join('、')}`
|
||||
: '',
|
||||
npc.initialItems.length > 0
|
||||
? `随身物:${npc.initialItems
|
||||
.map((item) => `${item.name}x${item.quantity}`)
|
||||
.join('、')}`
|
||||
narrativeProfile.publicMask ? `公开面:${narrativeProfile.publicMask}` : '',
|
||||
narrativeProfile.immediatePressure
|
||||
? `当前压力:${narrativeProfile.immediatePressure}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||||
monsterPresetId: monsterPreset?.id,
|
||||
hostileNpcPresetId: monsterPreset?.id,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
hostile,
|
||||
recruitable: !hostile,
|
||||
@@ -363,6 +375,7 @@ function buildCustomSceneNpc(
|
||||
})),
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
narrativeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -375,7 +388,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
|
||||
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
|
||||
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
|
||||
.flatMap((scene: ScenePreset) => scene.monsterIds)
|
||||
.flatMap((scene: ScenePreset) => getSceneHostileNpcPresetIds(scene))
|
||||
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
|
||||
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
|
||||
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
|
||||
@@ -423,11 +436,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
|
||||
connections: campConnections,
|
||||
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
|
||||
monsterIds: [],
|
||||
treasureHints: [
|
||||
`${profile.name}地图残页`,
|
||||
...profile.landmarks.slice(0, 3).map(landmark => `${landmark.name}的旧线索`),
|
||||
].slice(0, 4),
|
||||
narrativeResidues: buildSceneNarrativeResidues({
|
||||
sceneId: campSceneId,
|
||||
sceneName: buildCustomCampSceneName(profile),
|
||||
profile,
|
||||
}),
|
||||
npcs: campNpcs,
|
||||
},
|
||||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||||
@@ -483,10 +500,11 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
connections.map((connection) => connection.sceneId),
|
||||
);
|
||||
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
|
||||
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||||
const hostileNpcs = monsterIds
|
||||
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||||
const hostileNpcs = seedMonsterIds
|
||||
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
|
||||
|
||||
return {
|
||||
id: buildCustomSceneId('landmark', index),
|
||||
@@ -497,13 +515,20 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
connectedSceneIds,
|
||||
connections,
|
||||
forwardSceneId: pickForwardSceneIdFromConnections(connections),
|
||||
monsterIds,
|
||||
treasureHints: [
|
||||
`${landmark.name}的旧线索`,
|
||||
`${profile.name}相关遗物`,
|
||||
profile.storyNpcs[index]?.name ? `${profile.storyNpcs[index]!.name}留下的痕迹` : `${profile.playerGoal.slice(0, 10)}相关痕迹`,
|
||||
],
|
||||
npcs: [...sceneNpcs, ...hostileNpcs],
|
||||
narrativeResidues:
|
||||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||||
? landmark.narrativeResidues
|
||||
: buildSceneNarrativeResidues({
|
||||
sceneId: buildCustomSceneId('landmark', index),
|
||||
sceneName: landmark.name,
|
||||
profile,
|
||||
}),
|
||||
npcs: combinedNpcs,
|
||||
};
|
||||
}),
|
||||
];
|
||||
@@ -609,7 +634,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '竹林古道',
|
||||
description: '风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-13', 'monster-08'],
|
||||
hostileNpcPresetIds: ['monster-13', 'monster-08'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
|
||||
@@ -622,7 +647,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '山门石阶',
|
||||
description: '青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-06'],
|
||||
hostileNpcPresetIds: ['monster-04', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
|
||||
forwardSceneId: 'wuxia-temple-forecourt',
|
||||
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
|
||||
@@ -635,7 +660,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '雨夜长街',
|
||||
description: '长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-11', 'monster-07'],
|
||||
hostileNpcPresetIds: ['monster-11', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-ferry-bridge',
|
||||
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
|
||||
@@ -648,7 +673,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '荒村断垣',
|
||||
description: '残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-03', 'monster-07'],
|
||||
hostileNpcPresetIds: ['monster-03', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
|
||||
@@ -661,7 +686,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '古桥渡口',
|
||||
description: '桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-11'],
|
||||
hostileNpcPresetIds: ['monster-04', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-border-camp',
|
||||
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
|
||||
@@ -674,7 +699,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '雾林小径',
|
||||
description: '晨雾久久不散,树影像一层层压下来,适合毒蛇与潜伏兽狩猎。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-08', 'monster-13', 'monster-07'],
|
||||
hostileNpcPresetIds: ['monster-08', 'monster-13', 'monster-07'],
|
||||
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
|
||||
forwardSceneId: 'wuxia-ruined-village',
|
||||
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
|
||||
@@ -687,7 +712,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '边关营地',
|
||||
description: '营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-18', 'monster-11'],
|
||||
hostileNpcPresetIds: ['monster-18', 'monster-11'],
|
||||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
|
||||
@@ -700,7 +725,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '地宫通道',
|
||||
description: '地砖尽头传来回声,石壁上的裂隙像无数只正在张望的眼。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-03', 'monster-06'],
|
||||
hostileNpcPresetIds: ['monster-03', 'monster-06'],
|
||||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
|
||||
forwardSceneId: 'wuxia-mine-depths',
|
||||
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
|
||||
@@ -713,7 +738,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '寺庙前庭',
|
||||
description: '香灰、古钟和石灯挤在一处,清净里始终藏着不安的回响。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-04', 'monster-03'],
|
||||
hostileNpcPresetIds: ['monster-04', 'monster-03'],
|
||||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
|
||||
forwardSceneId: 'wuxia-crypt-passage',
|
||||
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
|
||||
@@ -726,7 +751,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '矿道深处',
|
||||
description: '碎石与矿灯照出曲折坑道,深处总有重物挪动与甲壳摩擦声。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-06', 'monster-18'],
|
||||
hostileNpcPresetIds: ['monster-06', 'monster-18'],
|
||||
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-forge-works',
|
||||
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
|
||||
@@ -739,7 +764,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '铸坊工场',
|
||||
description: '火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-18', 'monster-04'],
|
||||
hostileNpcPresetIds: ['monster-18', 'monster-04'],
|
||||
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
|
||||
forwardSceneId: 'wuxia-palace-court',
|
||||
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
|
||||
@@ -752,7 +777,7 @@ const WUXIA_SCENES: SceneTemplate[] = [
|
||||
name: '宫苑内庭',
|
||||
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
|
||||
worldType: WorldType.WUXIA,
|
||||
monsterIds: ['monster-11', 'monster-13'],
|
||||
hostileNpcPresetIds: ['monster-11', 'monster-13'],
|
||||
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
|
||||
forwardSceneId: 'wuxia-rain-street',
|
||||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||||
@@ -768,7 +793,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '云海仙门',
|
||||
description: '云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
hostileNpcPresetIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-celestial-corridor',
|
||||
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
|
||||
@@ -781,7 +806,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '悬空仙岛',
|
||||
description: '浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-16'],
|
||||
hostileNpcPresetIds: ['monster-12', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
|
||||
@@ -794,7 +819,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '天宫长廊',
|
||||
description: '廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-14'],
|
||||
hostileNpcPresetIds: ['monster-02', 'monster-14'],
|
||||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-thunder-altar',
|
||||
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
|
||||
@@ -807,7 +832,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '灵药花圃',
|
||||
description: '灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
hostileNpcPresetIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
|
||||
@@ -820,7 +845,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '寒玉洞天',
|
||||
description: '洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-10', 'monster-12', 'monster-20'],
|
||||
hostileNpcPresetIds: ['monster-10', 'monster-12', 'monster-20'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
|
||||
forwardSceneId: 'xianxia-moon-lake',
|
||||
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
|
||||
@@ -833,7 +858,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '熔岩秘境',
|
||||
description: '热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-14', 'monster-10'],
|
||||
hostileNpcPresetIds: ['monster-14', 'monster-10'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
|
||||
@@ -846,7 +871,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '雷殿祭坛',
|
||||
description: '祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-16'],
|
||||
hostileNpcPresetIds: ['monster-02', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
|
||||
forwardSceneId: 'xianxia-star-vessel',
|
||||
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
|
||||
@@ -859,7 +884,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '星舟甲板',
|
||||
description: '甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-16', 'monster-02'],
|
||||
hostileNpcPresetIds: ['monster-12', 'monster-16', 'monster-02'],
|
||||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-floating-isle',
|
||||
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
|
||||
@@ -872,7 +897,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '月湖仙洲',
|
||||
description: '湖光像铺开的镜面,水灵、章灵与花影都可能从月色里浮出来。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-20', 'monster-14', 'monster-15'],
|
||||
hostileNpcPresetIds: ['monster-20', 'monster-14', 'monster-15'],
|
||||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
|
||||
forwardSceneId: 'xianxia-herb-garden',
|
||||
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
|
||||
@@ -885,7 +910,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '古仙遗迹',
|
||||
description: '残碑、断墙与旧阵纹密密叠在一起,最容易招来书妖和骨灵残念。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-02', 'monster-05', 'monster-12'],
|
||||
hostileNpcPresetIds: ['monster-02', 'monster-05', 'monster-12'],
|
||||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
|
||||
forwardSceneId: 'xianxia-sacred-tree',
|
||||
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
|
||||
@@ -898,7 +923,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '神木秘境',
|
||||
description: '古树根系盘踞成殿,枝叶遮天,最易孕出噬灵花与窥视灵眼。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-15', 'monster-05'],
|
||||
hostileNpcPresetIds: ['monster-15', 'monster-05'],
|
||||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
|
||||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||||
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
|
||||
@@ -911,7 +936,7 @@ const XIANXIA_SCENES: SceneTemplate[] = [
|
||||
name: '飞瀑仙崖',
|
||||
description: '瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。',
|
||||
worldType: WorldType.XIANXIA,
|
||||
monsterIds: ['monster-12', 'monster-20', 'monster-16'],
|
||||
hostileNpcPresetIds: ['monster-12', 'monster-20', 'monster-16'],
|
||||
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
|
||||
forwardSceneId: 'xianxia-cloud-gate',
|
||||
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
|
||||
@@ -926,9 +951,10 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
|
||||
|
||||
return templates.map((template, index) => {
|
||||
const characterNpcs = buildCharacterNpcPool(template.id, template.worldType);
|
||||
const hostileNpcs = template.monsterIds
|
||||
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const hostileNpcs = template.hostileNpcPresetIds
|
||||
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const mergedSceneNpcs = mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType);
|
||||
const sceneOverride = SCENE_OVERRIDES[template.id] ?? {};
|
||||
return {
|
||||
...template,
|
||||
@@ -938,7 +964,14 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
|
||||
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
|
||||
sceneOverride.forwardSceneId ?? template.forwardSceneId,
|
||||
),
|
||||
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
|
||||
narrativeResidues: template.treasureHints.slice(0, 2).map((hint, residueIndex) => ({
|
||||
id: `residue:${template.id}:${residueIndex + 1}`,
|
||||
title: `${template.name}的残痕 ${residueIndex + 1}`,
|
||||
visibleClue: hint,
|
||||
linkedFactIds: [],
|
||||
linkedThreadIds: [],
|
||||
})),
|
||||
npcs: mergedSceneNpcs,
|
||||
} satisfies ScenePreset;
|
||||
});
|
||||
}
|
||||
@@ -1027,8 +1060,9 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
|
||||
return '当前区域暂无可用实体目录。';
|
||||
}
|
||||
|
||||
const monsterText = scene.monsterIds.length > 0
|
||||
? scene.monsterIds
|
||||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(scene);
|
||||
const monsterText = hostileNpcPresetIds.length > 0
|
||||
? hostileNpcPresetIds
|
||||
.map(monsterId => getMonsterPresetById(worldType, monsterId)?.name ?? monsterId)
|
||||
.join('、')
|
||||
: '暂无明确怪物';
|
||||
@@ -1044,12 +1078,16 @@ export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: strin
|
||||
const treasureText = scene.treasureHints.length > 0
|
||||
? scene.treasureHints.join('、')
|
||||
: '暂无明确宝藏线索';
|
||||
const residueText = (scene.narrativeResidues?.length ?? 0) > 0
|
||||
? scene.narrativeResidues!.map((residue: NonNullable<ScenePresetInfo['narrativeResidues']>[number]) => `${residue.title}:${residue.visibleClue}`).join('、')
|
||||
: '暂无明显场景残痕';
|
||||
|
||||
return [
|
||||
`当前怪物:${monsterText}`,
|
||||
`当前敌对角色:${hostileNpcText}`,
|
||||
`当前场景角色:${friendlyNpcText}`,
|
||||
`当前宝藏线索:${treasureText}`,
|
||||
`当前场景残痕:${residueText}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
FunctionCategory,
|
||||
PlayerStateMode,
|
||||
SceneDirective,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
SkillStyle,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
@@ -73,7 +73,7 @@ export interface FunctionAvailabilityContext {
|
||||
inBattle: boolean;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
monsters: SceneMonster[];
|
||||
monsters: SceneHostileNpc[];
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
@@ -185,7 +185,7 @@ export function buildStateFunctionDefinitions(
|
||||
|
||||
const ALL_FUNCTIONS = buildStateFunctionDefinitions();
|
||||
|
||||
function hasAliveMonsters(monsters: SceneMonster[]) {
|
||||
function hasAliveMonsters(monsters: SceneHostileNpc[]) {
|
||||
return monsters.some((monster) => monster.hp > 0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user