init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

130
src/data/affinityLevels.ts Normal file
View File

@@ -0,0 +1,130 @@
import type { RoleRelationState } from '../types';
export type AffinityLevelId =
| 'hostile'
| 'guarded'
| 'eased'
| 'friendly'
| 'trusted'
| 'close';
export type AffinityLevelMeta = {
id: AffinityLevelId;
label: string;
minAffinity: number;
markerAffinity: number;
nextAffinity: number | null;
description: string;
accentClassName: string;
relationStance: RoleRelationState['stance'];
};
export const AFFINITY_PROGRESS_MIN = -40;
export const AFFINITY_PROGRESS_MAX = 90;
export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
{
id: 'hostile',
label: '敌对',
minAffinity: Number.NEGATIVE_INFINITY,
markerAffinity: AFFINITY_PROGRESS_MIN,
nextAffinity: 0,
description:
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
relationStance: 'hostile',
},
{
id: 'guarded',
label: '戒备',
minAffinity: 0,
markerAffinity: 0,
nextAffinity: 15,
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
relationStance: 'guarded',
},
{
id: 'eased',
label: '缓和',
minAffinity: 15,
markerAffinity: 15,
nextAffinity: 30,
description:
'戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
relationStance: 'neutral',
},
{
id: 'friendly',
label: '友善',
minAffinity: 30,
markerAffinity: 30,
nextAffinity: 60,
description:
'态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
accentClassName:
'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
relationStance: 'cooperative',
},
{
id: 'trusted',
label: '信任',
minAffinity: 60,
markerAffinity: 60,
nextAffinity: 90,
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
relationStance: 'bonded',
},
{
id: 'close',
label: '深交',
minAffinity: 90,
markerAffinity: 90,
nextAffinity: null,
description:
'关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
relationStance: 'bonded',
},
];
export const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
export const AFFINITY_PROGRESS_MARKERS = AFFINITY_LEVELS.map((level) => ({
value: level.markerAffinity,
label: level.label,
}));
export const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [
getAffinityLevelMetaById('eased').minAffinity,
getAffinityLevelMetaById('friendly').minAffinity,
getAffinityLevelMetaById('trusted').minAffinity,
getAffinityLevelMetaById('close').minAffinity,
] as const satisfies readonly [number, number, number, number];
export const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY =
getAffinityLevelMetaById('trusted').minAffinity;
export function getAffinityLevelMetaById(levelId: AffinityLevelId) {
const level = AFFINITY_LEVELS.find((entry) => entry.id === levelId);
if (!level) {
throw new Error(`Unknown affinity level id: ${levelId}`);
}
return level;
}
export function getAffinityLevelMeta(affinity: number) {
return (
[...AFFINITY_LEVELS]
.reverse()
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
);
}
export function resolveRelationStanceFromAffinity(
affinity: number,
): RoleRelationState['stance'] {
return getAffinityLevelMeta(affinity).relationStance;
}

108
src/data/attributeCombat.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { RoleAttributeProfile } from '../types';
const DEFAULT_ATTRIBUTE_SLOT_VALUE = 48;
export const ATTRIBUTE_COMBAT_BONUS_LABELS = {
axis_a: '攻击力',
axis_b: '生命上限',
axis_c: '生命恢复',
axis_d: '攻击速度',
axis_e: '暴击率',
axis_f: '暴击伤害',
} as const;
export interface RoleCombatStats {
attackPowerValue: number;
maxHpValue: number;
recoveryValue: number;
attackSpeedValue: number;
critChanceValue: number;
critDamageValue: number;
attackPowerMultiplier: number;
maxHpBonus: number;
storyRecovery: number;
turnSpeed: number;
critChance: number;
critDamageMultiplier: number;
}
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function getAttributeSlotValue(
profile: RoleAttributeProfile | null | undefined,
slotId: keyof typeof ATTRIBUTE_COMBAT_BONUS_LABELS,
) {
const value = profile?.values?.[slotId];
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
return DEFAULT_ATTRIBUTE_SLOT_VALUE;
}
export function resolveRoleCombatStats(
profile: RoleAttributeProfile | null | undefined,
options: {
baseSpeed?: number;
} = {},
): RoleCombatStats {
const attackPowerValue = getAttributeSlotValue(profile, 'axis_a');
const maxHpValue = getAttributeSlotValue(profile, 'axis_b');
const recoveryValue = getAttributeSlotValue(profile, 'axis_c');
const attackSpeedValue = getAttributeSlotValue(profile, 'axis_d');
const critChanceValue = getAttributeSlotValue(profile, 'axis_e');
const critDamageValue = getAttributeSlotValue(profile, 'axis_f');
const baseSpeed = options.baseSpeed ?? 0;
return {
attackPowerValue,
maxHpValue,
recoveryValue,
attackSpeedValue,
critChanceValue,
critDamageValue,
attackPowerMultiplier: roundNumber(1 + attackPowerValue / 240),
maxHpBonus: Math.max(1, Math.round(maxHpValue / 2)),
storyRecovery: Math.max(3, Math.round(recoveryValue / 12)),
turnSpeed: baseSpeed > 0
? roundNumber(baseSpeed * (0.55 + attackSpeedValue / 100))
: roundNumber(Math.max(1, attackSpeedValue / 12)),
critChance: roundNumber(clamp(critChanceValue / 500, 0.04, 0.24)),
critDamageMultiplier: roundNumber(
Math.max(1.45, 1.25 + critDamageValue / 120),
),
};
}
export function rollDeterministicCombatValue(seed: string) {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) % 10000) / 10000;
}
export function resolveCriticalStrike(
profile: RoleAttributeProfile | null | undefined,
seed: string,
) {
const stats = resolveRoleCombatStats(profile);
const roll = rollDeterministicCombatValue(seed);
return {
isCritical: roll < stats.critChance,
roll,
critChance: stats.critChance,
critDamageMultiplier: stats.critDamageMultiplier,
};
}

View File

@@ -0,0 +1,261 @@
import type {
AttributeMigrationTrace,
AttributeVector,
Character,
CharacterSkillDefinition,
CustomWorldItem,
CustomWorldNpc,
CustomWorldPlayableNpc,
InventoryItem,
ItemAttributeResonance,
LegacyAttributeSet,
RoleAttributeEvidence,
WorldAttributeSchema,
} from '../types';
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
import {buildDefaultAxisVector} from './attributeResolver';
import {ensureRoleAttributeProfile} from './attributeValidation';
const AXIS_KEYWORD_RULES: Array<{slotId: string; patterns: RegExp[]; weight: number; reason: string}> = [
{ slotId: 'axis_a', patterns: [/||||||||||/u], weight: 16, reason: '' },
{ slotId: 'axis_b', patterns: [/|||||||||/u], weight: 16, reason: '' },
{ slotId: 'axis_c', patterns: [/|||||||||/u], weight: 16, reason: '' },
{ slotId: 'axis_d', patterns: [/|||||||||/u], weight: 16, reason: '' },
{ slotId: 'axis_e', patterns: [/|||||||||/u], weight: 16, reason: '' },
{ slotId: 'axis_f', patterns: [/|||||||||/u], weight: 16, reason: '' },
];
const SKILL_STYLE_VECTORS: Record<CharacterSkillDefinition['style'], AttributeVector> = {
burst: buildDefaultAxisVector({ axis_a: 0.18, axis_c: 0.2, axis_d: 0.46, axis_f: 0.16 }),
steady: buildDefaultAxisVector({ axis_a: 0.16, axis_c: 0.18, axis_e: 0.14, axis_f: 0.52 }),
mobility: buildDefaultAxisVector({ axis_b: 0.52, axis_c: 0.12, axis_d: 0.16, axis_f: 0.2 }),
finisher: buildDefaultAxisVector({ axis_a: 0.3, axis_b: 0.22, axis_c: 0.2, axis_d: 0.28 }),
projectile: buildDefaultAxisVector({ axis_b: 0.26, axis_c: 0.34, axis_d: 0.1, axis_f: 0.3 }),
};
function applyKeywordWeights(
seed: AttributeVector,
sourceText: string,
evidence: RoleAttributeEvidence[],
schema: WorldAttributeSchema,
) {
AXIS_KEYWORD_RULES.forEach(rule => {
const matches = rule.patterns.reduce((count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), 0);
if (matches <= 0) return;
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
const slot = schema.slots.find(item => item.slotId === rule.slotId);
if (slot) {
evidence.push({
slotId: slot.slotId,
reason: `${slot.name}${rule.reason}`,
});
}
});
}
function buildLegacyAttributeSeed(attributes: LegacyAttributeSet) {
return buildDefaultAxisVector({
axis_a: attributes.strength * 8 + attributes.spirit * 2,
axis_b: attributes.agility * 9 + attributes.intelligence * 1,
axis_c: attributes.intelligence * 8 + attributes.agility * 2,
axis_d: attributes.spirit * 5 + attributes.strength * 4 + attributes.agility * 1,
axis_e: attributes.spirit * 4 + attributes.intelligence * 4 + attributes.agility * 1,
axis_f: attributes.spirit * 7 + attributes.strength * 3,
});
}
function uniqueEvidence(evidence: RoleAttributeEvidence[]) {
const seen = new Set<string>();
return evidence.filter(entry => {
const key = `${entry.slotId}:${entry.reason}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function buildRoleAttributeProfileFromLegacyData({
entityId,
schema,
legacyAttributes,
textBlocks,
extraWeights,
}: {
entityId: string;
schema: WorldAttributeSchema;
legacyAttributes?: LegacyAttributeSet | null;
textBlocks?: Array<string | null | undefined>;
extraWeights?: AttributeVector;
}) {
const evidence: RoleAttributeEvidence[] = [];
const seed = legacyAttributes
? buildLegacyAttributeSeed(legacyAttributes)
: buildDefaultAxisVector({
axis_a: 58,
axis_b: 58,
axis_c: 58,
axis_d: 58,
axis_e: 58,
axis_f: 58,
});
const sourceText = (textBlocks ?? []).filter(Boolean).join(' ');
if (sourceText) {
applyKeywordWeights(seed, sourceText, evidence, schema);
}
WORLD_ATTRIBUTE_SLOT_IDS.forEach(slotId => {
seed[slotId] = (seed[slotId] ?? 0) + (extraWeights?.[slotId] ?? 0);
});
const fallbackEvidence = uniqueEvidence(evidence).slice(0, 4);
const profile = ensureRoleAttributeProfile(
{
schemaId: schema.id,
values: seed,
},
schema,
{
fallbackValues: seed,
fallbackEvidence,
},
);
const trace: AttributeMigrationTrace = {
sourceCharacterId: entityId,
schemaId: schema.id,
oldAttributes: legacyAttributes ?? undefined,
inferredReasons: fallbackEvidence.map(entry => entry.reason),
fallbackUsed: false,
};
return {
profile,
trace,
};
}
export function buildCharacterAttributeProfile(character: Character, schema: WorldAttributeSchema) {
return buildRoleAttributeProfileFromLegacyData({
entityId: character.id,
schema,
legacyAttributes: character.attributes,
textBlocks: [
character.title,
character.description,
character.backstory,
character.personality,
...(character.combatTags ?? []),
...character.skills.map(skill => `${skill.name} ${skill.style} ${skill.delivery ?? ''}`),
],
}).profile;
}
export function buildCustomWorldPlayableNpcAttributeProfile(
npc: CustomWorldPlayableNpc,
schema: WorldAttributeSchema,
templateAttributes?: LegacyAttributeSet,
) {
return buildRoleAttributeProfileFromLegacyData({
entityId: npc.id,
schema,
legacyAttributes: templateAttributes,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
}).profile;
}
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
return buildRoleAttributeProfileFromLegacyData({
entityId: npc.id,
schema,
textBlocks: [
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
],
}).profile;
}
export function buildMonsterAttributeProfile(
monster: {
id: string;
name: string;
description: string;
introAction: string;
combatTags?: string[];
habitatTags?: string[];
baseStats: { attackRange: number; speed: number; maxHp: number };
},
schema: WorldAttributeSchema,
) {
return buildRoleAttributeProfileFromLegacyData({
entityId: monster.id,
schema,
textBlocks: [
monster.name,
monster.description,
monster.introAction,
...(monster.combatTags ?? []),
...(monster.habitatTags ?? []),
],
extraWeights: buildDefaultAxisVector({
axis_a: monster.baseStats.maxHp >= 150 ? 24 : 0,
axis_b: monster.baseStats.speed >= 7 ? 22 : 0,
axis_d: monster.baseStats.attackRange >= 1.5 ? 18 : 6,
axis_f: monster.baseStats.maxHp >= 180 ? 26 : 10,
}),
}).profile;
}
export function buildItemAttributeResonance(
item: Pick<InventoryItem | CustomWorldItem, 'category' | 'name' | 'description'> & {
tags?: string[];
buildProfile?: { resonanceVector?: AttributeVector | null } | null;
},
): ItemAttributeResonance {
const directVector = item.buildProfile?.resonanceVector;
if (directVector) {
return {
resonanceVector: directVector,
explanation: `${item.name}显式声明了属性共振向量。`,
};
}
const source = `${item.category} ${item.name} ${item.description ?? ''} ${(item.tags ?? []).join(' ')}`;
const vector = buildDefaultAxisVector({
axis_a: /||||||/u.test(source) ? 0.28 : 0.08,
axis_b: /||||||/u.test(source) ? 0.26 : 0.08,
axis_c: /||||||/u.test(source) ? 0.26 : 0.08,
axis_d: /|||||/u.test(source) ? 0.24 : 0.08,
axis_e: /||||||/u.test(source) ? 0.22 : 0.08,
axis_f: /||||||/u.test(source) ? 0.24 : 0.08,
});
return {
resonanceVector: vector,
explanation: `${item.name}的共振由品类与文本语义推断。`,
};
}
export function buildSkillAttributeProfile(skill: CharacterSkillDefinition) {
return {
intentVector: SKILL_STYLE_VECTORS[skill.style] ?? SKILL_STYLE_VECTORS.steady,
};
}

View File

@@ -0,0 +1,172 @@
import type {
AttributeVector,
Character,
CombatActionAttributeProfile,
CustomWorldProfile,
RoleActionDefinition,
RoleAttributeProfile,
RoleRelationState,
WorldAttributeSchema,
WorldAttributeSlot,
WorldType,
} from '../types';
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
import { resolveRelationStanceFromAffinity } from './affinityLevels';
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
import {getWorldAttributeSchema} from './worldAttributeSchemas';
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
return resolveRelationStanceFromAffinity(affinity);
}
export function buildRelationState(affinity: number): RoleRelationState {
return {
affinity,
stance: resolveRelationStance(affinity),
};
}
export function resolveAttributeSchema(
worldType: WorldType | null | undefined,
customWorldProfile?: CustomWorldProfile | null,
) {
return getWorldAttributeSchema(worldType, customWorldProfile);
}
export function resolveCharacterAttributeProfile(
character: Character,
worldType: WorldType | null | undefined,
customWorldProfile?: CustomWorldProfile | null,
) {
void customWorldProfile;
if (worldType && character.attributeProfiles?.[worldType]) {
return character.attributeProfiles[worldType] ?? character.attributeProfile;
}
if (worldType === 'CUSTOM' && character.attributeProfiles?.CUSTOM) {
return character.attributeProfiles.CUSTOM;
}
return character.attributeProfile;
}
export function getAttributeSlotValue(profile: RoleAttributeProfile | null | undefined, slotId: string) {
return profile?.values?.[slotId] ?? 0;
}
export function getNormalizedAttributeWeights(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
) {
return normalizeAttributeVector(profile?.values ?? {}, schema.slots.map(slot => slot.slotId));
}
export function scoreAttributeFit(
profile: RoleAttributeProfile | null | undefined,
vector: AttributeVector | null | undefined,
schema: WorldAttributeSchema,
) {
const weights = getNormalizedAttributeWeights(profile, schema);
const normalizedVector = normalizeAttributeVector(vector ?? {}, schema.slots.map(slot => slot.slotId));
return roundNumber(
schema.slots.reduce(
(sum, slot) => sum + (weights[slot.slotId] ?? 0) * (normalizedVector[slot.slotId] ?? 0),
0,
),
4,
);
}
export function scoreActionMatch(
actorProfile: RoleAttributeProfile | null | undefined,
action:
| Pick<RoleActionDefinition, 'intentVector' | 'resistVector' | 'baseScore'>
| Pick<CombatActionAttributeProfile, 'intentVector' | 'resistVector'>,
schema: WorldAttributeSchema,
options: {
targetProfile?: RoleAttributeProfile | null;
actorCoefficient?: number;
targetCoefficient?: number;
relationModifier?: number;
contextModifier?: number;
} = {},
) {
const actorFit = scoreAttributeFit(actorProfile, action.intentVector, schema);
const targetResistance = action.resistVector
? scoreAttributeFit(options.targetProfile, action.resistVector, schema)
: 0;
const actorCoefficient = options.actorCoefficient ?? 1;
const targetCoefficient = options.targetCoefficient ?? 1;
const baseScore = 'baseScore' in action ? action.baseScore : 0;
return roundNumber(
baseScore
+ actorFit * actorCoefficient
- targetResistance * targetCoefficient
+ (options.relationModifier ?? 0)
+ (options.contextModifier ?? 0),
4,
);
}
export function getSortedAttributeEntries(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
) {
return [...schema.slots]
.map(slot => ({
slot,
value: getAttributeSlotValue(profile, slot.slotId),
}))
.sort((left, right) => right.value - left.value);
}
export function describeTopAttributes(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
limit = 3,
) {
return getSortedAttributeEntries(profile, schema)
.slice(0, limit)
.map(entry => `${entry.slot.name}${entry.value}`);
}
export function formatAttributeList(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
limit = schema.slots.length,
) {
return getSortedAttributeEntries(profile, schema)
.slice(0, limit)
.map(entry => ({
slot: entry.slot,
value: entry.value,
}));
}
export function getLeadingAttributeSlot(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
) {
return getSortedAttributeEntries(profile, schema)[0]?.slot ?? schema.slots[0] ?? null;
}
export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) {
return schema.slots.slice(0, limit).map(slot => ({
name: slot.name,
definition: slot.definition,
}));
}
export function getSlotById(schema: WorldAttributeSchema, slotId: string): WorldAttributeSlot | null {
return schema.slots.find(slot => slot.slotId === slotId) ?? null;
}
export function buildDefaultAxisVector(overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>) {
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
result[slotId] = overrides[slotId] ?? 0;
return result;
}, {});
}

View File

@@ -0,0 +1,252 @@
import type {
AttributeVector,
RoleAttributeProfile,
WorldAttributeSchema,
WorldAttributeSlot,
WorldAttributeSlotId,
} from '../types';
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
const ATTRIBUTE_TOTAL_MIN = 300;
const ATTRIBUTE_TOTAL_MAX = 420;
const ATTRIBUTE_TOTAL_TARGET = 360;
const BANNED_ATTRIBUTE_TERMS = [
'生命',
'法力',
'护甲',
'攻击',
'防御',
'力量',
'敏捷',
'智力',
'精神',
'战士',
'法师',
'刺客',
'正道',
'魔道',
] as const;
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function roundNumber(value: number, digits = 2) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toOptionalText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toText(value: unknown, fallback: string) {
const normalized = toOptionalText(value);
return normalized || fallback;
}
function toStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return [...fallback];
}
const normalized = value
.map(item => toOptionalText(item))
.filter(Boolean);
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
}
export function coerceWorldAttributeSchema(
raw: unknown,
fallback: WorldAttributeSchema,
): WorldAttributeSchema {
if (!isRecord(raw)) {
return fallback;
}
const rawGeneratedFrom = isRecord(raw.generatedFrom) ? raw.generatedFrom : {};
const rawSlots = Array.isArray(raw.slots) ? raw.slots : [];
const candidate: WorldAttributeSchema = {
...fallback,
id: toText(raw.id, fallback.id),
worldId: toText(raw.worldId, fallback.worldId),
schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0
? Math.max(1, Math.round(raw.schemaVersion))
: fallback.schemaVersion,
schemaName: toOptionalText(raw.schemaName) || fallback.schemaName,
generatedFrom: {
...fallback.generatedFrom,
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
settingSummary: toText(rawGeneratedFrom.settingSummary, fallback.generatedFrom.settingSummary),
tone: toText(rawGeneratedFrom.tone, fallback.generatedFrom.tone),
conflictCore: toText(rawGeneratedFrom.conflictCore, fallback.generatedFrom.conflictCore),
},
slots: fallback.slots.map((fallbackSlot, index) => {
const rawSlot = isRecord(rawSlots[index]) ? rawSlots[index] : {};
return {
...fallbackSlot,
slotId: fallbackSlot.slotId,
name: toText(rawSlot.name, fallbackSlot.name),
definition: toText(rawSlot.definition, fallbackSlot.definition),
positiveSignals: toStringArray(rawSlot.positiveSignals, fallbackSlot.positiveSignals),
negativeSignals: toStringArray(rawSlot.negativeSignals, fallbackSlot.negativeSignals),
combatUseText: toText(rawSlot.combatUseText, fallbackSlot.combatUseText),
socialUseText: toText(rawSlot.socialUseText, fallbackSlot.socialUseText),
explorationUseText: toText(rawSlot.explorationUseText, fallbackSlot.explorationUseText),
};
}),
};
return validateWorldAttributeSchema(candidate).length > 0 ? fallback : candidate;
}
export function normalizeAttributeVector(
vector: AttributeVector,
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
) {
const total = slotIds.reduce((sum, slotId) => sum + Math.max(0, vector[slotId] ?? 0), 0);
if (total <= 0) {
const evenShare = 1 / Math.max(slotIds.length, 1);
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
}
return Object.fromEntries(
slotIds.map(slotId => [slotId, roundNumber(Math.max(0, vector[slotId] ?? 0) / total, 4)]),
);
}
function ensureSlotIds(slots: WorldAttributeSlot[]) {
return slots.map((slot, index) => ({
...slot,
slotId: slot.slotId ?? WORLD_ATTRIBUTE_SLOT_IDS[index] ?? `axis_${index}`,
}));
}
export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
const issues: string[] = [];
const slots = ensureSlotIds(schema.slots ?? []);
if (slots.length !== WORLD_ATTRIBUTE_SLOT_IDS.length) {
issues.push(`expected ${WORLD_ATTRIBUTE_SLOT_IDS.length} attribute slots, received ${slots.length}`);
}
const nameSet = new Set<string>();
slots.forEach(slot => {
const trimmedName = slot.name.trim();
if (!trimmedName) {
issues.push(`slot ${slot.slotId} is missing a name`);
}
if (nameSet.has(trimmedName)) {
issues.push(`duplicate attribute name "${trimmedName}"`);
}
nameSet.add(trimmedName);
if (BANNED_ATTRIBUTE_TERMS.some(term => trimmedName.includes(term))) {
issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
}
if (!slot.definition.trim()) {
issues.push(`slot ${slot.slotId} is missing a definition`);
}
if (/|||/u.test(slot.definition)) {
issues.push(`slot ${slot.slotId} definition is too derivative`);
}
if (!slot.combatUseText.trim() || !slot.socialUseText.trim() || !slot.explorationUseText.trim()) {
issues.push(`slot ${slot.slotId} must describe combat, social, and exploration usage`);
}
});
return issues;
}
export function normalizeAttributeValues(
values: AttributeVector,
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
targetTotal = ATTRIBUTE_TOTAL_TARGET,
) {
const positiveValues = slotIds.map(slotId => Math.max(0, values[slotId] ?? 0));
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
const normalized = rawTotal > 0
? positiveValues.map(value => (value / rawTotal) * targetTotal)
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
const rounded = normalized.map(value => clamp(Math.round(value), 0, 100));
let total = rounded.reduce((sum, value) => sum + value, 0);
while (total < ATTRIBUTE_TOTAL_MIN) {
const index = rounded.indexOf(Math.min(...rounded));
if (index < 0) {
break;
}
const currentValue = rounded[index] ?? 0;
rounded[index] = clamp(currentValue + 1, 0, 100);
total += 1;
}
while (total > ATTRIBUTE_TOTAL_MAX) {
const index = rounded.indexOf(Math.max(...rounded));
if (index < 0) {
break;
}
const currentValue = rounded[index] ?? 0;
rounded[index] = clamp(currentValue - 1, 0, 100);
total -= 1;
}
return Object.fromEntries(slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]));
}
export function ensureRoleAttributeProfile(
profile: Partial<RoleAttributeProfile> | null | undefined,
schema: WorldAttributeSchema,
options: {
fallbackValues?: AttributeVector;
fallbackEvidence?: RoleAttributeProfile['evidence'];
} = {},
): RoleAttributeProfile {
const slotIds = schema.slots.map(slot => slot.slotId);
const normalizedValues = normalizeAttributeValues(
{
...(options.fallbackValues ?? {}),
...(profile?.values ?? {}),
},
slotIds,
);
const sortedSlots = [...schema.slots]
.map(slot => ({
slot,
value: normalizedValues[slot.slotId] ?? 0,
}))
.sort((left, right) => right.value - left.value);
const strongestValue = sortedSlots[0]?.value ?? 0;
const topTraits = sortedSlots
.filter(entry => entry.value >= strongestValue - 8)
.slice(0, 2)
.map(entry => entry.slot.name);
return {
schemaId: profile?.schemaId ?? schema.id,
values: normalizedValues,
topTraits,
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
evidence: profile?.evidence?.length
? [...profile.evidence]
: options.fallbackEvidence?.length
? [...options.fallbackEvidence]
: sortedSlots.slice(0, 3).map(entry => ({
slotId: entry.slot.slotId as WorldAttributeSlotId,
reason: `${entry.slot.name}在当前画像中最突出。`,
})),
};
}

View File

@@ -0,0 +1,434 @@
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type EquipmentLoadout,
type GameState,
type InventoryItem,
WorldType,
} from '../types';
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
import {
getBuildContributionAttributeRows,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from './buildDamage';
import { getCharacterCombatTags } from './buildTags';
import { getCharacterById } from './characterPresets';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
function requireCharacter(characterId: string) {
const character = getCharacterById(characterId);
expect(character).toBeTruthy();
return character!;
}
function cloneCharacter(
character: Character,
overrides: Partial<Character> = {},
) {
const nextCharacter = {
...character,
...overrides,
attributes: {
...character.attributes,
...(overrides.attributes ?? {}),
},
} satisfies Character;
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
const wuxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
wuxiaSchema,
);
const xianxiaProfile = buildCharacterAttributeProfile(
nextCharacter,
xianxiaSchema,
);
return {
...nextCharacter,
attributeProfile: wuxiaProfile,
attributeProfiles: {
...nextCharacter.attributeProfiles,
[WorldType.WUXIA]: wuxiaProfile,
[WorldType.XIANXIA]: xianxiaProfile,
},
} satisfies Character;
}
function buildEquipmentItem(params: {
id: string;
name: string;
slot: 'weapon' | 'armor' | 'relic';
role: string;
tags: string[];
setId?: string;
setName?: string;
}): InventoryItem {
return {
id: params.id,
category:
params.slot === 'weapon'
? 'weapon'
: params.slot === 'armor'
? 'armor'
: 'relic',
name: params.name,
quantity: 1,
rarity: 'rare',
tags: params.tags,
equipmentSlotId: params.slot,
buildProfile: {
role: params.role,
tags: params.tags,
setId: params.setId,
setName: params.setName,
pieceName: params.slot,
synergy: params.tags,
},
};
}
function buildGameState(
loadout: EquipmentLoadout,
activeBuildBuffs: GameState['activeBuildBuffs'] = [],
) {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'test-scene',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'melee',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 100,
playerMaxMana: 100,
playerSkillCooldowns: {},
activeBuildBuffs,
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: loadout,
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies GameState;
}
describe('buildDamage', () => {
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
const character = requireCharacter('sword-princess');
const breakdown = getCompanionBuildDamageBreakdown(character);
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
expect(breakdown.rows.length).toBeGreaterThan(0);
breakdown.rows.forEach((row) => {
const contributionSum = Object.values(row.attributeContributions).reduce(
(sum, value) => sum + value,
0,
);
const modifierSum = Object.values(row.attributeModifierDeltas).reduce(
(sum, value) => sum + value,
0,
);
const attributeRows = getBuildContributionAttributeRows(row, schema);
const activeSlots = Object.entries(row.attributeModifierDeltas).filter(
([, value]) => value > 0.0001,
);
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
expect(attributeRows.length).toBeGreaterThan(0);
expect(activeSlots.length).toBeLessThanOrEqual(2);
attributeRows.forEach((attributeRow) => {
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
});
});
});
it('removing one tag only removes that tag row and does not recalculate shared rows', () => {
const baseCharacter = requireCharacter('sword-princess');
const combatTags = getCharacterCombatTags(baseCharacter);
expect(combatTags.length).toBeGreaterThanOrEqual(3);
const fullBreakdown = getCompanionBuildDamageBreakdown(
cloneCharacter(baseCharacter, {
combatTags,
}),
);
const trimmedBreakdown = getCompanionBuildDamageBreakdown(
cloneCharacter(baseCharacter, {
combatTags: combatTags.slice(0, 2),
}),
);
const sharedLabels = combatTags.slice(0, 2);
sharedLabels.forEach((label) => {
const fullRow = fullBreakdown.rows.find((row) => row.label === label);
const trimmedRow = trimmedBreakdown.rows.find(
(row) => row.label === label,
);
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
});
expect(
trimmedBreakdown.rows.find((row) => row.label === combatTags[2]),
).toBeUndefined();
});
it('keeps the same build multiplier for different attribute profiles when tags are unchanged', () => {
const baseCharacter = requireCharacter('sword-princess');
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
const loadout = {
weapon: buildEquipmentItem({
id: 'test-weapon',
name: 'Test Weapon',
slot: 'weapon',
role: primaryTag,
tags: [primaryTag, secondaryTag],
setId: 'set-duelist',
setName: 'Duelist',
}),
armor: buildEquipmentItem({
id: 'test-armor',
name: 'Test Armor',
slot: 'armor',
role: secondaryTag,
tags: [primaryTag, secondaryTag],
setId: 'set-duelist',
setName: 'Duelist',
}),
relic: null,
} satisfies EquipmentLoadout;
const agileCharacter = cloneCharacter(baseCharacter, {
attributes: {
strength: 7,
agility: 11,
intelligence: 3,
spirit: 3,
},
});
const mageCharacter = cloneCharacter(baseCharacter, {
attributes: {
strength: 3,
agility: 4,
intelligence: 10,
spirit: 9,
},
});
const agileBreakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout),
agileCharacter,
);
const mageBreakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout),
mageCharacter,
);
expect(agileBreakdown.buildDamageMultiplier).toBe(
mageBreakdown.buildDamageMultiplier,
);
expect(agileBreakdown.buildDamageBonus).toBe(
mageBreakdown.buildDamageBonus,
);
});
it('includes both buff tags and set tags in the final additive build bonus', () => {
const character = requireCharacter('sword-princess');
const [primaryTag, secondaryTag] = getCharacterCombatTags(character);
const loadout = {
weapon: buildEquipmentItem({
id: 'set-weapon',
name: 'Set Weapon',
slot: 'weapon',
role: primaryTag,
tags: [primaryTag],
setId: 'set-runner',
setName: 'Runner',
}),
armor: buildEquipmentItem({
id: 'set-armor',
name: 'Set Armor',
slot: 'armor',
role: secondaryTag,
tags: [secondaryTag],
setId: 'set-runner',
setName: 'Runner',
}),
relic: null,
} satisfies EquipmentLoadout;
const breakdown = getPlayerBuildDamageBreakdown(
buildGameState(loadout, [
{
id: 'buff-1',
sourceType: 'skill',
sourceId: 'test-skill',
name: 'Test Buff',
tags: [primaryTag],
durationTurns: 2,
},
]),
character,
);
expect(breakdown.rows.some((row) => row.source === 'buff')).toBe(true);
expect(breakdown.rows.some((row) => row.source === 'set')).toBe(true);
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
});
it('uses different source coefficients for weapon, armor, and relic tags', () => {
const character = requireCharacter('sword-princess');
const equipmentOnlyTag = 'balanced';
const weaponBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'weapon-only',
name: 'Weapon Only',
slot: 'weapon',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
armor: null,
relic: null,
}),
character,
);
const armorBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: null,
armor: buildEquipmentItem({
id: 'armor-only',
name: 'Armor Only',
slot: 'armor',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
relic: null,
}),
character,
);
const relicBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: null,
armor: null,
relic: buildEquipmentItem({
id: 'relic-only',
name: 'Relic Only',
slot: 'relic',
role: equipmentOnlyTag,
tags: [equipmentOnlyTag],
}),
}),
character,
);
const weaponRow = weaponBreakdown.rows.find(
(row) => row.source === 'weapon',
);
const armorRow = armorBreakdown.rows.find((row) => row.source === 'armor');
const relicRow = relicBreakdown.rows.find((row) => row.source === 'relic');
expect(weaponRow?.sourceCoefficient).toBe(0.85);
expect(armorRow?.sourceCoefficient).toBe(0.75);
expect(relicRow?.sourceCoefficient).toBe(0.8);
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(
relicRow?.bonusDelta ?? 0,
);
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(
armorRow?.bonusDelta ?? 0,
);
});
it('does not allow resource attributes to enter tag bonus rows', () => {
const character = requireCharacter('sword-princess');
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
const mpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'mana-weapon',
name: 'Mana Weapon',
slot: 'weapon',
role: 'mana',
tags: ['mana'],
}),
armor: null,
relic: null,
}),
character,
);
const hpBreakdown = getPlayerBuildDamageBreakdown(
buildGameState({
weapon: buildEquipmentItem({
id: 'fortress-weapon',
name: 'Fortress Weapon',
slot: 'weapon',
role: 'fortress',
tags: ['fortress'],
}),
armor: null,
relic: null,
}),
character,
);
const mpRow = mpBreakdown.rows.find((row) => row.source === 'weapon');
const hpRow = hpBreakdown.rows.find((row) => row.source === 'weapon');
const mpAttributeRows = mpRow
? getBuildContributionAttributeRows(mpRow, schema)
: [];
const hpAttributeRows = hpRow
? getBuildContributionAttributeRows(hpRow, schema)
: [];
expect(
mpAttributeRows.every(
(attribute) => !attribute.slotId.startsWith('resource_'),
),
).toBe(true);
expect(
hpAttributeRows.every(
(attribute) => !attribute.slotId.startsWith('resource_'),
),
).toBe(true);
});
});

915
src/data/buildDamage.ts Normal file
View File

@@ -0,0 +1,915 @@
import type {
AttributeVector,
Character,
CustomWorldProfile,
EquipmentLoadout,
GameState,
InventoryItem,
SceneHostileNpc,
TimedBuildBuff,
WorldAttributeSchema,
} from '../types';
import { WorldType } from '../types';
import {
resolveCriticalStrike,
resolveRoleCombatStats,
} from './attributeCombat';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from './attributeResolver';
import { normalizeAttributeVector } from './attributeValidation';
import { getBuildTagAttributeSimilarityProfile } from './buildTagAttributeAffinity';
import {
buildSetBuildTagLabel,
getBuildTagDefinition,
getCharacterCombatTags,
getSceneMonsterCombatTags,
getTimedBuildBuffTags,
normalizeBuildRole,
normalizeBuildTags,
} from './buildTags';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
import { getEquipmentBonuses } from './equipmentEffects';
const MAX_ACTIVE_BUILD_TAGS = 8;
export const BASE_TAG_BONUS = 0.12;
export const MAX_BUILD_BONUS = 0.6;
export type BuildContributionQuality =
| 'common'
| 'fine'
| 'rare'
| 'epic'
| 'legendary';
export type BuildContributionResourceLabels = {
maxHp?: string | null;
maxMp?: string | null;
};
export type BuildTagSource =
| 'buff'
| 'character'
| 'weapon'
| 'armor'
| 'relic'
| 'set'
| 'monster';
type ResolvedBuildTag = {
label: string;
source: BuildTagSource;
priority: number;
relatedTags?: string[];
};
export type BuildContributionRow = {
label: string;
source: BuildTagSource;
fitScore: number;
sourceCoefficient: number;
bonusDelta: number;
attributeSimilarities: AttributeVector;
attributeWeights: AttributeVector;
attributeContributions: AttributeVector;
attributeModifierDeltas: AttributeVector;
};
export type BuildDamageBreakdown = {
tags: string[];
baseTagCount: number;
buildDamageBonus: number;
buildDamageMultiplier: number;
rows: BuildContributionRow[];
};
export type BuildContributionAttributeRow = {
slotId: string;
label: string;
definition: string;
similarity: number;
weight: number;
value: number;
modifierDelta: number;
percent: number;
};
export type OutgoingDamageResult = {
damage: number;
isCritical: boolean;
critChance: number;
critDamageMultiplier: number;
attackPowerMultiplier: number;
};
type BuildContributionTarget = {
slotId: string;
label: string;
definition: string;
};
type ResolvedTagAffinity = {
rawSimilarity: AttributeVector;
};
const BUILD_CONTRIBUTION_QUALITY_LEVELS: Array<{
tier: BuildContributionQuality;
label: string;
minimumBonus: number;
colorRatio: number;
}> = [
{ tier: 'legendary', label: '传说', minimumBonus: 0.06, colorRatio: 1 },
{ tier: 'epic', label: '史诗', minimumBonus: 0.045, colorRatio: 0.78 },
{ tier: 'rare', label: '稀有', minimumBonus: 0.03, colorRatio: 0.56 },
{ tier: 'fine', label: '优秀', minimumBonus: 0.018, colorRatio: 0.32 },
{ tier: 'common', label: '普通', minimumBonus: 0, colorRatio: 0.08 },
];
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function getSourceCoefficient(source: BuildTagSource) {
switch (source) {
case 'buff':
return 1;
case 'character':
return 0.9;
case 'weapon':
return 0.85;
case 'armor':
return 0.75;
case 'relic':
return 0.8;
case 'set':
return 0.9;
case 'monster':
return 0.88;
default:
return 0.8;
}
}
function pushTag(
target: ResolvedBuildTag[],
label: string | null | undefined,
source: BuildTagSource,
priority: number,
relatedTags?: string[],
) {
const normalizedLabel = label?.trim();
if (!normalizedLabel) return;
target.push({
label: normalizedLabel,
source,
priority,
relatedTags:
relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
});
}
function getItemBuildTags(item: InventoryItem | null) {
if (!item?.buildProfile) return [];
return normalizeBuildTags([
normalizeBuildRole(item.buildProfile.role),
...(item.buildProfile.tags ?? []),
]);
}
function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
if (!loadout) return [];
const tags: ResolvedBuildTag[] = [];
const setPieces = new Map<
string,
{ count: number; tags: string[]; setName: string }
>();
(
[
['weapon', loadout.weapon],
['armor', loadout.armor],
['relic', loadout.relic],
] as const
).forEach(([slotId, item]) => {
if (!item) return;
const itemTags = getItemBuildTags(item);
itemTags.forEach((tag) => pushTag(tags, tag, slotId, 60));
const setId = item.buildProfile?.setId?.trim();
const setName = item.buildProfile?.setName?.trim();
if (!setId || !setName) return;
const entry = setPieces.get(setId) ?? {
count: 0,
tags: [],
setName,
};
entry.count += 1;
entry.tags = [...new Set([...entry.tags, ...itemTags])];
setPieces.set(setId, entry);
});
setPieces.forEach((entry) => {
if (entry.count < 2) return;
pushTag(
tags,
buildSetBuildTagLabel(entry.setName, entry.count),
'set',
entry.count >= 3 ? 70 : 65,
entry.tags,
);
});
return tags;
}
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
const bestByLabel = new Map<string, ResolvedBuildTag>();
tags.forEach((tag) => {
const existing = bestByLabel.get(tag.label);
if (!existing || tag.priority > existing.priority) {
bestByLabel.set(tag.label, tag);
}
});
return [...bestByLabel.values()]
.sort(
(left, right) =>
right.priority - left.priority ||
left.label.localeCompare(right.label, 'zh-CN'),
)
.slice(0, MAX_ACTIVE_BUILD_TAGS);
}
function averageAttributeVectors(
vectors: AttributeVector[],
slotIds: readonly string[],
) {
if (vectors.length === 0) {
const evenShare = 1 / Math.max(slotIds.length, 1);
return Object.fromEntries(slotIds.map((slotId) => [slotId, evenShare]));
}
return Object.fromEntries(
slotIds.map((slotId) => [
slotId,
roundNumber(
vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) /
vectors.length,
4,
),
]),
);
}
function resolveTagAffinity(
tag: ResolvedBuildTag,
schema: WorldAttributeSchema,
) {
const definition = getBuildTagDefinition(tag.label);
if (definition) {
return {
rawSimilarity: getBuildTagAttributeSimilarityProfile(
definition.id,
schema,
).rawSimilarity,
} satisfies ResolvedTagAffinity;
}
const relatedSchemaAffinities = (tag.relatedTags ?? []).flatMap(
(relatedTag) => {
const relatedDefinition = getBuildTagDefinition(relatedTag);
if (!relatedDefinition) {
return [];
}
return [
getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema)
.rawSimilarity,
];
},
);
const rawSimilarity = averageAttributeVectors(
relatedSchemaAffinities,
schema.slots.map((slot) => slot.slotId),
);
return {
rawSimilarity,
} satisfies ResolvedTagAffinity;
}
function resolveContributionTargets(
schema: WorldAttributeSchema,
_resourceLabels?: BuildContributionResourceLabels | null,
) {
return schema.slots.map((slot) => ({
slotId: slot.slotId,
label: slot.name,
definition: slot.definition,
})) satisfies BuildContributionTarget[];
}
function buildAttributeContributions(
tagAffinity: ResolvedTagAffinity,
schema: WorldAttributeSchema,
sourceCoefficient: number,
resourceLabels?: BuildContributionResourceLabels | null,
) {
const targets = resolveContributionTargets(schema, resourceLabels);
const slotIds = targets.map((target) => target.slotId);
const rawSimilarity = Object.fromEntries(
targets.map((target) => {
return [
target.slotId,
roundNumber(tagAffinity.rawSimilarity[target.slotId] ?? 0, 4),
];
}),
);
const normalizedAffinity = normalizeAttributeVector(rawSimilarity, slotIds);
const effectiveSlotIds = new Set(
[...slotIds]
.sort((left, right) => {
const difference =
(normalizedAffinity[right] ?? 0) - (normalizedAffinity[left] ?? 0);
if (Math.abs(difference) > 0.0001) {
return difference;
}
return left.localeCompare(right, 'zh-CN');
})
.slice(0, 2),
);
const attributeContributions = Object.fromEntries(
slotIds.map((slotId) => [
slotId,
roundNumber(
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
4,
),
]),
);
const attributeWeights = Object.fromEntries(
slotIds.map((slotId) => [
slotId,
roundNumber(
effectiveSlotIds.has(slotId) ? (normalizedAffinity[slotId] ?? 0) : 0,
4,
),
]),
);
const attributeModifierDeltas = Object.fromEntries(
slotIds.map((slotId) => [
slotId,
roundNumber(
BASE_TAG_BONUS *
sourceCoefficient *
(attributeContributions[slotId] ?? 0),
4,
),
]),
);
const fitScore = roundNumber(
slotIds.reduce(
(sum, slotId) => sum + (attributeContributions[slotId] ?? 0),
0,
),
4,
);
return {
fitScore,
attributeWeights,
normalizedAffinity,
attributeContributions,
attributeModifierDeltas,
};
}
function buildBreakdownFromTags(
tags: ResolvedBuildTag[],
schema: WorldAttributeSchema,
resourceLabels?: BuildContributionResourceLabels | null,
): BuildDamageBreakdown {
if (tags.length === 0) {
return {
tags: [],
baseTagCount: 0,
buildDamageBonus: 0,
buildDamageMultiplier: 1,
rows: [],
};
}
const rows = tags.map((currentTag) => {
const tagAffinity = resolveTagAffinity(currentTag, schema);
const sourceCoefficient = getSourceCoefficient(currentTag.source);
const {
fitScore,
attributeWeights,
normalizedAffinity,
attributeContributions,
attributeModifierDeltas,
} = buildAttributeContributions(
tagAffinity,
schema,
sourceCoefficient,
resourceLabels,
);
const bonusDelta = roundNumber(
Object.values(attributeModifierDeltas).reduce(
(sum, value) => sum + value,
0,
),
4,
);
return {
label: currentTag.label,
source: currentTag.source,
fitScore,
sourceCoefficient,
bonusDelta,
attributeSimilarities: normalizedAffinity,
attributeWeights,
attributeContributions,
attributeModifierDeltas,
} satisfies BuildContributionRow;
});
const buildDamageBonus = roundNumber(
clamp(
rows.reduce((sum, row) => sum + row.bonusDelta, 0),
0,
MAX_BUILD_BONUS,
),
4,
);
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
return {
tags: tags.map((tag) => tag.label),
baseTagCount: tags.length,
buildDamageBonus,
buildDamageMultiplier,
rows,
};
}
export function getBuildSourceLabel(source: BuildTagSource) {
switch (source) {
case 'buff':
return '\u589e\u76ca';
case 'character':
return '\u89d2\u8272\u56fa\u6709';
case 'weapon':
return '\u6b66\u5668';
case 'armor':
return '\u62a4\u7532';
case 'relic':
return '\u9970\u54c1';
case 'set':
return '\u5957\u88c5';
case 'monster':
return '\u602a\u7269';
default:
return '\u6807\u7b7e';
}
}
export function getBuildContributionQuality(
bonusDelta: number,
): (typeof BUILD_CONTRIBUTION_QUALITY_LEVELS)[number] {
const fallbackLevel =
BUILD_CONTRIBUTION_QUALITY_LEVELS[
BUILD_CONTRIBUTION_QUALITY_LEVELS.length - 1
] ?? BUILD_CONTRIBUTION_QUALITY_LEVELS[0]!;
return (
BUILD_CONTRIBUTION_QUALITY_LEVELS.find(
(level) => bonusDelta >= level.minimumBonus,
) ?? fallbackLevel
);
}
export function getBuildContributionQualityLabel(bonusDelta: number) {
return getBuildContributionQuality(bonusDelta).label;
}
export function getBuildContributionQualityRatio(bonusDelta: number) {
return getBuildContributionQuality(bonusDelta).colorRatio;
}
export function formatBuildContributionPercent(value: number, digits = 1) {
const percentValue = roundNumber(value * 100, digits);
const normalizedDigits = Math.max(0, digits);
return `${percentValue >= 0 ? '+' : ''}${percentValue.toFixed(normalizedDigits)}%`;
}
export function getBuildContributionAttributeRows(
row: Pick<
BuildContributionRow,
| 'attributeContributions'
| 'attributeModifierDeltas'
| 'attributeSimilarities'
| 'attributeWeights'
>,
schema: WorldAttributeSchema,
options: {
minimumValue?: number;
resourceLabels?: BuildContributionResourceLabels | null;
} = {},
) {
const minimumValue = options.minimumValue ?? 0.0001;
const totalModifierDelta = Object.values(
row.attributeModifierDeltas ?? {},
).reduce((sum, value) => sum + value, 0);
const targets = resolveContributionTargets(schema, options.resourceLabels);
return targets
.map((target) => {
const value = roundNumber(
row.attributeContributions[target.slotId] ?? 0,
4,
);
const modifierDelta = roundNumber(
row.attributeModifierDeltas?.[target.slotId] ?? 0,
4,
);
const percent =
totalModifierDelta > 0
? roundNumber(modifierDelta / totalModifierDelta, 4)
: 0;
return {
slotId: target.slotId,
label: target.label,
definition: target.definition,
similarity: roundNumber(
row.attributeSimilarities?.[target.slotId] ?? 0,
4,
),
weight: roundNumber(row.attributeWeights?.[target.slotId] ?? 0, 4),
value,
modifierDelta,
percent,
} satisfies BuildContributionAttributeRow;
})
.filter(
(entry) =>
entry.value > minimumValue || entry.modifierDelta > minimumValue,
)
.sort(
(left, right) =>
right.modifierDelta - left.modifierDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
}
export function describeBuildContribution(
row: Pick<
BuildContributionRow,
| 'attributeContributions'
| 'attributeModifierDeltas'
| 'attributeSimilarities'
| 'attributeWeights'
>,
schema: WorldAttributeSchema,
options: {
limit?: number;
resourceLabels?: BuildContributionResourceLabels | null;
} = {},
) {
const limit = options.limit ?? 2;
const topRows = getBuildContributionAttributeRows(row, schema, options).slice(
0,
limit,
);
if (topRows.length === 0) {
return '\u6682\u65e0\u53ef\u89c1\u5c5e\u6027\u52a0\u6210';
}
return topRows
.map(
(entry) =>
`${entry.label} ${formatBuildContributionPercent(entry.modifierDelta)}`,
)
.join('');
}
function getPlayerBuffs(gameState: GameState) {
return (gameState.activeBuildBuffs ?? []).filter(
(buff) => (buff.durationTurns ?? 0) > 0,
);
}
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
return (buffs ?? [])
.map((buff) => ({
...buff,
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
}))
.filter((buff) => buff.durationTurns > 0);
}
export function appendBuildBuffs(
baseBuffs: TimedBuildBuff[] | null | undefined,
additions: TimedBuildBuff[] | null | undefined,
) {
const merged = new Map<string, TimedBuildBuff>();
[...(baseBuffs ?? []), ...(additions ?? [])].forEach((buff) => {
const existing = merged.get(buff.id);
if (
!existing ||
(buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)
) {
merged.set(buff.id, {
...buff,
tags: normalizeBuildTags(buff.tags),
});
}
});
return [...merged.values()].filter(
(buff) => buff.tags.length > 0 && buff.durationTurns > 0,
);
}
export function getPlayerBuildDamageBreakdown(
gameState: GameState,
character: Character,
) {
const tags: ResolvedBuildTag[] = [];
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach((tag) =>
pushTag(tags, tag, 'buff', 100),
);
getCharacterCombatTags(character).forEach((tag) =>
pushTag(tags, tag, 'character', 90),
);
getLoadoutBuildTags(gameState.playerEquipment).forEach((tag) =>
tags.push(tag),
);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
);
}
export function getCompanionBuildDamageBreakdown(
character: Character,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const tags: ResolvedBuildTag[] = [];
getCharacterCombatTags(character).forEach((tag) =>
pushTag(tags, tag, 'character', 90),
);
const resolvedWorldType =
worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
);
}
export function getMonsterBuildDamageBreakdown(
monster: SceneHostileNpc,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const tags: ResolvedBuildTag[] = [];
getSceneMonsterCombatTags(monster).forEach((tag) =>
pushTag(tags, tag, 'monster', 90),
);
const resolvedWorldType =
worldType ??
(monster.attributeProfile?.schemaId?.includes('xianxia')
? WorldType.XIANXIA
: customWorldProfile
? WorldType.CUSTOM
: WorldType.WUXIA);
return buildBreakdownFromTags(
dedupeAndLimitTags(tags),
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
);
}
export function calculateOutgoingDamage(
baseDamage: number,
options: {
functionMultiplier?: number;
equipmentMultiplier?: number;
buildMultiplier?: number;
attackPowerMultiplier?: number;
} = {},
) {
return Math.max(
1,
Math.round(
baseDamage *
(options.functionMultiplier ?? 1) *
(options.equipmentMultiplier ?? 1) *
(options.buildMultiplier ?? 1) *
(options.attackPowerMultiplier ?? 1),
),
);
}
export function calculateOutgoingDamageResult(
baseDamage: number,
options: {
functionMultiplier?: number;
equipmentMultiplier?: number;
buildMultiplier?: number;
attackPowerMultiplier?: number;
criticalHit?: boolean;
critDamageMultiplier?: number;
critChance?: number;
} = {},
): OutgoingDamageResult {
const baseResolvedDamage = calculateOutgoingDamage(baseDamage, options);
const isCritical = options.criticalHit ?? false;
const critDamageMultiplier = options.critDamageMultiplier ?? 1;
return {
damage: Math.max(
1,
Math.round(baseResolvedDamage * (isCritical ? critDamageMultiplier : 1)),
),
isCritical,
critChance: options.critChance ?? 0,
critDamageMultiplier,
attackPowerMultiplier: options.attackPowerMultiplier ?? 1,
};
}
export function resolvePlayerOutgoingDamage(
gameState: GameState,
character: Character,
baseDamage: number,
functionMultiplier = 1,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
gameState.worldType,
gameState.customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
return calculateOutgoingDamage(baseDamage, {
functionMultiplier,
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolvePlayerOutgoingDamageResult(
gameState: GameState,
character: Character,
baseDamage: number,
functionMultiplier = 1,
critRollSeed?: string,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
gameState.worldType,
gameState.customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(attributeProfile, critRollSeed)
: null;
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}
export function resolveCompanionOutgoingDamage(
character: Character,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
return calculateOutgoingDamage(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolveCompanionOutgoingDamageResult(
character: Character,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
critRollSeed?: string,
) {
const attributeProfile = resolveCharacterAttributeProfile(
character,
worldType,
customWorldProfile,
);
const combatStats = resolveRoleCombatStats(attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(attributeProfile, critRollSeed)
: null;
const buildBreakdown = getCompanionBuildDamageBreakdown(
character,
worldType,
customWorldProfile,
);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}
export function resolveMonsterOutgoingDamage(
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
const buildBreakdown = getMonsterBuildDamageBreakdown(
monster,
worldType,
customWorldProfile,
);
return calculateOutgoingDamage(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
});
}
export function resolveMonsterOutgoingDamageResult(
monster: SceneHostileNpc,
baseDamage: number,
functionMultiplier = 1,
worldType: WorldType | null = null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
critRollSeed?: string,
) {
const combatStats = resolveRoleCombatStats(monster.attributeProfile);
const criticalStrike = critRollSeed
? resolveCriticalStrike(monster.attributeProfile, critRollSeed)
: null;
const buildBreakdown = getMonsterBuildDamageBreakdown(
monster,
worldType,
customWorldProfile,
);
return calculateOutgoingDamageResult(baseDamage, {
functionMultiplier,
buildMultiplier: buildBreakdown.buildDamageMultiplier,
attackPowerMultiplier: combatStats.attackPowerMultiplier,
criticalHit: criticalStrike?.isCritical ?? false,
critChance: combatStats.critChance,
critDamageMultiplier: combatStats.critDamageMultiplier,
});
}

View File

@@ -0,0 +1,184 @@
import type {AttributeVector, WorldAttributeSchema, WorldAttributeSlot} from '../types';
import {normalizeAttributeVector} from './attributeValidation';
type BuildTagAttributeAffinityMap = Record<string, AttributeVector>;
type SemanticAxisRule = {
axisId: string;
patterns: RegExp[];
};
const SEMANTIC_SLOT_RULES: SemanticAxisRule[] = [
{
axisId: 'axis_a',
patterns: [/||||||||||||||||||/u],
},
{
axisId: 'axis_b',
patterns: [/||||||||||穿||||/u],
},
{
axisId: 'axis_c',
patterns: [/|||||||||穿||||||||/u],
},
{
axisId: 'axis_d',
patterns: [/||||||||||||||||/u],
},
{
axisId: 'axis_e',
patterns: [/|||||||||||||||||/u],
},
{
axisId: 'axis_f',
patterns: [/|||||||||||||||/u],
},
];
function roundNumber(value: number, digits = 4) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function affinity(
strength: number,
agility: number,
intelligence: number,
spirit: number,
): AttributeVector {
return {
axis_a: roundNumber(strength * 0.72 + spirit * 0.28),
axis_b: roundNumber(agility * 0.88 + intelligence * 0.12),
axis_c: roundNumber(intelligence * 0.78 + agility * 0.22),
axis_d: roundNumber(strength * 0.62 + agility * 0.18 + intelligence * 0.2),
axis_e: roundNumber(spirit * 0.72 + intelligence * 0.28),
axis_f: roundNumber(spirit * 0.74 + strength * 0.26),
};
}
function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
const sourceText = [
slot.slotId,
slot.name,
slot.definition,
slot.combatUseText,
slot.socialUseText,
slot.explorationUseText,
...(slot.positiveSignals ?? []),
...(slot.negativeSignals ?? []),
].join(' ');
const semanticVector: AttributeVector = {};
SEMANTIC_SLOT_RULES.forEach((rule, ruleIndex) => {
let score = 0;
if (slot.slotId === rule.axisId) {
score += 2.2;
} else if (slot.slotId === `axis_${String.fromCharCode(97 + ruleIndex)}`) {
score += 1.2;
}
score += rule.patterns.reduce((sum, pattern) => sum + (pattern.test(sourceText) ? 1 : 0), 0);
if (score > 0) {
semanticVector[rule.axisId] = roundNumber(score, 4);
}
});
if (Object.keys(semanticVector).length === 0) {
const fallbackAxisId = SEMANTIC_SLOT_RULES[index]?.axisId ?? slot.slotId;
semanticVector[fallbackAxisId] = 1;
}
return normalizeAttributeVector(
semanticVector,
SEMANTIC_SLOT_RULES.map(rule => rule.axisId),
);
}
function calculateVectorSimilarity(left: AttributeVector, right: AttributeVector) {
return roundNumber(
Object.keys({...left, ...right}).reduce(
(sum, key) => sum + ((left[key] ?? 0) * (right[key] ?? 0)),
0,
),
4,
);
}
function resolveSchemaAwareAffinity(tagAffinity: AttributeVector, schema: WorldAttributeSchema) {
const rawSimilarity = Object.fromEntries(
schema.slots.map((slot, index) => [
slot.slotId,
calculateVectorSimilarity(tagAffinity, buildSlotSemanticVector(slot, index)),
]),
);
return {
rawSimilarity,
normalizedSimilarity: normalizeAttributeVector(
rawSimilarity,
schema.slots.map(slot => slot.slotId),
),
};
}
export const BUILD_TAG_ATTRIBUTE_AFFINITY: BuildTagAttributeAffinityMap = {
quickblade: affinity(0.35, 1, 0.1, 0.05),
combo: affinity(0.3, 0.92, 0.18, 0.08),
dash: affinity(0.45, 0.95, 0, 0),
pursuit: affinity(0.38, 0.88, 0.08, 0.02),
swiftstrike: affinity(0.22, 0.98, 0.12, 0.04),
ranged: affinity(0.18, 0.82, 0.34, 0.08),
guerrilla: affinity(0.24, 0.9, 0.28, 0.12),
mobility: affinity(0.18, 1, 0.08, 0.08),
windrun: affinity(0.08, 1, 0.1, 0.1),
heavyhit: affinity(1, 0.28, 0.02, 0.04),
burst: affinity(0.72, 0.58, 0.36, 0.08),
armorbreak: affinity(0.92, 0.28, 0.08, 0.02),
pressure: affinity(0.62, 0.64, 0.1, 0.08),
bloodrush: affinity(0.84, 0.54, 0.04, 0.18),
guard: affinity(0.7, 0.18, 0.04, 0.72),
barrier: affinity(0.48, 0.08, 0.2, 0.92),
heavyarmor: affinity(0.88, 0.04, 0.02, 0.54),
counter: affinity(0.66, 0.46, 0.14, 0.36),
banish: affinity(0.24, 0.06, 0.54, 0.88),
caster: affinity(0, 0.1, 1, 0.6),
mana: affinity(0.02, 0.08, 0.94, 0.74),
thunder: affinity(0.06, 0.24, 0.96, 0.42),
formation: affinity(0.08, 0.12, 0.82, 0.96),
control: affinity(0.12, 0.34, 0.78, 0.72),
overload: affinity(0.14, 0.18, 0.92, 0.38),
heal: affinity(0.02, 0.08, 0.56, 1),
support: affinity(0.14, 0.14, 0.58, 0.98),
sustain: affinity(0.34, 0.18, 0.22, 0.9),
fate: affinity(0.08, 0.22, 0.72, 0.84),
fortune: affinity(0.06, 0.34, 0.7, 0.78),
cooldown: affinity(0.04, 0.46, 0.82, 0.4),
command: affinity(0.38, 0.26, 0.72, 0.82),
balanced: affinity(0.58, 0.58, 0.58, 0.58),
craft: affinity(0.24, 0.16, 0.74, 0.5),
alchemy: affinity(0.08, 0.16, 0.84, 0.76),
vanguard: affinity(0.82, 0.44, 0.08, 0.34),
berserk: affinity(0.98, 0.42, 0, 0.22),
spellblade: affinity(0.42, 0.42, 0.88, 0.38),
paladin: affinity(0.58, 0.12, 0.42, 0.96),
fortress: affinity(0.94, 0.04, 0.08, 0.82),
starter: affinity(0.42, 0.42, 0.42, 0.42),
};
export function getBuildTagAttributeAffinity(tagId: string, schema?: WorldAttributeSchema) {
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
if (!schema) {
return semanticAffinity;
}
return resolveSchemaAwareAffinity(semanticAffinity, schema).normalizedSimilarity;
}
export function getBuildTagAttributeSimilarityProfile(tagId: string, schema: WorldAttributeSchema) {
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
return resolveSchemaAwareAffinity(semanticAffinity, schema);
}

297
src/data/buildTags.ts Normal file
View File

@@ -0,0 +1,297 @@
import type {
AttributeVector,
BuildTagCategory,
BuildTagDefinition,
Character,
SceneHostileNpc,
TimedBuildBuff,
} from '../types';
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
const TAG_CATEGORIES = {
flow: '流派' as BuildTagCategory,
style: '风格' as BuildTagCategory,
resource: '资源' as BuildTagCategory,
defense: '防御' as BuildTagCategory,
element: '元素' as BuildTagCategory,
craft: '工艺' as BuildTagCategory,
} satisfies Record<string, BuildTagCategory>;
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: '快速近身施压。' },
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: '连续命中与多段输出节奏。' },
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: '拉近身位并抢先发起接敌。' },
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: '追身压制与后续补刀。' },
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: '短窗口刺杀与弱点爆发。' },
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: '依靠身位经营的中远程输出。' },
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: '打了就走的周旋消耗。' },
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: '高机动与快速换位。' },
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: '轻身疾行带来的速度优势。' },
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: '重击蓄势与一击压迫。' },
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: '短时间内抬高伤害峰值。' },
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: '专破防御与硬目标。' },
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: '靠连续进攻掌控节奏。' },
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: '残血时用安全换更高输出。' },
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: '稳定承伤与前线站位。' },
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: '护体、防护与异常抵抗。' },
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: '甲胄硬度与站场能力。' },
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: '抓住破绽后的反制回击。' },
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: '压制邪祟与敌对异力。' },
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: '以术法驱动输出与控场。' },
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: '围绕法力池展开的消耗与回转。' },
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: '雷属性打击与瞬时压制。' },
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: '预布效果与战场塑形。' },
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: '限制对手移动与出手选择。' },
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: '高消耗高回报的施法窗口。' },
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: '恢复状态并重整战线。' },
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: '增益队友并稳住队形。' },
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: '持久在线战斗的稳定性。' },
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: '围绕标记、触发与命数循环。' },
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: '时机与机缘触发价值。' },
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: '加快轮转与恢复速度。' },
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: '协调队伍行动与站位。' },
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: '泛用稳健、风险较低的成长路线。' },
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: '工艺制造、装置与工程支援。' },
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: '药剂调配与临时强化。' },
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: '抢前排节奏并打开战线。' },
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: '高风险高收益的进攻态势。' },
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: '兵刃与术法并行的混合战斗。' },
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: '兼顾防护、恢复与神圣惩戒。' },
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: '极端防守与反打锚点。' },
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: '适合作为起手与新手成型路线。' },
];
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({
...definition,
attributeAffinity: getBuildTagAttributeAffinity(definition.id),
}));
const CHARACTER_BUILD_TAGS: Record<string, string[]> = {
'sword-princess': ['快剑', '突进', '压制'],
'archer-hero': ['远射', '游击', '风行'],
'girl-hero': ['快袭', '连段', '追击'],
'punch-hero': ['重击', '爆发', '压血'],
'fighter-4': ['守御', '护体', '先锋'],
};
const STRUCTURAL_BUILD_TAGS = new Set([
'weapon',
'armor',
'relic',
'material',
'consumable',
'rare',
'wuxia',
'xianxia',
'neutral',
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
]);
const aliasToCanonical = new Map<string, string>();
const definitionByLabel = new Map<string, BuildTagDefinition>();
for (const definition of BUILD_TAG_DEFINITIONS) {
definitionByLabel.set(definition.label, definition);
aliasToCanonical.set(definition.label.toLowerCase(), definition.label);
definition.aliases.forEach(alias => aliasToCanonical.set(alias.toLowerCase(), definition.label));
}
function normalizeLookupValue(value: string) {
return value.trim().toLowerCase();
}
function uniqueTags(tags: string[]) {
return [...new Set(tags.filter(Boolean))];
}
function normalizeAffinity(affinity: AttributeVector) {
const values = Object.values(affinity);
const magnitude = Math.hypot(...values);
if (magnitude <= 0.0001) {
return Object.fromEntries(Object.keys(affinity).map(key => [key, 0]));
}
return Object.fromEntries(
Object.entries(affinity).map(([key, value]) => [key, value / magnitude]),
);
}
function calculateAffinitySimilarity(left: AttributeVector, right: AttributeVector) {
const normalizedLeft = normalizeAffinity(left);
const normalizedRight = normalizeAffinity(right);
return Object.keys({...normalizedLeft, ...normalizedRight}).reduce(
(sum, key) => sum + ((normalizedLeft[key] ?? 0) * (normalizedRight[key] ?? 0)),
0,
);
}
export function getBuildTagDefinitions() {
return BUILD_TAG_DEFINITIONS.map(definition => ({
...definition,
aliases: [...definition.aliases],
attributeAffinity: { ...definition.attributeAffinity },
}));
}
export function getBuildTagDefinition(tag: string | null | undefined) {
const normalized = normalizeBuildTag(tag);
return normalized ? definitionByLabel.get(normalized) ?? null : null;
}
export function normalizeBuildTag(tag: string | null | undefined) {
const value = tag?.trim();
if (!value) return null;
if (STRUCTURAL_BUILD_TAGS.has(value) || /^set:/iu.test(value) || /^piece:/iu.test(value)) return null;
const canonical = aliasToCanonical.get(normalizeLookupValue(value));
if (canonical) return canonical;
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) {
return value;
}
return null;
}
export function normalizeBuildRole(role: string | null | undefined) {
const normalized = normalizeBuildTag(role);
if (normalized) return normalized;
const value = role?.trim();
if (!value) return '均衡';
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) return value;
return '均衡';
}
export function normalizeBuildTags(tags: string[] | null | undefined, maxCount?: number) {
const normalized = uniqueTags((tags ?? [])
.map(normalizeBuildTag)
.filter((tag): tag is string => Boolean(tag)));
return typeof maxCount === 'number' ? normalized.slice(0, maxCount) : normalized;
}
export function getBuildTagSimilarity(left: string, right: string) {
const normalizedLeft = normalizeBuildTag(left);
const normalizedRight = normalizeBuildTag(right);
if (!normalizedLeft || !normalizedRight) return 0;
if (normalizedLeft === normalizedRight) return 1;
const leftDefinition = definitionByLabel.get(normalizedLeft);
const rightDefinition = definitionByLabel.get(normalizedRight);
if (!leftDefinition || !rightDefinition) return 0;
const affinitySimilarity = calculateAffinitySimilarity(
leftDefinition.attributeAffinity,
rightDefinition.attributeAffinity,
);
const categoryBonus = leftDefinition.category === rightDefinition.category ? 0.08 : 0;
return Math.min(1, Number((affinitySimilarity + categoryBonus).toFixed(4)));
}
export function getSimilarBuildTags(tag: string, minimumSimilarity = 0.55) {
const normalized = normalizeBuildTag(tag);
if (!normalized) return [];
return BUILD_TAG_DEFINITIONS
.map(definition => definition.label)
.filter(label => label !== normalized)
.map(label => ({
label,
similarity: getBuildTagSimilarity(normalized, label),
}))
.filter(entry => entry.similarity >= minimumSimilarity)
.sort((left, right) => right.similarity - left.similarity || left.label.localeCompare(right.label, 'zh-CN'))
.map(entry => entry.label);
}
export function getCharacterCombatTags(character: Character) {
if (character.combatTags?.length) {
return normalizeBuildTags(character.combatTags, 3);
}
const fixedTags = CHARACTER_BUILD_TAGS[character.id];
if (fixedTags) {
return normalizeBuildTags(fixedTags, 3);
}
const derivedTags: string[] = [];
const styles = new Set(character.skills.map(skill => skill.style));
if (styles.has('mobility')) derivedTags.push('突进');
if (styles.has('projectile')) derivedTags.push('远射');
if (styles.has('finisher')) derivedTags.push('重击');
if (styles.has('burst')) derivedTags.push('爆发');
if (styles.has('steady')) derivedTags.push('连段');
const {strength, agility, intelligence, spirit} = character.attributes;
if (agility >= Math.max(strength, intelligence, spirit)) derivedTags.push('机动');
if (intelligence + spirit >= strength + 2) derivedTags.push('法修');
if (strength >= intelligence && strength >= spirit) derivedTags.push('压制');
return normalizeBuildTags(derivedTags, 3);
}
function inferMonsterTagsFromText(source: string) {
const tags: string[] = [];
const normalized = source.toLowerCase();
if (/|lightning|thunder|storm/u.test(normalized)) tags.push('');
if (/|formation|sigil|seal/u.test(normalized)) tags.push('');
if (/|control|bind|freeze|curse/u.test(normalized)) tags.push('');
if (/|holy|banish|purge/u.test(normalized)) tags.push('');
if (/|counter|parry/u.test(normalized)) tags.push('');
return tags;
}
export function getSceneMonsterCombatTags(monster: SceneHostileNpc) {
if (monster.combatTags?.length) {
return normalizeBuildTags(monster.combatTags, 3);
}
const derivedTags: string[] = [];
derivedTags.push(...inferMonsterTagsFromText(`${monster.name} ${monster.action} ${monster.description}`));
if (monster.speed >= 7) derivedTags.push('机动');
if (monster.attackRange >= 1.6) derivedTags.push('突进');
if (monster.hp >= 150) derivedTags.push('重击');
if (monster.hp >= 170) derivedTags.push('守御');
return normalizeBuildTags(derivedTags, 3);
}
export function getTimedBuildBuffTags(buffs: TimedBuildBuff[] | null | undefined) {
return normalizeBuildTags(
(buffs ?? [])
.filter(buff => (buff.durationTurns ?? 0) > 0)
.flatMap(buff => buff.tags),
6,
);
}
export function buildSetBuildTagLabel(setName: string, pieceCount: number) {
const normalizedName = setName
.split('/')
.map(part => part.trim())
.find(Boolean) ?? setName.trim();
return pieceCount >= 3 ? `宗匠${normalizedName}` : `套装${normalizedName}`;
}
export function buildEmbeddingPromptText(definition: BuildTagDefinition) {
return `${definition.label}${definition.description} 别名:${definition.aliases.join('、')}。属性亲和:${Object.entries(definition.attributeAffinity)
.map(([slotId, value]) => `${slotId} ${value}`)
.join('')}`;
}

127
src/data/characterCombat.ts Normal file
View File

@@ -0,0 +1,127 @@
import {
AnimationState,
Character,
CharacterAnimationConfig,
CharacterSkillDefinition,
CombatDelivery,
SpriteSequenceDefinition,
} from '../types';
const DEFAULT_FRAME_MS = 100;
const DEFAULT_FPS = 10;
function getCharacterRoot(character: Character) {
return `/character/${encodeURIComponent(character.assetFolder)}/${encodeURIComponent(character.assetVariant)}`;
}
function buildFramesFromConfig(
character: Character,
config: CharacterAnimationConfig,
folderPrefix = 'Hero',
) {
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
const extension = config.extension ?? 'png';
if (normalizedBasePath) {
if (config.file) {
return [`${normalizedBasePath}/${encodeURIComponent(config.file)}`];
}
const frames: string[] = [];
const startFrame = config.startFrame ?? 1;
for (let index = 0; index < config.frames; index += 1) {
const frameNumber = (startFrame + index).toString().padStart(2, '0');
frames.push(`${normalizedBasePath}/${config.prefix}${frameNumber}.${extension}`);
}
return frames;
}
const root = getCharacterRoot(character);
const folder = encodeURIComponent(config.folder);
if (config.file) {
return [`${root}/${folderPrefix}/${folder}/${encodeURIComponent(config.file)}`];
}
const frames: string[] = [];
const startFrame = config.startFrame ?? 1;
for (let index = 0; index < config.frames; index += 1) {
const frameNumber = (startFrame + index).toString().padStart(2, '0');
frames.push(`${root}/${folderPrefix}/${folder}/${config.prefix}${frameNumber}.${extension}`);
}
return frames;
}
function buildFramesFromAsset(
character: Character,
sequence: Extract<SpriteSequenceDefinition, { source: 'asset' }>,
) {
const root = getCharacterRoot(character);
const folder = sequence.folder
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
if (sequence.file) {
return [`${root}/${folder}/${encodeURIComponent(sequence.file)}`];
}
const frames: string[] = [];
const totalFrames = Math.max(1, sequence.frames ?? 1);
const startFrame = sequence.startFrame ?? 1;
const extension = sequence.extension ?? 'png';
for (let index = 0; index < totalFrames; index += 1) {
const frameNumber = (startFrame + index).toString().padStart(2, '0');
frames.push(`${root}/${folder}/${sequence.prefix ?? ''}${frameNumber}.${extension}`);
}
return frames;
}
export function getCharacterAnimationConfig(
character: Character,
animation: AnimationState,
) {
return character.animationMap?.[animation] ?? null;
}
export function getCharacterAnimationDurationMs(
character: Character,
animation: AnimationState,
) {
const config = getCharacterAnimationConfig(character, animation);
if (!config) return DEFAULT_FRAME_MS;
return Math.max(DEFAULT_FRAME_MS, config.frames * DEFAULT_FRAME_MS);
}
export function getSequenceFps(sequence: SpriteSequenceDefinition) {
return sequence.fps ?? DEFAULT_FPS;
}
export function getSequenceDurationMs(sequence: SpriteSequenceDefinition, frameCount: number) {
const fps = getSequenceFps(sequence);
return Math.max(DEFAULT_FRAME_MS, Math.ceil((Math.max(1, frameCount) * 1000) / fps));
}
export function resolveSequenceFrames(
character: Character,
sequence: SpriteSequenceDefinition,
) {
if (sequence.source === 'animation') {
const config = getCharacterAnimationConfig(character, sequence.animation);
return config ? buildFramesFromConfig(character, config) : [];
}
return buildFramesFromAsset(character, sequence);
}
export function getSkillCasterAnimation(skill: CharacterSkillDefinition) {
return skill.casterAnimation ?? skill.animation;
}
export function getSkillDelivery(skill: CharacterSkillDefinition): CombatDelivery {
return skill.delivery ?? (skill.style === 'projectile' ? 'ranged' : 'melee');
}

View File

@@ -0,0 +1,6 @@
{
"sword-princess": {
"generatedVisualAssetId": "visual-1775558475200",
"portrait": "/generated-characters/sword-princess/visual/visual-1775558475200/master.png"
}
}

View File

@@ -0,0 +1,286 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder';
import { AnimationState } from '../types';
import {
buildCustomWorldPlayableCharacters,
buildCustomWorldRuntimeCharacters,
getCharacterById,
resolveEncounterRecruitCharacter,
setRuntimeCharacterOverrides,
} from './characterPresets';
import { setRuntimeCustomWorldProfile } from './customWorldRuntime';
function createRole(index: number) {
return {
name: `角色${index + 1}`,
title: `头衔${index + 1}`,
role: `身份${index + 1}`,
description: `角色描述${index + 1}`,
backstory: `角色背景${index + 1}`,
personality: `角色性格${index + 1}`,
motivation: `角色动机${index + 1}`,
combatStyle: `角色战斗风格${index + 1}`,
initialAffinity: 18,
relationshipHooks: [`关系${index + 1}`],
tags: [`标签${index + 1}`],
backstoryReveal: {
publicSummary: `公开背景${index + 1}`,
chapters: [
{
id: `surface-${index + 1}`,
title: '表层来意',
affinityRequired: 10,
teaser: `提示${index + 1}-1`,
content: `内容${index + 1}-1`,
contextSnippet: `摘要${index + 1}-1`,
},
{
id: `scar-${index + 1}`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `提示${index + 1}-2`,
content: `内容${index + 1}-2`,
contextSnippet: `摘要${index + 1}-2`,
},
{
id: `hidden-${index + 1}`,
title: '隐藏执念',
affinityRequired: 55,
teaser: `提示${index + 1}-3`,
content: `内容${index + 1}-3`,
contextSnippet: `摘要${index + 1}-3`,
},
{
id: `final-${index + 1}`,
title: '最终底牌',
affinityRequired: 80,
teaser: `提示${index + 1}-4`,
content: `内容${index + 1}-4`,
contextSnippet: `摘要${index + 1}-4`,
},
],
},
skills: [
{ name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' },
{ name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' },
{ name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' },
],
initialItems: [
{
name: `武器${index + 1}`,
category: '武器',
quantity: 1,
rarity: 'rare' as const,
description: '武器描述',
tags: ['武器标签'],
},
{
name: `补给${index + 1}`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon' as const,
description: '补给描述',
tags: ['补给标签'],
},
{
name: `信物${index + 1}`,
category: '专属物品',
quantity: 1,
rarity: 'rare' as const,
description: '信物描述',
tags: ['信物标签'],
},
],
};
}
describe('characterPresets custom world runtime characters', () => {
afterEach(() => {
setRuntimeCharacterOverrides(null);
setRuntimeCustomWorldProfile(null);
});
it('hydrates story npcs into runtime characters and preserves custom dossiers', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '裂潮边城',
subtitle: '潮痕未褪',
summary: '一座围绕潮路、断桥和夜港旧案展开的世界。',
tone: '潮湿、压抑、克制',
playerGoal: '查清夜港失踪案和潮路背后的势力牵连。',
templateWorldType: 'WUXIA',
playableNpcs: Array.from({ length: 5 }, (_, index) =>
createRole(index),
),
storyNpcs: [
{
...createRole(10),
name: '沈雾',
title: '潮路领航人',
role: '夜港向导',
description: '熟悉潮路暗栈与旧渡的人。',
backstory: '曾在断桥坠潮夜里失去整队同伴。',
personality: '谨慎冷静,先观察再表态。',
motivation: '想把失踪航线重新找出来。',
combatStyle: '短刀试探后再借地形逼近。',
initialAffinity: 12,
relationshipHooks: ['断桥旧案', '夜港潮路'],
tags: ['码头', '潮路', '短刀'],
imageSrc: '/custom/npcs/shenwu.png',
generatedVisualAssetId: 'visual-custom-shenwu',
generatedAnimationSetId: 'animation-set-custom-shenwu',
animationMap: {
[AnimationState.IDLE]: {
folder: 'idle',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
},
[AnimationState.ATTACK]: {
folder: 'attack',
prefix: 'frame',
frames: 8,
startFrame: 1,
extension: 'png',
basePath:
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/attack',
},
},
visual: {
race: 'human',
bodyColor: 'blue',
headIndex: 2,
hairColorIndex: 3,
hairStyleFrame: 5,
facialHairEnabled: false,
facialHairColorIndex: 1,
facialHairStyleFrame: 0,
mainHand: {
type: 'melee',
file: 'dagger.png',
frameIndex: 4,
},
},
},
{
...createRole(11),
name: '陆沉',
title: '断桥守更',
role: '守桥人',
description: '夜里守着断桥口旧灯火的人。',
},
{
...createRole(12),
name: '顾潮',
title: '潮册记录员',
role: '账房记录员',
description: '在潮账房里整理失踪名册的人。',
},
],
landmarks: [
{
name: '夜港旧栈',
description: '潮雾和旧木桥把视线切成断续几段。',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '断桥外沿',
relativePosition: 'forward',
summary: '顺着潮路继续前压就是断桥外沿。',
},
],
},
{
name: '断桥外沿',
description: '旧桥断口还挂着潮湿残旗。',
sceneNpcNames: ['沈雾', '陆沉', '顾潮'],
connections: [
{
targetLandmarkName: '夜港旧栈',
relativePosition: 'back',
summary: '沿旧潮路退回夜港旧栈。',
},
],
},
],
},
'玩家想要一个围绕夜港潮路与断桥旧案展开的世界。',
);
setRuntimeCustomWorldProfile(profile);
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
setRuntimeCharacterOverrides(runtimeCharacters);
const storyRole = profile.storyNpcs[0];
expect(storyRole).toBeTruthy();
const storyCharacter = getCharacterById(storyRole!.id);
const runtimeStoryCharacter = runtimeCharacters.find(
(character) => character.id === storyRole!.id,
);
expect(storyCharacter).toBeTruthy();
expect(runtimeStoryCharacter).toBeTruthy();
expect(storyCharacter?.name).toBe('沈雾');
expect(storyCharacter?.title).toBe('潮路领航人');
expect(storyCharacter?.backstory).toContain('断桥坠潮夜');
expect(storyCharacter?.skills[0]?.name).toBe('技能11-1');
expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png');
expect(storyCharacter?.generatedVisualAssetId).toBe(
'visual-custom-shenwu',
);
expect(storyCharacter?.generatedAnimationSetId).toBe(
'animation-set-custom-shenwu',
);
expect(storyCharacter?.animationMap?.[AnimationState.IDLE]?.basePath).toBe(
'/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle',
);
expect(storyCharacter?.visual).toEqual(storyRole?.visual);
expect(storyCharacter?.groundOffsetY).toBe(22);
const recruitCharacter = resolveEncounterRecruitCharacter({
characterId: storyRole!.id,
context: storyRole!.role,
npcName: storyRole!.name,
});
expect(recruitCharacter?.id).toBe(storyRole!.id);
expect(recruitCharacter?.name).toBe('沈雾');
});
it('uses draft playable role image directly before generated animations exist', () => {
const profile = buildExpandedCustomWorldProfile(
{
name: '潮雾列岛',
subtitle: '灯塔未眠',
summary: '围绕潮雾、灯塔和失踪航路展开的世界。',
tone: '冷峻、潮湿、悬疑',
playerGoal: '找到灯塔失踪航路。',
templateWorldType: 'WUXIA',
playableNpcs: [
{
...createRole(0),
id: 'playable-lighthouse-keeper',
imageSrc: '/generated-characters/lighthouse-keeper/portrait.png',
generatedVisualAssetId: 'assetobj-lighthouse-keeper',
generatedAnimationSetId: undefined,
animationMap: undefined,
},
],
},
'玩家想测试灯塔守望者草稿。',
);
const [playableCharacter] = buildCustomWorldPlayableCharacters(profile);
expect(playableCharacter?.portrait).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.avatar).toBe(
'/generated-characters/lighthouse-keeper/portrait.png',
);
expect(playableCharacter?.animationMap).toBeUndefined();
});
});

2061
src/data/characterPresets.ts Normal file

File diff suppressed because it is too large Load Diff

128
src/data/companionRoster.ts Normal file
View File

@@ -0,0 +1,128 @@
import { CompanionState, GameState } from '../types';
import { MAX_COMPANIONS } from './npcInteractions';
function upsertCompanion(list: CompanionState[], companion: CompanionState) {
const next = [...list];
const existingIndex = next.findIndex(item => item.npcId === companion.npcId);
if (existingIndex >= 0) {
next[existingIndex] = companion;
return next;
}
next.push(companion);
return next;
}
function removeCompanion(list: CompanionState[], npcId: string) {
return list.filter(item => item.npcId !== npcId);
}
export function getRecruitedNpcIds(state: Pick<GameState, 'companions' | 'roster'>) {
return new Set([
...state.companions.map(companion => companion.npcId),
...state.roster.map(companion => companion.npcId),
]);
}
export function normalizeRoster(roster: CompanionState[], activeCompanions: CompanionState[]) {
const activeIds = new Set(activeCompanions.map(companion => companion.npcId));
return roster
.filter(companion => !activeIds.has(companion.npcId))
.reduce<CompanionState[]>((next, companion) => upsertCompanion(next, companion), []);
}
export function benchActiveCompanion(state: GameState, npcId: string) {
const activeCompanion = state.companions.find(companion => companion.npcId === npcId);
if (!activeCompanion) return state;
return {
...state,
companions: state.companions.filter(companion => companion.npcId !== npcId),
roster: upsertCompanion(state.roster, activeCompanion),
};
}
export function activateRosterCompanion(state: GameState, npcId: string, swapNpcId?: string | null) {
const reserveCompanion = state.roster.find(companion => companion.npcId === npcId);
if (!reserveCompanion) return state;
if (state.companions.some(companion => companion.npcId === npcId)) {
return {
...state,
roster: removeCompanion(state.roster, npcId),
};
}
if (state.companions.length < MAX_COMPANIONS) {
return {
...state,
companions: [...state.companions, reserveCompanion],
roster: removeCompanion(state.roster, npcId),
};
}
if (!swapNpcId) return state;
const swapIndex = state.companions.findIndex(companion => companion.npcId === swapNpcId);
if (swapIndex < 0) return state;
const swappedOut = state.companions[swapIndex];
if (!swappedOut) {
return state;
}
const nextCompanions = [...state.companions];
nextCompanions[swapIndex] = reserveCompanion;
return {
...state,
companions: nextCompanions,
roster: upsertCompanion(removeCompanion(state.roster, npcId), swappedOut),
};
}
export function recruitCompanionToParty(
state: GameState,
companion: CompanionState,
replacedNpcId?: string | null,
) {
const nextReserve = removeCompanion(state.roster, companion.npcId);
if (!replacedNpcId && state.companions.length < MAX_COMPANIONS) {
return {
...state,
companions: [...state.companions, companion],
roster: nextReserve,
};
}
if (!replacedNpcId) {
return {
...state,
companions: state.companions.slice(0, MAX_COMPANIONS),
roster: normalizeRoster(nextReserve, state.companions),
};
}
const replaceIndex = state.companions.findIndex(item => item.npcId === replacedNpcId);
if (replaceIndex < 0) {
return {
...state,
companions: [...state.companions, companion].slice(0, MAX_COMPANIONS),
roster: nextReserve,
};
}
const replacedCompanion = state.companions[replaceIndex];
if (!replacedCompanion) {
return state;
}
const nextCompanions = [...state.companions];
nextCompanions[replaceIndex] = companion;
return {
...state,
companions: nextCompanions,
roster: upsertCompanion(nextReserve, replacedCompanion),
};
}

View File

@@ -0,0 +1,164 @@
import { type CustomWorldThemeMode,detectCustomWorldThemeMode } from '../services/customWorldTheme';
import { type Character, type CustomWorldPlayableNpc, type CustomWorldProfile } from '../types';
import { normalizeBuildTags } from './buildTags';
type CustomWorldTagProfile = Pick<
CustomWorldProfile,
'name' | 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'
>;
type CustomWorldTagRole = Pick<
CustomWorldPlayableNpc,
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
>;
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
'sword-princess': ['\u5feb\u5251', '\u7a81\u8fdb', '\u538b\u5236'],
'archer-hero': ['\u8fdc\u5c04', '\u6e38\u51fb', '\u673a\u52a8'],
'girl-hero': ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
'punch-hero': ['\u91cd\u51fb', '\u7206\u53d1', '\u538b\u8840'],
'fighter-4': ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
};
const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
mythic: [],
martial: [],
arcane: ['\u6cd5\u4fee', '\u7b26\u9635', '\u6cd5\u529b'],
machina: ['\u5de5\u5de7', '\u63a7\u573a', '\u62a4\u4f53'],
tide: ['\u673a\u52a8', '\u7eed\u6218', '\u8fdc\u5c04'],
rift: ['\u6cd5\u4fee', '\u63a7\u573a', '\u8fc7\u8f7d'],
};
const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
{
pattern: /\u5f13|\u7bad|\u5f29|\u72d9|\u8fdc\u7a0b|\u8239|\u822a|\u5de1|\u730e|\u5c04/u,
tags: ['\u8fdc\u5c04', '\u673a\u52a8'],
},
{
pattern: /\u5251|\u5203|\u5200|\u950b|\u4fa0|\u6597\u4fee|\u5feb\u5251/u,
tags: ['\u5feb\u5251', '\u7a81\u8fdb'],
},
{
pattern: /\u62f3|\u9524|\u65a7|\u7206\u53d1|\u91cd\u51fb|\u9707/u,
tags: ['\u91cd\u51fb', '\u7206\u53d1'],
},
{
pattern: /\u76fe|\u5b88|\u536b|\u9635|\u524d\u950b|\u9547\u5b88|\u7532/u,
tags: ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
},
{
pattern: /\u836f|\u533b|\u7597|\u4e39|\u9732|\u8349|\u8c37|\u6108|\u8865\u7ed9/u,
tags: ['\u56de\u590d', '\u7eed\u6218', '\u70bc\u836f'],
},
{
pattern: /\u7b26|\u9635|\u5492|\u7075|\u6cd5|\u4fee|\u9053|\u4ed9|\u79d8\u5883|\u88c2\u9699|\u754c\u95e8|\u754c\u57df/u,
tags: ['\u6cd5\u4fee', '\u63a7\u573a', '\u7b26\u9635'],
},
{
pattern: /\u96f7|\u9706|\u7535/u,
tags: ['\u96f7\u6cd5', '\u7206\u53d1'],
},
{
pattern: /\u673a\u5173|\u5668\u4fee|\u953b|\u94f8|\u5de5\u574a|\u6cd5\u5668|\u673a\u5de7/u,
tags: ['\u5de5\u5de7', '\u62a4\u4f53'],
},
{
pattern: /\u6697|\u5f71|\u6f5c|\u4f0f|\u523a|\u591c|\u8c0d|\u8ffd\u67e5|\u65e7\u6848|\u5de1\u67e5/u,
tags: ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
},
{
pattern: /\u6307\u6325|\u7edf\u9886|\u519b|\u9635\u7ebf|\u961f\u957f|\u53f7\u4ee4/u,
tags: ['\u7edf\u5fa1', '\u6276\u6301', '\u5148\u950b'],
},
];
function uniqueStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
}
function inferBuildTagsFromTexts(values: string[]) {
const source = values.join(' ');
if (!source.trim()) {
return [] as string[];
}
return normalizeBuildTags(
TEXT_TAG_RULES
.filter(rule => rule.pattern.test(source))
.flatMap(rule => rule.tags),
);
}
export function deriveCustomWorldCombatTags(
profile: CustomWorldTagProfile,
role: CustomWorldTagRole,
options: {
fallbackTags?: string[];
templateCharacterId?: string | null;
maxCount?: number;
} = {},
) {
const sourceTexts = uniqueStrings([
profile.name,
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
role.name,
role.title,
role.description,
role.backstory,
role.personality,
role.combatStyle,
...(role.tags ?? []),
]);
const explicitTags = normalizeBuildTags(role.tags, 6);
const inferredTags = inferBuildTagsFromTexts(sourceTexts);
const themeTags = THEME_FALLBACK_TAGS[detectCustomWorldThemeMode(profile)];
const templateTags = options.templateCharacterId
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
: [];
return normalizeBuildTags([
...explicitTags,
...inferredTags,
...themeTags,
...templateTags,
...(options.fallbackTags ?? []),
]).slice(0, options.maxCount ?? 3);
}
export function mergeCustomWorldPlayableNpcTags(
profile: CustomWorldTagProfile,
role: CustomWorldTagRole,
options: {
fallbackTags?: string[];
templateCharacterId?: string | null;
maxCount?: number;
} = {},
) {
const combatTags = deriveCustomWorldCombatTags(profile, role, options);
const templateTags = options.templateCharacterId
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
: [];
return uniqueStrings([
...combatTags,
...role.tags,
...templateTags,
...(options.fallbackTags ?? []),
]).slice(0, options.maxCount ?? 5);
}
export function deriveCustomWorldCharacterCombatTags(
profile: CustomWorldTagProfile,
role: CustomWorldTagRole,
baseCharacter: Character,
) {
return deriveCustomWorldCombatTags(profile, role, {
fallbackTags: normalizeBuildTags(baseCharacter.combatTags, 3),
templateCharacterId: baseCharacter.id,
maxCount: 3,
});
}

View File

@@ -0,0 +1,392 @@
import {
Character,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
EquipmentSlotId,
InventoryItem,
} from '../types';
import {
buildRuntimeCustomWorldInventoryItems,
getRuntimeCustomWorldProfile,
type RuntimeCustomWorldItemQueryOptions,
} from './customWorldRuntime';
const CATEGORY_ORDER = new Map<string, number>([
['武器', 0],
['护甲', 1],
['饰品', 2],
['消耗品', 3],
['材料', 4],
['稀有品', 5],
['专属物品', 6],
]);
const STOP_PHRASES = new Set([
'这个世界',
'当前世界',
'玩家进入',
'玩家核心',
'世界设定',
'世界概述',
'世界基调',
'角色背景',
'角色描述',
'角色设定',
'角色故事',
'剧情关键',
'后续冒险',
'完整角色',
'当前局<E5898D>?',
'进入世界',
'核心目标',
'可扮<E58FAF>?',
'主角候<E8A792>?',
'主要角色',
'当前角色',
'这趟旅程',
'No retreat',
'真正起点',
]);
const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
{ pattern: /range|bow|shot|sniper|scout/i, tags: ['range', 'mobility', 'explore', 'weapon'] },
{ pattern: /blade|sword|slash|duel|charge/i, tags: ['melee', 'combat', 'weapon'] },
{ pattern: /fist|hammer|burst|smash|impact/i, tags: ['burst', 'combat', 'weapon'] },
{ pattern: /armor|shield|guard|wall|vanguard/i, tags: ['guard', 'defense', 'armor'] },
{ pattern: /medic|herb|potion|heal|remedy/i, tags: ['alchemy', 'healing', 'supply'] },
{ pattern: /rune|sigil|spell|mana|arcane|focus/i, tags: ['mana', 'arcane', 'glyph', 'focus'] },
{ pattern: /rare|relic|archive|key|history/i, tags: ['rare', 'clue', 'history', 'secret'] },
{ pattern: /travel|map|road|route|trail/i, tags: ['explore', 'route', 'supply'] },
{ pattern: /forge|craft|tool|gear|metal/i, tags: ['craft', 'material', 'forge'] },
{ pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] },
];
function normalizeExplicitItemCategory(category: string) {
const normalized = category.trim();
return normalized === '专属物' ? '专属物品' : normalized;
}
function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | null {
const normalized = normalizeExplicitItemCategory(category);
if (normalized === '武器') return 'weapon';
if (normalized === '护甲') return 'armor';
if (
normalized === '饰品'
|| normalized === '稀有品'
|| normalized === '专属物品'
) {
return 'relic';
}
return null;
}
function buildExplicitRoleInventoryItem(
role: CustomWorldPlayableNpc | CustomWorldNpc,
item: CustomWorldRoleInitialItem,
index: number,
): InventoryItem {
const category = normalizeExplicitItemCategory(item.category);
return {
id: `custom-role-item:${role.id}:${index + 1}`,
category,
name: item.name,
quantity: Math.max(1, item.quantity),
rarity: item.rarity,
tags: [...item.tags],
description: item.description,
equipmentSlotId: inferEquipmentSlotFromCategory(category),
runtimeMetadata: {
origin: 'ai_compiled',
generationChannel: 'discovery',
seedKey: `${role.id}:${index + 1}`,
relationAnchor: {
type: 'npc',
npcId: role.id,
npcName: role.name,
roleText: role.role,
},
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
},
};
}
function buildExplicitRoleInventoryItems(
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
if (!role) {
return [] as InventoryItem[];
}
return role.initialItems.map((item, index) =>
buildExplicitRoleInventoryItem(role, item, index),
);
}
function resolveCustomWorldRole(
profile: CustomWorldProfile,
character: Character,
) {
return profile.playableNpcs.find(role => role.id === character.id)
?? profile.storyNpcs.find(role => role.id === character.id)
?? profile.playableNpcs.find(role => role.name === character.name)
?? profile.storyNpcs.find(role => role.name === character.name)
?? null;
}
function dedupeStrings(values: string[], max = 32) {
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
}
function sortInventoryByCategory(items: InventoryItem[]) {
return [...items].sort((left, right) => {
const categoryDelta = (CATEGORY_ORDER.get(left.category) ?? 99) - (CATEGORY_ORDER.get(right.category) ?? 99);
if (categoryDelta !== 0) {
return categoryDelta;
}
return left.name.localeCompare(right.name, 'zh-Hans-CN');
});
}
function collectPhrases(sourceTexts: string[]) {
return sourceTexts.flatMap(text =>
text
.split(/[[\]\s<EFBFBD>?.!?:()<EFBFBD>?]+/u)
.map(segment => segment.trim())
.filter(segment => segment.length >= 2 && segment.length <= 12)
.filter(segment => !STOP_PHRASES.has(segment)),
);
}
function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 16) {
const source = value.replace(/[^\u4e00-\u9fa5]/g, '');
const grams: string[] = [];
for (let size = minSize; size <= maxSize; size += 1) {
for (let index = 0; index <= source.length - size; index += 1) {
const gram = source.slice(index, index + size);
if (STOP_PHRASES.has(gram)) {
continue;
}
grams.push(gram);
if (grams.length >= limit) {
return grams;
}
}
}
return grams;
}
function buildKeywordBundle(
profile: CustomWorldProfile,
character: Character,
role: CustomWorldPlayableNpc | CustomWorldNpc | null,
) {
const roleTexts = [
role?.title ?? '',
role?.description ?? '',
role?.backstory ?? '',
role?.backstoryReveal.publicSummary ?? '',
role?.combatStyle ?? '',
...(role?.skills.map(skill => `${skill.name} ${skill.summary} ${skill.style}`) ?? []),
...(role?.initialItems.map(item => `${item.name} ${item.category} ${item.description}`) ?? []),
...(role?.tags ?? []),
];
const characterTexts = [
character.description,
character.backstory,
character.personality,
...(character.combatTags ?? []),
];
const worldTexts = [
profile.name,
profile.settingText,
profile.summary,
profile.tone,
profile.playerGoal,
];
const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(Boolean);
const phrases = collectPhrases(sourceTexts);
const ngrams = [
...collectChineseNgrams(role?.title ?? '', 2, 4, 12),
...collectChineseNgrams(role?.combatStyle ?? '', 2, 4, 12),
...collectChineseNgrams((role?.tags ?? []).join(' '), 2, 4, 10),
...collectChineseNgrams(profile.name, 2, 4, 10),
];
const heuristics = THEME_TAG_RULES
.filter(rule => rule.pattern.test(sourceTexts.join(' ')))
.flatMap(rule => rule.tags);
return {
preferredTags: dedupeStrings([
...(role?.tags ?? []),
...(role?.initialItems.flatMap(item => item.tags) ?? []),
...(character.combatTags ?? []),
...heuristics,
], 18),
keywords: dedupeStrings([
...phrases,
...ngrams,
...(role?.skills.map(skill => skill.name) ?? []),
...(role?.initialItems.map(item => item.name) ?? []),
...heuristics,
], 36),
};
}
function queryItems(
seedKey: string,
baseOptions: RuntimeCustomWorldItemQueryOptions,
fallbackOptions?: RuntimeCustomWorldItemQueryOptions,
) {
const items = buildRuntimeCustomWorldInventoryItems(seedKey, baseOptions);
const categoryFallbackTriggered = Boolean(
fallbackOptions
&& baseOptions.categories?.length
&& items.some(item => !baseOptions.categories!.includes(item.category)),
);
if ((items.length > 0 && !categoryFallbackTriggered) || !fallbackOptions) {
return items;
}
return buildRuntimeCustomWorldInventoryItems(seedKey, fallbackOptions);
}
function mergeUniqueItems(...groups: InventoryItem[][]) {
const result: InventoryItem[] = [];
const seen = new Set<string>();
groups.flat().forEach(item => {
const key = `${item.category}:${item.name}`;
if (seen.has(key)) {
return;
}
seen.add(key);
result.push(item);
});
return result;
}
export function buildCustomWorldStarterEquipmentItems(
character: Character,
explicitProfile?: CustomWorldProfile | null,
) {
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
if (!profile) {
return {
weapon: null,
armor: null,
relic: null,
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
}
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const explicitWeapon =
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
const explicitArmor =
explicitItems.find(item => item.equipmentSlotId === 'armor') ?? null;
const explicitRelic =
explicitItems.find(item => item.equipmentSlotId === 'relic') ?? null;
const bundle = buildKeywordBundle(profile, character, role);
const baseTextKeywords = bundle.keywords;
const baseTags = bundle.preferredTags;
const [weapon] = queryItems(`equipment:${character.id}:weapon`, {
count: 1,
categories: ['武器'],
rarityFloor: 'rare',
preferredTags: dedupeStrings([...baseTags, 'weapon', '战斗']),
keywords: dedupeStrings([...baseTextKeywords, role?.combatStyle ?? '', '武器', '战斗']),
});
const [armor] = queryItems(`equipment:${character.id}:armor`, {
count: 1,
categories: ['护甲'],
rarityFloor: 'rare',
preferredTags: dedupeStrings([...baseTags, 'armor', '防护', '护体']),
keywords: dedupeStrings([...baseTextKeywords, role?.personality ?? character.personality, '护甲', '守御']),
});
const [relic] = queryItems(`equipment:${character.id}:relic`, {
count: 1,
categories: ['饰品'],
rarityFloor: 'rare',
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
}, {
count: 1,
categories: ['饰品', '稀有品', '专属物品'],
rarityFloor: 'rare',
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
});
return {
weapon: explicitWeapon ?? weapon ?? null,
armor: explicitArmor ?? armor ?? null,
relic: explicitRelic ?? relic ?? null,
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
}
export function buildCustomWorldStarterInventoryItems(
character: Character,
explicitProfile?: CustomWorldProfile | null,
) {
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
if (!profile) {
return [] as InventoryItem[];
}
const role = resolveCustomWorldRole(profile, character);
const explicitItems = buildExplicitRoleInventoryItems(role);
const bundle = buildKeywordBundle(profile, character, role);
const consumables = queryItems(`inventory:${character.id}:consumables`, {
count: 2,
quantity: 2,
categories: ['消耗品'],
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
keywords: dedupeStrings([
...bundle.keywords,
role?.combatStyle ?? '',
...explicitItems.map(item => item.name),
'调息',
'续战',
]),
});
const materials = queryItems(`inventory:${character.id}:materials`, {
count: 1,
quantity: 2,
categories: ['材料'],
preferredTags: dedupeStrings([...bundle.preferredTags, 'material', 'forge', 'alchemy']),
keywords: dedupeStrings([...bundle.keywords, role?.backstory ?? character.backstory, '材料']),
});
const rareUtility = queryItems(`inventory:${character.id}:rare-utility`, {
count: 1,
categories: ['饰品', '稀有品'],
rarityFloor: 'uncommon',
preferredTags: dedupeStrings([...bundle.preferredTags, 'relic', 'rare', '线索', '寻路']),
keywords: dedupeStrings([...bundle.keywords, profile.settingText, profile.summary, '线索', '寻路']),
});
const signature = queryItems(`inventory:${character.id}:signature`, {
count: 1,
categories: ['专属物品', '稀有品'],
rarityFloor: 'rare',
preferredTags: dedupeStrings([...bundle.preferredTags, '剧情关键', '异变', '旧史', 'rare']),
keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']),
});
const merged = mergeUniqueItems(explicitItems, consumables, materials, rareUtility, signature);
if (merged.length >= 5) {
return sortInventoryByCategory(merged.slice(0, 5));
}
const filler = queryItems(`inventory:${character.id}:filler`, {
count: 5 - merged.length,
categories: ['消耗品', '材料', '饰品', '稀有品', '专属物品'],
preferredTags: bundle.preferredTags,
keywords: bundle.keywords,
});
return sortInventoryByCategory(mergeUniqueItems(merged, filler).slice(0, 5));
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { normalizeCustomWorldProfileRecord } from './customWorldLibrary';
describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
it('保留草稿生成阶段产出的角色形象描述字段', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
description: '追查旧案的人',
visualDescription: '瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
actionDescription: '抬灯照出雾中航线,侧身抽出卷边海图迅速标记。',
sceneVisualDescription: '旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
},
],
storyNpcs: [
{
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
description: '压住旧档的人',
visualDescription: '银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
actionDescription: '用印信压住卷宗,抬手示意巡海队封锁出口。',
sceneVisualDescription: '议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
},
],
});
expect(profile?.playableNpcs[0]?.visualDescription).toBe(
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
);
expect(profile?.playableNpcs[0]?.actionDescription).toContain('抬灯');
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain('旧灯塔');
expect(profile?.storyNpcs[0]?.visualDescription).toBe(
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
);
expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信');
expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅');
});
it('保留 Agent 发布门槛需要的顶层 worldHook 和 playerPremise', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
summary: '海雾会吞掉记错航线的人。',
worldHook: '在失真的海图上追查一场被篡改的沉船事故。',
playerPremise: '玩家是返乡调查旧案的守灯人。',
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '失灯港',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '玩家在雾港发现灯册被改写。',
},
],
},
],
});
expect(profile?.worldHook).toBe(
'在失真的海图上追查一场被篡改的沉船事故。',
);
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
it('直接读取 Rust 草稿角色字段和形象资源', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
id: 'playable-cendeng',
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
publicMask: '深蓝旧雨衣、铜灯和卷边海图。',
currentPressure: '灯塔记录被人改写,旧案正在逼近。',
relationToPlayer: '这是玩家进入世界的第一视角。',
imageSrc: '/generated-characters/playable-cendeng/portrait.png',
generatedVisualAssetId: 'visual-playable-cendeng',
},
],
storyNpcs: [
{
id: 'story-yizhang',
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
publicIdentity: '压住旧档的人。',
hiddenHook: '长期维持群岛议会体面并遮掩沉船旧案。',
relationToPlayer: '会阻止玩家继续追查。',
imageSrc: '/generated-characters/story-yizhang/portrait.png',
},
],
});
expect(profile?.playableNpcs[0]?.description).toBe(
'深蓝旧雨衣、铜灯和卷边海图。',
);
expect(profile?.playableNpcs[0]?.backstory).toContain('灯塔记录');
expect(profile?.playableNpcs[0]?.relationshipHooks[0]).toBe(
'这是玩家进入世界的第一视角。',
);
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-cendeng/portrait.png',
);
expect(profile?.storyNpcs[0]?.description).toBe('压住旧档的人。');
expect(profile?.storyNpcs[0]?.backstory).toContain('沉船旧案');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-yizhang/portrait.png',
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
import {
collectCreatureArchetypeSignals,
resolveCreatureArchetypeForSource,
} from '../services/customWorldReferenceSignals';
import {
type CustomWorldNpc,
type CustomWorldPlayableNpc,
type CustomWorldProfile,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
import {
getMonsterPresetsByWorld,
type HostileNpcPreset,
} from './hostileNpcPresets';
type CustomWorldMonsterSource = Partial<
Pick<
CustomWorldNpc & CustomWorldPlayableNpc,
| 'name'
| 'title'
| 'role'
| 'description'
| 'backstory'
| 'personality'
| 'motivation'
| 'combatStyle'
| 'initialAffinity'
| 'relationshipHooks'
| 'tags'
>
>;
const MONSTER_SIGNAL_PATTERN =
/||||||||||||||||||||||||||/u;
const MONSTER_SIGNAL_STOP_CHARS = new Set([
'妖',
'魔',
'鬼',
'怪',
'兽',
'灵',
'尸',
'祟',
'凶',
'异',
'夜',
'古',
]);
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash >>> 0;
}
function getMonsterPresetPool(worldType?: WorldType | null) {
if (worldType) {
return getMonsterPresetsByWorld(worldType);
}
const seen = new Set<string>();
return [
...getMonsterPresetsByWorld(WorldType.WUXIA),
...getMonsterPresetsByWorld(WorldType.XIANXIA),
].filter((preset) => {
if (seen.has(preset.id)) {
return false;
}
seen.add(preset.id);
return true;
});
}
function getAllMonsterPresets() {
return getMonsterPresetPool(null);
}
function uniqueText(values: Array<string | null | undefined>) {
return [
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
];
}
function buildMonsterSourceText(npc: CustomWorldMonsterSource) {
return uniqueText([
npc.name,
npc.title,
npc.role,
npc.description,
npc.backstory,
npc.personality,
npc.motivation,
npc.combatStyle,
...(npc.relationshipHooks ?? []),
...(npc.tags ?? []),
]).join(' ');
}
function buildSignalChars(label: string) {
return [
...new Set(
label
.replace(/[^\u4e00-\u9fa5]+/g, '')
.split('')
.filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)),
),
];
}
function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
let score = 0;
if (sourceText.includes(preset.name)) {
score += 24;
}
for (const signalChar of buildSignalChars(preset.name)) {
if (sourceText.includes(signalChar)) {
score += 3;
}
}
for (const tag of [...preset.habitatTags, ...preset.combatTags]) {
if (tag && sourceText.includes(tag)) {
score += 2;
}
}
return score;
}
function scoreMonsterPresetWithArchetype(
preset: HostileNpcPreset,
sourceText: string,
options: {
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
preferredWorldType?: WorldType | null;
} = {},
) {
let score = scoreMonsterPreset(preset, sourceText);
const { archetypeSignals, preferredWorldType } = options;
if (archetypeSignals) {
archetypeSignals.keywords.forEach((keyword) => {
if (!keyword) {
return;
}
if (
preset.name.includes(keyword)
|| preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|| preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
) {
score += keyword.length >= 3 ? 6 : 4;
}
});
archetypeSignals.combatTags.forEach((tag) => {
if (preset.combatTags.includes(tag)) {
score += 8;
}
});
archetypeSignals.habitatTags.forEach((tag) => {
if (preset.habitatTags.includes(tag)) {
score += 6;
}
});
}
if (
preferredWorldType
&& preferredWorldType !== WorldType.CUSTOM
&& preset.worldType === preferredWorldType
) {
score += 3;
}
return score;
}
export function getCustomWorldMonsterPresetPool(
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const presets = getAllMonsterPresets();
const creatureArchetypes =
profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? [];
if (creatureArchetypes.length === 0) {
return presets;
}
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: null;
const scoredPresets = presets
.map((preset) => {
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
const nextScore = scoreMonsterPresetWithArchetype(
preset,
preset.name,
{
archetypeSignals: collectCreatureArchetypeSignals(archetype),
preferredWorldType,
},
);
return Math.max(bestScore, nextScore);
}, 0);
return {
preset,
score: archetypeScore,
};
})
.sort((left, right) => right.score - left.score);
const filtered = scoredPresets
.filter((entry) => entry.score > 0)
.map((entry) => entry.preset);
return filtered.length > 0 ? filtered : presets;
}
export function resolveCustomWorldNpcMonsterPreset(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
const sourceText = buildMonsterSourceText(npc);
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
return null;
}
const hostileBias = (npc.initialAffinity ?? 0) < 0;
if (!hostileBias) {
return null;
}
const preferredWorldType = profile
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
: worldType ?? null;
const referenceArchetype = resolveCreatureArchetypeForSource(
profile as CustomWorldProfile | null | undefined,
npc,
);
const archetypeSignals = referenceArchetype
? collectCreatureArchetypeSignals(referenceArchetype)
: null;
const candidates =
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
? getCustomWorldMonsterPresetPool(profile)
: getMonsterPresetPool(worldType);
if (candidates.length === 0) {
return null;
}
const scoredCandidates = candidates
.map((candidate) => ({
candidate,
score: scoreMonsterPresetWithArchetype(candidate, sourceText, {
archetypeSignals,
preferredWorldType,
}),
}))
.sort((left, right) => right.score - left.score);
if ((scoredCandidates[0]?.score ?? 0) >= 3) {
return scoredCandidates[0]?.candidate ?? null;
}
return candidates[hashText(sourceText) % candidates.length] ?? null;
}
export function resolveCustomWorldNpcMonsterPresetId(
npc: CustomWorldMonsterSource,
worldType?: WorldType | null,
profile?: Pick<
CustomWorldProfile,
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
> | null,
) {
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
}

View File

@@ -0,0 +1,373 @@
import {
type CustomWorldThemeMode,
detectCustomWorldThemeMode,
resolveCustomWorldCompatibilityTemplateWorldType,
} from '../services/customWorldTheme';
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
export function setRuntimeCustomWorldProfile(profile: CustomWorldProfile | null) {
runtimeCustomWorldProfile = profile;
}
export function getRuntimeCustomWorldProfile() {
return runtimeCustomWorldProfile;
}
export function resolveCompatibilityTemplateWorldType(
worldType: WorldType | null | undefined,
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
): WorldTemplateType | null {
if (!worldType) return null;
if (worldType === WorldType.CUSTOM) {
return customWorldProfile
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
: WorldType.WUXIA;
}
return worldType;
}
export function isCustomWorldType(worldType: WorldType | null | undefined) {
return worldType === WorldType.CUSTOM;
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash >>> 0;
}
function compactStrings(values: Array<string | null | undefined | false>) {
return [...new Set(
values
.map(value => typeof value === 'string' ? value.trim() : '')
.filter(Boolean),
)];
}
function pickCyclic<T>(items: readonly T[], index: number, fallback: T): T {
return items[index % items.length] ?? fallback;
}
function normalizeInventoryItemId(item: CustomWorldItem, quantity: number, seedKey: string) {
return `custom:${item.id}:${quantity}:${hashText(seedKey).toString(36)}`;
}
function toInventoryItem(item: CustomWorldItem, quantity: number, seedKey: string): InventoryItem {
return {
id: normalizeInventoryItemId(item, quantity, seedKey),
category: item.category,
name: item.name,
quantity,
rarity: item.rarity,
tags: [...item.tags],
iconSrc: item.iconSrc,
description: item.description,
equipmentSlotId: item.equipmentSlotId ?? null,
statProfile: item.statProfile ?? null,
useProfile: item.useProfile ?? null,
value: item.value,
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'discovery',
seedKey,
sourceReason: `围绕自定义世界 ${runtimeCustomWorldProfile?.name ?? '未知世界'} 的主题即时生成。`,
},
};
}
export interface RuntimeCustomWorldItemQueryOptions {
categories?: string[];
tags?: string[];
preferredTags?: string[];
keywords?: string[];
count?: number;
quantity?: number;
rarityFloor?: CustomWorldItem['rarity'];
}
const RARITY_ORDER: CustomWorldItem['rarity'][] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const DEFAULT_RUNTIME_CATEGORIES = ['武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物'] as const;
const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
: ['weapon', '战斗'],
: ['armor', '防护'],
: ['relic', 'mana'],
: ['healing', '补给'],
: ['material', '采集'],
: ['rare', '线索'],
: ['rare', '剧情关键'],
};
const WORLD_ITEM_PREFIXES: Record<CustomWorldThemeMode, string[]> = {
mythic: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
martial: ['风雨', '断桥', '青锋', '旧案', '夜行', '残影'],
arcane: ['灵纹', '道痕', '云篆', '星芒', '界辉', '玉简'],
machina: ['铁脊', '脉冲', '新星', '等离', '钢律', '核列'],
tide: ['潮纹', '霜浪', '天澜', '海晕', '潮歌', '沧流'],
rift: ['裂痕', '灰域', '界桥', '断层', '回响', '前哨'],
};
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
: ['刃', '剑', '弓', '枪', '印', '锤'],
: ['甲', '衣', '护符', '披风', '战铠', '护腕'],
: ['坠', '环', '佩', '珠', '印记', '信物'],
: ['药', '露', '符', '瓶', '包', '散'],
: ['砂', '石', '铁', '木', '羽', '晶'],
: ['残页', '密卷', '古钥', '图录', '印匣', '秘函'],
: ['遗物', '核心', '母印', '真符', '遗钥', '界核'],
};
function normalizeLookupText(value: string) {
return value.trim().toLowerCase();
}
function getRarityFloorValue(rarityFloor?: CustomWorldItem['rarity']) {
return rarityFloor ? RARITY_ORDER.indexOf(rarityFloor) : -1;
}
function sanitizeNameFragment(value: string) {
return value.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '').slice(0, 4);
}
function getWorldSeedLabel(profile: CustomWorldProfile) {
const fromName = sanitizeNameFragment(profile.name);
if (fromName) return fromName;
const fromSetting = sanitizeNameFragment(profile.settingText);
if (fromSetting) return fromSetting;
return '旅境';
}
function buildRuntimeItemTags(
category: string,
options: RuntimeCustomWorldItemQueryOptions,
seed: number,
) {
const baseTags = [...(CATEGORY_DEFAULT_TAGS[category] ?? ['world-item'])];
const preferredTags = [...new Set((options.preferredTags ?? []).map(tag => tag.trim()).filter(Boolean))];
const keywordTags = [...new Set((options.keywords ?? []).map(tag => tag.trim()).filter(Boolean))];
const selectedPreferredTag = preferredTags.length > 0
? preferredTags[seed % preferredTags.length]
: undefined;
const selectedKeywordTag = keywordTags.length > 0
? keywordTags[(seed >>> 3) % keywordTags.length]
: undefined;
if (category === '消耗品' && preferredTags.some(tag => /mana|||/u.test(tag))) {
baseTags.push('mana');
}
if (category === '消耗品' && preferredTags.some(tag => /heal|||/u.test(tag))) {
baseTags.push('healing');
}
return compactStrings([...baseTags, selectedPreferredTag, selectedKeywordTag]).slice(0, 5);
}
function inferRuntimeItemRarity(seed: number, rarityFloorValue: number): CustomWorldItem['rarity'] {
const rolledRarity = [0, 1, 1, 2, 2, 2, 3, 3, 4][seed % 9] ?? 0;
return RARITY_ORDER[Math.max(rarityFloorValue, rolledRarity)] ?? 'common';
}
function inferRuntimeItemMechanics(
category: string,
rarity: CustomWorldItem['rarity'],
tags: string[],
seed: number,
) {
const rarityTier = Math.max(1, RARITY_ORDER.indexOf(rarity) + 1);
if (category === '武器') {
return {
equipmentSlotId: 'weapon' as const,
statProfile: {
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
},
useProfile: null,
value: 28 * rarityTier,
};
}
if (category === '护甲') {
return {
equipmentSlotId: 'armor' as const,
statProfile: {
maxHpBonus: 10 * rarityTier + (seed % 8),
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
},
useProfile: null,
value: 26 * rarityTier,
};
}
if (category === '饰品' || category === '稀有品' || category === '专属物') {
return {
equipmentSlotId: 'relic' as const,
statProfile: {
maxManaBonus: 8 * rarityTier + (seed % 7),
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
},
useProfile: null,
value: 32 * rarityTier,
};
}
if (category === '消耗品') {
return {
equipmentSlotId: null,
statProfile: null,
useProfile: tags.includes('mana')
? { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 }
: { hpRestore: 16 * rarityTier },
value: 18 * rarityTier,
};
}
return {
equipmentSlotId: null,
statProfile: null,
useProfile: null,
value: 10 * rarityTier,
};
}
function buildProceduralRuntimeItem(
profile: CustomWorldProfile,
seedKey: string,
options: RuntimeCustomWorldItemQueryOptions,
index: number,
) {
const themeMode = detectCustomWorldThemeMode(profile);
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
const category = pickCyclic(categories, seed, defaultCategory);
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
const tags = buildRuntimeItemTags(category, options, seed);
const prefixPool = WORLD_ITEM_PREFIXES[themeMode];
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.;
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
const resolvedNounPool = nounPool ?? fallbackNounPool;
const worldSeed = getWorldSeedLabel(profile);
const optionSeed = sanitizeNameFragment((options.preferredTags ?? [])[0] ?? '') || sanitizeNameFragment((options.keywords ?? [])[0] ?? '');
const prefix = pickCyclic(prefixPool, seed >>> 2, prefixPool[0] ?? 'world');
const noun = pickCyclic(resolvedNounPool, seed >>> 5, fallbackNounPool[0]);
const name = `${prefix}${optionSeed || worldSeed}${noun}${index + 1}`;
const mechanics = inferRuntimeItemMechanics(category, rarity, tags, seed);
return {
id: `runtime-item:${hashText(`${seedKey}:${index}`).toString(36)}`,
name,
category,
rarity,
description: `围绕“${profile.playerGoal}”即时生成的${category},适合在 ${profile.name} 中作为掉落、交易或补给资源。`,
tags,
origin: 'generated' as const,
equipmentSlotId: mechanics.equipmentSlotId,
statProfile: mechanics.statProfile,
useProfile: mechanics.useProfile,
value: mechanics.value,
} satisfies CustomWorldItem;
}
function matchesRuntimeQuery(
item: CustomWorldItem,
options: RuntimeCustomWorldItemQueryOptions,
rarityFloorValue: number,
) {
if (options.categories?.length && !options.categories.includes(item.category)) {
return false;
}
if (options.tags?.length && !options.tags.some(tag => item.tags.includes(tag))) {
return false;
}
if (rarityFloorValue >= 0) {
const itemRarityValue = RARITY_ORDER.indexOf(item.rarity);
if (itemRarityValue < rarityFloorValue) {
return false;
}
}
return true;
}
function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldItemQueryOptions) {
const haystack = normalizeLookupText([
item.name,
item.category,
item.description,
...(item.tags ?? []),
].join(' '));
const itemTags = new Set((item.tags ?? []).map(tag => normalizeLookupText(tag)));
let score = 0;
const preferredTags = [...new Set((options.preferredTags ?? []).map(normalizeLookupText).filter(Boolean))];
preferredTags.forEach(tag => {
if (itemTags.has(tag)) {
score += 10;
return;
}
if (haystack.includes(tag)) {
score += 4;
}
});
const keywords = [...new Set((options.keywords ?? []).map(normalizeLookupText).filter(keyword => keyword.length >= 2))];
keywords.forEach(keyword => {
if (!haystack.includes(keyword)) {
return;
}
score += keyword.length >= 4 ? 7 : keyword.length === 3 ? 5 : 3;
});
if (options.categories?.includes(item.category)) {
score += 2;
}
if (item.origin === 'generated') {
score += 1;
}
return score;
}
function rankItems(items: CustomWorldItem[], seedKey: string, options: RuntimeCustomWorldItemQueryOptions = {}) {
const seed = hashText(seedKey);
return [...items].sort((left, right) => {
const relevanceDelta = scoreItemRelevance(right, options) - scoreItemRelevance(left, options);
if (relevanceDelta !== 0) {
return relevanceDelta;
}
const leftScore = hashText(`${left.id}:${seed}`) % 997;
const rightScore = hashText(`${right.id}:${seed}`) % 997;
return leftScore - rightScore;
});
}
export function pickRuntimeCustomWorldItems(
seedKey: string,
options: RuntimeCustomWorldItemQueryOptions = {},
) {
const profile = runtimeCustomWorldProfile;
if (!profile) return [] as CustomWorldItem[];
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
const sourceItems = Array.from({ length: Math.max(16, (options.count ?? 1) * 10) }, (_, index) =>
buildProceduralRuntimeItem(profile, seedKey, options, index),
);
const filtered = sourceItems.filter(item => matchesRuntimeQuery(item, options, rarityFloorValue));
return rankItems(filtered.length > 0 ? filtered : sourceItems, seedKey, options).slice(0, options.count ?? 1);
}
export function buildRuntimeCustomWorldInventoryItems(
seedKey: string,
options: RuntimeCustomWorldItemQueryOptions = {},
) {
const count = options.count ?? 1;
return pickRuntimeCustomWorldItems(seedKey, options)
.slice(0, count)
.map((item, index) => toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`));
}

View File

@@ -0,0 +1,426 @@
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldSceneConnection,
CustomWorldSceneRelativePosition,
} from '../types';
export type CustomWorldSceneConnectionDraft = {
targetLandmarkId?: string;
targetLandmarkName?: string;
relativePosition?: unknown;
summary?: string;
};
export type CustomWorldLandmarkDraft = Omit<
CustomWorldLandmark,
'sceneNpcIds' | 'connections'
> & {
sceneNpcIds?: string[];
actNPCNames?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldSceneConnectionDraft[];
};
export const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS: Array<{
value: CustomWorldSceneRelativePosition;
label: string;
}> = [
{ value: 'forward', label: '前方' },
{ value: 'back', label: '后方' },
{ value: 'left', label: '左侧' },
{ value: 'right', label: '右侧' },
{ value: 'north', label: '北侧' },
{ value: 'south', label: '南侧' },
{ value: 'east', label: '东侧' },
{ value: 'west', label: '西侧' },
{ value: 'up', label: '上方' },
{ value: 'down', label: '下方' },
{ value: 'inside', label: '内部' },
{ value: 'outside', label: '外部' },
{ value: 'portal', label: '传送节点' },
] as const;
const RELATIVE_POSITION_ALIASES: Record<
CustomWorldSceneRelativePosition,
string[]
> = {
forward: ['forward', 'front', 'ahead', '前方', '前面', '前侧', '向前'],
back: ['back', 'rear', 'behind', '后方', '后面', '后侧', '回程'],
left: ['left', '左侧', '左边', '左方'],
right: ['right', '右侧', '右边', '右方'],
north: ['north', '北侧', '北边', '北方', '上北'],
south: ['south', '南侧', '南边', '南方', '下南'],
east: ['east', '东侧', '东边', '东方'],
west: ['west', '西侧', '西边', '西方'],
up: ['up', 'upper', 'above', '上方', '上层', '高处', '顶部'],
down: ['down', 'lower', 'below', '下方', '下层', '低处', '底部'],
inside: ['inside', 'inner', 'indoors', '内部', '内侧', '内里', '室内'],
outside: ['outside', 'outer', 'outdoors', '外部', '外侧', '外围', '室外'],
portal: ['portal', 'gate', 'path', 'junction', '传送', '门', '入口', '通道'],
};
const RELATIVE_POSITION_LABELS = Object.fromEntries(
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map((option) => [
option.value,
option.label,
]),
) as Record<CustomWorldSceneRelativePosition, string>;
const RELATIVE_POSITION_DISPLAY_ORDER: CustomWorldSceneRelativePosition[] = [
'forward',
'north',
'east',
'right',
'up',
'outside',
'portal',
'left',
'west',
'south',
'down',
'inside',
'back',
];
function normalizeKey(value: string) {
return value.trim().toLowerCase();
}
function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
const lookup = new Map<string, string>();
storyNpcs.forEach((npc) => {
const normalizedId = normalizeKey(npc.id);
const normalizedName = normalizeKey(npc.name);
if (normalizedId) {
lookup.set(normalizedId, npc.id);
}
if (normalizedName) {
lookup.set(normalizedName, npc.id);
}
});
return lookup;
}
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
const lookup = new Map<string, string>();
landmarks.forEach((landmark) => {
const normalizedId = normalizeKey(landmark.id);
const normalizedName = normalizeKey(landmark.name);
if (normalizedId) {
lookup.set(normalizedId, landmark.id);
}
if (normalizedName) {
lookup.set(normalizedName, landmark.id);
}
});
return lookup;
}
function compactUnique(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function sortConnections(connections: CustomWorldSceneConnection[]) {
return [...connections].sort((left, right) => {
const leftOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
left.relativePosition,
);
const rightOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
right.relativePosition,
);
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.targetLandmarkId.localeCompare(right.targetLandmarkId);
});
}
function dedupeConnections(connections: CustomWorldSceneConnection[]) {
const deduped = new Map<string, CustomWorldSceneConnection>();
connections.forEach((connection) => {
const key = [
connection.targetLandmarkId.trim(),
connection.relativePosition,
connection.summary.trim(),
].join('::');
if (!deduped.has(key)) {
deduped.set(key, {
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
});
}
});
return [...deduped.values()];
}
export function getCustomWorldSceneRelativePositionLabel(
value: CustomWorldSceneRelativePosition,
) {
return RELATIVE_POSITION_LABELS[value] ?? value;
}
export function normalizeCustomWorldSceneRelativePosition(
value: unknown,
): CustomWorldSceneRelativePosition {
const normalizedValue =
typeof value === 'string' ? normalizeKey(value) : '';
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
if (option.value === normalizedValue) {
return option.value;
}
if (RELATIVE_POSITION_ALIASES[option.value].includes(normalizedValue)) {
return option.value;
}
}
return 'forward';
}
export function invertCustomWorldSceneRelativePosition(
value: CustomWorldSceneRelativePosition,
): CustomWorldSceneRelativePosition {
switch (value) {
case 'forward':
return 'back';
case 'back':
return 'forward';
case 'left':
return 'right';
case 'right':
return 'left';
case 'north':
return 'south';
case 'south':
return 'north';
case 'east':
return 'west';
case 'west':
return 'east';
case 'up':
return 'down';
case 'down':
return 'up';
case 'inside':
return 'outside';
case 'outside':
return 'inside';
default:
return 'portal';
}
}
function buildFallbackSceneNpcIds(
storyNpcs: CustomWorldNpc[],
currentNpcIds: string[],
landmarkIndex: number,
) {
const targetCount = Math.min(3, storyNpcs.length);
if (targetCount <= currentNpcIds.length) {
return currentNpcIds.slice(0, targetCount);
}
const resolved = [...currentNpcIds];
for (
let offset = 0;
offset < storyNpcs.length && resolved.length < targetCount;
offset += 1
) {
const nextNpc = storyNpcs[(landmarkIndex + offset) % storyNpcs.length];
if (!nextNpc || resolved.includes(nextNpc.id)) {
continue;
}
resolved.push(nextNpc.id);
}
return resolved;
}
function resolveSceneNpcIdsForLandmark(
landmark: CustomWorldLandmarkDraft,
storyNpcs: CustomWorldNpc[],
lookup: Map<string, string>,
landmarkIndex: number,
) {
const references = compactUnique([
...(landmark.sceneNpcIds ?? []),
...(landmark.actNPCNames ?? []),
...(landmark.sceneNpcNames ?? []),
]);
const resolvedIds = compactUnique(
references
.map((reference) => lookup.get(normalizeKey(reference)) ?? '')
.filter(Boolean),
);
return buildFallbackSceneNpcIds(storyNpcs, resolvedIds, landmarkIndex);
}
function resolveConnectionsForLandmark(
landmark: CustomWorldLandmarkDraft,
landmarkLookup: Map<string, string>,
) {
return (landmark.connections ?? [])
.map((connection) => {
const targetReference =
connection.targetLandmarkId ?? connection.targetLandmarkName ?? '';
const targetLandmarkId =
landmarkLookup.get(normalizeKey(targetReference)) ?? '';
if (!targetLandmarkId || targetLandmarkId === landmark.id) {
return null;
}
return {
targetLandmarkId,
relativePosition: normalizeCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: typeof connection.summary === 'string'
? connection.summary.trim()
: '',
} satisfies CustomWorldSceneConnection;
})
.filter((connection): connection is CustomWorldSceneConnection =>
Boolean(connection),
);
}
function ensureReverseConnections(landmarks: CustomWorldLandmark[]) {
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
const nameMap = new Map(landmarks.map((landmark) => [landmark.id, landmark.name]));
landmarks.forEach((landmark) => {
landmark.connections.forEach((connection) => {
const reverseConnections = connectionMap.get(connection.targetLandmarkId);
if (!reverseConnections) {
return;
}
const hasReverseConnection = reverseConnections.some(
(item) => item.targetLandmarkId === landmark.id,
);
if (hasReverseConnection) {
return;
}
reverseConnections.push({
targetLandmarkId: landmark.id,
relativePosition: invertCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: nameMap.get(landmark.id)
? `可通往${nameMap.get(landmark.id)}`
: '',
});
});
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(
dedupeConnections(connectionMap.get(landmark.id) ?? []),
),
}));
}
function ensureFallbackLandmarkConnections(landmarks: CustomWorldLandmark[]) {
if (landmarks.length <= 1) {
return landmarks;
}
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
landmarks.forEach((landmark, index) => {
const nextLandmark = landmarks[(index + 1) % landmarks.length];
if (!nextLandmark || nextLandmark.id === landmark.id) {
return;
}
const existingConnections = connectionMap.get(landmark.id) ?? [];
if (
existingConnections.some(
(connection) => connection.targetLandmarkId === nextLandmark.id,
)
) {
return;
}
existingConnections.push({
targetLandmarkId: nextLandmark.id,
relativePosition: 'forward',
summary: `沿主路可继续前往${nextLandmark.name}`,
});
connectionMap.set(landmark.id, existingConnections);
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(connectionMap.get(landmark.id) ?? []),
}));
}
export function normalizeCustomWorldLandmarks(params: {
landmarks: CustomWorldLandmarkDraft[];
storyNpcs: CustomWorldNpc[];
}) {
const { landmarks, storyNpcs } = params;
const npcLookup = buildSceneNpcLookup(storyNpcs);
const landmarkLookup = buildLandmarkLookup(landmarks);
const resolvedLandmarks = landmarks.map((landmark, index) => ({
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: resolveSceneNpcIdsForLandmark(
landmark,
storyNpcs,
npcLookup,
index,
),
connections: sortConnections(
resolveConnectionsForLandmark(landmark, landmarkLookup),
),
}));
return ensureReverseConnections(
ensureFallbackLandmarkConnections(resolvedLandmarks),
);
}
export function syncCustomWorldLandmarkConnections(
landmarks: CustomWorldLandmark[],
) {
return normalizeCustomWorldLandmarks({
landmarks: landmarks.map((landmark) => ({
...landmark,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: landmark.sceneNpcIds,
connections: landmark.connections.map((connection) => ({
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
})),
storyNpcs: [],
}).map((landmark, index) => ({
...landmark,
sceneNpcIds: landmarks[index]?.sceneNpcIds ?? [],
}));
}

View File

@@ -0,0 +1,592 @@
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
collectSceneBucketSignalKeywords,
resolveSceneBucketForLandmark,
} from '../services/customWorldReferenceSignals';
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
import {
type CustomWorldLandmark,
type CustomWorldProfile,
type WorldTemplateType,
WorldType,
} from '../types';
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
'/character/Archer%20Hero/Original/Hero/idle/idle01.png',
'/character/Girl%20Hero%201/Original/Hero/Idle/Idle01.png',
'/character/Punch%20Hero%203/Original/Hero/Idle/Idle01.png',
'/character/Fighter%204/original/Hero/idle/idle01.png',
] as const;
const SCENE_BACKGROUND_PACKS = [
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
] as const;
type SceneImageReference = {
name: string;
keywords: string[];
};
const SCENE_MATCH_STOP_CHARS = new Set([
'的',
'之',
'与',
'和',
'里',
'处',
'中',
'外',
'前',
'后',
'上',
'下',
'左',
'右',
'一',
'二',
'三',
'四',
'五',
'六',
'七',
'八',
'九',
'十',
'场',
'景',
'地',
'方',
'区',
'域',
'路',
'道',
'门',
'台',
'楼',
'城',
'山',
'林',
'湖',
'河',
'谷',
'洞',
'宫',
'殿',
'营',
'崖',
'桥',
]);
const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '山门石阶',
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
},
{
name: '雨巷长街',
keywords: ['雨巷', '长街', '街市', '巷道', '城镇', '商铺'],
},
{
name: '竹林古道',
keywords: ['竹林', '古道', '林路', '林间', '小径', '山径'],
},
{
name: '断垣村落',
keywords: ['废村', '村落', '断墙', '残垣', '旧屋', '荒宅'],
},
{
name: '古桥渡口',
keywords: ['桥', '渡口', '河岸', '水路', '码头', '舟船'],
},
{
name: '雾林小径',
keywords: ['雾林', '迷雾', '树林', '暗林', '阴森', '野路'],
},
{
name: '边关营地',
keywords: ['营地', '驻地', '营火', '关隘', '边关', '据点', '归舍', '落脚', '住处'],
},
{
name: '地宫通道',
keywords: ['地宫', '墓道', '通道', '地底', '遗迹', '机关'],
},
{
name: '寺庙前庭',
keywords: ['寺庙', '庙宇', '神龛', '前庭', '祭坛', '佛堂'],
},
{
name: '矿道深处',
keywords: ['矿道', '矿坑', '坑道', '矿洞', '洞窟', '地下'],
},
{
name: '铸坊工场',
keywords: ['铸坊', '工场', '铁匠', '锻造', '熔炉', '火光'],
},
{
name: '宫苑内庭',
keywords: ['宫苑', '内庭', '庭院', '府邸', '回廊', '深宫'],
},
] as const;
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
{
name: '云海仙门',
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
},
{
name: '悬空仙岛',
keywords: ['浮岛', '仙岛', '悬空', '高空', '云岛', '浮空'],
},
{
name: '天宫长廊',
keywords: ['天宫', '长廊', '回廊', '宫阙', '高处', '仙宫'],
},
{
name: '灵药花圃',
keywords: ['药圃', '花圃', '灵草', '花海', '园林', '药园'],
},
{
name: '寒玉洞天',
keywords: ['寒玉', '冰洞', '洞天', '冰面', '寒气', '玉壁'],
},
{
name: '熔岩秘境',
keywords: ['熔岩', '火山', '赤焰', '岩浆', '灼热', '焦土'],
},
{
name: '雷殿祭坛',
keywords: ['雷殿', '祭坛', '雷霆', '神殿', '雷光', '仪式'],
},
{
name: '星舟甲板',
keywords: ['星舟', '甲板', '飞舟', '天舟', '高空', '航线'],
},
{
name: '月湖仙洲',
keywords: ['月湖', '湖岸', '湖心', '水面', '水边', '倒影'],
},
{
name: '古仙遗迹',
keywords: ['遗迹', '断碑', '残阵', '古殿', '残墙', '废墟'],
},
{
name: '神木秘境',
keywords: ['神木', '古树', '巨树', '树海', '灵木', '林境'],
},
{
name: '飞瀑仙崖',
keywords: ['飞瀑', '瀑布', '仙崖', '崖边', '水幕', '崖壁'],
},
] as const;
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
WorldTemplateType,
readonly SceneImageReference[]
> = {
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
};
type CustomWorldSceneImageMatchOptions = {
profile?: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'camp'
| 'ownedSettingLayers'
> | null;
landmark?: Pick<CustomWorldLandmark, 'id' | 'name' | 'description'> | null;
usedImageSrcs?: Iterable<string>;
};
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash >>> 0;
}
function buildSceneImagePath(packName: string, imageNumber: number) {
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
}
export function getAllCustomWorldSceneImages() {
const refs: string[] = [];
for (const pack of SCENE_BACKGROUND_PACKS) {
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
refs.push(buildSceneImagePath(pack.packName, imageNumber));
}
}
return refs;
}
function collectWorldSceneImagePool(worldType: WorldTemplateType) {
const refs: string[] = [];
let globalIndex = 0;
for (const pack of SCENE_BACKGROUND_PACKS) {
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
if (assignedWorld === worldType) {
refs.push(buildSceneImagePath(pack.packName, imageNumber));
}
globalIndex += 1;
}
}
return refs;
}
export function normalizeOptionalImageSrc(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function uniqueStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
}
function buildSceneReferencePool(worldType: WorldTemplateType) {
const pool = collectWorldSceneImagePool(worldType);
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
return references.map((reference, index) => ({
...reference,
imageSrc: pool[index] ?? pool[index % Math.max(pool.length, 1)] ?? '',
}));
}
function buildOwnedSceneReferencePool(
profile: Pick<
CustomWorldProfile,
'id' | 'name' | 'ownedSettingLayers'
>,
) {
const sceneBuckets =
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
if (sceneBuckets.length === 0) {
return [];
}
const pool = getAllCustomWorldSceneImages();
if (pool.length === 0) {
return [];
}
return sceneBuckets.map((bucket, index) => {
const offset =
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
% pool.length;
return {
name: bucket.label,
keywords: collectSceneBucketSignalKeywords(bucket),
imageSrc: pool[(offset + index) % pool.length] ?? '',
};
});
}
function buildSourceText(
seedKey: string,
index: number,
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions,
) {
const profile = options.profile;
const landmark = options.landmark;
const themeHints = profile
? ({
mythic: '归处 旧痕 路途 异象 线索',
martial: '刀剑 风尘 旧约 行路 关隘',
arcane: '云阶 法纹 星辉 秘藏 回响',
machina: '工坊 轨道 装置 核心 机械',
tide: '潮雾 港湾 岸线 水路 回潮',
rift: '裂痕 断层 前线 边界 异压',
} as const)[detectCustomWorldThemeMode(profile)]
: (worldType === WorldType.XIANXIA
? '云阶 法纹 星辉 秘藏 回响'
: '刀剑 风尘 旧约 行路 关隘');
return uniqueStrings([
profile?.name,
profile?.summary,
profile?.tone,
profile?.playerGoal,
profile?.settingText,
themeHints,
landmark?.name,
landmark?.description,
`scene-${index + 1}`,
seedKey,
]).join(' ');
}
function buildSignalChars(text: string) {
return [
...new Set(
text
.replace(/[^\u4e00-\u9fa5]+/g, '')
.split('')
.filter((char) => char && !SCENE_MATCH_STOP_CHARS.has(char)),
),
];
}
function scoreSceneReference(reference: SceneImageReference, sourceText: string) {
let score = 0;
if (sourceText.includes(reference.name)) {
score += 24;
}
reference.keywords.forEach((keyword) => {
if (!keyword || !sourceText.includes(keyword)) {
return;
}
if (keyword.length >= 4) {
score += 8;
return;
}
if (keyword.length === 3) {
score += 6;
return;
}
score += 4;
});
buildSignalChars([reference.name, ...reference.keywords].join('')).forEach(
(char) => {
if (sourceText.includes(char)) {
score += 1;
}
},
);
return score;
}
function getFirstUnusedImage(
candidates: string[],
usedImageSrcs: Set<string>,
) {
for (const candidate of candidates) {
if (candidate && !usedImageSrcs.has(candidate)) {
return candidate;
}
}
return candidates[0] ?? '';
}
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
}
export function getDefaultCustomWorldSceneImage(
seedKey: string,
index: number,
worldType: WorldTemplateType,
options: CustomWorldSceneImageMatchOptions = {},
) {
const ownedReferencePool = options.profile
? buildOwnedSceneReferencePool(options.profile)
: [];
const pool =
ownedReferencePool.length > 0
? getAllCustomWorldSceneImages()
: collectWorldSceneImagePool(worldType);
if (pool.length === 0) {
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
}
const usedImageSrcs = new Set(
[...(options.usedImageSrcs ?? [])]
.map((value) => normalizeOptionalImageSrc(value))
.filter((value): value is string => Boolean(value)),
);
const preferredSceneBucket =
options.profile && options.landmark
? resolveSceneBucketForLandmark(
options.profile as CustomWorldProfile,
options.landmark,
)
: null;
const sourceText = [
buildSourceText(seedKey, index, worldType, options),
preferredSceneBucket?.label ?? '',
...(preferredSceneBucket
? collectSceneBucketSignalKeywords(preferredSceneBucket)
: []),
].join(' ');
const referencePool =
ownedReferencePool.length > 0
? ownedReferencePool
: buildSceneReferencePool(worldType);
const scoredReferences = referencePool
.map((reference, referenceIndex) => ({
imageSrc: reference.imageSrc,
score:
scoreSceneReference(reference, sourceText)
+ (
preferredSceneBucket && reference.name === preferredSceneBucket.label
? 28
: 0
),
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
}))
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return left.tieBreaker - right.tieBreaker;
});
const matchedReferenceImages = scoredReferences
.filter((entry) => entry.score > 0 && entry.imageSrc)
.map((entry) => entry.imageSrc);
const matchedReferenceImage = getFirstUnusedImage(
matchedReferenceImages,
usedImageSrcs,
);
if (matchedReferenceImage) {
return matchedReferenceImage;
}
const offset = hashText(`${seedKey}:scene:${index}:${sourceText}`) % pool.length;
const rotatedPool = [
...pool.slice(offset),
...pool.slice(0, offset),
];
return getFirstUnusedImage(rotatedPool, usedImageSrcs);
}
export function resolveCustomWorldLandmarkImage(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'ownedSettingLayers'
>,
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'imageSrc'>,
index: number,
usedImageSrcs?: Iterable<string>,
) {
const explicitImageSrc = normalizeOptionalImageSrc(landmark.imageSrc);
if (explicitImageSrc) {
return explicitImageSrc;
}
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
index,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark,
usedImageSrcs,
},
);
}
export function resolveCustomWorldLandmarkImageMap(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const usedImageSrcs = new Set(
profile.landmarks
.map((landmark) => normalizeOptionalImageSrc(landmark.imageSrc))
.filter((imageSrc): imageSrc is string => Boolean(imageSrc)),
);
const imageMap = new Map<string, string>();
profile.landmarks.forEach((landmark, index) => {
const resolvedImageSrc = resolveCustomWorldLandmarkImage(
profile,
landmark,
index,
usedImageSrcs,
);
if (resolvedImageSrc) {
imageMap.set(landmark.id, resolvedImageSrc);
usedImageSrcs.add(resolvedImageSrc);
}
});
return imageMap;
}
export function resolveCustomWorldCampSceneImage(
profile: Pick<
CustomWorldProfile,
| 'id'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
| 'compatibilityTemplateWorldType'
| 'landmarks'
| 'camp'
| 'ownedSettingLayers'
>,
) {
const campScene = resolveCustomWorldCampScene(profile);
const explicitImageSrc = normalizeOptionalImageSrc(campScene.imageSrc);
if (explicitImageSrc) {
return explicitImageSrc;
}
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const usedImageSrcs = new Set(landmarkImageMap.values());
return getDefaultCustomWorldSceneImage(
profile.id || profile.name,
-1,
resolveCustomWorldCompatibilityTemplateWorldType(profile),
{
profile,
landmark: {
id: 'custom-scene-camp',
name: campScene.name,
description: campScene.description,
},
usedImageSrcs,
},
);
}

93
src/data/economy.ts Normal file
View File

@@ -0,0 +1,93 @@
import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSettingLayers';
import { CustomWorldProfile, InventoryItem, WorldType } from '../types';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
common: 12,
uncommon: 24,
rare: 48,
epic: 92,
legendary: 168,
};
function resolveEconomyProfile(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const profile =
customWorldProfile ??
(worldType === WorldType.CUSTOM ? getRuntimeCustomWorldProfile() : null);
return resolveCustomWorldRuleProfile(profile);
}
export function getCurrencyName(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.resourceLabels.currency;
}
if (worldType === WorldType.XIANXIA) return '灵石';
if (worldType === WorldType.WUXIA) return '铜钱';
return '钱币';
}
export function getInitialPlayerCurrency(
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
const ruleProfile = resolveEconomyProfile(worldType, customWorldProfile);
if (ruleProfile) {
return ruleProfile.economyProfile.initialCurrency;
}
return worldType === WorldType.XIANXIA ? 140 : 160;
}
export function getDiscountTierForAffinity(affinity: number) {
if (affinity >= 90) return 3;
if (affinity >= 60) return 2;
if (affinity >= 30) return 1;
return 0;
}
export function getInventoryItemValue(item: InventoryItem) {
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
return Math.max(8, Math.round(item.value));
}
let value = RARITY_BASE_VALUES[item.rarity];
if (item.tags.includes('weapon')) value += 14;
if (item.tags.includes('armor')) value += 12;
if (item.tags.includes('relic')) value += 16;
if (item.tags.includes('mana')) value += 8;
if (item.tags.includes('healing')) value += 8;
if (item.tags.includes('material')) value += 4;
if (item.category.includes('专属')) value += 10;
return Math.max(8, value);
}
export function getNpcPurchasePrice(item: InventoryItem, affinity: number) {
const discountTier = getDiscountTierForAffinity(affinity);
const discountMultiplier = 1 - (discountTier * 0.08);
return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier));
}
export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
const discountTier = getDiscountTierForAffinity(affinity);
const buybackMultiplier = 0.4 + (discountTier * 0.06);
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
}
export function formatCurrency(
value: number,
worldType: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
) {
return `${value} ${getCurrencyName(worldType, customWorldProfile)}`;
}

View File

@@ -0,0 +1,125 @@
import { GameState } from '../types';
import { getFacingTowardPlayer, getMonsterGroupAnchorX, PLAYER_BASE_X_METERS } from './hostileNpcs';
function roundMeters(value: number) {
return Number(value.toFixed(2));
}
function lerp(start: number, end: number, progress: number) {
return roundMeters(start + ((end - start) * progress));
}
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.sceneHostileNpcs.length > 0) {
const anchorX = getMonsterGroupAnchorX(finalState.sceneHostileNpcs);
return {
...finalState,
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const offset = monster.xMeters - anchorX;
const xMeters = roundMeters(entryX + offset);
return {
...monster,
xMeters,
animation: 'move' as const,
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
};
}),
currentEncounter: null,
};
}
if (finalState.currentEncounter) {
return {
...finalState,
currentEncounter: {
...finalState.currentEncounter,
xMeters: entryX,
},
sceneHostileNpcs: [],
};
}
return finalState;
}
export function buildEncounterTransitionState(
finalState: GameState,
sourceState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
): GameState {
if (finalState.sceneHostileNpcs.length > 0) {
const sourceById = new Map(sourceState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const sourceMonster = sourceById.get(monster.id);
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
return {
...monster,
xMeters,
animation: 'move' as const,
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
};
}),
currentEncounter: null,
};
}
if (finalState.currentEncounter) {
return {
...finalState,
currentEncounter: {
...finalState.currentEncounter,
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
},
sceneHostileNpcs: [],
};
}
return finalState;
}
export function interpolateEncounterTransitionState(
startState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
finalState: GameState,
progress: number,
): GameState {
if (finalState.sceneHostileNpcs.length > 0) {
const startById = new Map(startState.sceneHostileNpcs.map(monster => [monster.id, monster]));
return {
...finalState,
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
const startMonster = startById.get(monster.id);
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
return {
...monster,
xMeters,
animation: progress < 1 ? ('move' as const) : monster.animation,
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
};
}),
currentEncounter: null,
};
}
if (finalState.currentEncounter) {
const startX = startState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters ?? 0;
const endX = finalState.currentEncounter.xMeters ?? startX;
return {
...finalState,
currentEncounter: {
...finalState.currentEncounter,
xMeters: lerp(startX, endX, progress),
},
sceneHostileNpcs: [],
};
}
return finalState;
}

View File

@@ -0,0 +1,321 @@
import { Character, CustomWorldProfile, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
import type { CharacterEquipmentItem } from './characterPresets';
import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets';
export type EquipmentBonuses = {
maxHpBonus: number;
maxManaBonus: number;
outgoingDamageMultiplier: number;
incomingDamageMultiplier: number;
};
export const EQUIPMENT_SLOTS: EquipmentSlotId[] = ['weapon', 'armor', 'relic'];
const WEAPON_DAMAGE_BONUS: Record<ItemRarity, number> = {
common: 0.06,
uncommon: 0.1,
rare: 0.14,
epic: 0.2,
legendary: 0.28,
};
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
common: 14,
uncommon: 22,
rare: 32,
epic: 44,
legendary: 58,
};
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
common: 0.97,
uncommon: 0.94,
rare: 0.9,
epic: 0.86,
legendary: 0.8,
};
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
common: 10,
uncommon: 18,
rare: 28,
epic: 40,
legendary: 54,
};
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
common: 0.02,
uncommon: 0.04,
rare: 0.06,
epic: 0.09,
legendary: 0.12,
};
export function createEmptyEquipmentLoadout(): EquipmentLoadout {
return {
weapon: null,
armor: null,
relic: null,
};
}
export function getEquipmentSlotLabel(slot: EquipmentSlotId) {
return {
weapon: '武器',
armor: '护甲',
relic: '饰品',
}[slot];
}
export function getEquipmentRarityLabel(rarity: ItemRarity) {
return {
common: '普通',
uncommon: '优秀',
rare: '稀有',
epic: '史诗',
legendary: '传说',
}[rarity];
}
function normalizePresetRarity(rarityText: string | undefined): ItemRarity {
if (!rarityText) return 'common';
if (/传说|legendary/i.test(rarityText)) return 'legendary';
if (/史诗|epic/i.test(rarityText)) return 'epic';
if (/稀有|rare/i.test(rarityText)) return 'rare';
if (/优秀|uncommon/i.test(rarityText)) return 'uncommon';
return 'common';
}
function inferSlotFromText(value: string) {
if (/||||||||/u.test(value)) return 'weapon' as const;
if (/|||||/u.test(value)) return 'armor' as const;
if (/|||||||||/u.test(value)) return 'relic' as const;
return null;
}
function inferEquipmentTags(slot: EquipmentSlotId, name: string) {
const tags = new Set<string>([slot]);
if (/|||||/u.test(name)) tags.add('mana');
if (/|||/u.test(name)) tags.add('armor');
if (/||||/u.test(name)) tags.add('weapon');
if (/|||||/u.test(name)) tags.add('relic');
if (/||/u.test(name)) tags.add('healing');
return [...tags];
}
function buildStarterEquipmentItem(
characterId: string,
equipmentItem: CharacterEquipmentItem,
slot: EquipmentSlotId,
): InventoryItem {
return {
id: `starter:${characterId}:${slot}`,
category: getEquipmentSlotLabel(slot),
name: equipmentItem.item,
quantity: 1,
rarity: normalizePresetRarity(equipmentItem.rarity),
tags: inferEquipmentTags(slot, equipmentItem.item),
equipmentSlotId: slot,
buildProfile: inferStarterBuildProfile(slot, equipmentItem.item),
};
}
function inferStarterBuildProfile(slot: EquipmentSlotId, name: string): InventoryItem['buildProfile'] {
const source = `${slot} ${name}`;
if (/||/u.test(source)) {
return {
role: normalizeBuildRole('游击'),
tags: normalizeBuildTags(['远射', '游击', '风行']),
synergy: ['拉扯', '先手试探', '远程压制'],
forgeRank: 0,
};
}
if (/|||/u.test(source)) {
return {
role: normalizeBuildRole('先锋'),
tags: normalizeBuildTags(['守御', '护体', '先锋']),
synergy: ['正面承压', '稳定推进', '反手压场'],
forgeRank: 0,
};
}
if (/||/u.test(source)) {
return {
role: normalizeBuildRole('狂战'),
tags: normalizeBuildTags(['重击', '爆发', '压血']),
synergy: ['近身爆发', '压低血线', '强攻破面'],
forgeRank: 0,
};
}
if (/||||/u.test(source)) {
return {
role: normalizeBuildRole('法修'),
tags: normalizeBuildTags(['法力', '护体', '镇邪']),
synergy: ['法力支撑', '续战调息', '偏功能补位'],
forgeRank: 0,
};
}
if (slot === 'weapon') {
return {
role: normalizeBuildRole('快剑'),
tags: normalizeBuildTags(['快剑', '突进', '压制']),
synergy: ['贴身连击', '起手压制', '追身进攻'],
forgeRank: 0,
};
}
if (slot === 'armor') {
return {
role: normalizeBuildRole('守御'),
tags: normalizeBuildTags(['守御', '护体']),
synergy: ['过渡承伤', '基础防护'],
forgeRank: 0,
};
}
return {
role: normalizeBuildRole('均衡'),
tags: normalizeBuildTags(['均衡', '续战']),
synergy: ['过渡补强', '基础续航'],
forgeRank: 0,
};
}
export function getEquipmentSlotFromItem(item: InventoryItem): EquipmentSlotId | null {
if (item.equipmentSlotId) return item.equipmentSlotId;
if (item.tags.includes('weapon')) return 'weapon';
if (item.tags.includes('armor')) return 'armor';
if (item.tags.includes('relic')) return 'relic';
return inferSlotFromText(`${item.category} ${item.name}`);
}
export function isInventoryItemEquippable(item: InventoryItem) {
return getEquipmentSlotFromItem(item) !== null;
}
export function buildInitialEquipmentLoadout(
character: Character,
customWorldProfile: CustomWorldProfile | null = null,
) {
const loadout = createEmptyEquipmentLoadout();
const starterEquipment = getCharacterEquipment(character, customWorldProfile);
starterEquipment.forEach((equipmentItem, index) => {
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)
?? EQUIPMENT_SLOTS[index]
?? null;
if (!inferredSlot || loadout[inferredSlot]) return;
loadout[inferredSlot] = buildStarterEquipmentItem(character.id, equipmentItem, inferredSlot);
});
return loadout;
}
function getFallbackBonusesForItem(slot: EquipmentSlotId, rarity: ItemRarity) {
if (slot === 'weapon') {
return {
maxHpBonus: 0,
maxManaBonus: 0,
outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity],
incomingDamageMultiplier: 1,
};
}
if (slot === 'armor') {
return {
maxHpBonus: ARMOR_HP_BONUS[rarity],
maxManaBonus: 0,
outgoingDamageBonus: 0,
incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity],
};
}
return {
maxHpBonus: 0,
maxManaBonus: RELIC_MANA_BONUS[rarity],
outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity],
incomingDamageMultiplier: 1,
};
}
function getItemEquipmentBonuses(item: InventoryItem, slot: EquipmentSlotId) {
const fallback = getFallbackBonusesForItem(slot, item.rarity);
const statProfile = item.statProfile;
return {
maxHpBonus: statProfile?.maxHpBonus ?? fallback.maxHpBonus,
maxManaBonus: statProfile?.maxManaBonus ?? fallback.maxManaBonus,
outgoingDamageBonus: statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
incomingDamageMultiplier: statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
};
}
export function getEquipmentBonuses(loadout: EquipmentLoadout): EquipmentBonuses {
let maxHpBonus = 0;
let maxManaBonus = 0;
let outgoingDamageBonus = 0;
let incomingDamageMultiplier = 1;
EQUIPMENT_SLOTS.forEach(slot => {
const item = loadout[slot];
if (!item) return;
const itemBonuses = getItemEquipmentBonuses(item, slot);
maxHpBonus += itemBonuses.maxHpBonus;
maxManaBonus += itemBonuses.maxManaBonus;
outgoingDamageBonus += itemBonuses.outgoingDamageBonus;
incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier;
});
return {
maxHpBonus,
maxManaBonus,
outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)),
incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)),
};
}
export function applyEquipmentLoadoutToState(
state: GameState,
nextEquipment: EquipmentLoadout,
): GameState {
const nextBonuses = getEquipmentBonuses(nextEquipment);
const baseMaxHp = state.playerCharacter
? getCharacterMaxHp(
state.playerCharacter,
state.worldType,
state.customWorldProfile,
)
: Math.max(1, state.playerMaxHp);
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;
return {
...state,
playerMaxHp: nextMaxHp,
playerHp: Math.min(nextMaxHp, state.playerHp),
playerMaxMana: nextMaxMana,
playerMana: nextMaxMana,
playerEquipment: nextEquipment,
};
}
export function describeEquipmentBonuses(bonuses: EquipmentBonuses) {
const parts = [
bonuses.maxHpBonus > 0 ? `气血上限 +${bonuses.maxHpBonus}` : null,
bonuses.maxManaBonus > 0 ? `灵力上限 +${bonuses.maxManaBonus}` : null,
bonuses.outgoingDamageMultiplier > 1 ? `伤害 x${bonuses.outgoingDamageMultiplier}` : null,
bonuses.incomingDamageMultiplier < 1 ? `承伤 x${bonuses.incomingDamageMultiplier}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join('') : '暂无额外加成';
}

534
src/data/forgeSystem.ts Normal file
View File

@@ -0,0 +1,534 @@
import type {
EquipmentSlotId,
InventoryItem,
ItemBuildProfile,
ItemRarity,
ItemStatProfile,
WorldType,
} from '../types';
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
import { formatCurrency } from './economy';
import { getEquipmentSlotFromItem } from './equipmentEffects';
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
export type ForgeRecipeKind = 'synthesis' | 'forge';
type ForgeRequirement = {
id: string;
label: string;
quantity: number;
matches: (item: InventoryItem) => boolean;
};
type ForgeRecipeDefinition = {
id: string;
name: string;
kind: ForgeRecipeKind;
description: string;
resultLabel: string;
currencyCost: number;
requirements: ForgeRequirement[];
createResult: (worldType: WorldType | null) => InventoryItem;
};
export type ForgeRecipeView = {
id: string;
name: string;
kind: ForgeRecipeKind;
description: string;
resultLabel: string;
currencyCost: number;
currencyText: string;
requirements: Array<{
id: string;
label: string;
quantity: number;
owned: number;
}>;
canCraft: boolean;
};
export type ForgeExecutionResult = {
inventory: InventoryItem[];
currency: number;
createdItem: InventoryItem;
};
export type DismantleExecutionResult = {
inventory: InventoryItem[];
outputs: InventoryItem[];
};
export type ReforgeExecutionResult = {
inventory: InventoryItem[];
reforgedItem: InventoryItem;
currencyCost: number;
};
function createItemId(prefix: string) {
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeQuantity(quantity: number) {
return Math.max(1, Math.floor(quantity));
}
function buildMaterialItem(
name: string,
quantity: number,
tags: string[],
rarity: ItemRarity = 'uncommon',
description?: string,
): InventoryItem {
return {
id: createItemId(`forge-material:${name}`),
category: '材料',
name,
quantity: normalizeQuantity(quantity),
rarity,
tags: ['material', ...normalizeBuildTags(tags)],
description,
buildProfile: {
role: normalizeBuildRole('工巧'),
tags: normalizeBuildTags(tags),
craftTags: normalizeBuildTags(tags),
forgeRank: 0,
},
};
}
function buildEquipmentItem(params: {
name: string;
slot: EquipmentSlotId;
rarity: ItemRarity;
description: string;
role: string;
tags: string[];
setId: string;
setName: string;
pieceName: string;
synergy: string[];
statProfile: ItemStatProfile;
}) {
return {
id: createItemId(`forge-equip:${params.name}`),
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
name: params.name,
quantity: 1,
rarity: params.rarity,
tags: [
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
...normalizeBuildTags(params.tags),
],
description: params.description,
equipmentSlotId: params.slot,
statProfile: params.statProfile,
buildProfile: {
role: normalizeBuildRole(params.role),
tags: normalizeBuildTags(params.tags),
setId: params.setId,
setName: params.setName,
pieceName: params.pieceName,
synergy: params.synergy,
craftTags: normalizeBuildTags(params.tags),
forgeRank: 1,
} satisfies ItemBuildProfile,
} satisfies InventoryItem;
}
function buildRefinedIngot() {
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
}
function buildCondensedSilk() {
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
}
function buildTagEssence(tag: string) {
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
}
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
return {
id,
label,
quantity,
matches: item => item.tags.includes('material') || item.category.includes('材料'),
};
}
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
return {
id: `name:${name}`,
label: name,
quantity,
matches: item => item.name === name,
};
}
const FORGE_RECIPES: ForgeRecipeDefinition[] = [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
requirements: [
buildAnyMaterialRequirement('material:any', '任意材料', 3),
],
createResult: () => buildRefinedIngot(),
},
{
id: 'synthesis-condensed-silk',
name: '凝光纺丝',
kind: 'synthesis',
description: '用灵性残材与粉末纺出适合饰品锻造的凝光纱。',
resultLabel: '凝光纱',
currencyCost: 24,
requirements: [
buildAnyMaterialRequirement('material:any', '任意材料', 2),
{
id: 'tag:mana',
label: '含法力标签材料',
quantity: 1,
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
},
],
createResult: () => buildCondensedSilk(),
},
{
id: 'forge-duelist-blade',
name: '锻造 百炼追风剑',
kind: 'forge',
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
resultLabel: '百炼追风剑',
currencyCost: 72,
requirements: [
buildNamedMaterialRequirement('精炼锭材', 2),
buildNamedMaterialRequirement('快剑精粹', 1),
buildNamedMaterialRequirement('突进精粹', 1),
],
createResult: () => buildEquipmentItem({
name: '百炼追风剑',
slot: 'weapon',
rarity: 'epic',
description: '为快剑与追身构筑准备的锻造兵刃,挥动时更容易连续压进对手空门。',
role: '快剑',
tags: ['快剑', '突进', '追击'],
setId: 'forge-set-duelist',
setName: '追风连锋',
pieceName: 'weapon',
synergy: ['快剑', '突进', '追击'],
statProfile: {
maxManaBonus: 10,
outgoingDamageBonus: 0.2,
},
}),
},
{
id: 'forge-ward-armor',
name: '锻造 镇岳护甲',
kind: 'forge',
description: '面向前排承压的护甲,适合守御与护体构筑。',
resultLabel: '镇岳护甲',
currencyCost: 78,
requirements: [
buildNamedMaterialRequirement('精炼锭材', 2),
buildNamedMaterialRequirement('守御精粹', 1),
buildNamedMaterialRequirement('护体精粹', 1),
],
createResult: () => buildEquipmentItem({
name: '镇岳护甲',
slot: 'armor',
rarity: 'epic',
description: '厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。',
role: '守御',
tags: ['守御', '护体', '先锋'],
setId: 'forge-set-ward',
setName: '镇岳守阵',
pieceName: 'armor',
synergy: ['守御', '护体', '先锋'],
statProfile: {
maxHpBonus: 56,
maxManaBonus: 8,
outgoingDamageBonus: 0.08,
incomingDamageMultiplier: 0.84,
},
}),
},
{
id: 'forge-thunder-relic',
name: '锻造 雷纹灵坠',
kind: 'forge',
description: '为法修、雷法、过载 build 提供资源与爆发补强。',
resultLabel: '雷纹灵坠',
currencyCost: 88,
requirements: [
buildNamedMaterialRequirement('凝光纱', 2),
buildNamedMaterialRequirement('法力精粹', 1),
buildNamedMaterialRequirement('雷法精粹', 1),
],
createResult: () => buildEquipmentItem({
name: '雷纹灵坠',
slot: 'relic',
rarity: 'epic',
description: '内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。',
role: '法修',
tags: ['法修', '雷法', '过载'],
setId: 'forge-set-thunder',
setName: '雷纹御法',
pieceName: 'relic',
synergy: ['法修', '雷法', '过载'],
statProfile: {
maxHpBonus: 8,
maxManaBonus: 42,
outgoingDamageBonus: 0.14,
incomingDamageMultiplier: 0.92,
},
}),
},
];
function countMatchingItems(inventory: InventoryItem[], requirement: ForgeRequirement) {
return inventory
.filter(item => requirement.matches(item))
.reduce((sum, item) => sum + item.quantity, 0);
}
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
let remaining = requirement.quantity;
let nextInventory = [...inventory];
for (const item of inventory) {
if (remaining <= 0) break;
if (!requirement.matches(item)) continue;
const consumed = Math.min(item.quantity, remaining);
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
remaining -= consumed;
}
return remaining === 0 ? nextInventory : null;
}
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
let nextInventory = [...inventory];
for (const requirement of requirements) {
const consumedInventory = consumeRequirement(nextInventory, requirement);
if (!consumedInventory) return null;
nextInventory = consumedInventory;
}
return nextInventory;
}
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
const rarityScale: Record<ItemRarity, number> = {
common: 1,
uncommon: 2,
rare: 3,
epic: 4,
legendary: 5,
};
const amount = rarityScale[item.rarity];
if (slot === 'weapon') {
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
}
if (slot === 'armor') {
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
}
if (slot === 'relic') {
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
}
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
}
function buildDismantleEssences(item: InventoryItem) {
const buildTags = normalizeBuildTags([
...(item.buildProfile?.tags ?? []),
item.buildProfile?.role ?? '',
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
return buildTags.map(tag => buildTagEssence(tag));
}
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
const nextProfile = { ...(statProfile ?? {}) };
nextProfile.maxHpBonus = (nextProfile.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4);
nextProfile.maxManaBonus = (nextProfile.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4);
nextProfile.outgoingDamageBonus = Number(((nextProfile.outgoingDamageBonus ?? 0) + 0.03).toFixed(3));
if (typeof nextProfile.incomingDamageMultiplier === 'number') {
nextProfile.incomingDamageMultiplier = Number(Math.max(0.72, nextProfile.incomingDamageMultiplier - 0.03).toFixed(3));
} else if (slot === 'armor' || slot === 'relic') {
nextProfile.incomingDamageMultiplier = slot === 'armor' ? 0.94 : 0.97;
}
return nextProfile;
}
function buildReforgedItem(item: InventoryItem) {
const slot = getEquipmentSlotFromItem(item);
if (!slot || !item.buildProfile) return null;
const currentTags = normalizeBuildTags(item.buildProfile.tags);
const primaryTag = currentTags[0];
const replacement = primaryTag
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
: null;
const nextTags = normalizeBuildTags([
...(replacement ? [replacement] : []),
...currentTags.slice(replacement && replacement !== primaryTag ? 1 : 0),
]).slice(0, 3);
return {
...item,
id: createItemId(`reforge:${item.name}`),
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
statProfile: enhanceStatProfile(item.statProfile, slot),
buildProfile: {
...item.buildProfile,
role: normalizeBuildRole(item.buildProfile.role),
tags: nextTags,
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
synergy: nextTags,
},
} satisfies InventoryItem;
}
function getReforgeCost(slot: EquipmentSlotId | null) {
if (slot === 'relic') {
return {
requirements: [buildNamedMaterialRequirement('凝光纱', 1)],
currencyCost: 52,
};
}
return {
requirements: [buildNamedMaterialRequirement('精炼锭材', 1)],
currencyCost: 46,
};
}
export function getForgeRecipeViews(
inventory: InventoryItem[],
playerCurrency = 0,
worldType: WorldType | null = null,
) {
return FORGE_RECIPES.map(recipe => ({
id: recipe.id,
name: recipe.name,
kind: recipe.kind,
description: recipe.description,
resultLabel: recipe.resultLabel,
currencyCost: recipe.currencyCost,
currencyText: formatCurrency(recipe.currencyCost, worldType),
requirements: recipe.requirements.map(requirement => ({
id: requirement.id,
label: requirement.label,
quantity: requirement.quantity,
owned: countMatchingItems(inventory, requirement),
})),
canCraft:
playerCurrency >= recipe.currencyCost &&
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
})) satisfies ForgeRecipeView[];
}
export function executeForgeRecipe(
inventory: InventoryItem[],
recipeId: string,
worldType: WorldType | null,
playerCurrency: number,
): ForgeExecutionResult | null {
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
if (!recipe || playerCurrency < recipe.currencyCost) return null;
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
if (!consumedInventory) return null;
const createdItem = recipe.createResult(worldType);
return {
inventory: addInventoryItems(consumedInventory, [createdItem]),
currency: playerCurrency - recipe.currencyCost,
createdItem,
};
}
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
const targetItem = inventory.find(item => item.id === itemId);
if (!targetItem || targetItem.quantity <= 0) return null;
const slot = getEquipmentSlotFromItem(targetItem);
if (!slot && !targetItem.buildProfile) return null;
const outputs = [
...buildDismantleBaseMaterials(targetItem, slot),
...buildDismantleEssences(targetItem),
];
return {
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
outputs,
};
}
export function executeReforgeItem(
inventory: InventoryItem[],
itemId: string,
playerCurrency: number,
): ReforgeExecutionResult | null {
const targetItem = inventory.find(item => item.id === itemId);
if (!targetItem || targetItem.quantity <= 0) return null;
const slot = getEquipmentSlotFromItem(targetItem);
const reforgedItem = buildReforgedItem(targetItem);
const reforgeCost = getReforgeCost(slot);
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
const consumedInventory = applyRequirementsIfPossible(
removeInventoryItem(inventory, itemId, 1),
reforgeCost.requirements,
);
if (!consumedInventory) return null;
return {
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
reforgedItem,
currencyCost: reforgeCost.currencyCost,
};
}
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
const slot = getEquipmentSlotFromItem(item);
const cost = getReforgeCost(slot);
return {
currencyCost: cost.currencyCost,
currencyText: formatCurrency(cost.currencyCost, worldType),
requirements: cost.requirements.map(requirement => ({
id: requirement.id,
label: requirement.label,
quantity: requirement.quantity,
})),
};
}
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
sourceItemName?: string;
recipeName?: string;
createdItemName?: string;
outputNames?: string[];
currencyText?: string;
}) {
if (action === 'craft') {
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}`;
}
if (action === 'reforge') {
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}`;
}
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}`;
}

View File

@@ -0,0 +1,62 @@
import { AnimationState, type StoryOption } from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* camp_travel_home_scene
*
* 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。
* 这里除了元信息,也直接收口了它的按钮构造与判定 helper。
*/
export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 1.1,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
};
export function buildCampTravelHomeOption(sceneName: string): StoryOption {
return {
functionId: CAMP_TRAVEL_HOME_FUNCTION.id,
actionText: `前往 ${sceneName}`,
text: `前往 ${sceneName}`,
detailText: `离开营地,前往 ${sceneName}`,
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
};
}
export function isCampTravelHomeFunctionId(functionId: string) {
return functionId === CAMP_TRAVEL_HOME_FUNCTION.id;
}
export function isCampTravelHomeOption(option: StoryOption) {
return isCampTravelHomeFunctionId(option.functionId);
}
export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
id: 'camp_travel_home_scene',
domain: 'flow',
title: '前往角色主场景',
source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts',
summary: '营地开场后的专用旅行控制项。',
detailedDescription:
'它负责把开局同伴营地流程平稳切到角色真正的起始场景,并清理当前营地 encounter、战斗态和镜头残留状态。',
trigger: '常见于开局同伴营地对话后的跟进选项。',
execution:
'点击后不会走普通 state function 结算,而是执行一次定制的场景迁移和历史写入。',
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
active: true,
runtime: {
storyMode: 'special_travel',
uiMode: 'none',
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
executor:
'src/hooks/rpg-runtime-story/choiceActions.ts -> handleCampTravelHome',
animationNote:
'先播放营地离场的 run 演出,再切到正式场景并生成 encounter preview。',
storyNote:
'通过 commitGeneratedStateWithEncounterEntry 写入离营结果,并在新场景继续后续剧情。',
uiNote: '这是专用旅行流程,不会打开 modal。',
},
};

View File

@@ -0,0 +1,10 @@
import type { FunctionDocumentationEntry } from '../types';
import { CAMP_TRAVEL_HOME_FUNCTION } from './campTravelHomeScene';
import { CONTINUE_ADVENTURE_FUNCTION } from './storyContinueAdventure';
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from './storyOpeningCampDialogue';
export const FLOW_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
CONTINUE_ADVENTURE_FUNCTION,
CAMP_TRAVEL_HOME_FUNCTION,
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
];

View File

@@ -0,0 +1,61 @@
import { AnimationState, type StoryOption } from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* story_continue_adventure
*
* 聊天或特殊流程已经提前完成推理后,用于“把延后展示的 options 放出来”的控制 function。
* 这里除了说明文本外,也直接收口了这个 function 的按钮视觉和判定 helper。
*/
export const CONTINUE_ADVENTURE_OPTION_VISUALS: StoryOption['visuals'] = {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 1.1,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
};
export function buildContinueAdventureOption(): StoryOption {
return {
functionId: CONTINUE_ADVENTURE_FUNCTION.id,
actionText: CONTINUE_ADVENTURE_FUNCTION.title,
text: CONTINUE_ADVENTURE_FUNCTION.title,
priority: 99,
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
};
}
export function isContinueAdventureFunctionId(functionId: string) {
return functionId === CONTINUE_ADVENTURE_FUNCTION.id;
}
export function isContinueAdventureOption(option: StoryOption) {
return isContinueAdventureFunctionId(option.functionId);
}
export const CONTINUE_ADVENTURE_FUNCTION: FunctionDocumentationEntry = {
id: 'story_continue_adventure',
domain: 'flow',
title: '继续冒险',
source: 'src/data/functionCatalog/flow/storyContinueAdventure.ts',
summary: '承接 deferredOptions 的延迟展示控制项。',
detailedDescription:
'它不是重新推理剧情,而是在某些流程已经先算好后续 options 时,给玩家一个清晰的继续按钮,再把 deferredOptions 真正放回界面。',
trigger: '常见于 npc_chat 等先生成正文、后延迟显示选项的链路。',
execution:
'点击后主要走本地 UI / state 还原逻辑,而不是再请求一次新的故事推理。',
result:
'玩家会看到之前已经准备好的后续冒险选项,误以为“没继续生成”的风险也会降低。',
active: true,
runtime: {
storyMode: 'reveal_deferred_options',
uiMode: 'none',
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
executor: 'src/hooks/rpg-runtime-story/choiceActions.ts -> handleChoice',
animationNote: '按钮本身沿用轻量前进动画,但不驱动新的战斗或场景演出。',
storyNote:
'点击时直接把 deferredOptions 放回 currentStory.options不再请求新的 generateNextStep。',
uiNote: '这是一个流程确认按钮,不会弹 modal。',
},
};

View File

@@ -0,0 +1,38 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* story_opening_camp_dialogue
*
* 开局营地场景的特殊对话控制 function。
* 这里同时提供判定 helper供 prompt 和故事流程判断是否进入营地开场对白模式。
*/
export function isOpeningCampDialogueFunctionId(
functionId: string | null | undefined,
) {
return functionId === STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
}
export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
{
id: 'story_opening_camp_dialogue',
domain: 'flow',
title: '营地开场对话',
source: 'src/data/functionCatalog/flow/storyOpeningCampDialogue.ts',
summary: '驱动开局营地 4 到 6 行开场对白的流程项。',
detailedDescription:
'它告诉 prompt 与运行时:当前不是普通探索推进,而是要围绕营地背景、初始同伴态度和刚进入世界的紧张感生成一段结构化开场对白。',
trigger: '开局同伴营地场景进入正式对话时出现。',
execution:
'点击后会进入 opening adventure 的特殊对话生成链,而不是普通 function option 链路。',
result: '玩家会先看到一段营地对白,再衔接后续 npc_chat 或离营流程。',
active: true,
runtime: {
storyMode: 'special_sequence',
uiMode: 'none',
executor:
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action',
animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
uiNote: '不弹 modal直接进入对白流。',
},
};

View File

@@ -0,0 +1,157 @@
import { existsSync } from 'node:fs';
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import { describe, expect, it } from 'vitest';
import {
ALL_FUNCTION_DOCUMENTATION,
buildCampTravelHomeOption,
buildContinueAdventureOption,
buildNpcGiftModalState,
buildNpcPreviewTalkOption,
buildNpcRecruitModalState,
buildNpcTradeModalState,
CONTINUE_ADVENTURE_FUNCTION,
getFunctionDocumentationById,
isNpcPreviewTalkOption,
NPC_PREVIEW_TALK_FUNCTION,
shouldNpcRecruitOpenModal,
} from './index';
import type { Encounter, GameState, InventoryItem } from '../../types';
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: '梁伯',
npcDescription: '沿路摆摊的商人。',
npcAvatar: '梁',
context: '商贩',
...overrides,
};
}
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
description: `${name} 的测试描述`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
function createModalState(overrides: Partial<GameState> = {}): GameState {
return {
playerInventory: [
createInventoryItem('player-potion', '疗伤药'),
createInventoryItem('player-charm', '护符'),
],
companions: [
{
npcId: 'npc-ally-1',
characterId: 'ally-1',
name: '阿青',
role: '同伴',
joinedAtAffinity: 12,
},
],
...overrides,
} as GameState;
}
describe('functionCatalog', () => {
it('keeps function documentation ids unique and source files resolvable', () => {
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
expect(new Set(documentationIds).size).toBe(documentationIds.length);
ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
expect(existsSync(entry.source), `${entry.id} -> ${entry.source}`).toBe(
true,
);
expect(getFunctionDocumentationById(entry.id)).toEqual(entry);
});
});
it('covers every server runtime function id with documentation metadata', () => {
SERVER_RUNTIME_FUNCTION_IDS.forEach((functionId) => {
expect(getFunctionDocumentationById(functionId)).not.toBeNull();
});
});
it('builds flow helper options with the expected function ids', () => {
const continueOption = buildContinueAdventureOption();
const campTravelOption = buildCampTravelHomeOption('竹林古道');
expect(continueOption.functionId).toBe(CONTINUE_ADVENTURE_FUNCTION.id);
expect(continueOption.priority).toBe(99);
expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
expect(campTravelOption.actionText).toBe('前往 竹林古道');
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
});
it('builds npc preview talk options from the current encounter', () => {
const option = buildNpcPreviewTalkOption(createEncounter());
expect(option.functionId).toBe(NPC_PREVIEW_TALK_FUNCTION.id);
expect(option.actionText).toBe('与 梁伯 交谈');
expect(isNpcPreviewTalkOption(option)).toBe(true);
});
it('builds modal helper state for trade, gift and recruit flows', () => {
const state = createModalState();
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
state,
encounter,
'先看看货',
[
createInventoryItem('npc-herb', '止血草'),
createInventoryItem('npc-ore', '陨铁碎片'),
],
);
const giftModal = buildNpcGiftModalState(
state,
encounter,
'送你一样东西',
'player-charm',
);
const recruitModal = buildNpcRecruitModalState(
state,
encounter,
'谈谈同行的事',
);
expect(tradeModal.selectedNpcItemId).toBe('npc-herb');
expect(tradeModal.selectedPlayerItemId).toBe('player-potion');
expect(giftModal.selectedItemId).toBe('player-charm');
expect(recruitModal.selectedReleaseNpcId).toBe('npc-ally-1');
expect(shouldNpcRecruitOpenModal(2, 2)).toBe(true);
expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
});
it('prefers the first tradable player item when zero-quantity items exist', () => {
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
createModalState({
playerInventory: [
createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
],
}),
encounter,
'交易',
[createInventoryItem('npc-herb', '止血草')],
);
expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
});
});

View File

@@ -0,0 +1,61 @@
import { FLOW_FUNCTION_DOCUMENTATION } from './flow';
import { NPC_FUNCTION_DOCUMENTATION } from './npc';
import { PANEL_FUNCTION_DOCUMENTATION } from './panel';
import {
STATE_FUNCTION_DEFINITIONS,
STATE_FUNCTION_DOCUMENTATION,
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
STATE_FUNCTION_SOURCES,
} from './state';
import { TREASURE_FUNCTION_DOCUMENTATION } from './treasure';
import type { FunctionDocumentationEntry } from './types';
export * from './flow/campTravelHomeScene';
export * from './flow/storyContinueAdventure';
export * from './flow/storyOpeningCampDialogue';
export * from './npc/npcChat';
export * from './npc/npcChatQuestOffer';
export * from './npc/npcFight';
export * from './npc/npcGift';
export * from './npc/npcHelp';
export * from './npc/npcLeave';
export * from './npc/npcPreviewTalk';
export * from './npc/npcQuestAccept';
export * from './npc/npcQuestTurnIn';
export * from './npc/npcRecruit';
export * from './npc/npcSpar';
export * from './npc/npcTrade';
export * from './panel/equipmentEquip';
export * from './panel/equipmentUnequip';
export * from './panel/forgeCraft';
export * from './panel/forgeDismantle';
export * from './panel/forgeReforge';
export * from './panel/inventoryUse';
export * from './state';
export * from './treasure/treasureInspect';
export * from './treasure/treasureLeave';
export * from './treasure/treasureSecure';
export * from './types';
export const ALL_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
...STATE_FUNCTION_DOCUMENTATION,
...NPC_FUNCTION_DOCUMENTATION,
...TREASURE_FUNCTION_DOCUMENTATION,
...FLOW_FUNCTION_DOCUMENTATION,
...PANEL_FUNCTION_DOCUMENTATION,
];
export const ALL_FUNCTION_DOCUMENTATION_MAP = new Map(
ALL_FUNCTION_DOCUMENTATION.map((entry) => [entry.id, entry]),
);
export function getFunctionDocumentationById(functionId: string) {
return ALL_FUNCTION_DOCUMENTATION_MAP.get(functionId) ?? null;
}
export {
STATE_FUNCTION_DEFINITIONS,
STATE_FUNCTION_DOCUMENTATION,
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
STATE_FUNCTION_SOURCES,
};

View File

@@ -0,0 +1,34 @@
import type { FunctionDocumentationEntry } from '../types';
import { NPC_CHAT_FUNCTION } from './npcChat';
import {
NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION,
NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION,
NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION,
} from './npcChatQuestOffer';
import { NPC_FIGHT_FUNCTION } from './npcFight';
import { NPC_GIFT_FUNCTION } from './npcGift';
import { NPC_HELP_FUNCTION } from './npcHelp';
import { NPC_LEAVE_FUNCTION } from './npcLeave';
import { NPC_PREVIEW_TALK_FUNCTION } from './npcPreviewTalk';
import { NPC_QUEST_ACCEPT_FUNCTION } from './npcQuestAccept';
import { NPC_QUEST_TURN_IN_FUNCTION } from './npcQuestTurnIn';
import { NPC_RECRUIT_FUNCTION } from './npcRecruit';
import { NPC_SPAR_FUNCTION } from './npcSpar';
import { NPC_TRADE_FUNCTION } from './npcTrade';
export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
NPC_PREVIEW_TALK_FUNCTION,
NPC_TRADE_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_SPAR_FUNCTION,
NPC_HELP_FUNCTION,
NPC_CHAT_FUNCTION,
NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION,
NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION,
NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION,
NPC_GIFT_FUNCTION,
NPC_RECRUIT_FUNCTION,
NPC_QUEST_ACCEPT_FUNCTION,
NPC_QUEST_TURN_IN_FUNCTION,
NPC_LEAVE_FUNCTION,
];

View File

@@ -0,0 +1,34 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_chat
*
* 与眼前 NPC 围绕当前话题继续交谈的 function。
*/
export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_chat',
domain: 'npc',
title: '继续交谈',
source: 'src/data/functionCatalog/npc/npcChat.ts',
summary: '围绕当前话题展开聊天并累积关系推进。',
detailedDescription:
'它会先生成一段聊天正文,再在后台继续生成新的冒险选项。当前 UI 中,新选项通常会被延后到 story_continue_adventure 之后再展示。',
trigger:
'在 NPC 交互菜单里按不同话题重复出现functionId 相同但 actionText 和 detailText 可不同。',
execution:
'点击后先进入流式聊天,再触发一次新的剧情推理,并把真正的新 options 放入 deferredOptions。',
result:
'玩家会看到对话正文、关系变化和后续继续冒险入口,而不是立刻显示新一轮选项。',
active: true,
runtime: {
storyMode: 'stream_then_defer',
uiMode: 'none',
executor:
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> commitNpcChatState',
animationNote: '重点在流式对白和轻量站场,不额外打开窗口。',
storyNote:
'先生成聊天正文,再把真正的新选项放入 deferredOptions等待 continue adventure。',
uiNote: '不弹 modal直接进入聊天流。',
compactDetailText: '聊聊并试探口风',
},
};

View File

@@ -0,0 +1,86 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_chat_quest_offer_*
*
* NPC 聊天态里的临时委托处理 function。它们不是新的任务系统
* 而是高好感聊天中 pending quest offer 的查看、更换和放弃入口。
*/
const QUEST_OFFER_SOURCE = 'src/data/functionCatalog/npc/npcChatQuestOffer.ts';
const QUEST_OFFER_EXECUTOR =
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action';
export const NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_chat_quest_offer_view',
domain: 'npc',
title: '查看委托',
source: QUEST_OFFER_SOURCE,
summary: '查看当前聊天中 NPC 刚提出但尚未领取的委托。',
detailedDescription:
'它用于 pending quest offer 阶段,只打开或返回当前待领取任务详情,不把任务写入正式 quest log。',
trigger: 'NPC 聊天触发待领取委托后,任务处理态选项中出现。',
execution:
'后端读取当前 pending quest offer并返回可展示的任务详情与领取入口。',
result: '玩家可以查看任务目标和奖励,确认领取前不会改变正式任务日志。',
active: true,
runtime: {
storyMode: 'local_only',
uiMode: 'none',
executor: QUEST_OFFER_EXECUTOR,
animationNote: '不触发角色位移动画,重点是切换任务详情展示。',
storyNote: '只保留当前委托上下文,不生成新的聊天剧情。',
uiNote: '展示待领取任务详情,等待玩家领取、替换或返回聊天。',
compactDetailText: '查看这份委托',
},
};
export const NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION: FunctionDocumentationEntry =
{
id: 'npc_chat_quest_offer_replace',
domain: 'npc',
title: '更换委托',
source: QUEST_OFFER_SOURCE,
summary: '让 NPC 重新生成一份聊天内待领取委托。',
detailedDescription:
'它不会本地改写现有任务文案,而是重新走任务生成链,替换当前 pending quest offer。',
trigger: 'NPC 聊天任务处理态中,玩家不满意当前委托时出现。',
execution:
'后端调用任务生成链生成新 quest offer并覆盖当前聊天态 pending offer。',
result:
'当前待领取委托被替换,聊天仍停留在任务处理态,正式 quest log 不变。',
active: true,
runtime: {
storyMode: 'local_effect_then_generate',
uiMode: 'none',
executor: QUEST_OFFER_EXECUTOR,
animationNote: '不触发战斗或移动演出,只追加轻量聊天反馈。',
storyNote: '重新生成 pending quest offer并说明 NPC 换了一个委托。',
uiNote: '继续显示查看、更换、放弃这组任务处理选项。',
compactDetailText: '换一个委托',
},
};
export const NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION: FunctionDocumentationEntry =
{
id: 'npc_chat_quest_offer_abandon',
domain: 'npc',
title: '放弃委托',
source: QUEST_OFFER_SOURCE,
summary: '丢弃当前聊天中尚未领取的委托。',
detailedDescription:
'它只清理 pending quest offer不影响已经写入 quest log 的正式任务,也不会扣除奖励或结算任务失败。',
trigger: 'NPC 聊天任务处理态中,玩家暂时不想接这份委托时出现。',
execution:
'后端清空当前聊天态 pending quest offer并恢复普通 NPC 聊天选项。',
result: '待领取委托消失,玩家回到自由聊天或离开 NPC 的正常流程。',
active: true,
runtime: {
storyMode: 'local_only',
uiMode: 'none',
executor: QUEST_OFFER_EXECUTOR,
animationNote: '不触发额外演出,只回到普通聊天态。',
storyNote: '追加玩家暂时不接委托的轻量反馈。',
uiNote: '恢复普通 npc_chat 建议和自定义输入。',
compactDetailText: '暂时不接',
},
};

View File

@@ -0,0 +1,32 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_fight
*
* 与眼前 NPC 直接开战的强制冲突 function。
*/
export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_fight',
domain: 'npc',
title: '与对方战斗',
source: 'src/data/functionCatalog/npc/npcFight.ts',
summary: '把当前 NPC 交互直接导向敌对战斗。',
detailedDescription:
'无论对方原本是中立还是敌对,选择这个 function 都表示玩家主动接受或制造正面冲突,后续会切到 NPC 战斗模式。',
trigger: '在敌对 NPC 遭遇或普通 NPC 交互菜单里都可能出现。',
execution:
'点击后会切换 currentBattleNpcId / currentNpcBattleMode并进入本地战斗结算链路。',
result:
'交互界面转为战斗,战后会按 fight_victory 等结果处理掉落、好感和任务推进。',
active: true,
runtime: {
storyMode: 'special_sequence',
uiMode: 'none',
executor:
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。',
storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。',
uiNote: '不弹 modal直接进入战斗。',
compactDetailText: '战斗决胜负',
},
};

View File

@@ -0,0 +1,56 @@
import type { GiftModalState } from '../../../hooks/rpg-runtime-story/uiTypes';
import type { Encounter, GameState } from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_gift
*
* 向眼前 NPC 送礼的入口 function。
* 这里直接提供 gift modal 的默认构造逻辑。
*/
export function buildNpcGiftModalIntroText(encounter: Encounter) {
return [
'你:我想送你一样东西。',
`${encounter.npcName}:先让我看看你带了什么,我再决定该怎么收下。`,
].join('\n');
}
export function buildNpcGiftModalState(
state: GameState,
encounter: Encounter,
actionText: string,
selectedItemId: string | null = state.playerInventory[0]?.id ?? null,
): GiftModalState {
return {
encounter,
actionText,
introText: buildNpcGiftModalIntroText(encounter),
selectedItemId,
};
}
export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_gift',
domain: 'npc',
title: '向该角色送礼',
source: 'src/data/functionCatalog/npc/npcGift.ts',
summary: '打开送礼面板并根据礼物质量结算 affinity 变化。',
detailedDescription:
'它会把当前互动引到礼物选择 modal通过本地规则估算礼物对该 NPC 的吸引力和好感增益,避免送礼结果漂移。',
trigger: '玩家背包里存在可送出的物品时出现在 NPC 交互菜单里。',
execution:
'首次点击只打开 gift modal确认礼物后再调用 commitGeneratedState 把送礼结果写回主流程。',
result: '玩家可立即看到好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
active: true,
runtime: {
storyMode: 'modal_then_generate',
uiMode: 'gift_modal',
executor:
'src/hooks/rpg-runtime-story/storyGenerationState.ts + src/hooks/rpg-runtime-story/npcInteraction.ts',
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
storyNote:
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
uiNote: '会先打开 gift modal并默认选中当前最适合作为礼物的物品。',
compactDetailText: '送礼提升好感',
},
};

View File

@@ -0,0 +1,31 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_help
*
* 向眼前 NPC 寻求帮助或支援的 function。
*/
export const NPC_HELP_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_help',
domain: 'npc',
title: '向对方寻求帮助',
source: 'src/data/functionCatalog/npc/npcHelp.ts',
summary: '从 NPC 处申请一次性补给、回复或援助。',
detailedDescription:
'它把 NPC 互动导向资源支持,奖励内容由本地规则预先计算,避免关键数值完全交给模型临场决定。',
trigger: 'NPC 允许帮助且该角色尚未消耗过 helpUsed 时出现。',
execution: '点击后直接按本地奖励规则结算,然后继续推进后续剧情。',
result:
'玩家可能获得生命、灵力、冷却收益或道具补给,并让故事承接“被对方照应了一次”。',
active: true,
runtime: {
storyMode: 'local_effect_then_generate',
uiMode: 'none',
executor:
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。',
storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。',
uiNote: '不弹 modal直接获得帮助反馈。',
compactDetailText: '看看能得到什么帮助',
},
};

View File

@@ -0,0 +1,30 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_leave
*
* 结束当前 NPC 互动、回到探索态的 function。
*/
export const NPC_LEAVE_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_leave',
domain: 'npc',
title: '不作停留,继续前行',
source: 'src/data/functionCatalog/npc/npcLeave.ts',
summary: '退出当前 NPC 交互并把注意力拉回前路。',
detailedDescription:
'它为玩家提供一个明确的“暂时结束这段互动”的出口,避免必须通过交易、聊天或战斗才能离开当前 encounter。',
trigger: '绝大多数普通 NPC 菜单的默认退出项。',
execution: '点击后清理当前 NPC 交互态,并继续进入下一轮探索或故事推进。',
result: '玩家会离开当前角色,恢复到探索导向的故事节奏。',
active: true,
runtime: {
storyMode: 'local_effect_then_generate',
uiMode: 'none',
executor:
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '通常只做轻量离场,不单独打开窗口。',
storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。',
uiNote: '不弹 modal直接退出互动。',
compactDetailText: '离开并继续探索',
},
};

View File

@@ -0,0 +1,71 @@
import {
AnimationState,
type Encounter,
type StoryOption,
} from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_preview_talk
*
* 眼前出现 NPC 预览后,把玩家从“远处观察”切换到“正式交互”的入口 function。
* 这里直接收口了这个选项的视觉和构造 helper。
*/
export const NPC_PREVIEW_TALK_OPTION_VISUALS: StoryOption['visuals'] = {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
};
export function buildNpcPreviewTalkOption(encounter: Encounter): StoryOption {
const actionText = `${encounter.npcName} 交谈`;
return {
functionId: NPC_PREVIEW_TALK_FUNCTION.id,
actionText,
text: actionText,
detailText: '先专注于眼前的人,再决定如何回应。',
priority: 3,
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
};
}
export function isNpcPreviewTalkFunctionId(functionId: string) {
return functionId === NPC_PREVIEW_TALK_FUNCTION.id;
}
export function isNpcPreviewTalkOption(option: StoryOption) {
return isNpcPreviewTalkFunctionId(option.functionId);
}
export const NPC_PREVIEW_TALK_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_preview_talk',
domain: 'npc',
title: '转向眼前角色',
source: 'src/data/functionCatalog/npc/npcPreviewTalk.ts',
summary: '把当前遭遇从前探预览切入正式 NPC 交互层。',
detailedDescription:
'它不直接完成一轮聊天,而是把镜头、当前 encounter 和可选项池真正切换到角色互动上下文,为后续 trade / chat / recruit 等动作铺路。',
trigger:
'通常在探索过程中已经锁定眼前 NPC并且玩家准备正式和对方互动时出现。',
execution:
'第一次点击后主要进入 NPC interaction 流程,而不是直接结算完整剧情回合。',
result:
'玩家会进入针对该 NPC 的专属交互菜单,并开始看到交易、聊天、切磋等本地规则项。',
active: true,
runtime: {
storyMode: 'enter_interaction',
uiMode: 'npc_interaction_entry',
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
executor:
'src/hooks/rpg-runtime-story/choiceActions.ts + src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts',
animationNote:
'保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。',
storyNote:
'普通 NPC 直接进入 enterNpcInteraction初始同伴会改走 opening adventure 特殊序列。',
uiNote: '不弹 modal而是切换到 NPC interaction 选项面板。',
compactDetailText: '先专注于眼前的人',
},
};

View File

@@ -0,0 +1,20 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_quest_accept
*
* 接下眼前 NPC 委托的 function。
*/
export const NPC_QUEST_ACCEPT_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_quest_accept',
domain: 'npc',
title: '接下委托',
source: 'src/data/functionCatalog/npc/npcQuestAccept.ts',
summary: '把 NPC 提供的任务写入 quest log。',
detailedDescription:
'它用于把当前交互中的委托正式变成可追踪任务,并让故事明确承接“玩家已经答应了这件事”。',
trigger: 'NPC 当前没有活跃任务且本地规则成功为其生成了一个可接任务时出现。',
execution: '点击后会在本地 questFlow 中创建 active quest并继续推进剧情。',
result: '玩家获得新的任务目标、任务文本与后续交付条件。',
active: true,
};

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_quest_turn_in
*
* 向眼前 NPC 交付已完成委托的 function。
*/
export const NPC_QUEST_TURN_IN_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_quest_turn_in',
domain: 'npc',
title: '交付委托',
source: 'src/data/functionCatalog/npc/npcQuestTurnIn.ts',
summary: '完成任务回报与任务状态收尾的 NPC function。',
detailedDescription:
'当相关任务已经完成,它负责把“领奖、交付、兑现承诺”从普通聊天里独立出来,确保 quest log 与剧情反馈同步更新。',
trigger: '玩家在该 NPC 名下拥有 status=completed 的任务时出现。',
execution:
'点击后走本地 questFlow 的 turn-in 逻辑,结算奖励并推进 story history。',
result: '任务状态会被正式收尾,玩家获得奖励与交付文本。',
active: true,
};

View File

@@ -0,0 +1,65 @@
import type { RecruitModalState } from '../../../hooks/rpg-runtime-story/uiTypes';
import type { Encounter, GameState } from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_recruit
*
* 邀请眼前 NPC 加入队伍的 function。
* 这里直接收口了“队伍已满时弹窗,否则立即进入招募序列”的分流逻辑。
*/
export function buildNpcRecruitModalIntroText(encounter: Encounter) {
return [
'你:我想认真谈谈同行的事。',
`${encounter.npcName}:先把你队伍里的位置理顺,再给我一个明确答复。`,
].join('\n');
}
export function buildNpcRecruitModalState(
state: GameState,
encounter: Encounter,
actionText: string,
): RecruitModalState {
return {
encounter,
actionText,
introText: buildNpcRecruitModalIntroText(encounter),
selectedReleaseNpcId: state.companions[0]?.npcId ?? null,
};
}
export function shouldNpcRecruitOpenModal(
companionCount: number,
maxCompanions: number,
) {
return companionCount >= maxCompanions;
}
export const NPC_RECRUIT_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_recruit',
domain: 'npc',
title: '邀请该角色加入队伍',
source: 'src/data/functionCatalog/npc/npcRecruit.ts',
summary: '把 NPC 转化为同伴的招募入口。',
detailedDescription:
'它负责承接好感达标或开局同行的特殊情境,让 NPC 进入 recruitment 流程。若当前队伍已满,会先弹出替换同伴的确认流程。',
trigger: 'NPC 可招募、尚未 recruited且满足 affinity 或特殊开局条件时出现。',
execution:
'队伍未满时可直接进入招募流程;队伍已满时先打开 recruit modal再确认替换目标。',
result:
'成功后 NPC 会加入 companions / roster并改写后续剧情关系与队伍构成。',
active: true,
runtime: {
storyMode: 'special_sequence',
uiMode: 'recruit_modal_or_sequence',
executor:
'src/hooks/rpg-runtime-story/storyGenerationState.ts + src/hooks/rpg-runtime-story/npcInteraction.ts',
animationNote:
'若直接进入招募,会先播放招募对话流;若队伍已满,先进入替换弹窗。',
storyNote:
'队伍未满时第一次点击就会进入招募对白序列;队伍已满时要等 modal 确认后再继续。',
uiNote:
'会根据队伍人数决定是立刻招募,还是先打开 recruit modal 选择释放对象。',
compactDetailText: '谈谈是否愿意入队',
},
};

View File

@@ -0,0 +1,30 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_spar
*
* 与眼前 NPC 切磋武艺的非致命战斗 function。
*/
export const NPC_SPAR_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_spar',
domain: 'npc',
title: '与对方切磋武艺',
source: 'src/data/functionCatalog/npc/npcSpar.ts',
summary: '把 NPC 互动切到点到为止的 spar 战斗模式。',
detailedDescription:
'它和 npc_fight 共用战斗骨架,但数值与结算目标不同,重点是以低伤害对局换取关系推进,而不是击杀或掠夺。',
trigger: '在可交流的 NPC 菜单里作为友好或试探性过招选项出现。',
execution: '点击后进入 spar 模式,本地规则会限制伤害与战后回场逻辑。',
result: '切磋结束后通常返回 NPC 场景,并小幅提高 affinity 或推进相关任务。',
active: true,
runtime: {
storyMode: 'special_sequence',
uiMode: 'none',
executor:
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。',
storyNote: '不会先弹窗,直接进入点到为止的切磋流程。',
uiNote: '不弹 modal直接切磋。',
compactDetailText: '切磋几招看身手',
},
};

View File

@@ -0,0 +1,64 @@
import type { TradeModalState } from '../../../hooks/rpg-runtime-story/uiTypes';
import type { Encounter, GameState, InventoryItem } from '../../../types';
import type { FunctionDocumentationEntry } from '../types';
/**
* npc_trade
*
* 与眼前 NPC 发起交易的入口 function。
* 这里直接提供 trade modal 的默认构造逻辑,避免窗口初始化散落在别处。
*/
export function buildNpcTradeModalIntroText(encounter: Encounter) {
return [
'你:我想先看看你手里有什么能换。',
`${encounter.npcName}:先看货吧,买卖和回收的价都写得清楚。`,
].join('\n');
}
export function buildNpcTradeModalState(
state: GameState,
encounter: Encounter,
actionText: string,
npcInventory: InventoryItem[],
): TradeModalState {
const selectedNpcItemId =
npcInventory.find((item) => item.quantity > 0)?.id ?? null;
const selectedPlayerItemId =
state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
return {
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId,
selectedPlayerItemId,
selectedQuantity: 1,
};
}
export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = {
id: 'npc_trade',
domain: 'npc',
title: '与对方交易',
source: 'src/data/functionCatalog/npc/npcTrade.ts',
summary: '打开 NPC 交易流程并结算买卖或交换。',
detailedDescription:
'它负责把当前交互引到交易面板,展示 NPC 库存、折扣和可交换物。第一次点击通常只打开 modal真正确认后才继续推进剧情。',
trigger: '当 NPC 允许交易且自身库存非空时出现在 NPC 交互菜单里。',
execution:
'首次点击进入 trade modal确认后再通过 commitGeneratedState 把结果写回主流程。',
result: '玩家可以买入、以物易物,或在失败时得到明确的价值差提示。',
active: true,
runtime: {
storyMode: 'modal_then_generate',
uiMode: 'trade_modal',
executor:
'src/hooks/rpg-runtime-story/storyGenerationState.ts + src/hooks/rpg-runtime-story/npcInteraction.ts',
animationNote: '第一次点击不播额外战斗或位移动画,重点是切到交易窗口。',
storyNote:
'真正的剧情推进发生在 confirmTrade 之后,而不是打开 modal 的瞬间。',
uiNote: '会先打开交易 modal并预选 NPC 第一件商品与玩家第一件可卖物品。',
compactDetailText: '查看库存与价格',
},
};

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* equipment_equip
*
* 在装备面板中把背包物品穿戴到对应槽位的 function。
*/
export const EQUIPMENT_EQUIP_FUNCTION: FunctionDocumentationEntry = {
id: 'equipment_equip',
domain: 'panel',
title: '装备物品',
source: 'src/data/functionCatalog/panel/equipmentEquip.ts',
summary: '负责角色装备替换、背包回收和属性重算。',
detailedDescription:
'它是装备面板触发的局部动作,不通过自由叙事来决定结果,而是由本地规则严格处理槽位、被替换装备和属性改写。',
trigger: '玩家在非战斗状态下从背包点击一件可装备物品时触发。',
execution:
'先更新 equipment loadout 与背包,再通过 commitGeneratedState 把装备结果写进故事历史。',
result: '角色装备变更生效,必要时旧装备回到背包,故事中会留下装备变动说明。',
active: true,
};

View File

@@ -0,0 +1,20 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* equipment_unequip
*
* 从装备槽位卸下一件装备并放回背包的 function。
*/
export const EQUIPMENT_UNEQUIP_FUNCTION: FunctionDocumentationEntry = {
id: 'equipment_unequip',
domain: 'panel',
title: '卸下装备',
source: 'src/data/functionCatalog/panel/equipmentUnequip.ts',
summary: '处理卸装、背包回收和属性回退。',
detailedDescription:
'它是装备系统的反向动作,确保角色在非战斗状态下可以安全地卸装,而不会破坏背包数量或 loadout 一致性。',
trigger: '玩家在非战斗状态下从装备面板点击某个已装备槽位时触发。',
execution: '先把装备放回背包,再重算应用到 GameState 的角色装备效果。',
result: '装备槽位清空、背包新增对应物品,并留下卸装结果文本。',
active: true,
};

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* forge_craft
*
* 在锻造面板中制作配方产物的 function。
*/
export const FORGE_CRAFT_FUNCTION: FunctionDocumentationEntry = {
id: 'forge_craft',
domain: 'panel',
title: '制作配方',
source: 'src/data/functionCatalog/panel/forgeCraft.ts',
summary: '执行锻造配方、扣除材料和货币并产出新物品。',
detailedDescription:
'它由锻造系统本地校验配方合法性与资源足额情况,再把产物、消耗和结果文本统一写回游戏状态。',
trigger: '玩家在非战斗状态下从锻造面板选择一条可制作配方时触发。',
execution:
'先执行 executeForgeRecipe再通过 commitGeneratedState 写回制作结果。',
result: '玩家消耗材料和钱币,获得新物品,同时故事历史记录一次制作行为。',
active: true,
};

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* forge_dismantle
*
* 在锻造面板中拆解物品回收材料的 function。
*/
export const FORGE_DISMANTLE_FUNCTION: FunctionDocumentationEntry = {
id: 'forge_dismantle',
domain: 'panel',
title: '拆解物品',
source: 'src/data/functionCatalog/panel/forgeDismantle.ts',
summary: '执行拆解并返还材料收益的锻造 function。',
detailedDescription:
'它允许玩家把现有物品重新拆回材料,由本地锻造规则决定返还内容,避免拆解产出与物品设计脱节。',
trigger: '玩家在非战斗状态下于锻造面板选择可拆解物品时触发。',
execution:
'先执行 executeDismantleItem再通过 commitGeneratedState 记录拆解结果。',
result: '原物品被移除,背包增加拆解产物,并留下拆解说明文本。',
active: true,
};

View File

@@ -0,0 +1,22 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* forge_reforge
*
* 在锻造面板中重铸现有物品的 function。
*/
export const FORGE_REFORGE_FUNCTION: FunctionDocumentationEntry = {
id: 'forge_reforge',
domain: 'panel',
title: '重铸物品',
source: 'src/data/functionCatalog/panel/forgeReforge.ts',
summary: '支付货币后重构物品结果的锻造 function。',
detailedDescription:
'它用于把已有物品重新洗练成新的结果,由本地规则负责消耗、生成与可视化说明,避免重铸结果脱离装备系统。',
trigger: '玩家在非战斗状态下于锻造面板选择可重铸物品时触发。',
execution:
'先执行 executeReforgeItem 和花费计算,再通过 commitGeneratedState 写回重铸结果。',
result:
'玩家消耗货币、失去旧版本物品并获得重铸后的新物品,同时剧情历史记录本次重铸。',
active: true,
};

View File

@@ -0,0 +1,16 @@
import type { FunctionDocumentationEntry } from '../types';
import { EQUIPMENT_EQUIP_FUNCTION } from './equipmentEquip';
import { EQUIPMENT_UNEQUIP_FUNCTION } from './equipmentUnequip';
import { FORGE_CRAFT_FUNCTION } from './forgeCraft';
import { FORGE_DISMANTLE_FUNCTION } from './forgeDismantle';
import { FORGE_REFORGE_FUNCTION } from './forgeReforge';
import { INVENTORY_USE_FUNCTION } from './inventoryUse';
export const PANEL_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
INVENTORY_USE_FUNCTION,
EQUIPMENT_EQUIP_FUNCTION,
EQUIPMENT_UNEQUIP_FUNCTION,
FORGE_CRAFT_FUNCTION,
FORGE_DISMANTLE_FUNCTION,
FORGE_REFORGE_FUNCTION,
];

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* inventory_use
*
* 在背包中消耗一个可用物品的面板动作 function。
*/
export const INVENTORY_USE_FUNCTION: FunctionDocumentationEntry = {
id: 'inventory_use',
domain: 'panel',
title: '使用背包物品',
source: 'src/data/functionCatalog/panel/inventoryUse.ts',
summary: '从背包面板结算药品、灵力物或 build buff 道具。',
detailedDescription:
'它不属于场景探索目录,而是 UI 面板动作。点击具体物品后,本地规则会先结算回复与 buff再把结果写回剧情历史。',
trigger: '玩家在 InventoryPanel 中点击可使用物品时触发。',
execution:
'本地结算 hp / mana / cooldown / buildBuffs然后通过 commitGeneratedState 把结果挂回主故事。',
result: '物品数量减少,角色资源更新,故事文本会明确记录“使用了什么”。',
active: true,
};

View File

@@ -0,0 +1,60 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_all_in_crush
*
* 战斗中的正面爆发动作。它要求主角不绕、不拖,直接把当前回合的叙事、
* 技能权重和视觉表现都推向“强压正面敌人”的方向。
*/
export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_all_in_crush',
state: 'battle',
category: 'battle',
text: '战斗:全力压上',
description:
'正面强攻,优先触发高爆发和终结类技能,伤害更高,但承受的反击也更重。',
visual: {
playerAnimation: AnimationState.SKILL3,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}被正面强攻逼得节节后退',
monsterAnimation: 'attack',
monsterMoveMeters: 0,
},
effect: {
damageMultiplier: 1.3,
incomingDamageMultiplier: 1.15,
turnTimeMultiplier: 1,
skillWeights: {
finisher: 5,
burst: 4,
mobility: 2,
steady: 1.5,
projectile: 1.2,
},
},
},
promptDescription:
'对面前敌人全力猛攻,文案可以自然改写,但仍要保持这是正面强攻而不是别的行为。',
documentation: {
id: 'battle_all_in_crush',
domain: 'state',
title: '战斗:全力压上',
source: 'src/data/functionCatalog/state/battleAllInCrush.ts',
summary: '战斗阶段的高风险高收益强攻 function。',
detailedDescription:
'这个 function 用于把当前回合塑造成硬碰硬的压制回合,让主角优先打出爆发和终结倾向更强的技能或叙事动作。',
trigger: '仅在 battle 状态且场上仍有存活敌人时参与候选。',
execution:
'提高 damageMultiplier并抬高 finisher / burst 权重,同时略微提高 incomingDamageMultiplier突出换血压力。',
result: '适合在残局抢收头、需要快速压血,或者希望把敌人直接压回去时使用。',
state: 'battle',
category: 'battle',
active: true,
},
};

View File

@@ -0,0 +1,35 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* battle_attack_basic
*
* 后端单行为战斗模型的普通攻击入口。该 function 只登记文档和契约,
* 不进入前端本地 state function 候选池。
*/
export const BATTLE_ATTACK_BASIC_FUNCTION: FunctionDocumentationEntry = {
id: 'battle_attack_basic',
domain: 'state',
title: '普通攻击',
source: 'src/data/functionCatalog/state/battleAttackBasic.ts',
summary: '后端单行为战斗模型中的基础攻击 function。',
detailedDescription:
'这个 function 代表一次明确的普通攻击点击,后端直接结算伤害、敌方反击和下一轮战斗选项,不再请求 AI 续写整段战斗剧情。',
trigger: '仅在 battle 状态且场上仍有存活敌人时,由后端战斗 option 池下发。',
execution:
'前端透传 functionIdRust 后端经 story battle facade 调用 module-combat 按普通攻击规则结算本回合。',
result: '刷新 HP、战斗日志和下一轮战斗 options若敌人被击败再进入脱战剧情推理。',
state: 'battle',
category: 'battle',
active: true,
runtime: {
storyMode: 'local_only',
uiMode: 'none',
executor:
'server-rs/crates/module-combat/src/lib.rs -> resolve_combat_action',
animationNote: '播放一次基础攻击和受击反馈,不扩展成连续多段连击。',
storyNote:
'战斗未结束时只展示本次结算文本;战斗结束后才请求脱战剧情。',
uiNote: '由后端战斗 option 池生成,不进入前端本地 state function 候选。',
compactDetailText: '直接攻击眼前敌人',
},
};

View File

@@ -0,0 +1,55 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_escape_breakout
*
* 战斗中的脱离动作。它不是继续换血,而是明确让主角放弃当前缠斗,
* 把叙事重心切到“拉开距离、甩开追击、离开战场”。
*/
export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_escape_breakout',
state: 'battle',
category: 'escape',
text: '逃跑:转身甩开',
description: '立刻放弃缠斗,转身拉开距离,冲向下一片区域。',
visual: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterActionTemplate: '{monster}在后方死死咬住不放',
monsterAnimation: 'idle',
monsterMoveMeters: 0,
},
effect: {
escapeDurationMs: 5000,
escapeDistance: 5,
monsterLagStart: 0.52,
monsterLagEnd: 0.34,
sceneShift: 0,
enterBattle: false,
},
},
promptDescription:
'从当前战斗中脱身、拉开距离或撤离,文案可以自然改写,但仍要保持这是逃脱而不是继续交战。',
documentation: {
id: 'battle_escape_breakout',
domain: 'state',
title: '逃跑:转身甩开',
source: 'src/data/functionCatalog/state/battleEscapeBreakout.ts',
summary: '用于脱战、逃离和切离镜头压力的战斗 function。',
detailedDescription:
'它让回合从“继续打”切换到“先活下来”,并带出逃跑镜头、距离拉开和怪物追击落后的演出逻辑。',
trigger: '仅在 battle 状态下参与候选,并会在低血时提升优先级。',
execution:
'不追求伤害,而是提供 escapeDurationMs、escapeDistance 与 monsterLag 参数,驱动逃跑流程与镜头表现。',
result: '适合生命见底、资源不够,或玩家主动决定放弃当前战斗时使用。',
state: 'battle',
category: 'escape',
active: true,
},
};

View File

@@ -0,0 +1,60 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_feint_step
*
* 战斗中的机动切入动作。它把重点放在虚晃、变线与抢身位,
* 让战斗叙事更偏向灵活切入而不是硬扛伤害。
*/
export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_feint_step',
state: 'battle',
category: 'battle',
text: '战斗:虚晃切入',
description:
'通过假动作和变线切入制造破绽,偏向机动技能,伤害适中但更安全。',
visual: {
playerAnimation: AnimationState.SKILL1_JUMP,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}被晃得节奏发虚,动作明显迟疑',
monsterAnimation: 'attack',
monsterMoveMeters: 0,
},
effect: {
damageMultiplier: 1.05,
incomingDamageMultiplier: 0.8,
skillWeights: {
mobility: 5,
burst: 2.4,
steady: 2,
finisher: 1.4,
projectile: 1.2,
},
},
},
promptDescription:
'通过虚晃、变线或切步逼近面前敌人,文案可以自然改写,但仍要保持这是机动切入。',
documentation: {
id: 'battle_feint_step',
domain: 'state',
title: '战斗:虚晃切入',
source: 'src/data/functionCatalog/state/battleFeintStep.ts',
summary: '通过身位变化制造安全破绽的机动战斗 function。',
detailedDescription:
'它适合把回合讲成“先骗、再切、再进”的过程,让主角依靠节奏误导和位移逼出敌人的迟疑。',
trigger: '仅在 battle 状态下参与候选。',
execution:
'小幅提高输出,显著降低 incomingDamageMultiplier并把技能权重偏到 mobility让系统更愿意选机动型动作。',
result:
'适合想稳一点地逼近敌人、减少硬吃反击,或需要围绕灵巧角色塑造打法时使用。',
state: 'battle',
category: 'battle',
active: true,
},
};

View File

@@ -0,0 +1,59 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_finisher_window
*
* 战斗中的终结窗口动作。它要求系统把这一回合理解为“敌人已经露出空档”,
* 因而优先演出收割、补刀和终结技。
*/
export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_finisher_window',
state: 'battle',
category: 'battle',
text: '战斗:抓住破绽',
description: '专门针对敌人露出的空档压上终结技,节奏更短、更猛。',
visual: {
playerAnimation: AnimationState.SKILL3,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}来不及调整姿态,只能仓促迎击',
monsterAnimation: 'attack',
monsterMoveMeters: 0,
},
effect: {
damageMultiplier: 1.4,
incomingDamageMultiplier: 1.05,
turnTimeMultiplier: 0.92,
skillWeights: {
finisher: 6,
burst: 3.5,
mobility: 1.5,
steady: 0.8,
projectile: 0.6,
},
},
},
promptDescription:
'抓住面前敌人的破绽收割或终结,文案可以自然改写,但仍要保持这是终结窗口。',
documentation: {
id: 'battle_finisher_window',
domain: 'state',
title: '战斗:抓住破绽',
source: 'src/data/functionCatalog/state/battleFinisherWindow.ts',
summary: '围绕残局补刀与爆发终结的战斗 function。',
detailedDescription:
'它会强烈推动“敌人已经失衡、主角准备收口”的叙事判断,让 actionText 与技能选择都更像最后一击。',
trigger: '仅在 battle 状态下参与候选,并会在敌方血量偏低时明显升优先级。',
execution:
'提供最高档位之一的 damageMultiplier缩短 turnTimeMultiplier并极度偏向 finisher / burst 技能。',
result: '适合在敌方残血、明显露出空档,或需要一口气结束战斗时使用。',
state: 'battle',
category: 'battle',
active: true,
},
};

View File

@@ -0,0 +1,58 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_guard_break
*
* 战斗中的破架重击动作。它强调“针对敌人当前动作强拆架势”,
* 比纯换血更讲究把敌人的节奏打断。
*/
export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_guard_break',
state: 'battle',
category: 'battle',
text: '战斗:重击破架',
description: '针对怪物当前动作强拆架势,伤害偏高,反击压力略低。',
visual: {
playerAnimation: AnimationState.SKILL2,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}被重击震得动作一滞',
monsterAnimation: 'attack',
monsterMoveMeters: 0,
},
effect: {
damageMultiplier: 1.2,
incomingDamageMultiplier: 0.9,
skillWeights: {
burst: 4.5,
finisher: 3,
steady: 2.2,
mobility: 1.5,
projectile: 1,
},
},
},
promptDescription:
'针对面前敌人的架势或破绽重击破防,文案可以自然改写,但仍要保持这是破架强攻。',
documentation: {
id: 'battle_guard_break',
domain: 'state',
title: '战斗:重击破架',
source: 'src/data/functionCatalog/state/battleGuardBreak.ts',
summary: '围绕破架、震停和打断敌人节奏的战斗 function。',
detailedDescription:
'它不是单纯地压血,而是把回合重心放在“砸开架势、制造空档”,让后续剧情更容易承接敌人失衡的结果。',
trigger: '仅在 battle 状态且敌人仍在场时参与候选。',
execution:
'维持较高 damageMultiplier同时降低 incomingDamageMultiplier并偏向 burst / steady 组合,突出稳中带狠的重击感。',
result: '适合对付正在招架、准备反扑或看起来露出结构性破绽的敌人。',
state: 'battle',
category: 'battle',
active: true,
},
};

View File

@@ -0,0 +1,58 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_probe_pressure
*
* 战斗中的稳扎试探动作。适合在局势未明、资源需要保留时,
* 先用安全且持续的压制把信息和节奏摸出来。
*/
export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_probe_pressure',
state: 'battle',
category: 'battle',
text: '战斗:稳扎试探',
description: '以持续压制和稳健试探为主,更容易发动常规连段和中段技能。',
visual: {
playerAnimation: AnimationState.SKILL1,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}一边招架一边寻找反扑的缝隙',
monsterAnimation: 'attack',
monsterMoveMeters: 0,
},
effect: {
damageMultiplier: 1,
incomingDamageMultiplier: 0.95,
skillWeights: {
steady: 5,
burst: 2.2,
mobility: 2,
projectile: 2,
finisher: 1.2,
},
},
},
promptDescription:
'对面前敌人稳扎稳打地试探施压,文案可以自然改写,但仍要保持这是试探压制。',
documentation: {
id: 'battle_probe_pressure',
domain: 'state',
title: '战斗:稳扎试探',
source: 'src/data/functionCatalog/state/battleProbePressure.ts',
summary: '强调观察、压迫和中段连段的稳健战斗 function。',
detailedDescription:
'这个 function 让战斗回合更像“边压边看”,适合在想控制资源消耗、等待更好机会,或需要防止自己出手过重时使用。',
trigger: '仅在 battle 状态下参与候选。',
execution:
'保持标准 damageMultiplier轻微降低 incomingDamageMultiplier并把 skillWeights 明显偏向 steady兼顾少量 mobility / projectile。',
result: '适合拉平节奏、压住敌人反扑窗口,或在低蓝时维持安全输出。',
state: 'battle',
category: 'battle',
active: true,
},
};

View File

@@ -0,0 +1,61 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* battle_recover_breath
*
* 战斗中的恢复动作。它会把当前回合塑造成“先稳住伤势与灵力”,
* 让数值、冷却和叙事都朝回气与整顿节奏的方向靠拢。
*/
export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'battle_recover_breath',
state: 'battle',
category: 'recovery',
text: '战斗:收势调息',
description: '边守边调息,恢复少量生命和灵力,并让技能冷却更快转动。',
visual: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterActionTemplate: '{monster}仍在逼近,不给你轻松喘息的空当',
monsterAnimation: 'move',
monsterMoveMeters: -0.1,
},
effect: {
damageMultiplier: 0.72,
incomingDamageMultiplier: 0.7,
healAmount: 12,
manaRestore: 18,
cooldownTickBonus: 1,
skillWeights: {
steady: 3,
mobility: 2,
projectile: 1.8,
burst: 1,
finisher: 0.4,
},
},
},
promptDescription:
'在战斗中收势调息、稳住伤势或回气,文案可以自然改写,但仍要保持这是恢复而不是继续猛攻。',
documentation: {
id: 'battle_recover_breath',
domain: 'state',
title: '战斗:收势调息',
source: 'src/data/functionCatalog/state/battleRecoverBreath.ts',
summary: '战斗中的保命与回气 function。',
detailedDescription:
'这个 function 专门为低血、低蓝或技能轮转吃紧时准备,让一回合承担“止血、回蓝、缓冷却”的综合恢复职责。',
trigger: '仅在 battle 状态下参与候选,且通常会在低血或低蓝时被提权。',
execution:
'显著降低 damageMultiplier 和 incomingDamageMultiplier同时提供 healAmount、manaRestore 与 cooldownTickBonus。',
result: '适合临时止损、等关键技能转好,或把高压战斗拉回可控节奏。',
state: 'battle',
category: 'recovery',
active: true,
},
};

View File

@@ -0,0 +1,36 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* battle_use_skill
*
* 后端单行为战斗模型的技能释放入口。每个技能 option 复用同一个
* functionId具体技能必须由 runtimePayload.skillId 指定。
*/
export const BATTLE_USE_SKILL_FUNCTION: FunctionDocumentationEntry = {
id: 'battle_use_skill',
domain: 'state',
title: '释放技能',
source: 'src/data/functionCatalog/state/battleUseSkill.ts',
summary: '后端单行为战斗模型中的指定技能释放 function。',
detailedDescription:
'这个 functionId 可以对应多个技能 option 实例。前端只展示技能名和不可用原因,后端根据 runtimePayload.skillId 校验蓝量、冷却并结算本次技能效果。',
trigger: '仅在 battle 状态下由后端按角色技能列表生成,可能携带 disabled 状态。',
execution:
'前端透传 runtimePayload.skillIdRust 后端经 story battle facade 调用 module-combat 校验技能并完成一次技能动作结算。',
result:
'更新 MP、技能冷却、敌我 HP 和下一轮战斗 options若战斗结束再触发脱战剧情推理。',
state: 'battle',
category: 'battle',
active: true,
runtime: {
storyMode: 'local_only',
uiMode: 'none',
executor:
'server-rs/crates/module-combat/src/lib.rs -> resolve_combat_action',
animationNote: '根据技能 option 播放一次技能演出,不在本 function 内追加多回合动作。',
storyNote:
'战斗未结束时使用本次技能结算文本;只有战斗结束才请求新剧情。',
uiNote: '每个技能是一个后端下发的独立 option必须携带 skillId。',
compactDetailText: '释放一个指定技能',
},
};

View File

@@ -0,0 +1,48 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_call_out
*
* 空闲状态下的主动喊话动作。它会把探索从“静悄悄地摸过去”
* 转成“先出声试探,看谁先回应”的节奏。
*/
export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_call_out',
state: 'idle',
category: 'idle',
text: '主动出声试探',
description: '朝前方主动喊话试探,可能把藏着的角色、怪物或其他动静逼出来。',
visual: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
sceneShift: 0,
enterBattle: false,
},
},
promptDescription:
'主动朝前方出声试探,文案可以自然改写,但仍要保持这是出声试探。',
documentation: {
id: 'idle_call_out',
domain: 'state',
title: '主动出声试探',
source: 'src/data/functionCatalog/state/idleCallOut.ts',
summary: '通过主动发声引出附近实体反应的空闲 function。',
detailedDescription:
'它把探索节奏从被动观察切成主动打破寂静,更适合在玩家想逼出暗处角色、敌人或潜伏动静时使用。',
trigger: '仅在 idle 状态下参与候选,且在当前设计里优先级较高。',
execution:
'维持站立演出,不推动场景位移;上层会把它理解为向前方喊话,从而让实体更容易被直接引到眼前。',
result: '适合探草、叫阵、主动试探潜伏目标,或把隐藏互动直接拉到台前。',
state: 'idle',
category: 'idle',
active: true,
},
};

View File

@@ -0,0 +1,48 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_explore_forward
*
* 空闲状态下最核心的推进动作。它负责把“继续往前探”从一句泛化文案,
* 落成真正会引出下一幕遭遇的运行时 function。
*/
export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_explore_forward',
state: 'idle',
category: 'idle',
text: '继续向前探索',
description: '沿当前场景继续深入,很可能立刻撞上新的敌人或危险。',
visual: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0.9,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
sceneShift: 0,
enterBattle: true,
},
},
promptDescription:
'继续向前推进当前场景,文案可以自然改写,但仍要保持这是往前探索。',
documentation: {
id: 'idle_explore_forward',
domain: 'state',
title: '继续向前探索',
source: 'src/data/functionCatalog/state/idleExploreForward.ts',
summary: '空闲阶段默认的主推进 function。',
detailedDescription:
'它负责把玩家继续深入当前场景的意图交给运行时,让场景实体池、前探预览和下一幕遭遇有机会真正落地。',
trigger: '仅在 idle 状态下参与候选;营地场景会在运行时被额外过滤。',
execution:
'保持空闲探索态视觉上推动角色向前effect 中通过 enterBattle / sceneShift 让下一步遭遇有机会进入前探或战斗链路。',
result: '适合在当前场景继续摸深、主动触发新的角色、怪物、宝藏或危险。',
state: 'idle',
category: 'idle',
active: true,
},
};

View File

@@ -0,0 +1,48 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_follow_clue
*
* 空闲状态下的循线推进动作。它在源码定义层仍然存在,
* 但当前运行时会在聚合阶段被过滤,因此属于保留中的停用 function。
*/
export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_follow_clue',
state: 'idle',
category: 'idle',
text: '顺着线索靠近',
description: '沿着眼前痕迹、小道或声音来源继续靠近,可能更快撞上新的目标。',
visual: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0.6,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
sceneShift: 0,
enterBattle: false,
},
},
promptDescription:
'顺着眼前线索继续靠近目标,文案可以自然改写,但仍要保持这是循线逼近。',
documentation: {
id: 'idle_follow_clue',
domain: 'state',
title: '顺着线索靠近',
source: 'src/data/functionCatalog/state/idleFollowClue.ts',
summary: '保留在源码中的循线 function目前默认不进入运行时候选池。',
detailedDescription:
'它原本用于把观察结果进一步收束成“沿线索追过去”的动作,但当前项目在 applyRuntimeFunctionAdjustments 中会将其过滤。',
trigger: '定义上属于 idle 状态,但默认运行时不会提供给玩家。',
execution:
'保留了向前靠近的视觉与 effect 配置,方便后续重新启用或用于编辑器审计。',
result: '当前主要用于保留设计意图和支持后续恢复,不是默认可点选项。',
state: 'idle',
category: 'idle',
active: false,
},
};

View File

@@ -0,0 +1,48 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_observe_signs
*
* 空闲状态下的侦察动作。它把当前回合定义成“停下来观察”,
* 重点不是立刻推进,而是为后续选择生成可引用的观察结果。
*/
export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_observe_signs',
state: 'idle',
category: 'idle',
text: '停步观察动静',
description: '先收住脚步观察附近痕迹与风吹草动,判断前方是否有异样。',
visual: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
sceneShift: 0,
enterBattle: false,
},
},
promptDescription:
'停步观察周围动静、痕迹或征兆,文案可以自然改写,但仍要保持这是观察判断。',
documentation: {
id: 'idle_observe_signs',
domain: 'state',
title: '停步观察动静',
source: 'src/data/functionCatalog/state/idleObserveSigns.ts',
summary: '围绕侦察、判断和前情确认的空闲 function。',
detailedDescription:
'这个 function 不要求马上遇敌或推进,而是把回合用在收集线索、确认附近实体池与下一步风险上。',
trigger: '仅在 idle 状态下参与候选。',
execution:
'保持原地观察姿态,不推进 sceneShift上层 prompt 会把这一回合视为观察请求,要求模型输出可延续的侦察结论。',
result: '适合进入未知区域前先看风向、脚印、气息或异响,减少盲走。',
state: 'idle',
category: 'idle',
active: true,
},
};

View File

@@ -0,0 +1,50 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_rest_focus
*
* 空闲状态下的原地恢复动作。它不会推进遭遇,而是给玩家一个
* 在非战斗场景里回收少量血蓝的缓冲回合。
*/
export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_rest_focus',
state: 'idle',
category: 'recovery',
text: '原地调息恢复',
description: '暂时停步整理呼吸,恢复少量生命与灵力,继续保持空闲状态。',
visual: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
healAmount: 10,
manaRestore: 15,
sceneShift: 0,
enterBattle: false,
},
},
promptDescription:
'原地调息、休整或恢复状态,文案可以自然改写,但仍要保持这是恢复。',
documentation: {
id: 'idle_rest_focus',
domain: 'state',
title: '原地调息恢复',
source: 'src/data/functionCatalog/state/idleRestFocus.ts',
summary: '空闲阶段用于回血回蓝的恢复 function。',
detailedDescription:
'它承担非战斗状态下的短暂停步与内息整理,让玩家不推进主线遭遇,也能通过一回合修整状态。',
trigger: '仅在 idle 状态下参与候选,并会在生命或灵力偏低时提升优先级。',
execution:
'不推动场景前进effect 直接提供 healAmount 与 manaRestore确保结果稳定由本地规则控制。',
result: '适合战后休整、资源吃紧,或玩家想稳一下再继续探路时使用。',
state: 'idle',
category: 'recovery',
active: true,
},
};

View File

@@ -0,0 +1,48 @@
import { AnimationState } from '../../../types';
import type { StateFunctionSource } from '../types';
/**
* idle_travel_next_scene
*
* 空闲状态下的切场景动作。它代表玩家主动离开当前地点,
* 进入相邻场景重新开启新的遭遇周期。
*/
export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = {
definition: {
id: 'idle_travel_next_scene',
state: 'idle',
category: 'idle',
text: '前往其他场景',
description: '离开当前场景,进入下一处地点,并在那里重新遭遇新的威胁。',
visual: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 1.1,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
},
effect: {
sceneShift: 1,
enterBattle: true,
},
},
promptDescription:
'离开当前场景并前往下一个场景,文案可以自然改写,但仍要保持这是切换场景。',
documentation: {
id: 'idle_travel_next_scene',
domain: 'state',
title: '前往其他场景',
source: 'src/data/functionCatalog/state/idleTravelNextScene.ts',
summary: '空闲阶段的地图流转 function。',
detailedDescription:
'当玩家不再想在当前场景深挖,而是希望切换地形、敌人池和环境压力时,这个 function 负责驱动合法的场景迁移。',
trigger: '仅在 idle 状态下参与候选。',
execution:
'视觉上继续向前奔跑effect 里通过 sceneShift: 1 把结果导向相邻场景,并允许新的遭遇在新场景刷新。',
result: '适合主动换图、回避当前区域、或推进地图探索节奏时使用。',
state: 'idle',
category: 'idle',
active: true,
},
};

View File

@@ -0,0 +1,50 @@
import type { StateFunctionSource } from '../types';
import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush';
import { BATTLE_ATTACK_BASIC_FUNCTION } from './battleAttackBasic';
import { BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout';
import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep';
import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow';
import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak';
import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure';
import { BATTLE_RECOVER_BREATH_FUNCTION_SOURCE } from './battleRecoverBreath';
import { BATTLE_USE_SKILL_FUNCTION } from './battleUseSkill';
import { IDLE_CALL_OUT_FUNCTION_SOURCE } from './idleCallOut';
import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward';
import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue';
import { IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE } from './idleObserveSigns';
import { IDLE_REST_FOCUS_FUNCTION_SOURCE } from './idleRestFocus';
import { IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE } from './idleTravelNextScene';
export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [
BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE,
BATTLE_GUARD_BREAK_FUNCTION_SOURCE,
BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE,
BATTLE_FEINT_STEP_FUNCTION_SOURCE,
BATTLE_RECOVER_BREATH_FUNCTION_SOURCE,
BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE,
BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE,
IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE,
IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE,
IDLE_REST_FOCUS_FUNCTION_SOURCE,
IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE,
IDLE_FOLLOW_CLUE_FUNCTION_SOURCE,
IDLE_CALL_OUT_FUNCTION_SOURCE,
];
export const STATE_FUNCTION_DEFINITIONS = STATE_FUNCTION_SOURCES.map(
(source) => source.definition,
);
export const STATE_FUNCTION_PROMPT_DESCRIPTIONS = Object.fromEntries(
STATE_FUNCTION_SOURCES.map((source) => [
source.definition.id,
source.promptDescription,
]),
) as Record<string, string>;
export const STATE_FUNCTION_DOCUMENTATION = [
BATTLE_ATTACK_BASIC_FUNCTION,
BATTLE_USE_SKILL_FUNCTION,
...STATE_FUNCTION_SOURCES.map((source) => source.documentation),
];

View File

@@ -0,0 +1,10 @@
import type { FunctionDocumentationEntry } from '../types';
import { TREASURE_INSPECT_FUNCTION } from './treasureInspect';
import { TREASURE_LEAVE_FUNCTION } from './treasureLeave';
import { TREASURE_SECURE_FUNCTION } from './treasureSecure';
export const TREASURE_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
TREASURE_SECURE_FUNCTION,
TREASURE_INSPECT_FUNCTION,
TREASURE_LEAVE_FUNCTION,
];

View File

@@ -0,0 +1,20 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* treasure_inspect
*
* 先调查机关、线索与伪装,再收取宝藏的 function。
*/
export const TREASURE_INSPECT_FUNCTION: FunctionDocumentationEntry = {
id: 'treasure_inspect',
domain: 'treasure',
title: '先排查机关与线索',
source: 'src/data/functionCatalog/treasure/treasureInspect.ts',
summary: '以更谨慎的方式获取宝藏收益的调查项。',
detailedDescription:
'它强调先看清环境与机关,再拆开伪装拿收益,因此通常会附带额外恢复、线索或更丰富的战利品组合。',
trigger: '遭遇宝藏交互时的标准选项之一。',
execution: '点击后生成 inspect 变体奖励,并用更细致的结果文本描述排查过程。',
result: '玩家通常获得更完整的道具、金钱与恢复收益,但叙事上会多花一步检查。',
active: true,
};

View File

@@ -0,0 +1,21 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* treasure_leave
*
* 暂时放过眼前宝藏、回到主流程的 function。
*/
export const TREASURE_LEAVE_FUNCTION: FunctionDocumentationEntry = {
id: 'treasure_leave',
domain: 'treasure',
title: '先记下位置离开',
source: 'src/data/functionCatalog/treasure/treasureLeave.ts',
summary: '放弃本次收取、只保留线索记忆的宝藏退出项。',
detailedDescription:
'它允许玩家在风险、资源或节奏不合适时先不碰宝藏,把互动结果定格为“记住位置和异常”,而不是强行收取。',
trigger: '遭遇宝藏交互时的标准退出项。',
execution:
'点击后不发放宝藏奖励,而是直接写入 leave 结果文本并回到后续剧情。',
result: '玩家不会获得物品,但故事会保留“这里有异常宝藏”的记忆。',
active: true,
};

View File

@@ -0,0 +1,20 @@
import type { FunctionDocumentationEntry } from '../types';
/**
* treasure_secure
*
* 直接收取眼前宝藏的 function。
*/
export const TREASURE_SECURE_FUNCTION: FunctionDocumentationEntry = {
id: 'treasure_secure',
domain: 'treasure',
title: '先把宝藏收下',
source: 'src/data/functionCatalog/treasure/treasureSecure.ts',
summary: '快速结算宝藏收益的直接收取项。',
detailedDescription:
'它代表玩家不再额外排查机关,而是优先把主要收获拿到手。奖励仍由本地规则根据当前 encounter 和构筑上下文生成。',
trigger: '遭遇宝藏交互时的标准选项之一。',
execution: '点击后直接生成 secure 变体奖励,并继续推进主故事。',
result: '玩家立刻拿到主要物品和钱币,但放弃 inspect 路线的额外侦查收益。',
active: true,
};

View File

@@ -0,0 +1,58 @@
import type {
FunctionCategory,
PlayerStateMode,
StoryOption,
} from '../../types';
import type { StateFunctionDefinition } from '../stateFunctions';
export type FunctionDomain = 'state' | 'npc' | 'treasure' | 'flow' | 'panel';
export type FunctionStoryMode =
| 'local_effect_then_generate'
| 'modal_then_generate'
| 'stream_then_defer'
| 'reveal_deferred_options'
| 'enter_interaction'
| 'special_sequence'
| 'special_travel'
| 'local_only';
export type FunctionUiMode =
| 'none'
| 'npc_interaction_entry'
| 'trade_modal'
| 'gift_modal'
| 'recruit_modal_or_sequence';
export interface FunctionRuntimeGuide {
storyMode: FunctionStoryMode;
uiMode: FunctionUiMode;
visuals?: StoryOption['visuals'];
executor: string;
animationNote: string;
storyNote: string;
uiNote: string;
compactDetailText?: string;
}
export interface FunctionDocumentationEntry {
id: string;
domain: FunctionDomain;
title: string;
source: string;
summary: string;
detailedDescription: string;
trigger: string;
execution: string;
result: string;
state?: PlayerStateMode;
category?: FunctionCategory;
active?: boolean;
runtime?: FunctionRuntimeGuide;
}
export interface StateFunctionSource {
definition: StateFunctionDefinition;
documentation: FunctionDocumentationEntry;
promptDescription: string;
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,97 @@
import {describe, expect, it, vi} from 'vitest';
import type {GameState} from '../types';
import {AnimationState, WorldType} from '../types';
import {rollHostileNpcLoot} from './hostileNpcPresets';
function createGameState(): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: null,
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'ruins',
name: '断碑古道',
description: '阴气与碎骨混在旧路之间。',
imageSrc: '/ruins.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 60,
playerMaxMana: 60,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('hostileNpcPresets', () => {
it('combines preset loot with runtime semantic drops', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new TypeError('network disabled in test')),
);
try {
const loot = await rollHostileNpcLoot(createGameState(), [
{
id: 'monster-03',
name: '断骨祟灵',
},
]);
expect(loot.some(item => item.id === 'monster-loot:bone-dust')).toBe(true);
expect(
loot.some(item => item.runtimeMetadata?.generationChannel === 'monster_drop'),
).toBe(true);
expect(
loot.find(item => item.id === 'monster-loot:bone-dust')?.runtimeMetadata?.storyFingerprint,
).toBeTruthy();
} finally {
vi.unstubAllGlobals();
randomSpy.mockRestore();
}
});
});

File diff suppressed because it is too large Load Diff

389
src/data/hostileNpcs.ts Normal file
View File

@@ -0,0 +1,389 @@
import { HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
import {
AnimationState,
Encounter,
FacingDirection,
HostileNpcRenderAnimation,
SceneDirective,
SceneHostileNpc,
SceneHostileNpcChange,
StoryOption,
WorldType,
} from '../types';
import { resolveRoleCombatStats } from './attributeCombat';
import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime';
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
export const METERS_TO_PIXELS = 48;
export const PLAYER_BASE_X_METERS = 0;
export const MAX_HOSTILE_NPCS_PER_ENCOUNTER = 3;
export const HOSTILE_NPCS_BY_WORLD: Record<WorldType, HostileNpcSpriteConfig[]> = {
[WorldType.WUXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.WUXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.XIANXIA]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.XIANXIA].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
[WorldType.CUSTOM]: HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].map(({ description: _description, introAction: _introAction, baseStats: _baseStats, worldType: _worldType, ...spriteConfig }) => spriteConfig),
};
export const MONSTERS_BY_WORLD = HOSTILE_NPCS_BY_WORLD;
const UPPER_BACK_OFFSET_X_METERS = Number((56 / METERS_TO_PIXELS).toFixed(2));
const LOWER_BACK_OFFSET_X_METERS = Number((34 / METERS_TO_PIXELS).toFixed(2));
const UPPER_BACK_OFFSET_Y_PX = 66;
const LOWER_BACK_OFFSET_Y_PX = 10;
const FRONT_HOSTILE_NPC_ANCHOR_X: Record<WorldType, number> = {
[WorldType.WUXIA]: 3.2,
[WorldType.XIANXIA]: 3.6,
[WorldType.CUSTOM]: 3.4,
};
type HostileNpcFormationSlot = Pick<SceneHostileNpc, 'xMeters' | 'yOffset'>;
function getUniqueHostileNpcIds(hostileNpcIds: string[]) {
const seen = new Set<string>();
const uniqueIds: string[] = [];
hostileNpcIds.forEach(monsterId => {
const normalizedId = monsterId.trim();
if (!normalizedId || seen.has(normalizedId)) return;
seen.add(normalizedId);
uniqueIds.push(normalizedId);
});
return uniqueIds;
}
function shuffleItems<T>(items: T[]) {
const next = [...items];
for (let index = next.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
const currentItem = next[index];
const swapItem = next[swapIndex];
if (currentItem === undefined || swapItem === undefined) {
continue;
}
next[index] = swapItem;
next[swapIndex] = currentItem;
}
return next;
}
function getMaxSceneHostileNpcCount(worldType: WorldType) {
return getHostileNpcFormationSlots(worldType, MAX_HOSTILE_NPCS_PER_ENCOUNTER).length;
}
function getHostileNpcFormationSlots(
worldType: WorldType,
monsterCount: number,
): HostileNpcFormationSlot[] {
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
const centerSlot = { xMeters: frontX, yOffset: 0 };
const lowerBackSlot = {
xMeters: Number((frontX + LOWER_BACK_OFFSET_X_METERS).toFixed(2)),
yOffset: LOWER_BACK_OFFSET_Y_PX,
};
const upperBackSlot = {
xMeters: Number((frontX + UPPER_BACK_OFFSET_X_METERS).toFixed(2)),
yOffset: UPPER_BACK_OFFSET_Y_PX,
};
if (monsterCount <= 1) {
return [centerSlot];
}
if (monsterCount === 2) {
return [lowerBackSlot, upperBackSlot];
}
return [centerSlot, lowerBackSlot, upperBackSlot];
}
export function chooseEncounterMonsterCount(maxAvailableCount: number) {
const clampedCount = Math.max(0, Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, maxAvailableCount));
if (clampedCount <= 1) return clampedCount;
const weightedCounts = [
{ count: 1, weight: 0.6 },
{ count: 2, weight: 0.3 },
{ count: 3, weight: 0.1 },
].filter(entry => entry.count <= clampedCount);
const totalWeight = weightedCounts.reduce((sum, entry) => sum + entry.weight, 0);
let roll = Math.random() * totalWeight;
for (const entry of weightedCounts) {
roll -= entry.weight;
if (roll <= 0) {
return entry.count;
}
}
return weightedCounts[weightedCounts.length - 1]?.count ?? 1;
}
export function pickEncounterHostileNpcIds(availableMonsterIds: string[]) {
const pool = getUniqueHostileNpcIds(availableMonsterIds);
if (pool.length === 0) return [];
const targetCount = chooseEncounterMonsterCount(pool.length);
return shuffleItems(pool).slice(0, targetCount);
}
export function resolveEncounterHostileNpcIds(
availableMonsterIds: string[],
requestedMonsterIds: string[] = [],
) {
const pool = getUniqueHostileNpcIds(availableMonsterIds);
if (pool.length === 0) return [];
const requested = getUniqueHostileNpcIds(requestedMonsterIds).filter(monsterId => pool.includes(monsterId));
if (requested.length > 0) {
return requested.slice(0, Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, pool.length));
}
return pickEncounterHostileNpcIds(pool);
}
export function getHostileNpcGroupAnchorX(monsters: Array<Pick<SceneHostileNpc, 'xMeters'>>) {
if (monsters.length === 0) return PLAYER_BASE_X_METERS;
return Math.min(...monsters.map(monster => monster.xMeters));
}
export const getMonsterGroupAnchorX = getHostileNpcGroupAnchorX;
export function getFacingTowardPlayer(monsterX: number, playerX: number): FacingDirection {
return monsterX >= playerX ? 'left' : 'right';
}
export const pickEncounterMonsterIds = pickEncounterHostileNpcIds;
export function buildHostileNpcEncounter(
worldType: WorldType,
monsterId: string,
options: {
xMeters?: number;
} = {},
): Encounter | null {
const preset = getHostileNpcPresetById(worldType, monsterId);
if (!preset) return null;
return {
id: `monster:${worldType}:${preset.id}`,
kind: 'npc',
monsterPresetId: preset.id,
npcName: preset.name,
npcDescription: preset.description,
npcAvatar: preset.name.slice(0, 1) || '敌',
context: '敌对角色',
xMeters: options.xMeters,
initialAffinity: -40,
hostile: true,
attributeProfile: preset.attributeProfile,
};
}
export function createSceneHostileNpc(
worldType: WorldType,
monsterId: string,
playerX = PLAYER_BASE_X_METERS,
slotIndex = 0,
): SceneHostileNpc | null {
const preset = getHostileNpcPresetById(worldType, monsterId);
if (!preset) return null;
const combatStats = resolveRoleCombatStats(preset.attributeProfile, {
baseSpeed: preset.baseStats.speed,
});
const maxHp = preset.baseStats.maxHp + combatStats.maxHpBonus;
const formationSlots = getHostileNpcFormationSlots(
worldType,
Math.min(MAX_HOSTILE_NPCS_PER_ENCOUNTER, slotIndex + 1),
);
const position = formationSlots[Math.min(slotIndex, formationSlots.length - 1)];
if (!position) {
return null;
}
return {
id: preset.id,
name: preset.name,
action: preset.introAction,
description: preset.description,
animation: 'idle',
xMeters: position.xMeters,
yOffset: position.yOffset,
facing: getFacingTowardPlayer(position.xMeters, playerX),
attackRange: preset.baseStats.attackRange,
speed: combatStats.turnSpeed,
hp: maxHp,
maxHp,
renderKind: 'npc',
combatTags: preset.combatTags,
attributeProfile: preset.attributeProfile,
behaviorVectors: preset.behaviorVectors,
encounter: buildHostileNpcEncounter(worldType, preset.id, {
xMeters: position.xMeters,
}) ?? undefined,
};
}
export function createSceneHostileNpcsFromIds(
worldType: WorldType,
hostileNpcIds: string[],
playerX = PLAYER_BASE_X_METERS,
): SceneHostileNpc[] {
const fallbackMonsterPresets = getHostileNpcPresetsByWorld(worldType);
const resolvedFallbackId = fallbackMonsterPresets[0]?.id;
const resolvedIds = (hostileNpcIds.length > 0 ? hostileNpcIds : resolvedFallbackId ? [resolvedFallbackId] : [])
.slice(0, getMaxSceneHostileNpcCount(worldType));
const formationSlots = getHostileNpcFormationSlots(worldType, resolvedIds.length || 1);
return resolvedIds
.map((monsterId, index) => {
const monster = createSceneHostileNpc(worldType, monsterId, playerX, index);
const position = formationSlots[index] ?? formationSlots[formationSlots.length - 1];
if (!monster || !position) return null;
return {
...monster,
xMeters: position.xMeters,
yOffset: position.yOffset,
facing: getFacingTowardPlayer(position.xMeters, playerX),
};
})
.filter(Boolean) as SceneHostileNpc[];
}
export function createSceneHostileNpcsFromEncounters(
worldType: WorldType,
encounters: Encounter[],
playerX = PLAYER_BASE_X_METERS,
): SceneHostileNpc[] {
const hostileEncounters = encounters.filter(
(encounter): encounter is Encounter & { monsterPresetId: string } => Boolean(encounter.monsterPresetId),
);
if (hostileEncounters.length === 0) return [];
const baseMonsters = createSceneHostileNpcsFromIds(
worldType,
hostileEncounters.map(encounter => encounter.monsterPresetId),
playerX,
);
return baseMonsters.map((monster, index) => {
const encounter = hostileEncounters[index];
if (!encounter) return monster;
return {
...monster,
name: encounter.npcName,
description: encounter.npcDescription,
renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
encounter: {
...encounter,
xMeters: monster.xMeters,
},
};
});
}
export function getBaseSceneHostileNpcs(worldType: WorldType, playerX = PLAYER_BASE_X_METERS): SceneHostileNpc[] {
const fallbackId = getHostileNpcPresetsByWorld(worldType)[0]?.id;
return fallbackId ? createSceneHostileNpcsFromIds(worldType, [fallbackId], playerX) : [];
}
export function distanceBetweenPlayerAndClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[]) {
if (monsters.length === 0) return Infinity;
return Math.min(...monsters.map(monster => Math.abs(monster.xMeters - playerX)));
}
export function getClosestHostileNpc(playerX: number, monsters: SceneHostileNpc[]) {
if (monsters.length === 0) return null;
return [...monsters].sort((a, b) => Math.abs(a.xMeters - playerX) - Math.abs(b.xMeters - playerX))[0];
}
export function getHostileNpcDistance(playerX: number, monster: SceneHostileNpc) {
return Math.abs(monster.xMeters - playerX);
}
function normalizeHostileNpcAnimation(value: string | undefined): HostileNpcRenderAnimation {
return value === 'move' || value === 'attack' || value === 'die' ? value : 'idle';
}
export function normalizeHostileNpcChanges(
changes: SceneDirective['hostileNpcChanges'],
worldType: WorldType,
): SceneHostileNpcChange[] {
const resolvedAllowedIds = new Set(getHostileNpcPresetsByWorld(worldType).map(monster => monster.id));
const safeChanges = changes ?? [];
return safeChanges
.filter(change => resolvedAllowedIds.has(change.id))
.map(change => ({
id: change.id,
action: typeof change.action === 'string' && change.action.trim() ? change.action.trim() : '缁х画鍘嬭揩鐜╁',
animation: normalizeHostileNpcAnimation(change.animation),
moveMeters: typeof change.moveMeters === 'number' ? Number(change.moveMeters.toFixed(1)) : 0,
yOffset: 0,
}));
}
export function applySceneDirective(
monsters: SceneHostileNpc[],
directive: SceneDirective,
playerX: number,
): SceneHostileNpc[] {
const nextPlayerX = playerX + directive.playerMoveMeters;
const hostileNpcChanges = directive.hostileNpcChanges ?? [];
return monsters.map(monster => {
const change = hostileNpcChanges.find(item => item.id === monster.id);
const nextX = monster.xMeters + (change?.moveMeters ?? 0);
return {
...monster,
action: change?.action ?? monster.action,
animation: change?.animation ?? monster.animation,
xMeters: Number(nextX.toFixed(1)),
yOffset: 0,
facing: getFacingTowardPlayer(nextX, nextPlayerX),
};
});
}
export function settleHostileNpcAnimations(monsters: SceneHostileNpc[]) {
return monsters.map(monster => ({
...monster,
animation: 'idle' as const,
facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS),
}));
}
export function createFallbackOption(
functionId: string,
text: string,
playerAnimation: AnimationState,
moveMeters: number,
scrollWorld = false,
): StoryOption {
return {
functionId,
actionText: text,
text,
visuals: {
playerAnimation,
playerMoveMeters: moveMeters,
playerOffsetY: 0,
playerFacing: moveMeters < 0 ? 'left' : 'right',
scrollWorld,
monsterChanges: [],
hostileNpcChanges: [],
},
};
}

View File

@@ -0,0 +1,102 @@
import { Character, InventoryItem, TimedBuildBuff } from "../types";
export type InventoryUseEffect = {
hpRestore: number;
manaRestore: number;
cooldownReduction: number;
buildBuffs: TimedBuildBuff[];
};
function getRarityMultiplier(rarity: InventoryItem["rarity"]) {
switch (rarity) {
case "legendary":
return 2.4;
case "epic":
return 1.9;
case "rare":
return 1.55;
case "uncommon":
return 1.2;
default:
return 1;
}
}
export function isInventoryItemUsable(item: InventoryItem) {
return (
Boolean(item.useProfile) ||
item.tags.includes("healing") ||
item.tags.includes("mana")
);
}
export function resolveInventoryItemUseEffect(
item: InventoryItem,
character: Character,
): InventoryUseEffect | null {
if (!isInventoryItemUsable(item)) return null;
if (item.useProfile) {
return {
hpRestore: item.useProfile.hpRestore ?? 0,
manaRestore: item.useProfile.manaRestore ?? 0,
cooldownReduction: item.useProfile.cooldownReduction ?? 0,
buildBuffs: item.useProfile.buildBuffs ?? [],
};
}
const rarityMultiplier = getRarityMultiplier(item.rarity);
const hasHealing =
item.tags.includes("healing") ||
/药|包|补给|恢复|疗伤|meat|apple|mushroom|water/i.test(item.name);
const hasMana =
item.tags.includes("mana") ||
/灵液|法力|mana|crystal|essence|spirit/i.test(item.name);
const hpRestore = hasHealing
? Math.max(
10,
Math.round((14 + character.attributes.spirit * 1.4) * rarityMultiplier),
)
: 0;
const manaRestore = hasMana
? Math.max(
8,
Math.round(
(12 + character.attributes.intelligence * 1.4) * rarityMultiplier,
),
)
: 0;
const cooldownReduction = /凝神|回气|醒神|booster|essence/i.test(item.name)
? 1
: 0;
if (hpRestore <= 0 && manaRestore <= 0 && cooldownReduction <= 0) {
return null;
}
return {
hpRestore,
manaRestore,
cooldownReduction,
buildBuffs: [],
};
}
export function buildInventoryUseResultText(
item: InventoryItem,
effect: InventoryUseEffect,
) {
const parts = [
effect.hpRestore > 0 ? `恢复 ${effect.hpRestore} 点气血` : null,
effect.manaRestore > 0 ? `恢复 ${effect.manaRestore} 点灵力` : null,
effect.cooldownReduction > 0
? `额外推进 ${effect.cooldownReduction} 回合冷却`
: null,
effect.buildBuffs.length > 0
? `获得 ${effect.buildBuffs.map(buff => buff.name).join("、")}`
: null,
].filter(Boolean);
return `你取出${item.name}立刻使用,${parts.join("")}`;
}

337
src/data/itemCatalog.ts Normal file
View File

@@ -0,0 +1,337 @@
import type {
InventoryItem,
ItemCatalogEntry,
ItemCatalogOverride,
ItemRarity,
WorldType,
} from '../types';
import {
EDITOR_ITEM_CATALOG_API_PATH,
} from '../editor/shared/editorApiClient';
import { buildDesignedItemMetadata } from './itemDesign';
export { EDITOR_ITEM_CATALOG_API_PATH as ITEM_CATALOG_API_PATH };
export const ITEM_CATEGORY_OPTIONS = [
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属品',
] as const;
const CATEGORY_WEAPON = ITEM_CATEGORY_OPTIONS[0];
const CATEGORY_ARMOR = ITEM_CATEGORY_OPTIONS[1];
const CATEGORY_RELIC = ITEM_CATEGORY_OPTIONS[2];
const CATEGORY_CONSUMABLE = ITEM_CATEGORY_OPTIONS[3];
const CATEGORY_MATERIAL = ITEM_CATEGORY_OPTIONS[4];
const CATEGORY_RARE = ITEM_CATEGORY_OPTIONS[5];
const CATEGORY_EXCLUSIVE = ITEM_CATEGORY_OPTIONS[6];
const WEAPON_KEYWORDS = [
'weapon',
'sword',
'axe',
'bow',
'arrow',
'mace',
'wand',
'staff',
'pick',
'spade',
'blade',
'dagger',
'spear',
'hammer',
];
const ARMOR_KEYWORDS = [
'armor',
'armour',
'helm',
'helmet',
'chest',
'pants',
'boots',
'glove',
'glowes',
'shield',
'cloak',
'robe',
'cap',
];
const ACCESSORY_KEYWORDS = [
'ring',
'neck',
'amulet',
'jewel',
'jewelry',
'bracelet',
'relic',
'gem',
];
const CONSUMABLE_KEYWORDS = [
'potion',
'bottle',
'water',
'meat',
'apple',
'mushroom',
'bandage',
'torch',
'candle',
'food',
];
const MATERIAL_KEYWORDS = [
'wood',
'stone',
'leaf',
'flower',
'skin',
'rope',
'coin',
'silverbar',
'ore',
'bar',
'material',
];
const RARE_KEYWORDS = [
'scroll',
'book',
'bag',
'skull',
'cross',
'stairway',
'crystal',
'magic',
];
const EXCLUSIVE_KEYWORDS = [
'treasure',
'relic',
'artifact',
'legend',
'sacred',
];
function normalizeAssetPath(sourcePath: string) {
return sourcePath
.replace(/^public[\\/]/iu, '')
.replace(/\\/g, '/')
.replace(/^\/+/u, '');
}
function stripExtension(value: string) {
return value.replace(/\.[^.]+$/u, '');
}
function humanizeAssetPart(value: string) {
const cleaned = stripExtension(value)
.replace(/^\d+[_-]*/u, '')
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return '';
return cleaned
.split(' ')
.map(part => {
const firstCharacter = part[0];
return part && firstCharacter ? firstCharacter.toUpperCase() + part.slice(1) : '';
})
.join(' ');
}
function includesAnyKeyword(text: string, keywords: string[]) {
return keywords.some(keyword => text.includes(keyword));
}
function dedupeTags(tags: string[]) {
return [...new Set(tags.filter(Boolean))];
}
export function buildItemCatalogId(sourcePath: string) {
return stripExtension(normalizeAssetPath(sourcePath))
.toLowerCase()
.replace(/[^a-z0-9/]+/g, '-')
.replace(/\/+/g, '__')
.replace(/-+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '');
}
export function buildItemCatalogName(sourcePath: string) {
const normalized = normalizeAssetPath(sourcePath);
const parts = normalized.split('/');
const leafName = humanizeAssetPart(parts[parts.length - 1] ?? '');
const parentName = humanizeAssetPart(parts[parts.length - 2] ?? '');
if (!leafName && parentName) return parentName;
if (!leafName) return '未命名物品';
if (/^(Items|Icons|Singles|Variants|Text)$/u.test(leafName) && parentName) {
return `${parentName} ${leafName}`;
}
return leafName;
}
export function inferItemCatalogCategory(sourcePath: string) {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
if (includesAnyKeyword(normalized, WEAPON_KEYWORDS)) return '武器';
if (includesAnyKeyword(normalized, ARMOR_KEYWORDS)) return '护甲';
if (includesAnyKeyword(normalized, ACCESSORY_KEYWORDS)) return '饰品';
if (includesAnyKeyword(normalized, CONSUMABLE_KEYWORDS)) return '消耗品';
if (includesAnyKeyword(normalized, MATERIAL_KEYWORDS)) return '材料';
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return '专属品';
if (includesAnyKeyword(normalized, RARE_KEYWORDS)) return '稀有品';
return '稀有品';
}
export function inferItemCatalogRarity(sourcePath: string, category: string): ItemRarity {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
if (includesAnyKeyword(normalized, EXCLUSIVE_KEYWORDS)) return 'legendary';
if (includesAnyKeyword(normalized, ['magic', 'crystal', 'wand', 'gem', 'gold'])) return 'epic';
if (category === '武器' || category === '护甲' || category === '饰品' || category === '专属品') return 'rare';
if (category === '消耗品' || category === '材料') return 'uncommon';
return 'common';
}
export function inferItemCatalogTags(sourcePath: string, category: string) {
const normalized = normalizeAssetPath(sourcePath).toLowerCase();
const tags: string[] = [];
if (category === '武器') tags.push('weapon');
if (category === '护甲') tags.push('armor');
if (category === '饰品' || category === '专属品') tags.push('relic');
if (category === '材料') tags.push('material');
if (includesAnyKeyword(normalized, ['potion', 'bandage', 'water', 'meat', 'apple', 'mushroom'])) {
tags.push('healing');
}
if (includesAnyKeyword(normalized, ['mana', 'magic', 'crystal', 'gem', 'wand'])) {
tags.push('mana');
}
return dedupeTags(tags);
}
export function buildItemCatalogDescription(
sourcePath: string,
category: string,
name: string,
) {
return `由图标素材 ${normalizeAssetPath(sourcePath)} 自动生成的${category}物品“${name}”,可在编辑器中继续调整名称、稀有度、标签与描述。`;
}
export function buildBaseItemCatalogEntry(sourcePath: string): ItemCatalogEntry {
const normalizedSourcePath = normalizeAssetPath(sourcePath);
const name = buildItemCatalogName(normalizedSourcePath);
const category = inferItemCatalogCategory(normalizedSourcePath);
const rarity = inferItemCatalogRarity(normalizedSourcePath, category);
const tags = inferItemCatalogTags(normalizedSourcePath, category);
const designed = buildDesignedItemMetadata(
normalizedSourcePath,
name,
category,
rarity,
tags,
{
weapon: CATEGORY_WEAPON,
armor: CATEGORY_ARMOR,
relic: CATEGORY_RELIC,
consumable: CATEGORY_CONSUMABLE,
material: CATEGORY_MATERIAL,
rare: CATEGORY_RARE,
exclusive: CATEGORY_EXCLUSIVE,
},
);
return {
id: buildItemCatalogId(normalizedSourcePath),
sourcePath: normalizedSourcePath,
iconSrc: `/${normalizedSourcePath}`,
name: designed.name ?? name,
category: designed.category ?? category,
rarity: designed.rarity ?? rarity,
tags: dedupeTags(designed.tags ?? tags),
description: designed.description ?? buildItemCatalogDescription(normalizedSourcePath, category, name),
worldAffinity: designed.worldAffinity ?? 'neutral',
equipmentSlotId: designed.equipmentSlotId ?? null,
worldProfiles: designed.worldProfiles,
statProfile: designed.statProfile ?? null,
useProfile: designed.useProfile ?? null,
buildProfile: designed.buildProfile ?? null,
value: designed.value,
};
}
export function applyItemCatalogOverride(
baseItem: ItemCatalogEntry,
override?: ItemCatalogOverride | null,
): ItemCatalogEntry {
if (!override) return baseItem;
return {
...baseItem,
...override,
tags: override.tags ? dedupeTags(override.tags) : baseItem.tags,
worldProfiles: override.worldProfiles ?? baseItem.worldProfiles,
statProfile: override.statProfile ?? baseItem.statProfile,
useProfile: override.useProfile ?? baseItem.useProfile,
buildProfile: override.buildProfile ?? baseItem.buildProfile,
};
}
export function buildItemCatalogFromAssetPaths(
assetPaths: string[],
overrideMap: Record<string, ItemCatalogOverride> = {},
) {
return assetPaths
.map(sourcePath => buildBaseItemCatalogEntry(sourcePath))
.map(item => applyItemCatalogOverride(item, overrideMap[item.id]))
.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
}
export function createInventoryItemFromCatalogEntry(
item: ItemCatalogEntry,
quantity = 1,
worldType: WorldType | null = null,
): InventoryItem {
const worldProfile = worldType ? item.worldProfiles?.[worldType] : null;
return {
id: `catalog:${item.id}`,
catalogId: item.id,
category: item.category,
name: worldProfile?.name ?? item.name,
quantity,
rarity: item.rarity,
tags: [...item.tags],
iconSrc: item.iconSrc,
description: worldProfile?.description ?? item.description,
worldAffinity: item.worldAffinity,
equipmentSlotId: item.equipmentSlotId,
worldProfiles: item.worldProfiles,
statProfile: item.statProfile,
useProfile: item.useProfile,
buildProfile: item.buildProfile,
value: item.value,
runtimeMetadata: {
origin: 'catalog',
generationChannel: 'discovery',
seedKey: `catalog:${item.id}`,
sourceReason: '来自静态物品目录。',
},
};
}

962
src/data/itemDesign.ts Normal file
View File

@@ -0,0 +1,962 @@
import {
type EquipmentSlotId,
type ItemBuildProfile,
type ItemCatalogEntry,
type ItemRarity,
type ItemStatProfile,
type ItemUseProfile,
type ItemWorldAffinity,
type ItemWorldProfile,
WorldType,
} from "../types";
import { normalizeBuildRole, normalizeBuildTags } from "./buildTags";
export type ItemCategoryLabels = {
weapon: string;
armor: string;
relic: string;
consumable: string;
material: string;
rare: string;
exclusive: string;
};
type DesignedItemMetadata = Pick<
ItemCatalogEntry,
| "name"
| "category"
| "rarity"
| "tags"
| "description"
| "worldAffinity"
| "equipmentSlotId"
| "worldProfiles"
| "statProfile"
| "useProfile"
| "buildProfile"
| "value"
>;
type MaterialTheme = {
wuxia: string;
xianxia: string;
worldAffinity: ItemWorldAffinity;
role: string;
rarity: ItemRarity;
setWuxia: string;
setXianxia: string;
tags: string[];
synergy: string[];
};
const MATERIAL_THEMES: Record<string, MaterialTheme> = {
Wooden: {
wuxia: "乌木",
xianxia: "灵木",
worldAffinity: "neutral",
role: "fieldcraft",
rarity: "common",
setWuxia: "山行木作",
setXianxia: "灵木行旅",
tags: ["探索", "制作"],
synergy: ["探索", "采集", "过渡装备"],
},
Copper: {
wuxia: "赤铜",
xianxia: "赤炼铜",
worldAffinity: "wuxia",
role: "breaker",
rarity: "common",
setWuxia: "赤铜开山",
setXianxia: "赤炼破锋",
tags: ["破甲", "爆发"],
synergy: ["破甲", "前期开荒", "刚猛流"],
},
Iron: {
wuxia: "寒铁",
xianxia: "玄铁",
worldAffinity: "wuxia",
role: "vanguard",
rarity: "uncommon",
setWuxia: "寒铁镇岳",
setXianxia: "玄铁镇山",
tags: ["先锋", "守卫"],
synergy: ["承伤", "反击", "稳扎稳打"],
},
Steel: {
wuxia: "百炼钢",
xianxia: "灵钢",
worldAffinity: "neutral",
role: "duelist",
rarity: "rare",
setWuxia: "百炼争锋",
setXianxia: "灵钢斗枢",
tags: ["决斗者", "节奏"],
synergy: ["连击", "对拼", "压制"],
},
Silver: {
wuxia: "霜银",
xianxia: "月银",
worldAffinity: "xianxia",
role: "ward",
rarity: "rare",
setWuxia: "霜银辟祟",
setXianxia: "月银镇邪",
tags: ["守卫", "灵体"],
synergy: ["克制邪祟", "回复", "护体"],
},
Gold: {
wuxia: "鎏金",
xianxia: "金霞",
worldAffinity: "neutral",
role: "fortune",
rarity: "epic",
setWuxia: "鎏金富贵",
setXianxia: "金霞天赐",
tags: ["财富", "支援"],
synergy: ["经济", "爆发", "贵重馈赠"],
},
Cobalt: {
wuxia: "苍钴",
xianxia: "苍穹钴晶",
worldAffinity: "xianxia",
role: "caster",
rarity: "epic",
setWuxia: "苍钴引雷",
setXianxia: "钴晶御雷",
tags: ["施法者", "法力"],
synergy: ["法力", "远程", "雷系构筑"],
},
Crimson: {
wuxia: "绯钢",
xianxia: "赤煞晶钢",
worldAffinity: "wuxia",
role: "berserker",
rarity: "rare",
setWuxia: "绯钢狂锋",
setXianxia: "赤煞断岳",
tags: ["狂战士", "爆发"],
synergy: ["压血爆发", "破阵", "重击"],
},
Altair: {
wuxia: "星游",
xianxia: "天狼星辉",
worldAffinity: "xianxia",
role: "assassin",
rarity: "epic",
setWuxia: "星游夜行",
setXianxia: "星辉掠影",
tags: ["刺客", "机动性"],
synergy: ["身法", "暴击", "切后"],
},
Adamantine: {
wuxia: "玄钢",
xianxia: "玄金陨铁",
worldAffinity: "neutral",
role: "fortress",
rarity: "legendary",
setWuxia: "玄钢不坏",
setXianxia: "陨铁镇界",
tags: ["堡垒", "坦克"],
synergy: ["高承伤", "套装成型", "守中反打"],
},
Angelic: {
wuxia: "天辉",
xianxia: "羽化天灵",
worldAffinity: "xianxia",
role: "paladin",
rarity: "legendary",
setWuxia: "天辉护心",
setXianxia: "羽化圣辉",
tags: ["圣骑士", "支援"],
synergy: ["护盾", "回复", "圣光构筑"],
},
Nova: {
wuxia: "星火",
xianxia: "星爆灵核",
worldAffinity: "xianxia",
role: "spellblade",
rarity: "epic",
setWuxia: "星火裂空",
setXianxia: "星爆御剑",
tags: ["法术之刃", "法力"],
synergy: ["法武双修", "中距离压制", "星辰构筑"],
},
Platinum: {
wuxia: "白金",
xianxia: "霜白灵金",
worldAffinity: "neutral",
role: "commander",
rarity: "epic",
setWuxia: "白金威仪",
setXianxia: "灵金统御",
tags: ["指挥官", "平衡"],
synergy: ["全能", "队伍增益", "中后期构筑"],
},
Fateful: {
wuxia: "命纹",
xianxia: "天命玄纹",
worldAffinity: "xianxia",
role: "fate",
rarity: "legendary",
setWuxia: "命纹转祸",
setXianxia: "天命轮转",
tags: ["命运", "实用"],
synergy: ["冷却", "机缘", "运势构筑"],
},
};
const ARMOR_PIECE_LABELS: Record<
string,
{ wuxia: string; xianxia: string; pieceName: string; slot: EquipmentSlotId }
> = {
Boots: { wuxia: "踏云靴", xianxia: "凌霄履", pieceName: "boots", slot: "armor" },
Chestplate: { wuxia: "护心甲", xianxia: "灵铠", pieceName: "chest", slot: "armor" },
Gloves: { wuxia: "护腕", xianxia: "灵纹手甲", pieceName: "gloves", slot: "armor" },
Helmet: { wuxia: "冠盔", xianxia: "灵盔", pieceName: "helm", slot: "armor" },
Leggings: { wuxia: "行岳腿甲", xianxia: "踏虚护胫", pieceName: "leggings", slot: "armor" },
Shield: { wuxia: "镇势盾", xianxia: "护界灵盾", pieceName: "shield", slot: "armor" },
Weapon: { wuxia: "战兵", xianxia: "灵兵", pieceName: "weapon", slot: "weapon" },
};
const RARITY_ORDER: ItemRarity[] = ["common", "uncommon", "rare", "epic", "legendary"];
const GENERIC_TOKEN_LABELS: Record<string, { wuxia: string; xianxia: string }> = {
scroll: { wuxia: "秘卷", xianxia: "玉简" },
ring: { wuxia: "戒", xianxia: "灵戒" },
torch: { wuxia: "火把", xianxia: "明焰灯" },
helm: { wuxia: "盔", xianxia: "灵盔" },
helmet: { wuxia: "盔", xianxia: "灵盔" },
chest: { wuxia: "胸甲", xianxia: "灵铠" },
pants: { wuxia: "护腿", xianxia: "灵裤" },
boots: { wuxia: "靴", xianxia: "云履" },
gem: { wuxia: "宝石", xianxia: "灵晶" },
crystal: { wuxia: "晶簇", xianxia: "灵晶" },
cross: { wuxia: "镇煞十字", xianxia: "镇灵法印" },
potion: { wuxia: "药剂", xianxia: "灵液" },
water: { wuxia: "清水", xianxia: "灵泉水" },
bottle: { wuxia: "药瓶", xianxia: "灵瓶" },
neck: { wuxia: "项坠", xianxia: "灵坠" },
mushroom: { wuxia: "山菌", xianxia: "灵菌" },
meat: { wuxia: "肉脯", xianxia: "灵兽肉" },
apple: { wuxia: "果实", xianxia: "灵果" },
skull: { wuxia: "颅骨", xianxia: "骨印" },
bag: { wuxia: "行囊", xianxia: "乾坤袋" },
mace: { wuxia: "钉头锤", xianxia: "镇岳杵" },
spade: { wuxia: "铲", xianxia: "灵铲" },
coin: { wuxia: "铜钱", xianxia: "灵钱" },
stone: { wuxia: "石料", xianxia: "灵石料" },
wood: { wuxia: "木料", xianxia: "灵木材" },
glowes: { wuxia: "护手", xianxia: "灵纹手套" },
gloves: { wuxia: "护手", xianxia: "灵纹手套" },
book: { wuxia: "册页", xianxia: "道卷" },
leaf: { wuxia: "叶片", xianxia: "灵叶" },
sword: { wuxia: "剑", xianxia: "灵剑" },
bow: { wuxia: "弓", xianxia: "灵弓" },
arrow: { wuxia: "箭", xianxia: "灵矢" },
shield: { wuxia: "盾", xianxia: "灵盾" },
rope: { wuxia: "绳索", xianxia: "缚灵索" },
skin: { wuxia: "兽皮", xianxia: "妖皮" },
treasure: { wuxia: "宝匣", xianxia: "秘藏灵匣" },
pick: { wuxia: "鹤嘴镐", xianxia: "开脉灵镐" },
silverbar: { wuxia: "银锭", xianxia: "月银锭" },
flower: { wuxia: "花", xianxia: "灵花" },
wand: { wuxia: "法杖", xianxia: "灵杖" },
magic: { wuxia: "秘术核心", xianxia: "灵法结晶" },
};
function clampRarity(rank: number): ItemRarity {
return RARITY_ORDER[Math.max(0, Math.min(RARITY_ORDER.length - 1, rank))] ?? "common";
}
function rarityToRank(rarity: ItemRarity) {
return RARITY_ORDER.indexOf(rarity);
}
function bumpRarity(rarity: ItemRarity, delta: number) {
return clampRarity(rarityToRank(rarity) + delta);
}
function parseVariantIndex(normalizedSourcePath: string) {
const match = normalizedSourcePath.match(/(\d+)(?=\.png$)/iu);
return match ? Number(match[1]) : 1;
}
function buildWorldProfiles(
wuxiaName: string,
xianxiaName: string,
wuxiaDescription: string,
xianxiaDescription: string,
): Partial<Record<WorldType, ItemWorldProfile>> {
return {
WUXIA: {
name: wuxiaName,
description: wuxiaDescription,
},
XIANXIA: {
name: xianxiaName,
description: xianxiaDescription,
},
};
}
function dedupe(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function buildEquipmentStats(
slot: EquipmentSlotId,
rarity: ItemRarity,
role: string,
pieceName: string,
): ItemStatProfile {
const rank = rarityToRank(rarity) + 1;
if (slot === "weapon") {
const outgoingDamageBonus = Number((0.04 + rank * 0.018).toFixed(2));
const maxManaBonus = role === "caster" || role === "spellblade" ? 8 + rank * 4 : 0;
return {
outgoingDamageBonus,
maxManaBonus,
maxHpBonus: role === "fortress" ? 8 + rank * 6 : 0,
};
}
const baseHp =
pieceName === "shield"
? 16 + rank * 10
: pieceName === "chest"
? 14 + rank * 9
: pieceName === "helm"
? 10 + rank * 6
: 8 + rank * 5;
const incomingDamageMultiplier = Number(
Math.max(0.72, 0.99 - rank * 0.03 - (role === "fortress" ? 0.04 : 0)).toFixed(2),
);
return {
maxHpBonus: baseHp,
maxManaBonus: role === "caster" || role === "paladin" ? 6 + rank * 4 : 0,
outgoingDamageBonus:
role === "duelist" || role === "berserker" || role === "assassin"
? Number((0.02 + rank * 0.01).toFixed(2))
: 0,
incomingDamageMultiplier,
};
}
function buildRelicStats(rarity: ItemRarity, role: string): ItemStatProfile {
const rank = rarityToRank(rarity) + 1;
return {
maxHpBonus: role === "ward" || role === "paladin" ? 8 + rank * 4 : 0,
maxManaBonus:
role === "caster" || role === "fate" || role === "support"
? 12 + rank * 6
: 6 + rank * 3,
outgoingDamageBonus:
role === "assassin" || role === "berserker" || role === "spellblade"
? Number((0.03 + rank * 0.012).toFixed(2))
: Number((0.01 + rank * 0.008).toFixed(2)),
incomingDamageMultiplier:
role === "ward" || role === "support"
? Number(Math.max(0.8, 0.98 - rank * 0.02).toFixed(2))
: undefined,
};
}
function buildBuildProfile(
role: string,
tags: string[],
options: {
setId?: string;
setName?: string;
pieceName?: string;
synergy?: string[];
} = {},
): ItemBuildProfile {
return {
role: normalizeBuildRole(role),
tags: normalizeBuildTags([role, ...tags]),
setId: options.setId,
setName: options.setName,
pieceName: options.pieceName,
synergy: options.synergy ?? [],
forgeRank: 0,
};
}
function buildItemBuildBuffs(sourceId: string, name: string, tags: string[], durationTurns: number) {
return [{
id: `${sourceId}-buff`,
sourceType: "item" as const,
sourceId,
name,
tags: normalizeBuildTags(tags),
durationTurns,
}];
}
function rankValue(rarity: ItemRarity, slot: EquipmentSlotId | null, useProfile: ItemUseProfile | null) {
const rank = rarityToRank(rarity) + 1;
let value = 18 + rank * 14;
if (slot === "weapon") value += 22;
if (slot === "armor") value += 18;
if (slot === "relic") value += 20;
if (useProfile?.hpRestore || useProfile?.manaRestore) value += 16;
if (useProfile?.cooldownReduction) value += 22;
return value;
}
function detectRoleFromDescriptor(descriptor: string) {
const source = descriptor.toLowerCase();
if (/(wind|gust|nimble|rogue|hawk|arrow|sword_long|spear)/u.test(source)) return "assassin";
if (/(thunder|fierce|mighty|flame|serrated|punch|booster_iron|booster_steel)/u.test(source)) return "berserker";
if (/(arcane|esoteric|mage|ethereal|lunar|time|nightvision|copy|grimoire)/u.test(source)) return "caster";
if (/(fortitude|fortress|protector|shield|hearty|adaptable|honorable|cross)/u.test(source)) return "ward";
if (/(wedding|lovely|bud|oceanic|star|marbled|rich|vibrant|vivacious)/u.test(source)) return "support";
return "balanced";
}
function buildGenericTokenName(token: string, worldType: WorldType) {
const normalized = token.toLowerCase();
const mapped = GENERIC_TOKEN_LABELS[normalized];
if (mapped) {
return worldType === WorldType.WUXIA ? mapped.wuxia : mapped.xianxia;
}
return token
.replace(/\.[^.]+$/u, "")
.replace(/_/g, " ")
.trim();
}
function buildLegacyDesign(
normalizedSourcePath: string,
name: string,
category: string,
rarity: ItemRarity,
tags: string[],
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
if (normalizedSourcePath.includes("/")) return null;
const baseToken = normalizedSourcePath
.replace(/^\d+[_-]*/u, "")
.replace(/\.png$/iu, "")
.split(/[_-]/u)
.filter(Boolean)[0] ?? name;
const wuxiaName = buildGenericTokenName(baseToken, WorldType.WUXIA);
const xianxiaName = buildGenericTokenName(baseToken, WorldType.XIANXIA);
const slot: EquipmentSlotId | null =
category === labels.weapon ? "weapon" : category === labels.armor ? "armor" : category === labels.relic ? "relic" : null;
const useProfile: ItemUseProfile | null =
category === labels.consumable || tags.includes("healing") || tags.includes("mana")
? {
hpRestore: tags.includes("healing") ? 22 : 0,
manaRestore: tags.includes("mana") ? 18 : 0,
cooldownReduction: /power|mana|magic|bandage|torch/u.test(normalizedSourcePath) ? 1 : 0,
}
: null;
const statProfile =
slot === "weapon"
? buildEquipmentStats("weapon", rarity, "balanced", "weapon")
: slot === "armor"
? buildEquipmentStats("armor", rarity, "balanced", "armor")
: slot === "relic"
? buildRelicStats(rarity, "support")
: null;
return {
name,
category,
rarity,
tags: dedupe(tags),
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两套兼容模板中以不同风格登场,适合作为${category}基础模板继续扩展。`,
worldAffinity: "neutral",
equipmentSlotId: slot,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName},适用于边城模板的基础${category}条目。`,
`${xianxiaName},适用于灵潮模板的基础${category}条目。`,
),
statProfile,
useProfile,
buildProfile: buildBuildProfile("starter", ["legacy", ...tags]),
value: rankValue(rarity, slot, useProfile),
};
}
function buildArmoryDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
const match = normalizedSourcePath.match(
/Armory\/Singles\/(Armor Singles|Weapon Singles)\/([^/]+)\/([^/]+)\.png$/u,
);
if (!match) return null;
const family = match[2];
const filename = match[3];
if (!family || !filename) return null;
const theme = MATERIAL_THEMES[family];
if (!theme) return null;
const pieceMatch = filename.match(/_(Boots|Chestplate|Gloves|Helmet|Leggings|Shield|Weapon)(\d+)$/u);
if (!pieceMatch) return null;
const pieceKey = pieceMatch[1];
const variantIndex = Number(pieceMatch[2]);
if (!pieceKey || Number.isNaN(variantIndex)) return null;
const piece = ARMOR_PIECE_LABELS[pieceKey];
if (!piece) return null;
const gradeTier = variantIndex >= 25 ? 2 : variantIndex >= 13 ? 1 : 0;
const rarity: ItemRarity = bumpRarity(theme.rarity, gradeTier);
const gradeWuxia = ["初式", "精铸", "真传"][gradeTier];
const gradeXianxia = ["凡品", "灵铸", "道印"][gradeTier];
const wuxiaName = `${theme.wuxia}${piece.wuxia}${gradeWuxia}`;
const xianxiaName = `${theme.xianxia}${piece.xianxia}${gradeXianxia}`;
const slot = piece.slot;
const category = slot === "weapon" ? labels.weapon : labels.armor;
const setId = `set-armory-${family.toLowerCase()}`;
const setName = `${theme.setWuxia} / ${theme.setXianxia}`;
const statProfile =
slot === "weapon"
? buildEquipmentStats(slot, rarity, theme.role, piece.pieceName)
: buildEquipmentStats(slot, rarity, theme.role, piece.pieceName);
const tags = dedupe([
category === labels.weapon ? "weapon" : "armor",
theme.worldAffinity,
theme.role,
...theme.tags,
`set:${family.toLowerCase()}`,
`piece:${piece.pieceName}`,
]);
return {
name: theme.worldAffinity === "xianxia" ? xianxiaName : wuxiaName,
category,
rarity,
tags,
description: `${theme.setWuxia} / ${theme.setXianxia} 套装中的 ${piece.pieceName} 位。相邻编号代表同家族不同锻造阶段,适合围绕 ${theme.synergy.join("、")} 组 build。`,
worldAffinity: theme.worldAffinity,
equipmentSlotId: slot,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的边城模板 build。`,
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑灵潮模板战法。`,
),
statProfile,
useProfile: null,
buildProfile: buildBuildProfile(theme.role, theme.tags, {
setId,
setName,
pieceName: piece.pieceName,
synergy: theme.synergy,
}),
value: rankValue(rarity, slot, null),
};
}
function buildJewelryDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
const match = normalizedSourcePath.match(
/Jewelry\/(Rings|Necklaces|Bracelets)\/Singles(?:\/[^/]+)*\/([^/]+)\.png$/u,
);
if (!match) return null;
const jewelryType = match[1];
const filename = match[2];
if (!jewelryType || !filename) return null;
const descriptor = filename.replace(/^\d+_/u, "");
const role = detectRoleFromDescriptor(descriptor);
const sizeTier =
/Large|Fancy|Holder/u.test(descriptor) ? 2 : /Medium|Necklace|Bracelet/u.test(descriptor) ? 1 : 0;
const rarity: ItemRarity = bumpRarity(
sizeTier === 2 ? "rare" : sizeTier === 1 ? "uncommon" : "common",
/Arcane|Esoteric|Lunar|Relic/u.test(descriptor) ? 1 : 0,
);
const worldAffinity = role === "caster" ? "xianxia" : role === "berserker" || role === "assassin" ? "wuxia" : "neutral";
const baseWuxiaType = jewelryType === "Rings" ? "戒" : jewelryType === "Necklaces" ? "坠" : "镯";
const baseXianxiaType = jewelryType === "Rings" ? "灵戒" : jewelryType === "Necklaces" ? "灵坠" : "灵镯";
const leadingToken = descriptor.split("_").find(Boolean) ?? jewelryType;
const wuxiaName = `${buildGenericTokenName(leadingToken, WorldType.WUXIA)}${baseWuxiaType}`;
const xianxiaName = `${buildGenericTokenName(leadingToken, WorldType.XIANXIA)}${baseXianxiaType}`;
const tags = dedupe(["relic", role, jewelryType.toLowerCase(), worldAffinity]);
const setId = `set-jewelry-${role}`;
return {
name: worldAffinity === "xianxia" ? xianxiaName : wuxiaName,
category: labels.relic,
rarity,
tags,
description: `${jewelryType} 家族的 ${descriptor.replace(/_/g, " ")} 款式。围绕 ${role} build 提供核心词条,也可以与同角色定位的项链/手镯/戒指拼成饰品流派。`,
worldAffinity,
equipmentSlotId: "relic",
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}偏向${role}向的武侠搭配,可作为饰品核心件。`,
`${xianxiaName}更适合${role}向仙侠构筑,用于补足法力、爆发或护体短板。`,
),
statProfile: buildRelicStats(rarity, role),
useProfile: null,
buildProfile: buildBuildProfile(role, tags, {
setId,
setName: `${role} 饰品系`,
pieceName: jewelryType.toLowerCase(),
synergy: ["饰品 build", "定向补短", "三件成型"],
}),
value: rankValue(rarity, "relic", null),
};
}
function buildPotionDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
if (!/Potions\/Singles\//u.test(normalizedSourcePath)) return null;
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
if (/Glass|Bottle_/u.test(filename) && !/Health|Mana|Pure|Essence|Soul/u.test(filename)) {
return {
name: "空药瓶",
category: labels.material,
rarity: "common",
tags: ["material", "alchemy"],
description: "炼药与装液容器,可作为配方材料或支线道具。",
worldAffinity: "neutral",
equipmentSlotId: null,
worldProfiles: buildWorldProfiles(
"药瓶",
"灵瓶",
"边城模板里常见的炼药容器。",
"灵潮模板里常用的盛装灵液器皿。",
),
statProfile: null,
useProfile: null,
buildProfile: buildBuildProfile("alchemy", ["material", "alchemy"]),
value: 14,
};
}
const index = parseVariantIndex(normalizedSourcePath);
const isHealth = /Health/u.test(filename);
const isMana = /Mana/u.test(filename);
const isPure = /Pure/u.test(filename);
const isEssence = /Essence/u.test(filename);
const isSoul = /Soul/u.test(filename);
const rarity = isSoul
? "legendary"
: isPure || isEssence
? "epic"
: index > 118
? "rare"
: index > 108
? "uncommon"
: "common";
const gradeText = isSoul ? "封魂" : isPure ? "澄澈" : isEssence ? "萃华" : rarity === "rare" ? "上品" : rarity === "uncommon" ? "精制" : "常备";
const wuxiaName = isHealth
? `${gradeText}回春药`
: isMana
? `${gradeText}养神露`
: `${gradeText}奇药`;
const xianxiaName = isHealth
? `${gradeText}补元灵液`
: isMana
? `${gradeText}聚灵露`
: `${gradeText}灵酿`;
const useProfile: ItemUseProfile = {
hpRestore: isHealth ? (isSoul ? 120 : isPure ? 82 : isEssence ? 64 : rarity === "rare" ? 44 : rarity === "uncommon" ? 28 : 18) : 0,
manaRestore: isMana ? (isSoul ? 96 : isPure ? 70 : isEssence ? 54 : rarity === "rare" ? 38 : rarity === "uncommon" ? 24 : 16) : 0,
cooldownReduction: isSoul || /Power/u.test(filename) ? 1 : 0,
buildBuffs: isHealth
? buildItemBuildBuffs(
`potion-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
"续战药势",
["回复", "续战"],
isSoul ? 3 : 2,
)
: isMana
? buildItemBuildBuffs(
`potion-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
"聚灵药势",
["法力", "过载"],
isSoul ? 3 : 2,
)
: [],
};
const tags = dedupe([
"alchemy",
"consumable",
isHealth ? "healing" : "",
isMana ? "mana" : "",
isSoul ? "legendary-tonic" : "",
]);
return {
name: wuxiaName,
category: labels.consumable,
rarity,
tags,
description: "同形药瓶按纯度和封装级别区分强度,越靠后的高阶药剂越适合核心战斗循环与极限保命。",
worldAffinity: isMana || isSoul ? "xianxia" : "neutral",
equipmentSlotId: null,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}常见于边城模板的远行行囊,用于快速续战或调息。`,
`${xianxiaName}多用于灵潮模板的据点与试炼前后,负责补元、聚灵与压缩冷却。`,
),
statProfile: null,
useProfile,
buildProfile: buildBuildProfile("alchemy", tags, {
synergy: ["续航", "爆发前准备", "战中救急"],
}),
value: rankValue(rarity, null, useProfile),
};
}
function buildGemDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
if (!/Gems(?: II)?\/Singles\//u.test(normalizedSourcePath)) return null;
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
const tokenMatch = filename.match(/(Ruby|Onyx|Sapphire|Morganite|Emerald|Topaz|Amethyst|Diamond|Opal)/iu);
const token = tokenMatch?.[1] ?? "Crystal";
const role =
/Ruby|Crimson/u.test(token) ? "berserker"
: /Sapphire|Amethyst|Morganite/u.test(token) ? "caster"
: /Onyx|Diamond/u.test(token) ? "ward"
: /Topaz|Opal/u.test(token) ? "assassin"
: "support";
const rarity = /Dust/u.test(filename) ? "uncommon" : /Crystal/u.test(filename) ? "epic" : "rare";
const category = /Dust/u.test(filename) ? labels.material : labels.relic;
const wuxiaName = `${buildGenericTokenName(token, WorldType.WUXIA)}${/Dust/u.test(filename) ? "碎屑" : /Crystal/u.test(filename) ? "" : ""}`;
const xianxiaName = `${buildGenericTokenName(token, WorldType.XIANXIA)}${/Dust/u.test(filename) ? "粉末" : /Crystal/u.test(filename) ? "" : ""}`;
const tags = dedupe([
category === labels.material ? "material" : "relic",
token.toLowerCase(),
role,
"socket",
]);
return {
name: category === labels.material ? wuxiaName : xianxiaName,
category,
rarity,
tags,
description: `${token} 系晶石适合做强度梯度:粉尘是材料,宝石是中阶插件,晶体是高阶核心件。`,
worldAffinity: category === labels.relic ? "xianxia" : "neutral",
equipmentSlotId: category === labels.relic ? "relic" : null,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}偏向边城模板里的匠造、镶嵌与兵刃锻造。`,
`${xianxiaName}更适合灵潮模板里的灵器镶嵌与灵力 build 核心堆叠。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
useProfile: null,
buildProfile: buildBuildProfile(role, tags, {
setId: `gem-${token.toLowerCase()}`,
setName: `${token} 晶石谱系`,
pieceName: /Dust/u.test(filename) ? "dust" : /Crystal/u.test(filename) ? "crystal" : "gem",
synergy: ["镶嵌", "词条放大", "build 补强"],
}),
value: rankValue(rarity, category === labels.relic ? "relic" : null, null),
};
}
function buildSkillRelicDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata | null {
if (!/Skills\/Singles\//u.test(normalizedSourcePath) && !/Librarium\/Singles\//u.test(normalizedSourcePath)) {
return null;
}
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
const role = detectRoleFromDescriptor(filename);
const isBookLike = /Book|Grimoire|Literature/u.test(filename);
const isBooster = /Booster/u.test(filename);
const isPassive = /Passive/u.test(filename);
const isUtility = /Echolocation|Nightvision|Copy|Shout|Panic/u.test(filename);
const category = isBooster ? labels.consumable : isBookLike || isPassive || isUtility ? labels.relic : labels.rare;
const rarity = isPassive ? "epic" : isBooster ? "rare" : isBookLike ? "epic" : "rare";
const wuxiaName = isBookLike
? `·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`
: `秘术符印·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`;
const xianxiaName = isBookLike
? `灵诀玉简·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`
: `神通法印·${filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "")}`;
const useProfile =
category === labels.consumable
? {
hpRestore: 0,
manaRestore: 24 + rarityToRank(rarity) * 8,
cooldownReduction: 1,
buildBuffs: buildItemBuildBuffs(
`skill-relic-${filename.replace(/\.png$/iu, "").toLowerCase()}`,
"功法激发",
[role, /Arrow|Spear/u.test(filename) ? "" : /Shield/u.test(filename) ? "" : ""],
2,
),
}
: null;
const tags = dedupe([
category === labels.consumable ? "consumable" : category === labels.relic ? "relic" : "rare",
role,
isBooster ? "cooldown" : "",
/Fire/u.test(filename) ? "fire" : "",
/Lightning/u.test(filename) ? "lightning" : "",
/Shield/u.test(filename) ? "ward" : "",
/Sword|Punch/u.test(filename) ? "burst" : "",
/Arrow|Spear/u.test(filename) ? "projectile" : "",
]);
return {
name: xianxiaName,
category,
rarity,
tags,
description: "技能图标类物品会被设计成功法、符印、强化器或秘卷,用于支撑特定流派的 build 想象。",
worldAffinity: "xianxia",
equipmentSlotId: category === labels.relic ? "relic" : null,
worldProfiles: buildWorldProfiles(
wuxiaName,
xianxiaName,
`${wuxiaName}适合在边城模板里解释为武学秘卷、战术符印或绝招凭证。`,
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
),
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
useProfile,
buildProfile: buildBuildProfile(role, tags, {
setId: `skill-${role}`,
setName: `${role} 功法谱`,
synergy: ["职业核心", "技能联动", "法术 build"],
}),
value: rankValue(rarity, category === labels.relic ? "relic" : null, useProfile),
};
}
function buildUtilityDesign(
normalizedSourcePath: string,
labels: ItemCategoryLabels,
): DesignedItemMetadata {
const filename = normalizedSourcePath.split("/").pop() ?? normalizedSourcePath;
const readable = filename.replace(/^\d+_/u, "").replace(/_/g, " ").replace(/\.png$/iu, "");
const lower = readable.toLowerCase();
const category =
/ore|ingot|dust|bone|nail|card|flag|plushie|fish|string|rope|hook|cage|flower|seed|leaf|feather|skin|wood|stone/u.test(lower)
? labels.material
: /throwable|snowballs|meat|apple|mushroom/u.test(lower)
? labels.consumable
: /rod|pickaxe|sword|bow|mace|shield/u.test(lower)
? /shield/u.test(lower)
? labels.armor
: labels.weapon
: /book|relic|amulet|charm|skull|eyepatch|hook/u.test(lower)
? labels.relic
: labels.rare;
const rarity =
/gold|angelic|sacred|relic|crystal/u.test(lower)
? "epic"
: /steel|silver|special|pirate|magic/u.test(lower)
? "rare"
: /iron|bundle|advanced|fresh/u.test(lower)
? "uncommon"
: "common";
const slot: EquipmentSlotId | null =
category === labels.weapon ? "weapon" : category === labels.armor ? "armor" : category === labels.relic ? "relic" : null;
const role = detectRoleFromDescriptor(lower);
const useProfile: ItemUseProfile | null =
category === labels.consumable
? {
hpRestore: /meat|apple|mushroom/u.test(lower) ? 16 + rarityToRank(rarity) * 8 : 0,
manaRestore: /magic|crystal|water/u.test(lower) ? 14 + rarityToRank(rarity) * 6 : 0,
cooldownReduction: /throwable|snowballs/u.test(lower) ? 1 : 0,
}
: null;
const statProfile =
slot === "weapon"
? buildEquipmentStats("weapon", rarity, role, "weapon")
: slot === "armor"
? buildEquipmentStats("armor", rarity, role, "armor")
: slot === "relic"
? buildRelicStats(rarity, role)
: null;
const wuxiaName = readable
.split(" ")
.map((token) => buildGenericTokenName(token, WorldType.WUXIA))
.join("");
const xianxiaName = readable
.split(" ")
.map((token) => buildGenericTokenName(token, WorldType.XIANXIA))
.join("");
return {
name: wuxiaName || readable,
category,
rarity,
tags: dedupe([
...(/meat|apple|mushroom/u.test(lower) ? ["healing"] : []),
...(/magic|crystal|water/u.test(lower) ? ["mana"] : []),
...(slot === "weapon" ? ["weapon"] : slot === "armor" ? ["armor"] : slot === "relic" ? ["relic"] : []),
...(category === labels.material ? ["material"] : []),
role,
]),
description: `${readable} 根据视觉和路径被自动归入 ${category} 家族,可作为 ${role} 向 build 的支撑件或素材件。`,
worldAffinity: /magic|crystal|sacred|angelic|spirit|astral/u.test(lower) ? "xianxia" : "neutral",
equipmentSlotId: slot,
worldProfiles: buildWorldProfiles(
wuxiaName || readable,
xianxiaName || readable,
`${wuxiaName || readable}更适合边城模板的在地使用语境。`,
`${xianxiaName || readable}更适合灵潮模板的灵物/法器语境。`,
),
statProfile,
useProfile,
buildProfile: buildBuildProfile(role, [category, role], {
synergy: ["素材拓展", "过渡 build", "题材补完"],
}),
value: rankValue(rarity, slot, useProfile),
};
}
export function buildDesignedItemMetadata(
normalizedSourcePath: string,
baseName: string,
baseCategory: string,
baseRarity: ItemRarity,
baseTags: string[],
labels: ItemCategoryLabels,
): DesignedItemMetadata {
const specialized =
buildLegacyDesign(normalizedSourcePath, baseName, baseCategory, baseRarity, baseTags, labels) ??
buildArmoryDesign(normalizedSourcePath, labels) ??
buildJewelryDesign(normalizedSourcePath, labels) ??
buildPotionDesign(normalizedSourcePath, labels) ??
buildGemDesign(normalizedSourcePath, labels) ??
buildSkillRelicDesign(normalizedSourcePath, labels);
if (specialized) {
return {
...specialized,
tags: dedupe([...(specialized.tags ?? []), ...baseTags]),
};
}
const fallback = buildUtilityDesign(normalizedSourcePath, labels);
return {
...fallback,
name: fallback.name || baseName,
category: fallback.category || baseCategory,
rarity: fallback.rarity || baseRarity,
tags: dedupe([...(fallback.tags ?? []), ...baseTags]),
};
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,105 @@
import type {InventoryItem} from '../types';
import {getBuildTagDefinition} from './buildTags';
import type {InventoryUseEffect} from './inventoryEffects';
const STRUCTURAL_TAG_LABELS: Record<string, string> = {
weapon: '武器',
armor: '护甲',
relic: '遗物',
material: '材料',
consumable: '消耗品',
healing: '疗伤',
mana: '法力',
rare: '稀有',
wuxia: '边城模板',
xianxia: '灵潮模板',
neutral: '中性',
};
function dedupeStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
}
function buildEffectSummaryParts(
item: InventoryItem,
useEffect: InventoryUseEffect | null,
) {
if (useEffect) {
return [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
: null,
useEffect.buildBuffs.length > 0
? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}`
: null,
];
}
return [
item.tags.includes('healing') ? '在冒险中恢复生命值' : null,
item.tags.includes('mana') ? '帮助回转灵力与技能节奏' : null,
item.tags.includes('weapon') ? '适合进攻型构筑' : null,
item.tags.includes('armor') ? '适合防御型构筑' : null,
item.tags.includes('relic') ? '可作为稀有遗物长期携带' : null,
item.tags.includes('material') ? '可用于制作、锻造或交换' : null,
];
}
export function getInventoryTagLabel(tag: string) {
const normalized = tag.trim();
if (!normalized) return '';
const buildTag = getBuildTagDefinition(normalized);
if (buildTag) {
return buildTag.label;
}
return STRUCTURAL_TAG_LABELS[normalized.toLowerCase()] ?? normalized;
}
export function getInventoryTagLabels(tags: string[]) {
return dedupeStrings(tags.map(getInventoryTagLabel));
}
export function buildInventoryItemDescription(
item: InventoryItem,
useEffect: InventoryUseEffect | null = null,
) {
if (item.description?.trim()) return item.description.trim();
const storyFingerprint = item.runtimeMetadata?.storyFingerprint;
if (storyFingerprint) {
return [
storyFingerprint.visibleClue,
`${storyFingerprint.witnessMark} ${storyFingerprint.unresolvedQuestion}`,
`它会在此刻出现,是因为${storyFingerprint.currentAppearanceReason}`,
].join(' ');
}
const parts = buildEffectSummaryParts(item, useEffect).filter(
(part): part is string => Boolean(part),
);
if (parts.length > 0) {
return `${item.name} 当前可提供这些作用:${parts.join('')}`;
}
switch (item.category) {
case '武器':
return `${item.name} 更适合作为当前战利品中的主战装备,后续可用于替换现有武器或继续锻造。`;
case '护甲':
return `${item.name} 适合用来补足防护与承伤能力,也可留作后续重铸素材。`;
case '饰品':
case '稀有品':
case '专属品':
case '专属物':
case '专属物品':
return `${item.name} 更偏向长期携带与构筑补强,也可能牵出额外线索。`;
case '材料':
return `${item.name} 可用于制作、锻造、交易,或为后续掉落组合做准备。`;
default:
return `${item.name} 可用于后续路线规划、交易或构筑调整。`;
}
}

View File

@@ -0,0 +1,830 @@
import { type CustomWorldNpcVisual, type CustomWorldNpcVisualGear, Encounter } from '../types';
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
import npcVisualOverridesJson from './npcVisualOverrides.json';
export type MedievalRace = 'human' | 'elf' | 'orc' | 'goblin';
export type MedievalAtlasSourceType = 'cloth' | 'leather' | 'metal' | 'melee' | 'magic' | 'ranged';
export type MedievalAtlasUsage = 'headgear' | 'mainHand' | 'offHand';
export interface AtlasTileSpec {
src: string;
frameIndex: number;
columns: number;
tileWidth?: number;
tileHeight?: number;
renderOffsetX?: number;
renderOffsetY?: number;
}
export interface MedievalNpcVisualSpec {
race: MedievalRace;
bodySrc: string;
headSrc: string;
hairSrc: string;
handSrc: string;
facialHairSrc?: string;
headgear?: AtlasTileSpec;
mainHand?: AtlasTileSpec;
offHand?: AtlasTileSpec;
bodyFrames: number[];
headFrame: number;
hairFrame: number;
handFrame: number;
facialHairFrame?: number;
}
export type MedievalNpcVisualOverride = Partial<MedievalNpcVisualSpec> & {
race?: MedievalRace;
};
export interface MedievalAtlasAssetDefinition {
file: string;
label: string;
src: string;
columns: number;
frameCount: number;
tileWidth: number;
tileHeight: number;
}
export interface MedievalPoseOption {
value: number;
label: string;
}
type NpcRoleStyle = 'warrior' | 'guardian' | 'ranger' | 'mystic' | 'civilian' | 'rogue' | 'bruiser';
const BODY_COLORS = [
'black',
'blue',
'brown',
'gold',
'green',
'grey',
'orange',
'pink',
'purple',
'red',
'silver',
'yellow',
] as const;
const RACE_SPRITE_COUNTS: Record<MedievalRace, { head: number; hair: number; facialHair: number }> = {
human: { head: 7, hair: 8, facialHair: 8 },
elf: { head: 8, hair: 8, facialHair: 8 },
orc: { head: 4, hair: 8, facialHair: 8 },
goblin: { head: 4, hair: 8, facialHair: 8 },
};
const HEAD_TONE_LABELS_BY_RACE: Record<MedievalRace, string[]> = {
human: ['象牙肤', '暖米肤', '小麦肤', '日晒肤', '古铜肤', '栗棕肤', '冷棕肤'],
elf: ['月白肤', '晨光肤', '青杏肤', '薄金肤', '雾灰肤', '玫瑰肤', '银青肤', '古木肤'],
orc: ['浅橄榄肤', '深橄榄肤', '岩绿色', '灰褐绿肤'],
goblin: ['苔绿肤', '黄绿肤', '灰绿肤', '泥褐肤'],
};
const CLOTH_HAT_ASSETS = {
'hat_black.png': createAtlasAsset('cloth', 'hat_black.png', '黑布便帽', 10, 49, 32, 32),
'hat_blue.png': createAtlasAsset('cloth', 'hat_blue.png', '靛蓝便帽', 10, 49, 32, 32),
'hat_green.png': createAtlasAsset('cloth', 'hat_green.png', '苔绿便帽', 10, 49, 32, 32),
'hat_orange.png': createAtlasAsset('cloth', 'hat_orange.png', '赭橙便帽', 10, 49, 32, 32),
'hat_pink.png': createAtlasAsset('cloth', 'hat_pink.png', '胭粉便帽', 10, 49, 32, 32),
'hat_purple.png': createAtlasAsset('cloth', 'hat_purple.png', '紫布便帽', 10, 49, 32, 32),
'hat_red.png': createAtlasAsset('cloth', 'hat_red.png', '赤布便帽', 10, 49, 32, 32),
'hat_straw.png': createAtlasAsset('cloth', 'hat_straw.png', '草编宽檐帽', 5, 5, 32, 32),
'hat_yellow.png': createAtlasAsset('cloth', 'hat_yellow.png', '土黄便帽', 10, 49, 32, 32),
} as const;
const LEATHER_ASSETS = {
'leather01.png': createAtlasAsset('leather', 'leather01.png', '轻皮头帽', 10, 37, 32, 32),
'leather02.png': createAtlasAsset('leather', 'leather02.png', '束带皮盔', 4, 4, 32, 48),
} as const;
const METAL_ASSETS = {
'metal.png': createAtlasAsset('metal', 'metal.png', '铁面头盔', 10, 47, 32, 32),
'metal_black.png': createAtlasAsset('metal', 'metal_black.png', '黑钢重盔', 10, 23, 32, 48),
'metal_blue.png': createAtlasAsset('metal', 'metal_blue.png', '蓝钢重盔', 10, 23, 32, 48),
'metal_green.png': createAtlasAsset('metal', 'metal_green.png', '青钢重盔', 10, 23, 32, 48),
'metal_orange.png': createAtlasAsset('metal', 'metal_orange.png', '铜色重盔', 10, 23, 32, 48),
'metal_pink.png': createAtlasAsset('metal', 'metal_pink.png', '粉漆重盔', 10, 23, 32, 48),
'metal_purple.png': createAtlasAsset('metal', 'metal_purple.png', '紫钢重盔', 10, 23, 32, 48),
'metal_red.png': createAtlasAsset('metal', 'metal_red.png', '赤钢重盔', 10, 23, 32, 48),
'metal_yellow.png': createAtlasAsset('metal', 'metal_yellow.png', '金黄重盔', 10, 23, 32, 48),
} as const;
const MELEE_ASSETS = {
'axe.png': createAtlasAsset('melee', 'axe.png', '单手战斧', 10, 19, 32, 32),
'axe_big.png': createAtlasAsset('melee', 'axe_big.png', '巨刃战斧', 5, 5, 32, 48),
'blunt.png': createAtlasAsset('melee', 'blunt.png', '钉头战锤', 10, 19, 32, 32),
'dagger.png': createAtlasAsset('melee', 'dagger.png', '短匕首', 7, 14, 32, 32),
'polearm.png': createAtlasAsset('melee', 'polearm.png', '长柄武器', 12, 35, 32, 64),
'shield.png': createAtlasAsset('melee', 'shield.png', '圆盾', 10, 56, 32, 32),
'sword.png': createAtlasAsset('melee', 'sword.png', '骑士长剑', 7, 13, 32, 32),
'sword_big.png': createAtlasAsset('melee', 'sword_big.png', '阔身巨剑', 10, 20, 32, 48),
} as const;
const MAGIC_ASSETS = {
'staff.png': createAtlasAsset('magic', 'staff.png', '长法杖', 13, 25, 32, 64),
'wand.png': createAtlasAsset('magic', 'wand.png', '短魔杖', 6, 12, 32, 32),
} as const;
const RANGED_ASSETS = {
'arquebus_shot.png': createAtlasAsset('ranged', 'arquebus_shot.png', '火绳枪射击组', 4, 8, 64, 32),
'blunderbuss.png': createAtlasAsset('ranged', 'blunderbuss.png', '喇叭火枪', 5, 10, 64, 32),
'bow.png': createAtlasAsset('ranged', 'bow.png', '短弓', 7, 7, 32, 32),
'bow_shot.png': createAtlasAsset('ranged', 'bow_shot.png', '短弓满弦组', 12, 84, 64, 32),
'crossbow.png': createAtlasAsset('ranged', 'crossbow.png', '十字弩', 4, 4, 32, 32),
'crossbow_shot.png': createAtlasAsset('ranged', 'crossbow_shot.png', '十字弩发射组', 17, 68, 32, 32),
'musket.png': createAtlasAsset('ranged', 'musket.png', '长火枪', 5, 10, 64, 32),
'pistol.png': createAtlasAsset('ranged', 'pistol.png', '单手火枪', 4, 24, 32, 32),
'repeater_musket.png': createAtlasAsset('ranged', 'repeater_musket.png', '连发火枪', 4, 8, 64, 32),
'sling.png': createAtlasAsset('ranged', 'sling.png', '投石索', 10, 20, 64, 64),
'stick_sling.png': createAtlasAsset('ranged', 'stick_sling.png', '杖式投石索', 11, 21, 64, 64),
} as const;
const ATLAS_ASSET_MAPS = {
cloth: CLOTH_HAT_ASSETS,
leather: LEATHER_ASSETS,
metal: METAL_ASSETS,
melee: MELEE_ASSETS,
magic: MAGIC_ASSETS,
ranged: RANGED_ASSETS,
} satisfies Record<MedievalAtlasSourceType, Record<string, MedievalAtlasAssetDefinition>>;
const HEADGEAR_POSE_OPTIONS: MedievalPoseOption[] = [
{ value: 0, label: '正戴平视' },
{ value: 1, label: '低头压檐' },
{ value: 2, label: '抬头回正' },
{ value: 3, label: '侧肩偏戴' },
{ value: 10, label: '行进稳戴' },
{ value: 11, label: '行进压檐' },
{ value: 20, label: '疾行前压' },
{ value: 30, label: '跃起扬帽' },
{ value: 40, label: '骑乘稳戴' },
];
const MAIN_HAND_POSE_OPTIONS: MedievalPoseOption[] = [
{ value: 0, label: '垂手持握' },
{ value: 1, label: '斜举备战' },
{ value: 2, label: '横持压迫' },
{ value: 3, label: '高举蓄力' },
{ value: 4, label: '前伸突进' },
{ value: 5, label: '回收护身' },
{ value: 6, label: '终势回摆' },
{ value: 10, label: '行进持握' },
{ value: 11, label: '行进前压' },
{ value: 20, label: '冲刺挥击' },
{ value: 30, label: '腾空猛挥' },
{ value: 40, label: '高位压制' },
];
const OFF_HAND_POSE_OPTIONS: MedievalPoseOption[] = [
{ value: 0, label: '垂手副持' },
{ value: 1, label: '贴身护侧' },
{ value: 2, label: '前探协防' },
{ value: 3, label: '抬臂护肩' },
{ value: 4, label: '低位挡格' },
{ value: 5, label: '回收守势' },
{ value: 10, label: '行进协防' },
{ value: 20, label: '冲刺护身' },
{ value: 30, label: '跃起护面' },
];
const SHIELD_POSE_OPTIONS: MedievalPoseOption[] = [
{ value: 0, label: '垂盾待机' },
{ value: 1, label: '侧盾待命' },
{ value: 40, label: '正面举盾' },
{ value: 41, label: '侧身护胸' },
{ value: 42, label: '前架格挡' },
{ value: 43, label: '抬盾压进' },
{ value: 44, label: '护头防守' },
{ value: 45, label: '回盾收势' },
{ value: 50, label: '骑乘举盾' },
];
const NPC_VISUAL_OVERRIDES = npcVisualOverridesJson as Record<string, MedievalNpcVisualOverride>;
export const MEDIEVAL_BODY_COLORS = [...BODY_COLORS];
export const MEDIEVAL_BODY_COLOR_LABELS: Record<string, string> = {
black: '墨黑布袍',
blue: '深蓝布袍',
brown: '棕褐布袍',
gold: '暗金布袍',
green: '苔绿布袍',
grey: '灰布短衣',
orange: '赭橙短衣',
pink: '旧粉短衣',
purple: '暗紫长衣',
red: '深红长衣',
silver: '银灰短衣',
yellow: '土黄布袍',
};
export const MEDIEVAL_RACE_LABELS: Record<MedievalRace, string> = {
human: '人类',
elf: '精灵',
orc: '兽人',
goblin: '地精',
};
export const MEDIEVAL_HEAD_LABELS_BY_RACE = HEAD_TONE_LABELS_BY_RACE;
export const MEDIEVAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰'];
export const MEDIEVAL_FACIAL_HAIR_COLOR_LABELS: string[] = ['乌黑', '浅金', '蓝黑', '浅棕', '银白', '赤铜', '深紫', '苍灰'];
export const MEDIEVAL_HAIR_STYLE_LABELS: string[] = [
'后梳短发',
'偏分短发',
'额前碎发',
'束起短尾',
'厚刘海',
'蓬松短发',
'短冠发束',
'前额碎发',
'披肩短发',
'两侧内卷',
'中分长发',
'偏分垂发',
'侧束长发',
'齐耳短发',
'小辫短发',
'圆顶短发',
'发带束发',
'低束长发',
'角状长发',
'后坠卷发',
'碎卷长发',
'盘起短发',
'散落短卷',
'高束马尾',
'披散长发',
'双层短发',
'弧形短刘海',
'长辫后束',
'半束长发',
'短束碎发',
];
export const MEDIEVAL_FACIAL_HAIR_STYLE_LABELS: string[] = [
'短尖胡',
'细短髭',
'八字胡',
'唇上短须',
'弯弧胡',
'短山羊胡',
'络腮短须',
'短圆胡',
'下巴细须',
'下颌短须',
'方形短胡',
'卷尾胡',
'下颌垂须',
'方口胡',
'尖长胡',
'细卷髭',
'弯月胡',
'短髯须',
'厚嘴髭',
'锥形胡',
'大八字胡',
'窄下巴须',
'短络腮',
'双尖胡',
'长下巴胡',
'卷尖胡',
'长弯胡',
'翼状胡',
'细长胡',
'短胡尾',
];
export const MEDIEVAL_CLOTH_HATS = Object.keys(CLOTH_HAT_ASSETS);
export const MEDIEVAL_LEATHER_GEAR = Object.keys(LEATHER_ASSETS);
export const MEDIEVAL_METAL_GEAR = Object.keys(METAL_ASSETS);
export const MEDIEVAL_MELEE_WEAPONS = Object.keys(MELEE_ASSETS);
export const MEDIEVAL_MAGIC_WEAPONS = Object.keys(MAGIC_ASSETS);
export const MEDIEVAL_RANGED_WEAPONS = Object.keys(RANGED_ASSETS);
export const MEDIEVAL_CLOTH_HAT_LABELS = toLabelMap(CLOTH_HAT_ASSETS);
export const MEDIEVAL_LEATHER_GEAR_LABELS = toLabelMap(LEATHER_ASSETS);
export const MEDIEVAL_METAL_GEAR_LABELS = toLabelMap(METAL_ASSETS);
export const MEDIEVAL_MELEE_WEAPON_LABELS = toLabelMap(MELEE_ASSETS);
export const MEDIEVAL_MAGIC_WEAPON_LABELS = toLabelMap(MAGIC_ASSETS);
export const MEDIEVAL_RANGED_WEAPON_LABELS = toLabelMap(RANGED_ASSETS);
export function getRaceSpriteCounts(race: MedievalRace) {
return RACE_SPRITE_COUNTS[race];
}
export function getMedievalHeadOptions(race: MedievalRace): Array<{ value: number; label: string }> {
return HEAD_TONE_LABELS_BY_RACE[race].map((label, index) => ({
value: index + 1,
label,
}));
}
export function getMedievalAtlasAsset(type: MedievalAtlasSourceType, file: string) {
const assetMap = ATLAS_ASSET_MAPS[type] as Record<string, MedievalAtlasAssetDefinition>;
return assetMap[file] ?? null;
}
export function getMedievalAtlasOptions(type: MedievalAtlasSourceType) {
return Object.values(ATLAS_ASSET_MAPS[type]);
}
export function getMedievalPoseOptions(
type: MedievalAtlasSourceType,
file: string,
usage: MedievalAtlasUsage,
): MedievalPoseOption[] {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) return [];
const baseOptions = usage === 'offHand' && file === 'shield.png'
? SHIELD_POSE_OPTIONS
: usage === 'headgear'
? HEADGEAR_POSE_OPTIONS
: usage === 'mainHand'
? MAIN_HAND_POSE_OPTIONS
: OFF_HAND_POSE_OPTIONS;
const filtered = baseOptions.filter(option => option.value < asset.frameCount);
if (filtered.length > 0) {
return filtered;
}
return buildFallbackPoseOptions(asset.frameCount, usage);
}
export function clampMedievalAtlasFrame(type: MedievalAtlasSourceType, file: string, frameIndex: number) {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) return frameIndex;
return Math.max(0, Math.min(frameIndex, asset.frameCount - 1));
}
export function buildClothHatPath(file: string) {
return CLOTH_HAT_ASSETS[file as keyof typeof CLOTH_HAT_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`;
}
export function buildLeatherGearPath(file: string) {
return LEATHER_ASSETS[file as keyof typeof LEATHER_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`;
}
export function buildMetalGearPath(file: string) {
return METAL_ASSETS[file as keyof typeof METAL_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`;
}
export function buildMeleeWeaponPath(file: string) {
return MELEE_ASSETS[file as keyof typeof MELEE_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`;
}
export function buildMagicWeaponPath(file: string) {
return MAGIC_ASSETS[file as keyof typeof MAGIC_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`;
}
export function buildRangedWeaponPath(file: string) {
return RANGED_ASSETS[file as keyof typeof RANGED_ASSETS]?.src
?? `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`;
}
export function buildMedievalAtlasSpec(
type: MedievalAtlasSourceType,
file: string,
frameIndex: number,
): AtlasTileSpec | undefined {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) return undefined;
return {
src: asset.src,
frameIndex: clampMedievalAtlasFrame(type, file, frameIndex),
columns: asset.columns,
tileWidth: asset.tileWidth,
tileHeight: asset.tileHeight,
};
}
function inferAtlasSourceType(src: string | undefined): MedievalAtlasSourceType | null {
if (!src) return null;
if (src.includes('/wardrobe/cloth/')) return 'cloth';
if (src.includes('/wardrobe/leather/')) return 'leather';
if (src.includes('/wardrobe/metal/')) return 'metal';
if (src.includes('/weapons/melee weapons/')) return 'melee';
if (src.includes('/weapons/magic weapons/')) return 'magic';
if (src.includes('/weapons/ranged weapons/')) return 'ranged';
return null;
}
function sanitizeCustomWorldNpcVisualGear(
gear: CustomWorldNpcVisualGear | null | undefined,
usage: MedievalAtlasUsage,
): CustomWorldNpcVisualGear | null {
if (!gear?.file) return null;
const poseOptions = getMedievalPoseOptions(gear.type, gear.file, usage);
if (poseOptions.length === 0) {
return {
...gear,
frameIndex: clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex),
};
}
const frameIndex = poseOptions.some(option => option.value === gear.frameIndex)
? clampMedievalAtlasFrame(gear.type, gear.file, gear.frameIndex)
: poseOptions[0]!.value;
return {
...gear,
frameIndex,
};
}
function parseCustomWorldNpcVisualGear(
spec: AtlasTileSpec | undefined,
usage: MedievalAtlasUsage,
): CustomWorldNpcVisualGear | null {
const type = inferAtlasSourceType(spec?.src);
const file = spec?.src.split('/').pop();
if (!type || !file) {
return null;
}
return sanitizeCustomWorldNpcVisualGear(
{
type,
file,
frameIndex: spec?.frameIndex ?? 0,
},
usage,
);
}
export function sanitizeCustomWorldNpcVisual(visual: CustomWorldNpcVisual): CustomWorldNpcVisual {
const spriteCounts = RACE_SPRITE_COUNTS[visual.race];
const bodyColor = BODY_COLORS.includes(visual.bodyColor as (typeof BODY_COLORS)[number])
? visual.bodyColor
: BODY_COLORS[0];
return {
race: visual.race,
bodyColor,
headIndex: Math.max(1, Math.min(visual.headIndex, spriteCounts.head)),
hairColorIndex: Math.max(1, Math.min(visual.hairColorIndex, spriteCounts.hair)),
hairStyleFrame: Math.max(0, Math.min(visual.hairStyleFrame, MEDIEVAL_HAIR_STYLE_LABELS.length - 1)),
facialHairEnabled: visual.facialHairEnabled,
facialHairColorIndex: Math.max(1, Math.min(visual.facialHairColorIndex, spriteCounts.facialHair)),
facialHairStyleFrame: Math.max(0, Math.min(visual.facialHairStyleFrame, MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.length - 1)),
headgear: sanitizeCustomWorldNpcVisualGear(visual.headgear, 'headgear'),
mainHand: sanitizeCustomWorldNpcVisualGear(visual.mainHand, 'mainHand'),
offHand: sanitizeCustomWorldNpcVisualGear(visual.offHand, 'offHand'),
};
}
export function parseCustomWorldNpcVisualFromSpec(spec: MedievalNpcVisualSpec): CustomWorldNpcVisual {
const visual = {
race: spec.race,
bodyColor: spec.bodySrc.match(/body_(.+)\.png$/u)?.[1] ?? BODY_COLORS[0],
headIndex: Number(spec.headSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
hairColorIndex: Number(spec.hairSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
hairStyleFrame: spec.hairFrame ?? 0,
facialHairEnabled: Boolean(spec.facialHairSrc),
facialHairColorIndex: Number(spec.facialHairSrc?.match(/_(\d+)\.png$/u)?.[1] ?? '1'),
facialHairStyleFrame: spec.facialHairFrame ?? 0,
headgear: parseCustomWorldNpcVisualGear(spec.headgear, 'headgear'),
mainHand: parseCustomWorldNpcVisualGear(spec.mainHand, 'mainHand'),
offHand: parseCustomWorldNpcVisualGear(spec.offHand, 'offHand'),
} satisfies CustomWorldNpcVisual;
return sanitizeCustomWorldNpcVisual(visual);
}
export function buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual: CustomWorldNpcVisual): MedievalNpcVisualOverride {
const sanitizedVisual = sanitizeCustomWorldNpcVisual(visual);
const bodyColor = BODY_COLORS.includes(sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number])
? sanitizedVisual.bodyColor as (typeof BODY_COLORS)[number]
: BODY_COLORS[0];
return {
race: sanitizedVisual.race,
bodySrc: buildBodyPath(bodyColor),
headSrc: buildRaceAssetPath(sanitizedVisual.race, 'head', sanitizedVisual.headIndex),
hairSrc: buildRaceAssetPath(sanitizedVisual.race, 'hair', sanitizedVisual.hairColorIndex),
handSrc: buildRaceAssetPath(sanitizedVisual.race, 'hand', 1),
facialHairSrc: sanitizedVisual.facialHairEnabled
? buildRaceAssetPath(sanitizedVisual.race, 'facialHair', sanitizedVisual.facialHairColorIndex)
: undefined,
headgear: sanitizedVisual.headgear
? buildMedievalAtlasSpec(sanitizedVisual.headgear.type, sanitizedVisual.headgear.file, sanitizedVisual.headgear.frameIndex)
: undefined,
mainHand: sanitizedVisual.mainHand
? buildMedievalAtlasSpec(sanitizedVisual.mainHand.type, sanitizedVisual.mainHand.file, sanitizedVisual.mainHand.frameIndex)
: undefined,
offHand: sanitizedVisual.offHand
? buildMedievalAtlasSpec(sanitizedVisual.offHand.type, sanitizedVisual.offHand.file, sanitizedVisual.offHand.frameIndex)
: undefined,
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: sanitizedVisual.hairStyleFrame,
handFrame: 0,
facialHairFrame: sanitizedVisual.facialHairEnabled ? sanitizedVisual.facialHairStyleFrame : undefined,
};
}
export function buildMedievalNpcVisualFromCustomWorldVisual(
visual: CustomWorldNpcVisual,
): MedievalNpcVisualSpec {
const override = buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual);
const race = override.race ?? 'human';
return {
race,
bodySrc: override.bodySrc ?? buildBodyPath('black'),
headSrc: override.headSrc ?? buildRaceAssetPath(race, 'head', 1),
hairSrc: override.hairSrc ?? buildRaceAssetPath(race, 'hair', 1),
handSrc: override.handSrc ?? buildRaceAssetPath(race, 'hand', 1),
facialHairSrc: override.facialHairSrc,
headgear: override.headgear,
mainHand: override.mainHand,
offHand: override.offHand,
bodyFrames: override.bodyFrames ?? [0, 1, 2, 3],
headFrame: override.headFrame ?? 0,
hairFrame: override.hairFrame ?? 0,
handFrame: override.handFrame ?? 0,
facialHairFrame: override.facialHairFrame,
};
}
export function getNpcVisualOverrideById(overrideId: string) {
return NPC_VISUAL_OVERRIDES[overrideId] ?? null;
}
function getRuntimeCustomWorldNpcOverride(encounter: Encounter) {
if (!encounter.id) return null;
const runtimeProfile = getRuntimeCustomWorldProfile();
const storyNpc = runtimeProfile?.storyNpcs.find(npc => npc.id === encounter.id);
if (!storyNpc?.visual) {
return null;
}
return buildMedievalNpcVisualOverrideFromCustomWorldVisual(storyNpc.visual);
}
function createAtlasAsset(
type: MedievalAtlasSourceType,
file: string,
label: string,
columns: number,
frameCount: number,
tileWidth: number,
tileHeight: number,
): MedievalAtlasAssetDefinition {
return {
file,
label,
src: buildAtlasAssetPath(type, file),
columns,
frameCount,
tileWidth,
tileHeight,
};
}
function buildAtlasAssetPath(type: MedievalAtlasSourceType, file: string) {
if (type === 'cloth') return `/character/MedievalFantasyCharacters/sprites/wardrobe/cloth/${file}`;
if (type === 'leather') return `/character/MedievalFantasyCharacters/sprites/wardrobe/leather/${file}`;
if (type === 'metal') return `/character/MedievalFantasyCharacters/sprites/wardrobe/metal/${file}`;
if (type === 'melee') return `/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/${file}`;
if (type === 'magic') return `/character/MedievalFantasyCharacters/sprites/weapons/magic weapons/${file}`;
return `/character/MedievalFantasyCharacters/sprites/weapons/ranged weapons/${file}`;
}
function toLabelMap(definitions: Record<string, MedievalAtlasAssetDefinition>) {
return Object.fromEntries(
Object.values(definitions).map(definition => [definition.file, definition.label]),
) as Record<string, string>;
}
function buildFallbackPoseOptions(frameCount: number, usage: MedievalAtlasUsage): MedievalPoseOption[] {
const labelsByUsage: Record<MedievalAtlasUsage, string[]> = {
headgear: ['平视佩戴', '轻压帽檐', '低头稳帽', '抬头回正', '侧身偏戴', '前行稳帽', '快步压檐', '跃起扬帽'],
mainHand: ['基础持握', '低位收势', '中位平举', '高位抬手', '前伸出手', '横持压迫', '回收护身', '终势停稳'],
offHand: ['副手待命', '贴身护侧', '前探协防', '抬臂护肩', '低位挡格', '回收守势', '前行护身', '高位封挡'],
};
return Array.from({ length: frameCount }, (_, index) => ({
value: index,
label: labelsByUsage[usage][index] ?? labelsByUsage[usage][labelsByUsage[usage].length - 1] ?? `${usage}-${index}`,
}));
}
function hashString(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function pickFromArray<T>(items: readonly T[], seed: number, salt: number): T {
if (items.length === 0) {
throw new Error('Cannot pick from an empty array.');
}
const picked = items[(seed + salt) % items.length];
const fallbackItem = items[0];
if (fallbackItem === undefined) {
throw new Error('Expected a fallback item.');
}
return picked ?? fallbackItem;
}
function pickPoseFrame(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage, seed: number, salt: number) {
const poseOptions = getMedievalPoseOptions(type, file, usage);
if (poseOptions.length === 0) return 0;
return pickFromArray(poseOptions, seed, salt).value;
}
export function buildRaceAssetPath(race: MedievalRace, section: 'head' | 'hair' | 'facialHair' | 'hand', index: number) {
const base = '/character/MedievalFantasyCharacters/sprites/Characters';
if (section === 'head') {
return `${base}/${race}/head/${race}_head_skin_${index}.png`;
}
if (section === 'hair') {
return `${base}/${race}/hair/hairstyle/${race}_hair_${index}.png`;
}
if (section === 'facialHair') {
return `${base}/${race}/hair/facial hair/${race}_facialhair_${index}.png`;
}
return `${base}/${race}/hand/${race}_hand.png`;
}
export function buildBodyPath(color: (typeof BODY_COLORS)[number]) {
return `/character/MedievalFantasyCharacters/sprites/Characters/body/body_${color}.png`;
}
function buildHeadgear(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
if (roleStyle === 'civilian') {
const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 7);
return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 17));
}
if (roleStyle === 'rogue') {
const file = pickFromArray(MEDIEVAL_LEATHER_GEAR, seed, 11);
return buildMedievalAtlasSpec('leather', file, pickPoseFrame('leather', file, 'headgear', seed, 19));
}
if (roleStyle === 'warrior' || roleStyle === 'guardian') {
const file = pickFromArray(MEDIEVAL_METAL_GEAR, seed, 13);
return buildMedievalAtlasSpec('metal', file, pickPoseFrame('metal', file, 'headgear', seed, 23));
}
if (roleStyle === 'mystic') {
const file = pickFromArray(MEDIEVAL_CLOTH_HATS, seed, 17);
return buildMedievalAtlasSpec('cloth', file, pickPoseFrame('cloth', file, 'headgear', seed, 29));
}
return undefined;
}
function buildMainHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
if (roleStyle === 'mystic') {
const file = pickFromArray(MEDIEVAL_MAGIC_WEAPONS, seed, 23);
return buildMedievalAtlasSpec('magic', file, pickPoseFrame('magic', file, 'mainHand', seed, 31));
}
if (roleStyle === 'ranger') {
const preferred = ['bow.png', 'crossbow.png', 'sling.png'] as const;
const file = pickFromArray(preferred, seed, 29);
return buildMedievalAtlasSpec('ranged', file, pickPoseFrame('ranged', file, 'mainHand', seed, 37));
}
if (roleStyle === 'guardian') {
return buildMedievalAtlasSpec('melee', 'polearm.png', pickPoseFrame('melee', 'polearm.png', 'mainHand', seed, 41));
}
if (roleStyle === 'warrior' || roleStyle === 'rogue' || roleStyle === 'bruiser') {
const preferred = ['sword.png', 'axe.png', 'dagger.png', 'blunt.png', 'sword_big.png', 'axe_big.png'] as const;
const file = pickFromArray(preferred, seed, 31);
return buildMedievalAtlasSpec('melee', file, pickPoseFrame('melee', file, 'mainHand', seed, 43));
}
return undefined;
}
function buildOffHand(roleStyle: NpcRoleStyle, seed: number): AtlasTileSpec | undefined {
if (roleStyle !== 'guardian') {
return undefined;
}
return buildMedievalAtlasSpec('melee', 'shield.png', pickPoseFrame('melee', 'shield.png', 'offHand', seed, 47));
}
function inferRoleStyle(encounter: Encounter): NpcRoleStyle {
const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase();
if (source.includes('archer-hero') || source.includes('猎') || source.includes('弓') || source.includes('巡修')) {
return 'ranger';
}
if (source.includes('gate-disciple') || source.includes('门') || source.includes('守使') || source.includes('灵侍')) {
return 'guardian';
}
if (source.includes('sword-princess') || source.includes('守山') || source.includes('宫人') || source.includes('舵手')) {
return 'warrior';
}
if (source.includes('fighter-4') || source.includes('军需') || source.includes('铸匠') || source.includes('炼匠')) {
return 'guardian';
}
if (source.includes('punch-hero') || source.includes('矿') || source.includes('匠') || source.includes('渡工')) {
return 'bruiser';
}
if (source.includes('司录') || source.includes('学者') || source.includes('药师') || source.includes('书生') || source.includes('侍者')) {
return 'mystic';
}
if (source.includes('girl-hero') || source.includes('侍女') || source.includes('散修') || source.includes('访客') || source.includes('琴师')) {
return 'rogue';
}
return 'civilian';
}
function inferRace(encounter: Encounter, roleStyle: NpcRoleStyle, seed: number): MedievalRace {
const source = `${encounter.id ?? ''} ${encounter.characterId ?? ''} ${encounter.npcName} ${encounter.context}`.toLowerCase();
if (source.includes('archer-hero') || source.includes('精灵') || source.includes('琴师') || source.includes('云')) {
return 'elf';
}
if (source.includes('punch-hero') || source.includes('铸匠') || source.includes('矿') || source.includes('熔')) {
return 'orc';
}
if (source.includes('girl-hero') || source.includes('散修') || source.includes('访客')) {
return 'goblin';
}
if (roleStyle === 'bruiser') {
return seed % 2 === 0 ? 'orc' : 'human';
}
return 'human';
}
function shouldUseFacialHair(race: MedievalRace, roleStyle: NpcRoleStyle, seed: number) {
if (race === 'elf') return seed % 5 === 0;
if (race === 'goblin') return seed % 4 === 0;
if (roleStyle === 'civilian') return seed % 2 === 0;
return seed % 3 !== 0;
}
export function buildMedievalNpcVisual(encounter: Encounter): MedievalNpcVisualSpec {
const seed = hashString(`${encounter.id ?? encounter.npcName}:${encounter.context}:${encounter.characterId ?? ''}`);
const override = getRuntimeCustomWorldNpcOverride(encounter) ?? (encounter.id ? NPC_VISUAL_OVERRIDES[encounter.id] : undefined);
if (override) {
return {
race: override.race ?? 'human',
bodySrc: override.bodySrc ?? buildBodyPath('black'),
headSrc: override.headSrc ?? buildRaceAssetPath(override.race ?? 'human', 'head', 1),
hairSrc: override.hairSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hair', 1),
handSrc: override.handSrc ?? buildRaceAssetPath(override.race ?? 'human', 'hand', 1),
facialHairSrc: override.facialHairSrc,
headgear: override.headgear,
mainHand: override.mainHand,
offHand: override.offHand,
bodyFrames: override.bodyFrames ?? [0, 1, 2, 3],
headFrame: override.headFrame ?? 0,
hairFrame: override.hairFrame ?? 0,
handFrame: override.handFrame ?? 0,
facialHairFrame: override.facialHairFrame,
};
}
const roleStyle = inferRoleStyle(encounter);
const race = inferRace(encounter, roleStyle, seed);
const counts = RACE_SPRITE_COUNTS[race];
const bodyColor = pickFromArray(BODY_COLORS, seed, 3);
const headIndex = (seed % counts.head) + 1;
const hairIndex = ((seed >> 3) % counts.hair) + 1;
const facialHairIndex = ((seed >> 5) % counts.facialHair) + 1;
const useFacialHair = shouldUseFacialHair(race, roleStyle, seed);
return {
race,
bodySrc: buildBodyPath(bodyColor),
headSrc: buildRaceAssetPath(race, 'head', headIndex),
hairSrc: buildRaceAssetPath(race, 'hair', hairIndex),
handSrc: buildRaceAssetPath(race, 'hand', 1),
facialHairSrc: useFacialHair ? buildRaceAssetPath(race, 'facialHair', facialHairIndex) : undefined,
headgear: buildHeadgear(roleStyle, seed),
mainHand: buildMainHand(roleStyle, seed),
offHand: buildOffHand(roleStyle, seed),
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: 0,
handFrame: 0,
facialHairFrame: useFacialHair ? 0 : undefined,
};
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,387 @@
import {
type AttributeVector,
type Character,
type CustomWorldProfile,
type Encounter,
type InventoryItem,
type NpcPersistentState,
type RoleAttributeProfile,
type WorldAttributeSchema,
WorldType,
} from '../types';
import {
buildItemAttributeResonance,
buildRoleAttributeProfileFromLegacyData,
} from './attributeProfileGenerator';
import {
buildDefaultAxisVector,
getSortedAttributeEntries,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
scoreAttributeFit,
} from './attributeResolver';
import {normalizeAttributeVector} from './attributeValidation';
import {getCharacterById} from './characterPresets';
import {getRuntimeCustomWorldProfile} from './customWorldRuntime';
import {getMonsterPresetById} from './hostileNpcPresets';
export type GiftAffinityInsight = {
resonanceScore: number;
resonanceBonus: number;
matchedAttributes: string[];
reasonText: string;
};
export type ChatAffinityOutcome = {
affinityGain: number;
matchedAttributes: string[];
summary: string;
};
export type RecruitReadinessInsight = {
readinessScore: number;
resonanceBonus: number;
tabooPenalty: number;
matchedAttributes: string[];
summary: string;
};
function resolveCustomWorldProfile(profile?: CustomWorldProfile | null) {
return profile ?? getRuntimeCustomWorldProfile();
}
function resolveInteractionWorldType(
worldType?: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
encounter?: Encounter | null,
) {
if (worldType) {
return worldType;
}
if (customWorldProfile || getRuntimeCustomWorldProfile()) {
return WorldType.CUSTOM;
}
const schemaId = encounter?.attributeProfile?.schemaId ?? '';
if (schemaId.includes('xianxia')) {
return WorldType.XIANXIA;
}
return WorldType.WUXIA;
}
function buildSchemaContext(
worldType?: WorldType | null,
customWorldProfile?: CustomWorldProfile | null,
encounter?: Encounter | null,
) {
const resolvedCustomWorldProfile = resolveCustomWorldProfile(customWorldProfile);
const resolvedWorldType = resolveInteractionWorldType(
worldType,
resolvedCustomWorldProfile,
encounter,
);
return {
worldType: resolvedWorldType,
customWorldProfile: resolvedCustomWorldProfile,
schema: resolveAttributeSchema(resolvedWorldType, resolvedCustomWorldProfile),
};
}
function resolveMonsterProfile(
encounter: Encounter,
worldType: WorldType,
) {
if (!encounter.monsterPresetId) {
return null;
}
return getMonsterPresetById(worldType, encounter.monsterPresetId)?.attributeProfile
?? getMonsterPresetById(WorldType.WUXIA, encounter.monsterPresetId)?.attributeProfile
?? getMonsterPresetById(WorldType.XIANXIA, encounter.monsterPresetId)?.attributeProfile
?? getMonsterPresetById(WorldType.CUSTOM, encounter.monsterPresetId)?.attributeProfile
?? null;
}
export function resolveEncounterAttributeProfile(
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
} = {},
) {
const schemaContext = buildSchemaContext(
options.worldType,
options.customWorldProfile,
encounter,
);
if (encounter.characterId) {
const character = getCharacterById(encounter.characterId);
if (character) {
return resolveCharacterAttributeProfile(
character,
schemaContext.worldType,
schemaContext.customWorldProfile,
) ?? encounter.attributeProfile ?? null;
}
}
if (encounter.attributeProfile) {
return encounter.attributeProfile;
}
const monsterProfile = resolveMonsterProfile(encounter, schemaContext.worldType);
if (monsterProfile) {
return monsterProfile;
}
return buildRoleAttributeProfileFromLegacyData({
entityId: encounter.id ?? encounter.npcName,
schema: schemaContext.schema,
textBlocks: [
encounter.npcName,
encounter.context,
encounter.npcDescription,
],
}).profile;
}
function buildFocusVector(
profile: RoleAttributeProfile | null | undefined,
schema: WorldAttributeSchema,
limit: number,
direction: 'top' | 'bottom' = 'top',
) {
const slotIds = schema.slots.map(slot => slot.slotId);
const sourceEntries = getSortedAttributeEntries(profile, schema);
const orderedEntries = direction === 'bottom' ? [...sourceEntries].reverse() : sourceEntries;
const selectedEntries = orderedEntries.slice(0, limit);
const seed = Object.fromEntries(slotIds.map(slotId => [slotId, 0])) as AttributeVector;
selectedEntries.forEach(entry => {
seed[entry.slot.slotId] = entry.value;
});
return normalizeAttributeVector(seed, slotIds);
}
function getTopContributionAttributeNames(
profile: RoleAttributeProfile | null | undefined,
vector: AttributeVector,
schema: WorldAttributeSchema,
limit = 2,
) {
const slotIds = schema.slots.map(slot => slot.slotId);
const profileWeights = normalizeAttributeVector(profile?.values ?? {}, slotIds);
const vectorWeights = normalizeAttributeVector(vector, slotIds);
return schema.slots
.map(slot => ({
name: slot.name,
contribution: (profileWeights[slot.slotId] ?? 0) * (vectorWeights[slot.slotId] ?? 0),
}))
.sort((left, right) => right.contribution - left.contribution)
.filter(entry => entry.contribution > 0)
.slice(0, limit)
.map(entry => entry.name);
}
function uniqueNames(names: string[]) {
return [...new Set(names.filter(Boolean))];
}
function buildDialogueIntentVector(actionText: string, schema: WorldAttributeSchema) {
const slotIds = schema.slots.map(slot => slot.slotId);
const source = actionText.trim();
const seed = buildDefaultAxisVector({});
const addWeight = (slotId: string, weight: number, pattern: RegExp) => {
if (pattern.test(source)) {
seed[slotId] = (seed[slotId] ?? 0) + weight;
}
};
addWeight('axis_a', 0.24, /||||/u);
addWeight('axis_b', 0.2, /|||||/u);
addWeight('axis_c', 0.34, /||穿|||线||/u);
addWeight('axis_d', 0.3, /|||||||/u);
addWeight('axis_e', 0.34, /||||||||/u);
addWeight('axis_f', 0.22, /|||||/u);
const total = slotIds.reduce((sum, slotId) => sum + (seed[slotId] ?? 0), 0);
if (total <= 0) {
return normalizeAttributeVector(
buildDefaultAxisVector({
axis_c: 0.34,
axis_e: 0.38,
axis_f: 0.28,
}),
slotIds,
);
}
return normalizeAttributeVector(seed, slotIds);
}
export function buildEncounterAttributeRumors(
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
limit?: number;
} = {},
) {
const schemaContext = buildSchemaContext(
options.worldType,
options.customWorldProfile,
encounter,
);
const profile = resolveEncounterAttributeProfile(encounter, options);
const evidence = profile?.evidence?.slice(0, options.limit ?? 2).map(entry => entry.reason) ?? [];
if (evidence.length > 0) {
return evidence;
}
return getSortedAttributeEntries(profile, schemaContext.schema)
.slice(0, options.limit ?? 2)
.map(entry => `${entry.slot.name}${entry.slot.definition}`);
}
export function buildGiftAffinityInsight(
item: InventoryItem,
encounter: Encounter,
options: {
worldType?: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
} = {},
): GiftAffinityInsight {
const schemaContext = buildSchemaContext(
options.worldType,
options.customWorldProfile,
encounter,
);
const npcProfile = resolveEncounterAttributeProfile(encounter, options);
const attributeResonance = item.attributeResonance ?? buildItemAttributeResonance(item);
const resonanceScore = scoreAttributeFit(
npcProfile,
attributeResonance.resonanceVector,
schemaContext.schema,
);
const resonanceBonus = Math.max(0, Math.min(6, Math.round(resonanceScore * 18) - 1));
const matchedAttributes = getTopContributionAttributeNames(
npcProfile,
attributeResonance.resonanceVector,
schemaContext.schema,
);
return {
resonanceScore,
resonanceBonus,
matchedAttributes,
reasonText: matchedAttributes.length > 0
? `${item.name}更对上对方看重的${matchedAttributes.join('、')}`
: attributeResonance.explanation ?? `${item.name}带来的属性共振暂时不够明显。`,
};
}
export function buildChatAffinityOutcome(params: {
playerCharacter: Character;
encounter: Encounter;
npcState: NpcPersistentState;
actionText: string;
worldType?: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
}): ChatAffinityOutcome {
const schemaContext = buildSchemaContext(
params.worldType,
params.customWorldProfile,
params.encounter,
);
const playerProfile = resolveCharacterAttributeProfile(
params.playerCharacter,
schemaContext.worldType,
schemaContext.customWorldProfile,
);
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
worldType: schemaContext.worldType,
customWorldProfile: schemaContext.customWorldProfile,
});
const intentVector = buildDialogueIntentVector(params.actionText, schemaContext.schema);
const actorFit = scoreAttributeFit(playerProfile, intentVector, schemaContext.schema);
const targetFit = scoreAttributeFit(npcProfile, intentVector, schemaContext.schema);
const sharedFocusVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
const sharedResonance = scoreAttributeFit(playerProfile, sharedFocusVector, schemaContext.schema);
const baseGain = Math.max(4, 8 - params.npcState.chattedCount);
const bonusGain = Math.max(
0,
Math.min(4, Math.round(actorFit * 8 + targetFit * 6 + sharedResonance * 8) - 2),
);
const matchedAttributes = uniqueNames([
...getTopContributionAttributeNames(playerProfile, intentVector, schemaContext.schema),
...getTopContributionAttributeNames(npcProfile, intentVector, schemaContext.schema),
]).slice(0, 2);
return {
affinityGain: baseGain + bonusGain,
matchedAttributes,
summary: matchedAttributes.length > 0
? `你这番说法更对上了对方在${matchedAttributes.join('、')}上的判断方式。`
: '这轮交谈让对方更愿意继续观察你的来意。',
};
}
export function buildRecruitmentInsight(params: {
encounter: Encounter;
npcState: NpcPersistentState;
playerCharacter: Character;
worldType?: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
}): RecruitReadinessInsight {
const schemaContext = buildSchemaContext(
params.worldType,
params.customWorldProfile,
params.encounter,
);
const playerProfile = resolveCharacterAttributeProfile(
params.playerCharacter,
schemaContext.worldType,
schemaContext.customWorldProfile,
);
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
worldType: schemaContext.worldType,
customWorldProfile: schemaContext.customWorldProfile,
});
const preferenceVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
const tabooVector = buildFocusVector(npcProfile, schemaContext.schema, 1, 'bottom');
const resonanceScore = scoreAttributeFit(playerProfile, preferenceVector, schemaContext.schema);
const tabooScore = scoreAttributeFit(playerProfile, tabooVector, schemaContext.schema);
const resonanceBonus = Math.max(0, Math.min(8, Math.round(resonanceScore * 24) - 2));
const tabooPenalty = Math.max(0, Math.min(4, Math.round(tabooScore * 12) - 5));
const matchedAttributes = getTopContributionAttributeNames(
playerProfile,
preferenceVector,
schemaContext.schema,
);
const tabooAttributeName = getTopContributionAttributeNames(
playerProfile,
tabooVector,
schemaContext.schema,
1,
)[0];
return {
readinessScore: params.npcState.affinity + resonanceBonus - tabooPenalty,
resonanceBonus,
tabooPenalty,
matchedAttributes,
summary: matchedAttributes.length > 0
? tabooPenalty > 0 && tabooAttributeName
? `你在${matchedAttributes.join('、')}上的做派更容易被对方视为可同行,但${tabooAttributeName}这一路数仍让他保留几分。`
: `你在${matchedAttributes.join('、')}上的做派,正是对方愿意并肩同行的那类信号。`
: '对方主要还是在看你们当前积累下来的信任。',
};
}

View File

@@ -0,0 +1,306 @@
import { describe, expect, it } from 'vitest';
import type { Character, Encounter, GameState, InventoryItem } from '../types';
import { AnimationState, WorldType } from '../types';
import {
buildGiftCandidateSummary,
buildInitialNpcState,
buildNpcEncounterStoryMoment,
buildNpcHelpReward,
buildNpcTradeTransactionActionText,
syncNpcTradeInventory,
} from './npcInteractions';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createEncounter(): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
};
}
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
description: `${name} description`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
function createGameState(
encounter: Encounter,
overrides: Partial<GameState> = {},
): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-camp',
name: 'Camp',
description: 'A temporary camp.',
imageSrc: '/camp.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 80,
playerMaxHp: 100,
playerMana: 40,
playerMaxMana: 60,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
};
}
describe('npcInteractions', () => {
it('builds a readable fallback summary for empty gift candidates', () => {
expect(buildGiftCandidateSummary([])).toBe('暂无合适礼物');
});
it('includes gift candidate context in the npc gift option detail text', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [
createInventoryItem('jade-token', 'Jade Token', {
rarity: 'rare',
category: '专属',
tags: ['merchant'],
}),
createInventoryItem('tea-brick', 'Tea Brick'),
],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const giftOption = story.options.find((option) => option.functionId === 'npc_gift');
expect(giftOption).toBeTruthy();
expect(giftOption?.detailText).toContain('Jade Token');
expect(giftOption?.detailText).toContain('Tea Brick');
});
it('omits the npc gift option when the player has no gift candidates', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-1',
name: 'Camp',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.options.some((option) => option.functionId === 'npc_gift')).toBe(false);
});
it('uses ai-first copy for quest offers instead of prebuilding a fallback quest preview', () => {
const encounter = createEncounter();
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: buildInitialNpcState(encounter, WorldType.WUXIA),
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-ruins',
name: '遗迹外缘',
npcs: [],
treasureHints: ['半截封泥'],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
const questOption = story.options.find((option) => option.functionId === 'npc_quest_accept');
expect(questOption).toBeTruthy();
expect(questOption?.detailText).toContain('AI 剧情引擎');
expect(questOption?.detailText).not.toContain('完成后可获得');
});
it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => {
const encounter = createEncounter();
const hostileState = {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: -12,
};
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: hostileState,
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-pass',
name: '断桥口',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.displayMode).toBe('dialogue');
expect(story.dialogue).toEqual([
expect.objectContaining({
speaker: 'npc',
speakerName: 'Trader Lin',
}),
]);
expect(story.options.map((option) => option.functionId)).toEqual([
'battle_escape_breakout',
'npc_fight',
]);
expect(story.options.map((option) => option.actionText)).toEqual([
'逃跑',
'与他对战',
]);
});
it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter();
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'buy',
item: createInventoryItem('jade-token', 'Jade Token'),
quantity: 2,
}),
).toBe('从Trader Lin手里买下Jade Token x2');
expect(
buildNpcTradeTransactionActionText({
encounter,
mode: 'sell',
item: createInventoryItem('tea-brick', 'Tea Brick'),
quantity: 1,
}),
).toBe('把Tea Brick卖给Trader Lin');
});
it('syncs generic trade stock to the current build while preserving sold-in items', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const state = createGameState(encounter);
const syncedState = syncNpcTradeInventory(state, encounter, {
...buildInitialNpcState(encounter, WorldType.WUXIA),
inventory: [createInventoryItem('sold-tea', 'Tea Brick')],
tradeStockSignature: 'stale-build',
});
expect(syncedState.tradeStockSignature).not.toBe('stale-build');
expect(syncedState.inventory.some(item => item.id === 'sold-tea')).toBe(true);
expect(
syncedState.inventory.some(
item => item.runtimeMetadata?.generationChannel === 'npc_trade',
),
).toBe(true);
});
it('builds npc help rewards from the runtime director', () => {
const encounter: Encounter = {
...createEncounter(),
context: '商贩',
};
const reward = buildNpcHelpReward(encounter, createGameState(encounter));
expect(reward.items.length).toBeGreaterThan(0);
expect(reward.items[0]?.runtimeMetadata?.generationChannel).toBe('npc_reward');
expect(reward.storyHint).toBeTruthy();
});
});

2485
src/data/npcInteractions.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"body": {
"x": 0,
"y": 0
},
"head": {
"x": 0,
"y": 1
},
"facialHair": {
"x": 0,
"y": 1
},
"hair": {
"x": 0,
"y": 1
},
"headgear": {
"x": 0,
"y": -3
},
"hand": {
"x": -7,
"y": 7
},
"mainHand": {
"x": -8,
"y": -10
},
"offHand": {
"x": 5,
"y": 8
}
}

View File

@@ -0,0 +1,28 @@
{
"wuxia-npc-gate-disciple": {
"race": "human",
"bodySrc": "/character/MedievalFantasyCharacters/sprites/Characters/body/body_blue.png",
"headSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/head/human_head_skin_1.png",
"hairSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/hair/hairstyle/human_hair_2.png",
"handSrc": "/character/MedievalFantasyCharacters/sprites/Characters/human/hand/human_hand.png",
"headgear": {
"src": "/character/MedievalFantasyCharacters/sprites/wardrobe/metal/metal_blue.png",
"frameIndex": 15,
"columns": 10
},
"mainHand": {
"src": "/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/polearm.png",
"frameIndex": 8,
"columns": 7
},
"offHand": {
"src": "/character/MedievalFantasyCharacters/sprites/weapons/melee weapons/shield.png",
"frameIndex": 42,
"columns": 7
},
"bodyFrames": [0, 1, 2, 3],
"headFrame": 0,
"hairFrame": 0,
"handFrame": 0
}
}

View 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),
};
}

275
src/data/questFlow.test.ts Normal file
View File

@@ -0,0 +1,275 @@
import { describe, expect, it } from 'vitest';
import type { QuestLogEntry, QuestStep, ScenePresetInfo } from '../types';
import { WorldType } from '../types';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
buildChapterQuestForScene,
buildQuestForEncounter,
isQuestReadyToClaim,
normalizeQuestLogEntries,
} from './questFlow';
const TEST_SCENE = {
id: 'forest_path',
name: 'Forest Path',
description: 'A narrow trail with fresh claw marks.',
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' | 'npcs' | 'treasureHints'
>;
const CHAPTER_SCENE = {
id: 'palace_court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'npc-maid',
name: '旧宫侍女',
description: '她总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court',
name: '宫苑内庭',
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
npcs: [
{
id: 'wuxia-npc-maid',
name: '旧宫侍女',
description: '嘴上说得少,却总知道哪条回廊最近不该过去。',
avatar: '侍',
role: '宫人',
initialAffinity: 8,
hostile: false,
},
{
id: 'hostile-guard',
name: '旧宫戍影',
description: '巡行在回廊深处的敌影。',
avatar: '戍',
role: '敌对角色',
monsterPresetId: 'monster-11',
initialAffinity: -40,
hostile: true,
},
],
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);
expect(step).toBeTruthy();
return step!;
}
describe('questFlow', () => {
it('builds a staged quest contract for an encounter preview', () => {
const quest = buildQuestForEncounter({
issuerNpcId: 'npc_scout',
issuerNpcName: 'Scout Lin',
roleText: 'tracker',
scene: TEST_SCENE,
worldType: WorldType.WUXIA,
currentQuests: [],
});
expect(quest).toBeTruthy();
expect(quest?.steps).toHaveLength(2);
expect(quest?.objective.kind).toBe('defeat_hostile_npc');
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.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', () => {
const quest = buildQuestForEncounter({
issuerNpcId: 'npc_scout',
issuerNpcName: 'Scout Lin',
roleText: 'tracker',
scene: TEST_SCENE,
worldType: WorldType.WUXIA,
currentQuests: [],
});
expect(quest).toBeTruthy();
const afterBattle = applyQuestProgressFromHostileNpcDefeat(
[quest!],
TEST_SCENE.id,
['wolf_alpha'],
)[0];
expect(afterBattle?.objective.kind).toBe('talk_to_npc');
expect(afterBattle?.status).toBe('active');
const afterReport = applyQuestProgressFromNpcTalk(
[afterBattle!],
'npc_scout',
)[0];
expect(afterReport?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterReport!)).toBe(true);
});
it('normalizes legacy single-objective quests into step-aware entries', () => {
const normalized = normalizeQuestLogEntries([
{
id: 'legacy',
issuerNpcId: 'npc_scout',
issuerNpcName: 'Scout Lin',
sceneId: TEST_SCENE.id,
title: 'Legacy Quest',
description: 'Legacy description',
summary: 'Legacy summary',
objective: {
kind: 'inspect_treasure',
targetSceneId: TEST_SCENE.id,
requiredCount: 1,
},
progress: 1,
status: 'completed',
completionNotified: false,
reward: {
affinityBonus: 10,
currency: 20,
experience: 0,
items: [],
},
rewardText: 'Legacy reward text',
},
])[0];
expect(normalized?.steps).toHaveLength(1);
expect(normalized?.steps?.[0]?.kind).toBe('inspect_treasure');
expect(normalized?.status).toBe('completed');
expect(normalized?.progress).toBe(1);
});
it('builds a scene chapter quest that reuses staged quest steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
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',
'talk_to_npc',
]);
});
it('uses sceneTaskDescription as first-entry chapter quest context', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
sceneChapterContext: {
sceneTaskDescription: '首次进入宫苑内庭时,追查旧宫戍影为何守住回廊暗格。',
actEventDescriptions: ['旧宫侍女先指出回廊暗格。'],
primaryNpcName: '旧宫侍女',
},
});
expect(quest?.description).toBe(
'首次进入宫苑内庭时,追查旧宫戍影为何守住回廊暗格。',
);
expect(quest?.narrativeBinding.dramaticNeed).toBe(
'首次进入宫苑内庭时,追查旧宫戍影为何守住回廊暗格。',
);
expect(quest?.narrativeBinding.playerHook).toContain('旧宫侍女');
expect(quest?.narrativeBinding.followupHooks).toContain(
'旧宫侍女先指出回廊暗格。',
);
});
it('lets scene chapter quests advance through npc talk and scene pressure steps', () => {
const quest = buildChapterQuestForScene({
scene: CHAPTER_SCENE,
worldType: WorldType.WUXIA,
});
expect(quest).toBeTruthy();
const afterOpeningTalk = applyQuestProgressFromNpcTalk(
[quest!],
'npc-maid',
)[0];
expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat(
[afterOpeningTalk!],
CHAPTER_SCENE.id,
['monster-11'],
)[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc');
const afterTurningTalk = applyQuestProgressFromNpcTalk(
[afterPressure!],
'npc-maid',
)[0];
expect(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
});
it('uses scene chapter overrides to prefer investigation beats on key scenes', () => {
const quest = buildChapterQuestForScene({
scene: OVERRIDDEN_SCENE,
worldType: WorldType.WUXIA,
});
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(
'拿旧金牌去对问侍女',
);
});
});

1855
src/data/questFlow.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
import type {
InventoryItem,
ItemBuildProfile,
ItemRarity,
ItemStatProfile,
ItemUseProfile,
RuntimeItemAiIntent,
RuntimeItemCompileBudget,
RuntimeItemGenerationContext,
RuntimeItemPlan,
TimedBuildBuff,
} from '../types';
import {normalizeBuildRole, normalizeBuildTags} from './buildTags';
const RARITY_ORDER: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
function clampIndex(index: number) {
return Math.max(0, Math.min(RARITY_ORDER.length - 1, index));
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return Math.abs(hash);
}
function buildStructuralTags(plan: RuntimeItemPlan) {
switch (plan.itemKind) {
case 'equipment':
return ['weapon'];
case 'relic':
return ['relic'];
case 'material':
return ['material'];
case 'consumable':
return ['consumable'];
case 'quest':
return ['relic'];
default:
return [];
}
}
function resolveEquipmentSlotId(plan: RuntimeItemPlan) {
if (plan.itemKind === 'equipment') {
if (plan.targetBuildDirection.includes('守御') || plan.targetBuildDirection.includes('护体')) {
return 'armor' as const;
}
return 'weapon' as const;
}
if (plan.itemKind === 'relic' || plan.itemKind === 'quest') {
return 'relic' as const;
}
return null;
}
function resolveCategory(plan: RuntimeItemPlan) {
switch (plan.itemKind) {
case 'equipment':
return resolveEquipmentSlotId(plan) === 'armor' ? '护甲' : '武器';
case 'consumable':
return '消耗品';
case 'material':
return '材料';
case 'quest':
return '专属物';
default:
return '稀有品';
}
}
function buildRuntimeBudget(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
seedKey: string,
): RuntimeItemCompileBudget {
const seed = hashText(`${context.generationChannel}:${seedKey}:${plan.slot}:${plan.itemKind}`);
const channelBaseIndex = context.generationChannel === 'quest_reward'
? 2
: context.generationChannel === 'treasure'
? 1
: context.generationChannel === 'monster_drop'
? 0
: 1;
const slotBonus = plan.slot === 'primary' ? 1 : 0;
const permanenceBonus = plan.permanence === 'permanent' ? 1 : 0;
const rarityIndex = clampIndex(channelBaseIndex + slotBonus + permanenceBonus + (seed % 2));
const rarity = RARITY_ORDER[rarityIndex] ?? 'common';
return {
rarity,
buildTagLimit: rarity === 'legendary' ? 2 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
timedBuffTagLimit: rarity === 'legendary' ? 3 : rarity === 'epic' || rarity === 'rare' ? 2 : 1,
timedBuffDuration: rarity === 'legendary' ? 3 : rarity === 'epic' ? 3 : 2,
statBudgetTier: rarityIndex + 1,
};
}
function buildTimedBuffs(
itemId: string,
intent: RuntimeItemAiIntent,
budget: RuntimeItemCompileBudget,
): TimedBuildBuff[] {
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.timedBuffTagLimit);
if (tags.length <= 0) return [];
return [{
id: `${itemId}:buff`,
sourceType: 'item',
sourceId: itemId,
name: `${tags[0]}增益`,
tags,
durationTurns: budget.timedBuffDuration,
}];
}
function buildUseProfile(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
budget: RuntimeItemCompileBudget,
itemId: string,
): ItemUseProfile | null {
if (plan.itemKind !== 'consumable' && plan.permanence === 'permanent') return null;
const useProfile: ItemUseProfile = {};
if (intent.desiredFunctionalBias.includes('heal')) {
useProfile.hpRestore = 16 + budget.statBudgetTier * 10;
}
if (intent.desiredFunctionalBias.includes('mana')) {
useProfile.manaRestore = 14 + budget.statBudgetTier * 8;
}
if (intent.desiredFunctionalBias.includes('cooldown')) {
useProfile.cooldownReduction = budget.rarity === 'legendary' || budget.rarity === 'epic' ? 1 : 0;
}
if (plan.permanence === 'timed' || plan.itemKind === 'consumable') {
useProfile.buildBuffs = buildTimedBuffs(itemId, intent, budget);
}
if (
(useProfile.hpRestore ?? 0) <= 0
&& (useProfile.manaRestore ?? 0) <= 0
&& (useProfile.cooldownReduction ?? 0) <= 0
&& (useProfile.buildBuffs?.length ?? 0) <= 0
) {
return null;
}
return useProfile;
}
function buildStatProfile(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
budget: RuntimeItemCompileBudget,
): ItemStatProfile | null {
if (plan.itemKind === 'material') return null;
const statProfile: ItemStatProfile = {};
const tier = budget.statBudgetTier;
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic' || plan.itemKind === 'quest') {
if (intent.desiredFunctionalBias.includes('guard') || plan.targetBuildDirection.includes('守御')) {
statProfile.maxHpBonus = 6 * tier + (plan.itemKind === 'equipment' ? 10 : 4);
statProfile.incomingDamageMultiplier = Number(Math.max(0.84, 1 - tier * 0.03).toFixed(2));
}
if (intent.desiredFunctionalBias.includes('mana') || plan.targetBuildDirection.includes('法力')) {
statProfile.maxManaBonus = 8 * tier + (plan.itemKind === 'relic' ? 10 : 0);
}
if (intent.desiredFunctionalBias.includes('damage') || plan.targetBuildDirection.length > 0) {
statProfile.outgoingDamageBonus = Number((0.03 * tier).toFixed(2));
}
}
if (Object.keys(statProfile).length <= 0) return null;
return statProfile;
}
function buildProfile(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
budget: RuntimeItemCompileBudget,
): ItemBuildProfile | null {
if (plan.permanence !== 'permanent' || (plan.itemKind !== 'equipment' && plan.itemKind !== 'relic' && plan.itemKind !== 'quest')) {
return null;
}
const tags = normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit);
if (tags.length <= 0) return null;
return {
role: normalizeBuildRole(tags[0]),
tags,
synergy: normalizeBuildTags([
...plan.targetBuildDirection,
...intent.desiredBuildTags,
], 3),
craftTags: tags,
forgeRank: 0,
};
}
function buildValue(
budget: RuntimeItemCompileBudget,
plan: RuntimeItemPlan,
) {
const baseValue = {
common: 18,
uncommon: 32,
rare: 52,
epic: 80,
legendary: 118,
}[budget.rarity];
if (plan.itemKind === 'material') return baseValue - 8;
if (plan.itemKind === 'consumable') return baseValue - 4;
if (plan.itemKind === 'quest') return baseValue + 12;
return baseValue;
}
function buildItemTags(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
budget: RuntimeItemCompileBudget,
) {
const tags = [
...buildStructuralTags(plan),
...normalizeBuildTags(intent.desiredBuildTags, budget.buildTagLimit + 1),
];
if (intent.desiredFunctionalBias.includes('heal')) tags.push('healing');
if (intent.desiredFunctionalBias.includes('mana')) tags.push('mana');
if (resolveEquipmentSlotId(plan) === 'armor') tags.push('armor');
if (resolveEquipmentSlotId(plan) === 'weapon') tags.push('weapon');
if (resolveEquipmentSlotId(plan) === 'relic') tags.push('relic');
return [...new Set(tags.filter(Boolean))];
}
export function compileRuntimeItem(params: {
seedKey: string;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const {seedKey, context, plan, intent} = params;
const budget = buildRuntimeBudget(context, plan, seedKey);
const itemId = `runtime:${context.generationChannel}:${hashText(seedKey).toString(36)}`;
const runtimeMetadata = {
origin: 'ai_compiled' as const,
generationChannel: context.generationChannel,
seedKey,
relationAnchor: plan.relationAnchor,
sourceReason: intent.reasonToAppear,
recentEventHook: context.recentActions[0],
};
return {
id: itemId,
category: resolveCategory(plan),
name: intent.shortNameSeed || '未命名秘物',
quantity: 1,
rarity: budget.rarity,
tags: buildItemTags(plan, intent, budget),
equipmentSlotId: resolveEquipmentSlotId(plan),
statProfile: buildStatProfile(plan, intent, budget),
useProfile: buildUseProfile(plan, intent, budget, itemId),
buildProfile: buildProfile(plan, intent, budget),
value: buildValue(budget, plan),
runtimeMetadata,
} satisfies InventoryItem;
}

View File

@@ -0,0 +1,342 @@
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,
RuntimeItemGenerationChannel,
RuntimeItemGenerationContext,
ScenePresetInfo,
} from '../types';
import {getCharacterCombatTags, getTimedBuildBuffTags, normalizeBuildTags} from './buildTags';
type GapDefinition = {
id: string;
tags: string[];
};
const BUILD_GAP_DEFINITIONS: GapDefinition[] = [
{id: 'survival_gap', tags: ['守御', '护体', '回复', '续战']},
{id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载']},
{id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制']},
{id: 'mobility_gap', tags: ['突进', '快袭', '风行', '游击']},
{id: 'control_gap', tags: ['控场', '符阵', '镇邪', '反击']},
];
function dedupeStrings(values: Array<string | null | undefined | false>) {
return [...new Set(
values
.map(value => typeof value === 'string' ? value.trim() : '')
.filter(Boolean),
)];
}
function collectLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
if (!loadout) return [] as string[];
return normalizeBuildTags([
...(loadout.weapon?.buildProfile?.tags ?? []),
loadout.weapon?.buildProfile?.role ?? '',
...(loadout.armor?.buildProfile?.tags ?? []),
loadout.armor?.buildProfile?.role ?? '',
...(loadout.relic?.buildProfile?.tags ?? []),
loadout.relic?.buildProfile?.role ?? '',
], 6);
}
function buildSceneTags(scene: Pick<ScenePresetInfo, 'name' | 'description' | 'treasureHints'> | null) {
if (!scene) return [] as string[];
const seedParts = dedupeStrings([
scene.name,
...(scene.treasureHints ?? []),
]);
return seedParts
.flatMap(part => part.split(/[\s/]+/u))
.map(part => part.trim())
.filter(part => part.length >= 2)
.slice(0, 8);
}
function buildRecentStorySummary(lines: string[]) {
if (lines.length <= 0) return '最近没有形成稳定的事件线索。';
return lines.join(' / ');
}
function buildRecentStoryLines(storyHistory: GameState['storyHistory']) {
return storyHistory
.slice(-4)
.map(moment => moment.text.trim())
.filter(Boolean)
.slice(-3);
}
function derivePlayerBuildGaps(playerBuildTags: string[]) {
const tagSet = new Set(playerBuildTags);
return BUILD_GAP_DEFINITIONS
.filter(definition => definition.tags.filter(tag => tagSet.has(tag)).length <= 0)
.map(definition => definition.id)
.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[];
playerEquipmentTags: string[];
generationChannel: RuntimeItemGenerationChannel;
}) {
const {
worldType,
customWorldProfile,
scene,
encounter,
relatedNpcState,
storyEngineMemory,
storyHistory,
playerCharacterId,
playerBuildTags,
playerEquipmentTags,
generationChannel,
} = params;
const recentStoryLines = buildRecentStoryLines(storyHistory);
const relatedNpcNarrativeProfile = resolveRelatedNpcNarrativeProfile({
customWorldProfile,
encounter,
});
const activeThreadIds = resolveActiveThreadIds({
customWorldProfile,
relatedNpcNarrativeProfile,
storyEngineMemory,
});
return {
worldType,
customWorldProfile,
sceneId: scene?.id ?? null,
sceneName: scene?.name ?? null,
sceneDescription: scene?.description ?? null,
sceneTags: buildSceneTags(scene),
treasureHints: [...(scene?.treasureHints ?? [])],
encounter: encounter ?? null,
encounterNpcId: encounter?.id ?? encounter?.characterId ?? encounter?.monsterPresetId ?? encounter?.npcName ?? null,
encounterNpcName: encounter?.npcName ?? null,
encounterContextText: encounter?.context ?? null,
relatedNpcState,
relatedNpcNarrativeProfile,
relatedScene: scene,
recentStorySummary: buildRecentStorySummary(recentStoryLines),
recentActions: recentStoryLines,
activeThreadIds,
playerCharacterId,
playerBuildTags,
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
playerEquipmentTags,
generationChannel,
} satisfies RuntimeItemGenerationContext;
}
export function buildLooseRuntimeItemGenerationContext(params: {
worldType: GameState['worldType'];
customWorldProfile?: GameState['customWorldProfile'];
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
encounter?: GameState['currentEncounter'];
relatedNpcState?: GameState['npcStates'][string] | null;
storyHistory?: GameState['storyHistory'];
playerCharacterId?: string;
playerBuildTags?: string[];
playerEquipmentTags?: string[];
generationChannel: RuntimeItemGenerationChannel;
}) {
return buildBaseRuntimeContext({
worldType: params.worldType,
customWorldProfile: params.customWorldProfile ?? null,
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 ?? [],
playerEquipmentTags: params.playerEquipmentTags ?? [],
generationChannel: params.generationChannel,
});
}
export function buildRuntimeItemGenerationContext(params: {
state: GameState;
generationChannel: RuntimeItemGenerationChannel;
encounter?: GameState['currentEncounter'];
scene?: GameState['currentScenePreset'];
}) {
const {state, generationChannel} = params;
const encounter = params.encounter ?? state.currentEncounter;
const scene = params.scene ?? state.currentScenePreset;
const relatedNpcState = encounter
? state.npcStates[encounter.id ?? encounter.npcName] ?? null
: null;
const playerBuildTags = state.playerCharacter
? normalizeBuildTags([
...getCharacterCombatTags(state.playerCharacter),
...collectLoadoutBuildTags(state.playerEquipment),
...getTimedBuildBuffTags(state.activeBuildBuffs),
], 6)
: [];
return buildBaseRuntimeContext({
worldType: state.worldType,
customWorldProfile: state.customWorldProfile,
scene,
encounter,
relatedNpcState,
storyEngineMemory: state.storyEngineMemory,
storyHistory: state.storyHistory,
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
playerBuildTags,
playerEquipmentTags: collectLoadoutBuildTags(state.playerEquipment),
generationChannel,
});
}
export function buildQuestRuntimeItemGenerationContext(params: {
context: QuestGenerationContext;
generationChannel?: RuntimeItemGenerationChannel;
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
}) {
const {
context,
issuerNpcId,
issuerNpcName,
roleText,
scene,
generationChannel = 'quest_reward',
} = params;
const playerBuildTags = context.playerCharacter
? normalizeBuildTags([
...getCharacterCombatTags(context.playerCharacter),
...collectLoadoutBuildTags(context.playerEquipment),
], 6)
: [];
return buildBaseRuntimeContext({
worldType: context.worldType,
customWorldProfile: context.customWorldProfile ?? null,
scene: scene ?? (
context.currentSceneName
? {
id: context.currentSceneId ?? '',
name: context.currentSceneName,
description: context.currentSceneDescription ?? '',
treasureHints: [],
}
: null
),
encounter: {
id: issuerNpcId,
kind: 'npc',
npcName: issuerNpcName,
npcDescription: roleText,
npcAvatar: '',
context: roleText,
},
relatedNpcState: context.issuerAffinity == null
? null
: {
affinity: context.issuerAffinity,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
revealedFacts: [],
},
storyEngineMemory: context.activeThreadIds,
storyHistory: context.recentStoryMoments ?? [],
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
playerBuildTags,
playerEquipmentTags: collectLoadoutBuildTags(context.playerEquipment),
generationChannel,
});
}

View File

@@ -0,0 +1,123 @@
import {describe, expect, it} from 'vitest';
import {WorldType} from '../types';
import {addInventoryItems} from './npcInteractions';
import {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
} from './runtimeItemContext';
import {buildDirectedRuntimeReward, buildRuntimeInventoryStock} from './runtimeItemDirector';
describe('runtime item director', () => {
it('builds treasure rewards with runtime metadata and relation anchors', () => {
const context = buildLooseRuntimeItemGenerationContext({
worldType: WorldType.WUXIA,
scene: {
id: 'scene-ruins',
name: '断碑古道',
description: '碎碑与旧誓散落在路旁。',
treasureHints: ['残匣', '旧祭火'],
},
encounter: {
id: 'treasure-altar',
kind: 'treasure',
npcName: '断誓秘匣',
npcDescription: '匣盖上留着未熄的旧印。',
npcAvatar: '',
context: '古道祭坛',
},
playerCharacterId: 'hero',
playerBuildTags: ['快剑', '追击'],
generationChannel: 'treasure',
});
const reward = buildDirectedRuntimeReward(context, {
seedKey: 'test:treasure',
fixedKinds: ['relic', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
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', () => {
const baseItem = buildRuntimeInventoryStock(
buildLooseRuntimeItemGenerationContext({
worldType: WorldType.WUXIA,
encounter: {
id: 'npc-blackmarket',
kind: 'npc',
npcName: '黑市牙人',
npcDescription: '在阴影里兜售消息与暗器。',
npcAvatar: '',
context: '黑市',
},
playerCharacterId: 'hero',
playerBuildTags: ['快袭', '风行'],
generationChannel: 'npc_trade',
}),
{
seedKey: 'test:stock',
itemCount: 1,
fixedKinds: ['relic'],
fixedPermanence: ['permanent'],
},
)[0];
const secondItem = {
...baseItem,
id: `${baseItem.id}:variant`,
runtimeMetadata: baseItem.runtimeMetadata
? {
...baseItem.runtimeMetadata,
seedKey: `${baseItem.runtimeMetadata.seedKey}:variant`,
}
: null,
};
const merged = addInventoryItems([], [baseItem, secondItem]);
expect(merged).toHaveLength(2);
});
it('can build quest-flavored runtime rewards from quest context', () => {
const context = buildQuestRuntimeItemGenerationContext({
context: {
worldType: WorldType.XIANXIA,
currentSceneId: 'scene-cloud',
currentSceneName: '云阙旧渡',
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
issuerNpcContext: '巡守',
issuerAffinity: 24,
recentStoryMoments: [],
playerCharacter: null,
},
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
roleText: '巡守',
scene: {
id: 'scene-cloud',
name: '云阙旧渡',
description: '旧渡口残留着灵潮和巡守痕迹。',
treasureHints: ['旧印'],
},
});
const reward = buildDirectedRuntimeReward(context, {
seedKey: 'test:quest',
fixedKinds: ['equipment', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
expect(reward.primaryItem?.runtimeMetadata?.generationChannel).toBe('quest_reward');
expect(reward.supportItems.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,242 @@
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import type {
DirectedRuntimeReward,
InventoryItem,
RuntimeItemGenerationChannel,
RuntimeItemGenerationContext,
RuntimeItemKind,
RuntimeItemPermanence,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import {compileRuntimeItem} from './runtimeItemCompiler';
import {
applyRuntimeItemNarrative,
buildRuntimeItemAiIntent,
buildRuntimeRewardStoryHint,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
type RuntimeRewardOptions = {
seedKey: string;
variant?: string;
itemCount?: number;
fixedKinds?: RuntimeItemKind[];
fixedPermanence?: RuntimeItemPermanence[];
baseHp?: number;
baseMana?: number;
baseCurrency?: number;
storyHint?: string;
};
const GAP_TO_TAGS: Record<string, string[]> = {
survival_gap: ['守御', '护体', '回复', '续战'],
mana_gap: ['法力', '冷却', '过载'],
finisher_gap: ['爆发', '重击', '追击'],
mobility_gap: ['突进', '快袭', '风行', '游击'],
control_gap: ['控场', '符阵', '镇邪'],
};
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return Math.abs(hash);
}
function resolveGapDrivenTags(context: RuntimeItemGenerationContext) {
const gapTags = context.playerBuildGaps.flatMap(gap => GAP_TO_TAGS[gap] ?? []);
return [...new Set([
...gapTags,
...context.playerBuildTags,
])].slice(0, 3);
}
function resolveRelationAnchor(context: RuntimeItemGenerationContext): RuntimeRelationAnchor {
if (context.encounter?.monsterPresetId) {
return {
type: 'monster',
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
};
}
if (context.encounterNpcName) {
return {
type: 'npc',
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
};
}
if (context.sceneName) {
return {
type: 'scene',
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName,
};
}
return {
type: 'landmark',
landmarkName: context.customWorldProfile?.name ?? '无名之地',
};
}
function getChannelTemplate(channel: RuntimeItemGenerationChannel) {
switch (channel) {
case 'treasure':
return {
kinds: ['relic', 'consumable', 'material'] as RuntimeItemKind[],
permanence: ['permanent', 'timed', 'resource'] as RuntimeItemPermanence[],
defaultCount: 2,
};
case 'npc_trade':
return {
kinds: ['consumable', 'material', 'relic', 'equipment'] as RuntimeItemKind[],
permanence: ['timed', 'resource', 'permanent', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 4,
};
case 'npc_reward':
return {
kinds: ['consumable', 'relic', 'equipment'] as RuntimeItemKind[],
permanence: ['timed', 'permanent', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 1,
};
case 'monster_drop':
return {
kinds: ['material', 'consumable', 'relic'] as RuntimeItemKind[],
permanence: ['resource', 'timed', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 2,
};
case 'quest_reward':
return {
kinds: ['equipment', 'relic', 'consumable'] as RuntimeItemKind[],
permanence: ['permanent', 'permanent', 'timed'] as RuntimeItemPermanence[],
defaultCount: 2,
};
default:
return {
kinds: ['consumable', 'material', 'relic'] as RuntimeItemKind[],
permanence: ['timed', 'resource', 'permanent'] as RuntimeItemPermanence[],
defaultCount: 2,
};
}
}
function planRuntimeItems(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): RuntimeItemPlan[] {
const template = getChannelTemplate(context.generationChannel);
const count = Math.max(1, options.itemCount ?? template.defaultCount);
const relationAnchor = resolveRelationAnchor(context);
const targetBuildDirection = resolveGapDrivenTags(context);
const seed = hashText(`${options.seedKey}:${options.variant ?? 'default'}:${context.generationChannel}`);
return Array.from({length: count}, (_, index) => {
const kinds = options.fixedKinds?.length ? options.fixedKinds : template.kinds;
const permanences = options.fixedPermanence?.length ? options.fixedPermanence : template.permanence;
const itemKind = kinds[(seed + index) % kinds.length] ?? 'consumable';
const permanence = permanences[(seed + index) % permanences.length] ?? 'timed';
return {
slot: index === 0 ? 'primary' : index === 1 ? 'secondary' : 'support',
itemKind,
permanence,
narrativeWeight: index === 0 ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor,
} satisfies RuntimeItemPlan;
});
}
function compilePlannedItem(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
seedKey: string,
intentOverride?: ReturnType<typeof buildRuntimeItemAiIntent>,
) {
const intent = intentOverride ?? buildRuntimeItemAiIntent(context, plan);
const compiled = compileRuntimeItem({
seedKey,
context,
plan,
intent,
});
return applyRuntimeItemNarrative({
item: compiled,
context,
plan,
intent,
});
}
function buildDirectedRewardFromItems(
compiledItems: InventoryItem[],
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const reward: DirectedRuntimeReward = {
primaryItem: compiledItems[0] ?? null,
supportItems: compiledItems.slice(1),
hp: options.baseHp,
mana: options.baseMana,
currency: options.baseCurrency,
storyHint: options.storyHint,
};
return {
...reward,
storyHint: buildRuntimeRewardStoryHint(reward),
};
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): DirectedRuntimeReward {
const plans = planRuntimeItems(context, options);
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(context, plan, `${options.seedKey}:${plan.slot}:${index}`),
);
return buildDirectedRewardFromItems(compiledItems, options);
}
export async function generateDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): Promise<DirectedRuntimeReward> {
const plans = planRuntimeItems(context, options);
try {
const aiIntents = await generateRuntimeItemAiIntents({
context,
plans,
});
const compiledItems = plans.map((plan, index) =>
compilePlannedItem(
context,
plan,
`${options.seedKey}:${plan.slot}:${index}`,
aiIntents[index],
),
);
return buildDirectedRewardFromItems(compiledItems, options);
} catch (error) {
console.warn('[RuntimeItemDirector] falling back to deterministic item intent', error);
return buildDirectedRuntimeReward(context, options);
}
}
export function buildRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): InventoryItem[] {
return flattenDirectedRuntimeRewardItems(buildDirectedRuntimeReward(context, options));
}

View File

@@ -0,0 +1,230 @@
import {
buildCarrierNarrativeDescription,
buildCarrierNarrativeName,
buildRuntimeItemStoryFingerprint,
} from '../services/storyEngine/carrierNarrativeCompiler';
import type {
DirectedRuntimeReward,
InventoryItem,
RuntimeItemAiIntent,
RuntimeItemAiPromptInput,
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
.slice(0, maxLength);
}
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return anchor.npcName;
case 'scene':
return anchor.sceneName;
case 'landmark':
return anchor.landmarkName;
case 'monster':
return anchor.monsterName;
case 'faction':
return anchor.factionName;
case 'quest':
return anchor.questName;
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.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,
desiredItemKind: plan.itemKind,
permanence: plan.permanence,
};
}
export function buildRuntimeItemAiIntent(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
): RuntimeItemAiIntent {
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const sourceSeed = sanitizeFragment(context.sceneName, 4)
|| sanitizeFragment(context.customWorldProfile?.name, 4)
|| sanitizeFragment(anchorLabel, 4)
|| '旧誓';
const functionalBias: RuntimeItemAiIntent['desiredFunctionalBias'] = [];
if (plan.permanence === 'timed') {
functionalBias.push(context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown');
}
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
if (
functionalBias.length <= 0
|| context.playerBuildGaps.includes('finisher_gap')
|| plan.itemKind === 'equipment'
) {
functionalBias.push('damage');
}
return {
shortNameSeed: sourceSeed,
sourcePhrase: anchorLabel,
reasonToAppear: context.generationChannel === 'monster_drop'
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
: `${anchorLabel}与最近局势把它推到了你面前。`,
relationHooks: [
context.encounterContextText ?? context.sceneName ?? anchorLabel,
...context.recentActions,
].filter(Boolean).slice(0, 2) as string[],
desiredBuildTags: [...new Set([
...plan.targetBuildDirection,
...context.playerBuildTags.slice(0, 2),
])].slice(0, 3),
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
tone: context.generationChannel === 'monster_drop'
? 'grim'
: context.generationChannel === 'quest_reward'
? 'ritual'
: context.playerBuildGaps.includes('survival_gap')
? 'survival'
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine
?? `${anchorLabel}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden
?? `${anchorLabel}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction
?? `${anchorLabel}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo
?? `${anchorLabel}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
].slice(0, 4),
namingPattern:
plan.itemKind === 'quest'
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'monster'
? 'monster_trophy'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
};
}
export function applyRuntimeItemNarrative(params: {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
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 {
...params.item,
name: buildCarrierNarrativeName(params),
description: buildCarrierNarrativeDescription(params),
runtimeMetadata: {
...runtimeMetadata,
storyFingerprint: fingerprint,
},
} satisfies InventoryItem;
}
export function applyRuntimeItemNarrativeToExistingItem(params: {
item: InventoryItem;
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
preserveName?: boolean;
}) {
const fingerprint = buildRuntimeItemStoryFingerprint(params);
const runtimeMetadata =
params.item.runtimeMetadata ?? {
origin: 'procedural' as const,
generationChannel: params.context.generationChannel,
relationAnchor: params.plan.relationAnchor,
seedKey: `${params.context.generationChannel}:${params.item.id}`,
sourceReason: params.intent.reasonToAppear,
};
return {
...params.item,
name: params.preserveName
? params.item.name
: buildCarrierNarrativeName(params),
description: buildCarrierNarrativeDescription(params),
runtimeMetadata: {
...runtimeMetadata,
relationAnchor: runtimeMetadata.relationAnchor ?? params.plan.relationAnchor,
sourceReason: params.intent.reasonToAppear,
storyFingerprint: fingerprint,
},
} satisfies InventoryItem;
}
export function describeRuntimeRelationAnchor(anchor: RuntimeRelationAnchor | undefined) {
if (!anchor) return '无明确锚点';
return `${anchor.type}:${resolveAnchorLabel(anchor)}`;
}
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
return [
...(reward.primaryItem ? [reward.primaryItem] : []),
...reward.supportItems,
];
}
export function buildRuntimeRewardStoryHint(reward: DirectedRuntimeReward) {
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}`;
}

102
src/data/runtimeStats.ts Normal file
View File

@@ -0,0 +1,102 @@
import { GameRuntimeStats, GameState } from '../types';
function clampNonNegativeInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value));
}
function getIsoTimestamp(now: number) {
return new Date(now).toISOString();
}
export function createInitialGameRuntimeStats(
options: {
isActiveRun?: boolean;
now?: number;
} = {},
): GameRuntimeStats {
const now = options.now ?? Date.now();
return {
playTimeMs: 0,
lastPlayTickAt: options.isActiveRun ? getIsoTimestamp(now) : null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
};
}
export function normalizeGameRuntimeStats(
stats: Partial<GameRuntimeStats> | null | undefined,
options: {
isActiveRun?: boolean;
now?: number;
} = {},
): GameRuntimeStats {
const now = options.now ?? Date.now();
return {
playTimeMs: typeof stats?.playTimeMs === 'number' && Number.isFinite(stats.playTimeMs)
? Math.max(0, stats.playTimeMs)
: 0,
lastPlayTickAt: options.isActiveRun ? getIsoTimestamp(now) : null,
hostileNpcsDefeated: clampNonNegativeInteger(stats?.hostileNpcsDefeated),
questsAccepted: clampNonNegativeInteger(stats?.questsAccepted),
itemsUsed: clampNonNegativeInteger(stats?.itemsUsed),
scenesTraveled: clampNonNegativeInteger(stats?.scenesTraveled),
};
}
export function incrementGameRuntimeStats(
stats: GameRuntimeStats,
increments: Partial<Pick<GameRuntimeStats, 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>,
): GameRuntimeStats {
return {
...stats,
hostileNpcsDefeated: stats.hostileNpcsDefeated + clampNonNegativeInteger(increments.hostileNpcsDefeated),
questsAccepted: stats.questsAccepted + clampNonNegativeInteger(increments.questsAccepted),
itemsUsed: stats.itemsUsed + clampNonNegativeInteger(increments.itemsUsed),
scenesTraveled: stats.scenesTraveled + clampNonNegativeInteger(increments.scenesTraveled),
};
}
export function syncGameRuntimePlayTime(stats: GameRuntimeStats, now = Date.now()): GameRuntimeStats {
if (!stats.lastPlayTickAt) {
return {
...stats,
lastPlayTickAt: getIsoTimestamp(now),
};
}
const lastTickMs = Date.parse(stats.lastPlayTickAt);
if (Number.isNaN(lastTickMs)) {
return {
...stats,
lastPlayTickAt: getIsoTimestamp(now),
};
}
return {
...stats,
playTimeMs: stats.playTimeMs + Math.max(0, now - lastTickMs),
lastPlayTickAt: getIsoTimestamp(now),
};
}
export function getLiveGamePlayTimeMs(stats: GameRuntimeStats, now = Date.now()) {
if (!stats.lastPlayTickAt) return stats.playTimeMs;
const lastTickMs = Date.parse(stats.lastPlayTickAt);
if (Number.isNaN(lastTickMs)) return stats.playTimeMs;
return stats.playTimeMs + Math.max(0, now - lastTickMs);
}
export function syncGameStatePlayTime(state: GameState, now = Date.now()): GameState {
return {
...state,
runtimeStats: syncGameRuntimePlayTime(state.runtimeStats, now),
};
}

View File

@@ -0,0 +1,118 @@
import fs from 'node:fs';
import path from 'node:path';
import { CustomWorldProfile, WorldType } from '../types';
import {
getDefaultCustomWorldSceneImage,
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from './customWorldVisuals';
import { getScenePresetsByWorld } from './scenePresets';
function resolvePublicAssetPath(assetPath: string) {
return path.resolve(process.cwd(), 'public', assetPath.replace(/^\/+/, ''));
}
describe('scene background assets', () => {
it('ships background files for every compatibility template scene preset', () => {
const scenes = [
...getScenePresetsByWorld(WorldType.WUXIA),
...getScenePresetsByWorld(WorldType.XIANXIA),
];
expect(scenes.length).toBeGreaterThan(0);
for (const scene of scenes) {
expect(fs.existsSync(resolvePublicAssetPath(scene.imageSrc))).toBe(true);
}
});
it('returns existing default custom world backgrounds for both compatibility templates', () => {
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
expect(fs.existsSync(resolvePublicAssetPath(wuxiaImage))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(xianxiaImage))).toBe(true);
});
it('keeps ungenerated custom world scenes on independent matched backgrounds', () => {
const generatedImage =
'/generated-custom-world-scenes/test-world/generated-ruins.png';
const profile: CustomWorldProfile = {
id: 'custom-world-test',
settingText: '荒城断碑与边关旧营并存的边城地界',
name: '断碑边城',
subtitle: '烽烟未熄',
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
tone: '压抑、克制、潜伏危机',
playerGoal: '追查残城旧案背后的真相',
templateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: ['边关旧案复起'],
attributeSchema: {
id: 'schema:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '断碑边城',
settingSummary: '边关旧案',
tone: '压抑',
conflictCore: '旧案复起',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '残城旧营',
description: '断墙、军帐与营火灰烬混在一起,像一处被遗弃的边关驻地。',
imageSrc: generatedImage,
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-2',
name: '雾锁渡桥',
description: '古桥横跨冷河,雾气压在水面上,只有残灯还在摇晃。',
sceneNpcIds: [],
connections: [],
},
{
id: 'landmark-3',
name: '地宫裂隙',
description: '墓道向下坍塌,石阶与机关残痕一路通往地底深处。',
sceneNpcIds: [],
connections: [],
},
],
themePack: null,
storyGraph: null,
creatorIntent: null,
anchorPack: null,
lockState: null,
generationMode: 'full',
generationStatus: 'complete',
};
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
const secondImage = landmarkImageMap.get('landmark-2');
const thirdImage = landmarkImageMap.get('landmark-3');
const campImage = resolveCustomWorldCampSceneImage(profile);
expect(landmarkImageMap.get('landmark-1')).toBe(generatedImage);
expect(secondImage).toBeTruthy();
expect(thirdImage).toBeTruthy();
expect(secondImage).not.toBe(generatedImage);
expect(thirdImage).not.toBe(generatedImage);
expect(secondImage).not.toBe(thirdImage);
expect(campImage).toBeTruthy();
expect(campImage).not.toBe(generatedImage);
expect(fs.existsSync(resolvePublicAssetPath(secondImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(thirdImage!))).toBe(true);
expect(fs.existsSync(resolvePublicAssetPath(campImage))).toBe(true);
});
});

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
WorldType,
} from '../types';
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
import { createSceneHostileNpc } from './hostileNpcs';
import { buildInitialNpcState } from './npcInteractions';
import {
hasAutoBattleSceneEncounter,
resolveSceneEncounterPreview,
} from './sceneEncounterPreviews';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createEncounter(): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
initialAffinity: 12,
hostile: false,
};
}
function createBaseState(): GameState {
const encounter = createEncounter();
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-1',
name: 'Trail',
description: 'A mountain trail.',
imageSrc: '/trail.png',
connectedSceneIds: [],
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
[encounter.id!]: {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: -5,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('sceneEncounterPreviews', () => {
it('treats negative-affinity npc encounters as immediate battles', () => {
const state = createBaseState();
expect(hasAutoBattleSceneEncounter(state)).toBe(true);
const resolved = resolveSceneEncounterPreview(state);
expect(resolved.inBattle).toBe(true);
expect(resolved.currentEncounter).toBeNull();
expect(resolved.currentBattleNpcId).toBe('npc-trader');
expect(resolved.currentNpcBattleMode).toBe('fight');
expect(resolved.sceneHostileNpcs).toHaveLength(1);
expect(resolved.sceneHostileNpcs[0]?.encounter?.npcName).toBe('Trader Lin');
});
it('attaches npc encounter metadata to regular monsters', () => {
const monsterId = getMonsterPresetsByWorld(WorldType.WUXIA)[0]?.id;
if (!monsterId) {
throw new Error('Expected at least one monster preset');
}
const monster = createSceneHostileNpc(WorldType.WUXIA, monsterId);
expect(monster).not.toBeNull();
expect(monster?.encounter?.kind).toBe('npc');
expect(monster?.encounter?.monsterPresetId).toBe(monsterId);
expect(monster?.encounter?.hostile).toBe(true);
expect(monster?.encounter?.initialAffinity).toBe(-40);
});
});

Some files were not shown because too many files have changed in this diff Show More