@@ -39,6 +39,8 @@ import {
|
||||
KnowledgeFact,
|
||||
RoleAttributeProfile,
|
||||
SceneNarrativeResidue,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldStoryGraph,
|
||||
@@ -85,6 +87,18 @@ const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
|
||||
'magic',
|
||||
'ranged',
|
||||
]);
|
||||
const SCENE_ACT_STAGES = new Set([
|
||||
'opening',
|
||||
'expansion',
|
||||
'turning_point',
|
||||
'climax',
|
||||
'aftermath',
|
||||
] as const);
|
||||
const SCENE_ACT_ADVANCE_RULES = new Set([
|
||||
'after_primary_contact',
|
||||
'after_active_step_complete',
|
||||
'after_chapter_resolution',
|
||||
] as const);
|
||||
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
|
||||
'武器',
|
||||
'护甲',
|
||||
@@ -892,6 +906,97 @@ function normalizeLandmarkDraft(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneActStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
|
||||
SCENE_ACT_STAGES.has(entry as never),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function normalizeSceneActBlueprint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
sceneId: string,
|
||||
): SceneActBlueprint | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encounterNpcIds = toStringArray(value.encounterNpcIds);
|
||||
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
||||
const advanceRule = toText(value.advanceRule);
|
||||
const title = toText(value.title);
|
||||
const summary = toText(value.summary);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
|
||||
sceneId,
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary: summary || title || `围绕${sceneId}继续推进`,
|
||||
stageCoverage:
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
||||
linkedThreadIds: toStringArray(value.linkedThreadIds),
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
: 'after_active_step_complete',
|
||||
actGoal: toText(value.actGoal),
|
||||
transitionHook: toText(value.transitionHook),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.filter(isRecord)
|
||||
.map((entry, index) => {
|
||||
const sceneId = toText(entry.sceneId);
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: toText(entry.id, `saved-scene-chapter-${sceneId}-${index + 1}`),
|
||||
sceneId,
|
||||
title: toText(entry.title, toText(entry.sceneName, sceneId)),
|
||||
summary: toText(entry.summary),
|
||||
linkedThreadIds: toStringArray(entry.linkedThreadIds),
|
||||
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
|
||||
acts,
|
||||
} satisfies SceneChapterBlueprint;
|
||||
})
|
||||
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
@@ -979,15 +1084,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts:
|
||||
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
|
||||
threadContracts:
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
),
|
||||
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts:
|
||||
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
|
||||
threadContracts:
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
value.sceneChapterBlueprints,
|
||||
),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
),
|
||||
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
anchorPack:
|
||||
value.anchorPack && typeof value.anchorPack === 'object'
|
||||
|
||||
@@ -283,6 +283,8 @@ export function createSceneHostileNpcsFromEncounters(
|
||||
name: encounter.npcName,
|
||||
description: encounter.npcDescription,
|
||||
renderKind: 'npc' as const,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: encounter.experienceReward,
|
||||
encounter: {
|
||||
...encounter,
|
||||
xMeters: monster.xMeters,
|
||||
|
||||
@@ -1935,6 +1935,8 @@ export function createNpcBattleMonster(
|
||||
combatTags: monsterPreset.combatTags,
|
||||
attributeProfile: monsterPreset.attributeProfile,
|
||||
behaviorVectors: monsterPreset.behaviorVectors,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: encounter.experienceReward ?? 0,
|
||||
encounter: {
|
||||
...encounter,
|
||||
hostile: true,
|
||||
@@ -1987,6 +1989,8 @@ export function createNpcBattleMonster(
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
renderKind: 'npc' as const,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: 0,
|
||||
encounter: {
|
||||
...encounter,
|
||||
xMeters: 3.2,
|
||||
@@ -2008,6 +2012,8 @@ export function createNpcBattleMonster(
|
||||
hp: Math.max(baseHp, 80 + npcState.affinity),
|
||||
maxHp: Math.max(baseHp, 80 + npcState.affinity),
|
||||
renderKind: 'npc' as const,
|
||||
levelProfile: encounter.levelProfile,
|
||||
experienceReward: encounter.experienceReward ?? 0,
|
||||
encounter: {
|
||||
...encounter,
|
||||
xMeters: 3.2,
|
||||
|
||||
155
src/data/playerProgression.ts
Normal file
155
src/data/playerProgression.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { PlayerProgressionState } from '../types';
|
||||
|
||||
export interface LevelBenchmark {
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
cumulativeXpRequired: number;
|
||||
referenceStrength: number;
|
||||
baseHp: number;
|
||||
baseMana: number;
|
||||
baselineDamageScale: number;
|
||||
}
|
||||
|
||||
export const MAX_PLAYER_LEVEL = 20;
|
||||
|
||||
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 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]!
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlayerXpToNextLevel(level: number) {
|
||||
return getLevelBenchmark(level).xpToNextLevel;
|
||||
}
|
||||
|
||||
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: PlayerProgressionState['lastGrantedSource'] = 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: Partial<PlayerProgressionState> | null | undefined,
|
||||
): PlayerProgressionState {
|
||||
if (!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, getPlayerXpToNextLevel(explicitLevel));
|
||||
const lastGrantedSource =
|
||||
value.lastGrantedSource === 'quest' ||
|
||||
value.lastGrantedSource === 'hostile_npc'
|
||||
? value.lastGrantedSource
|
||||
: null;
|
||||
|
||||
return {
|
||||
...buildProgressionStateFromTotalXp(derivedTotalXp, lastGrantedSource),
|
||||
pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {QuestLogEntry, QuestStep, ScenePresetInfo} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
import type { QuestLogEntry, QuestStep, ScenePresetInfo } from '../types';
|
||||
import { WorldType } from '../types';
|
||||
import {
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromNpcTalk,
|
||||
@@ -28,7 +28,10 @@ const TEST_SCENE = {
|
||||
},
|
||||
],
|
||||
treasureHints: [],
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
|
||||
} satisfies Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
|
||||
>;
|
||||
|
||||
const CHAPTER_SCENE = {
|
||||
id: 'palace_court',
|
||||
@@ -56,7 +59,10 @@ const CHAPTER_SCENE = {
|
||||
},
|
||||
],
|
||||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>;
|
||||
} satisfies Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
|
||||
>;
|
||||
|
||||
const OVERRIDDEN_SCENE = {
|
||||
id: 'wuxia-palace-court',
|
||||
@@ -84,10 +90,13 @@ const OVERRIDDEN_SCENE = {
|
||||
},
|
||||
],
|
||||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||||
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | '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);
|
||||
const step = quest.steps?.find((item) => item.id === stepId);
|
||||
expect(step).toBeTruthy();
|
||||
return step!;
|
||||
}
|
||||
@@ -109,7 +118,11 @@ describe('questFlow', () => {
|
||||
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
|
||||
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
|
||||
expect(quest?.status).toBe('active');
|
||||
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward');
|
||||
expect(quest?.reward.experience).toBeGreaterThan(0);
|
||||
expect(quest?.rewardText).toContain('经验 +');
|
||||
expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe(
|
||||
'quest_reward',
|
||||
);
|
||||
});
|
||||
|
||||
it('advances from primary objective to report-back step and then reward-ready', () => {
|
||||
@@ -131,7 +144,10 @@ describe('questFlow', () => {
|
||||
expect(afterBattle?.objective.kind).toBe('talk_to_npc');
|
||||
expect(afterBattle?.status).toBe('active');
|
||||
|
||||
const afterReport = applyQuestProgressFromNpcTalk([afterBattle!], 'npc_scout')[0];
|
||||
const afterReport = applyQuestProgressFromNpcTalk(
|
||||
[afterBattle!],
|
||||
'npc_scout',
|
||||
)[0];
|
||||
expect(afterReport?.status).toBe('ready_to_turn_in');
|
||||
expect(isQuestReadyToClaim(afterReport!)).toBe(true);
|
||||
});
|
||||
@@ -157,6 +173,7 @@ describe('questFlow', () => {
|
||||
reward: {
|
||||
affinityBonus: 10,
|
||||
currency: 20,
|
||||
experience: 0,
|
||||
items: [],
|
||||
},
|
||||
rewardText: 'Legacy reward text',
|
||||
@@ -178,6 +195,7 @@ describe('questFlow', () => {
|
||||
expect(quest).toBeTruthy();
|
||||
expect(quest?.chapterId).toBe('chapter:scene:palace_court');
|
||||
expect(quest?.sceneId).toBe('palace_court');
|
||||
expect(quest?.reward.experience).toBeGreaterThan(0);
|
||||
expect(quest?.steps?.map((step) => step.kind)).toEqual([
|
||||
'talk_to_npc',
|
||||
'defeat_hostile_npc',
|
||||
@@ -192,7 +210,10 @@ describe('questFlow', () => {
|
||||
});
|
||||
expect(quest).toBeTruthy();
|
||||
|
||||
const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0];
|
||||
const afterOpeningTalk = applyQuestProgressFromNpcTalk(
|
||||
[quest!],
|
||||
'npc-maid',
|
||||
)[0];
|
||||
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
|
||||
|
||||
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
|
||||
@@ -202,7 +223,10 @@ describe('questFlow', () => {
|
||||
)[0];
|
||||
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
|
||||
|
||||
const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0];
|
||||
const afterTurningTalk = applyQuestProgressFromNpcTalk(
|
||||
[afterPressure!],
|
||||
'npc-maid',
|
||||
)[0];
|
||||
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
|
||||
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
|
||||
});
|
||||
@@ -215,8 +239,14 @@ describe('questFlow', () => {
|
||||
|
||||
expect(quest).toBeTruthy();
|
||||
expect(quest?.title).toBe('查清内庭旧痕');
|
||||
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure');
|
||||
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格');
|
||||
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女');
|
||||
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe(
|
||||
'inspect_treasure',
|
||||
);
|
||||
expect(requireStep(quest!, 'step_scene_pressure').title).toBe(
|
||||
'调查回廊暗格',
|
||||
);
|
||||
expect(requireStep(quest!, 'step_scene_turning').title).toBe(
|
||||
'拿旧金牌去对问侍女',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,10 @@ import {
|
||||
getSceneHostileNpcs,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
|
||||
export const EXPLORE_APPROACH_DURATION_MS = 4000;
|
||||
export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
@@ -33,6 +37,18 @@ function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
|
||||
if (encounter.kind !== 'npc') return false;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
const npcId = getNpcEncounterKey(encounter);
|
||||
if (
|
||||
canUseLimitedPrimaryNpcChat({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
npcId,
|
||||
affinity: npcState.affinity,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
|
||||
}
|
||||
|
||||
@@ -91,11 +107,23 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
&& state.currentScenePreset?.id
|
||||
&& getWorldCampScenePreset(state.worldType)?.id === state.currentScenePreset.id,
|
||||
);
|
||||
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
const activeActNpcIdSet = new Set(activeActNpcIds);
|
||||
|
||||
return getSceneFriendlyNpcs(state.currentScenePreset)
|
||||
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id));
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id))
|
||||
.filter(candidate =>
|
||||
activeActNpcIdSet.size === 0
|
||||
? true
|
||||
: activeActNpcIdSet.has(candidate.id)
|
||||
|| (candidate.characterId ? activeActNpcIdSet.has(candidate.characterId) : false),
|
||||
);
|
||||
}
|
||||
|
||||
function getAvailableHostileSceneNpcs(state: GameState) {
|
||||
|
||||
@@ -296,6 +296,7 @@ export function buildEncounterFromSceneNpc(
|
||||
imageSrc: npc.imageSrc,
|
||||
visual: npc.visual,
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
levelProfile: npc.levelProfile,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user