统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
This commit is contained in:
@@ -50,8 +50,7 @@ export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
minAffinity: 15,
|
||||
markerAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description:
|
||||
'戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
relationStance: 'neutral',
|
||||
},
|
||||
@@ -61,10 +60,8 @@ export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
minAffinity: 30,
|
||||
markerAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description:
|
||||
'态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName:
|
||||
'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
relationStance: 'cooperative',
|
||||
},
|
||||
{
|
||||
@@ -83,8 +80,7 @@ export const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
minAffinity: 90,
|
||||
markerAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description:
|
||||
'关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
relationStance: 'bonded',
|
||||
},
|
||||
|
||||
@@ -71,9 +71,10 @@ export function resolveRoleCombatStats(
|
||||
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)),
|
||||
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),
|
||||
|
||||
@@ -12,25 +12,88 @@ import type {
|
||||
RoleAttributeEvidence,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import {buildDefaultAxisVector} from './attributeResolver';
|
||||
import {ensureRoleAttributeProfile} from './attributeValidation';
|
||||
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 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 }),
|
||||
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(
|
||||
@@ -39,12 +102,15 @@ function applyKeywordWeights(
|
||||
evidence: RoleAttributeEvidence[],
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
AXIS_KEYWORD_RULES.forEach(rule => {
|
||||
const matches = rule.patterns.reduce((count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
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);
|
||||
const slot = schema.slots.find((item) => item.slotId === rule.slotId);
|
||||
if (slot) {
|
||||
evidence.push({
|
||||
slotId: slot.slotId,
|
||||
@@ -59,15 +125,19 @@ function buildLegacyAttributeSeed(attributes: LegacyAttributeSet) {
|
||||
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_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 => {
|
||||
return evidence.filter((entry) => {
|
||||
const key = `${entry.slotId}:${entry.reason}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
@@ -105,7 +175,7 @@ export function buildRoleAttributeProfileFromLegacyData({
|
||||
applyKeywordWeights(seed, sourceText, evidence, schema);
|
||||
}
|
||||
|
||||
WORLD_ATTRIBUTE_SLOT_IDS.forEach(slotId => {
|
||||
WORLD_ATTRIBUTE_SLOT_IDS.forEach((slotId) => {
|
||||
seed[slotId] = (seed[slotId] ?? 0) + (extraWeights?.[slotId] ?? 0);
|
||||
});
|
||||
|
||||
@@ -126,7 +196,7 @@ export function buildRoleAttributeProfileFromLegacyData({
|
||||
sourceCharacterId: entityId,
|
||||
schemaId: schema.id,
|
||||
oldAttributes: legacyAttributes ?? undefined,
|
||||
inferredReasons: fallbackEvidence.map(entry => entry.reason),
|
||||
inferredReasons: fallbackEvidence.map((entry) => entry.reason),
|
||||
fallbackUsed: false,
|
||||
};
|
||||
|
||||
@@ -136,7 +206,10 @@ export function buildRoleAttributeProfileFromLegacyData({
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterAttributeProfile(character: Character, schema: WorldAttributeSchema) {
|
||||
export function buildCharacterAttributeProfile(
|
||||
character: Character,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: character.id,
|
||||
schema,
|
||||
@@ -147,7 +220,9 @@ export function buildCharacterAttributeProfile(character: Character, schema: Wor
|
||||
character.backstory,
|
||||
character.personality,
|
||||
...(character.combatTags ?? []),
|
||||
...character.skills.map(skill => `${skill.name} ${skill.style} ${skill.delivery ?? ''}`),
|
||||
...character.skills.map(
|
||||
(skill) => `${skill.name} ${skill.style} ${skill.delivery ?? ''}`,
|
||||
),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
@@ -161,21 +236,24 @@ export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
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;
|
||||
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) {
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(
|
||||
npc: CustomWorldNpc,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
@@ -225,7 +303,10 @@ export function buildMonsterAttributeProfile(
|
||||
}
|
||||
|
||||
export function buildItemAttributeResonance(
|
||||
item: Pick<InventoryItem | CustomWorldItem, 'category' | 'name' | 'description'> & {
|
||||
item: Pick<
|
||||
InventoryItem | CustomWorldItem,
|
||||
'category' | 'name' | 'description'
|
||||
> & {
|
||||
tags?: string[];
|
||||
buildProfile?: { resonanceVector?: AttributeVector | null } | null;
|
||||
},
|
||||
@@ -256,6 +337,7 @@ export function buildItemAttributeResonance(
|
||||
|
||||
export function buildSkillAttributeProfile(skill: CharacterSkillDefinition) {
|
||||
return {
|
||||
intentVector: SKILL_STYLE_VECTORS[skill.style] ?? SKILL_STYLE_VECTORS.steady,
|
||||
intentVector:
|
||||
SKILL_STYLE_VECTORS[skill.style] ?? SKILL_STYLE_VECTORS.steady,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import type {
|
||||
WorldAttributeSlot,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import { WORLD_ATTRIBUTE_SLOT_IDS } from '../types';
|
||||
import { resolveRelationStanceFromAffinity } from './affinityLevels';
|
||||
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
|
||||
import {getWorldAttributeSchema} from './worldAttributeSchemas';
|
||||
import { normalizeAttributeVector, roundNumber } from './attributeValidation';
|
||||
import { getWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
|
||||
export function resolveRelationStance(
|
||||
affinity: number,
|
||||
): RoleRelationState['stance'] {
|
||||
return resolveRelationStanceFromAffinity(affinity);
|
||||
}
|
||||
|
||||
@@ -51,7 +53,10 @@ export function resolveCharacterAttributeProfile(
|
||||
return character.attributeProfile;
|
||||
}
|
||||
|
||||
export function getAttributeSlotValue(profile: RoleAttributeProfile | null | undefined, slotId: string) {
|
||||
export function getAttributeSlotValue(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
slotId: string,
|
||||
) {
|
||||
return profile?.values?.[slotId] ?? 0;
|
||||
}
|
||||
|
||||
@@ -59,7 +64,10 @@ export function getNormalizedAttributeWeights(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return normalizeAttributeVector(profile?.values ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
return normalizeAttributeVector(
|
||||
profile?.values ?? {},
|
||||
schema.slots.map((slot) => slot.slotId),
|
||||
);
|
||||
}
|
||||
|
||||
export function scoreAttributeFit(
|
||||
@@ -68,11 +76,16 @@ export function scoreAttributeFit(
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
const weights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedVector = normalizeAttributeVector(vector ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
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),
|
||||
(sum, slot) =>
|
||||
sum +
|
||||
(weights[slot.slotId] ?? 0) * (normalizedVector[slot.slotId] ?? 0),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
@@ -102,11 +115,11 @@ export function scoreActionMatch(
|
||||
const baseScore = 'baseScore' in action ? action.baseScore : 0;
|
||||
|
||||
return roundNumber(
|
||||
baseScore
|
||||
+ actorFit * actorCoefficient
|
||||
- targetResistance * targetCoefficient
|
||||
+ (options.relationModifier ?? 0)
|
||||
+ (options.contextModifier ?? 0),
|
||||
baseScore +
|
||||
actorFit * actorCoefficient -
|
||||
targetResistance * targetCoefficient +
|
||||
(options.relationModifier ?? 0) +
|
||||
(options.contextModifier ?? 0),
|
||||
4,
|
||||
);
|
||||
}
|
||||
@@ -116,7 +129,7 @@ export function getSortedAttributeEntries(
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return [...schema.slots]
|
||||
.map(slot => ({
|
||||
.map((slot) => ({
|
||||
slot,
|
||||
value: getAttributeSlotValue(profile, slot.slotId),
|
||||
}))
|
||||
@@ -130,7 +143,7 @@ export function describeTopAttributes(
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => `${entry.slot.name}${entry.value}`);
|
||||
.map((entry) => `${entry.slot.name}${entry.value}`);
|
||||
}
|
||||
|
||||
export function formatAttributeList(
|
||||
@@ -140,7 +153,7 @@ export function formatAttributeList(
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => ({
|
||||
.map((entry) => ({
|
||||
slot: entry.slot,
|
||||
value: entry.value,
|
||||
}));
|
||||
@@ -150,20 +163,29 @@ export function getLeadingAttributeSlot(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)[0]?.slot ?? schema.slots[0] ?? null;
|
||||
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 => ({
|
||||
return schema.slots.slice(0, limit).map((slot) => ({
|
||||
name: slot.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSlotById(schema: WorldAttributeSchema, slotId: string): WorldAttributeSlot | null {
|
||||
return schema.slots.find(slot => slot.slotId === slotId) ?? null;
|
||||
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>>) {
|
||||
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;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
WorldAttributeSlot,
|
||||
WorldAttributeSlotId,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import { WORLD_ATTRIBUTE_SLOT_IDS } from '../types';
|
||||
|
||||
const ATTRIBUTE_TOTAL_MIN = 300;
|
||||
const ATTRIBUTE_TOTAL_MAX = 420;
|
||||
@@ -64,15 +64,27 @@ export function coerceWorldAttributeSchema(
|
||||
...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,
|
||||
schemaVersion:
|
||||
typeof raw.schemaVersion === 'number' &&
|
||||
Number.isFinite(raw.schemaVersion) &&
|
||||
raw.schemaVersion > 0
|
||||
? Math.max(1, Math.round(raw.schemaVersion))
|
||||
: fallback.schemaVersion,
|
||||
generatedFrom: {
|
||||
...fallback.generatedFrom,
|
||||
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
|
||||
settingSummary: toText(rawGeneratedFrom.settingSummary, fallback.generatedFrom.settingSummary),
|
||||
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),
|
||||
conflictCore: toText(
|
||||
rawGeneratedFrom.conflictCore,
|
||||
fallback.generatedFrom.conflictCore,
|
||||
),
|
||||
},
|
||||
slots: fallback.slots.map((fallbackSlot, index) => {
|
||||
const rawSlot = isRecord(rawSlots[index]) ? rawSlots[index] : {};
|
||||
@@ -84,21 +96,29 @@ export function coerceWorldAttributeSchema(
|
||||
}),
|
||||
};
|
||||
|
||||
return validateWorldAttributeSchema(candidate).length > 0 ? fallback : candidate;
|
||||
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);
|
||||
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, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [slotId, roundNumber(Math.max(0, vector[slotId] ?? 0) / total, 4)]),
|
||||
slotIds.map((slotId) => [
|
||||
slotId,
|
||||
roundNumber(Math.max(0, vector[slotId] ?? 0) / total, 4),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,11 +134,13 @@ export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
|
||||
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}`);
|
||||
issues.push(
|
||||
`expected ${WORLD_ATTRIBUTE_SLOT_IDS.length} attribute slots, received ${slots.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const nameSet = new Set<string>();
|
||||
slots.forEach(slot => {
|
||||
slots.forEach((slot) => {
|
||||
const trimmedName = slot.name.trim();
|
||||
if (!trimmedName) {
|
||||
issues.push(`slot ${slot.slotId} is missing a name`);
|
||||
@@ -128,10 +150,11 @@ export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
|
||||
}
|
||||
nameSet.add(trimmedName);
|
||||
|
||||
if (BANNED_ATTRIBUTE_TERMS.some(term => trimmedName.includes(term))) {
|
||||
issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
|
||||
if (BANNED_ATTRIBUTE_TERMS.some((term) => trimmedName.includes(term))) {
|
||||
issues.push(
|
||||
`attribute name "${trimmedName}" contains banned legacy term`,
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
return issues;
|
||||
@@ -142,14 +165,17 @@ export function normalizeAttributeValues(
|
||||
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
|
||||
targetTotal = ATTRIBUTE_TOTAL_TARGET,
|
||||
) {
|
||||
const positiveValues = slotIds.map(slotId => Math.max(0, values[slotId] ?? 0));
|
||||
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 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));
|
||||
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) {
|
||||
@@ -172,7 +198,9 @@ export function normalizeAttributeValues(
|
||||
total -= 1;
|
||||
}
|
||||
|
||||
return Object.fromEntries(slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]));
|
||||
return Object.fromEntries(
|
||||
slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]),
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureRoleAttributeProfile(
|
||||
@@ -183,7 +211,7 @@ export function ensureRoleAttributeProfile(
|
||||
fallbackEvidence?: RoleAttributeProfile['evidence'];
|
||||
} = {},
|
||||
): RoleAttributeProfile {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const slotIds = schema.slots.map((slot) => slot.slotId);
|
||||
const normalizedValues = normalizeAttributeValues(
|
||||
{
|
||||
...(options.fallbackValues ?? {}),
|
||||
@@ -193,7 +221,7 @@ export function ensureRoleAttributeProfile(
|
||||
);
|
||||
|
||||
const sortedSlots = [...schema.slots]
|
||||
.map(slot => ({
|
||||
.map((slot) => ({
|
||||
slot,
|
||||
value: normalizedValues[slot.slotId] ?? 0,
|
||||
}))
|
||||
@@ -201,9 +229,9 @@ export function ensureRoleAttributeProfile(
|
||||
|
||||
const strongestValue = sortedSlots[0]?.value ?? 0;
|
||||
const topTraits = sortedSlots
|
||||
.filter(entry => entry.value >= strongestValue - 8)
|
||||
.filter((entry) => entry.value >= strongestValue - 8)
|
||||
.slice(0, 2)
|
||||
.map(entry => entry.slot.name);
|
||||
.map((entry) => entry.slot.name);
|
||||
|
||||
return {
|
||||
schemaId: profile?.schemaId ?? schema.id,
|
||||
@@ -214,7 +242,7 @@ export function ensureRoleAttributeProfile(
|
||||
? [...profile.evidence]
|
||||
: options.fallbackEvidence?.length
|
||||
? [...options.fallbackEvidence]
|
||||
: sortedSlots.slice(0, 3).map(entry => ({
|
||||
: sortedSlots.slice(0, 3).map((entry) => ({
|
||||
slotId: entry.slot.slotId as WorldAttributeSlotId,
|
||||
reason: `${entry.slot.name}在当前画像中最突出。`,
|
||||
})),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type {AttributeVector, WorldAttributeSchema, WorldAttributeSlot} from '../types';
|
||||
import {normalizeAttributeVector} from './attributeValidation';
|
||||
import type {
|
||||
AttributeVector,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
} from '../types';
|
||||
import { normalizeAttributeVector } from './attributeValidation';
|
||||
|
||||
type BuildTagAttributeAffinityMap = Record<string, AttributeVector>;
|
||||
|
||||
@@ -11,27 +15,39 @@ type SemanticAxisRule = {
|
||||
const SEMANTIC_SLOT_RULES: SemanticAxisRule[] = [
|
||||
{
|
||||
axisId: 'axis_a',
|
||||
patterns: [/骨|躯|甲|壳|体|锋|承压|抗压|结构|根基|底子|扛住|稳固|硬碰|机锋|潮骨|界躯|道骨|骨势/u],
|
||||
patterns: [
|
||||
/骨|躯|甲|壳|体|锋|承压|抗压|结构|根基|底子|扛住|稳固|硬碰|机锋|潮骨|界躯|道骨|骨势/u,
|
||||
],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_b',
|
||||
patterns: [/步|身法|位移|换位|机动|迅|闪|轻灵|抢位|转场|穿梭|步准|浪步|裂步|灵行/u],
|
||||
patterns: [
|
||||
/步|身法|位移|换位|机动|迅|闪|轻灵|抢位|转场|穿梭|步准|浪步|裂步|灵行/u,
|
||||
],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_c',
|
||||
patterns: [/识|察|算|谋|阵|符|术|洞察|解析|看穿|辨认|因果|规律|算识|舟识|界识|识海|眼脉/u],
|
||||
patterns: [
|
||||
/识|察|算|谋|阵|符|术|洞察|解析|看穿|辨认|因果|规律|算识|舟识|界识|识海|眼脉/u,
|
||||
],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_d',
|
||||
patterns: [/压|魄|焰|胆|威|决|推进|强推|压迫|定调|逼出|逆转|潮压|潮魄|界压|劫纹|心焰/u],
|
||||
patterns: [
|
||||
/压|魄|焰|胆|威|决|推进|强推|压迫|定调|逼出|逆转|潮压|潮魄|界压|劫纹|心焰/u,
|
||||
],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_e',
|
||||
patterns: [/缘|契|盟|信|交|助|协|共鸣|共振|联结|牵引|交换|安抚|协频|契汐|缚契|尘缘|心契/u],
|
||||
patterns: [
|
||||
/缘|契|盟|信|交|助|协|共鸣|共振|联结|牵引|交换|安抚|协频|契汐|缚契|尘缘|心契/u,
|
||||
],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_f',
|
||||
patterns: [/息|稳|续|回|养|持久|恢复|调息|循环|续航|回稳|稳态|续载|回澜|回脉|玄息/u],
|
||||
patterns: [
|
||||
/息|稳|续|回|养|持久|恢复|调息|循环|续航|回稳|稳态|续载|回澜|回脉|玄息/u,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,10 +73,7 @@ function affinity(
|
||||
}
|
||||
|
||||
function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
const sourceText = [
|
||||
slot.slotId,
|
||||
slot.name,
|
||||
].join(' ');
|
||||
const sourceText = [slot.slotId, slot.name].join(' ');
|
||||
|
||||
const semanticVector: AttributeVector = {};
|
||||
|
||||
@@ -73,7 +86,10 @@ function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
score += 1.2;
|
||||
}
|
||||
|
||||
score += rule.patterns.reduce((sum, pattern) => sum + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
score += rule.patterns.reduce(
|
||||
(sum, pattern) => sum + (pattern.test(sourceText) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (score > 0) {
|
||||
semanticVector[rule.axisId] = roundNumber(score, 4);
|
||||
@@ -87,25 +103,34 @@ function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
|
||||
return normalizeAttributeVector(
|
||||
semanticVector,
|
||||
SEMANTIC_SLOT_RULES.map(rule => rule.axisId),
|
||||
SEMANTIC_SLOT_RULES.map((rule) => rule.axisId),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateVectorSimilarity(left: AttributeVector, right: AttributeVector) {
|
||||
function calculateVectorSimilarity(
|
||||
left: AttributeVector,
|
||||
right: AttributeVector,
|
||||
) {
|
||||
return roundNumber(
|
||||
Object.keys({...left, ...right}).reduce(
|
||||
(sum, key) => sum + ((left[key] ?? 0) * (right[key] ?? 0)),
|
||||
Object.keys({ ...left, ...right }).reduce(
|
||||
(sum, key) => sum + (left[key] ?? 0) * (right[key] ?? 0),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSchemaAwareAffinity(tagAffinity: AttributeVector, schema: WorldAttributeSchema) {
|
||||
function resolveSchemaAwareAffinity(
|
||||
tagAffinity: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
const rawSimilarity = Object.fromEntries(
|
||||
schema.slots.map((slot, index) => [
|
||||
slot.slotId,
|
||||
calculateVectorSimilarity(tagAffinity, buildSlotSemanticVector(slot, index)),
|
||||
calculateVectorSimilarity(
|
||||
tagAffinity,
|
||||
buildSlotSemanticVector(slot, index),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -113,7 +138,7 @@ function resolveSchemaAwareAffinity(tagAffinity: AttributeVector, schema: WorldA
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(
|
||||
rawSimilarity,
|
||||
schema.slots.map(slot => slot.slotId),
|
||||
schema.slots.map((slot) => slot.slotId),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -162,17 +187,26 @@ export const BUILD_TAG_ATTRIBUTE_AFFINITY: BuildTagAttributeAffinityMap = {
|
||||
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);
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
+357
-78
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ function buildFramesFromConfig(
|
||||
|
||||
for (let index = 0; index < config.frames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${normalizedBasePath}/${config.prefix}${frameNumber}.${extension}`);
|
||||
frames.push(
|
||||
`${normalizedBasePath}/${config.prefix}${frameNumber}.${extension}`,
|
||||
);
|
||||
}
|
||||
|
||||
return frames;
|
||||
@@ -41,14 +43,18 @@ function buildFramesFromConfig(
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = encodeURIComponent(config.folder);
|
||||
if (config.file) {
|
||||
return [`${root}/${folderPrefix}/${folder}/${encodeURIComponent(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}`);
|
||||
frames.push(
|
||||
`${root}/${folderPrefix}/${folder}/${config.prefix}${frameNumber}.${extension}`,
|
||||
);
|
||||
}
|
||||
|
||||
return frames;
|
||||
@@ -61,7 +67,7 @@ function buildFramesFromAsset(
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = sequence.folder
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
if (sequence.file) {
|
||||
@@ -75,7 +81,9 @@ function buildFramesFromAsset(
|
||||
|
||||
for (let index = 0; index < totalFrames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${root}/${folder}/${sequence.prefix ?? ''}${frameNumber}.${extension}`);
|
||||
frames.push(
|
||||
`${root}/${folder}/${sequence.prefix ?? ''}${frameNumber}.${extension}`,
|
||||
);
|
||||
}
|
||||
|
||||
return frames;
|
||||
@@ -101,9 +109,15 @@ export function getSequenceFps(sequence: SpriteSequenceDefinition) {
|
||||
return sequence.fps ?? DEFAULT_FPS;
|
||||
}
|
||||
|
||||
export function getSequenceDurationMs(sequence: SpriteSequenceDefinition, frameCount: number) {
|
||||
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));
|
||||
return Math.max(
|
||||
DEFAULT_FRAME_MS,
|
||||
Math.ceil((Math.max(1, frameCount) * 1000) / fps),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSequenceFrames(
|
||||
@@ -122,6 +136,8 @@ export function getSkillCasterAnimation(skill: CharacterSkillDefinition) {
|
||||
return skill.casterAnimation ?? skill.animation;
|
||||
}
|
||||
|
||||
export function getSkillDelivery(skill: CharacterSkillDefinition): CombatDelivery {
|
||||
export function getSkillDelivery(
|
||||
skill: CharacterSkillDefinition,
|
||||
): CombatDelivery {
|
||||
return skill.delivery ?? (skill.style === 'projectile' ? 'ranged' : 'melee');
|
||||
}
|
||||
|
||||
@@ -229,9 +229,7 @@ describe('characterPresets custom world runtime characters', () => {
|
||||
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?.generatedVisualAssetId).toBe('visual-custom-shenwu');
|
||||
expect(storyCharacter?.generatedAnimationSetId).toBe(
|
||||
'animation-set-custom-shenwu',
|
||||
);
|
||||
|
||||
+975
-315
File diff suppressed because it is too large
Load Diff
+41
-16
@@ -3,7 +3,9 @@ import { MAX_COMPANIONS } from './npcInteractions';
|
||||
|
||||
function upsertCompanion(list: CompanionState[], companion: CompanionState) {
|
||||
const next = [...list];
|
||||
const existingIndex = next.findIndex(item => item.npcId === companion.npcId);
|
||||
const existingIndex = next.findIndex(
|
||||
(item) => item.npcId === companion.npcId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
next[existingIndex] = companion;
|
||||
return next;
|
||||
@@ -14,39 +16,58 @@ function upsertCompanion(list: CompanionState[], companion: CompanionState) {
|
||||
}
|
||||
|
||||
function removeCompanion(list: CompanionState[], npcId: string) {
|
||||
return list.filter(item => item.npcId !== npcId);
|
||||
return list.filter((item) => item.npcId !== npcId);
|
||||
}
|
||||
|
||||
export function getRecruitedNpcIds(state: Pick<GameState, 'companions' | 'roster'>) {
|
||||
export function getRecruitedNpcIds(
|
||||
state: Pick<GameState, 'companions' | 'roster'>,
|
||||
) {
|
||||
return new Set([
|
||||
...state.companions.map(companion => companion.npcId),
|
||||
...state.roster.map(companion => companion.npcId),
|
||||
...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));
|
||||
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), []);
|
||||
.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);
|
||||
const activeCompanion = state.companions.find(
|
||||
(companion) => companion.npcId === npcId,
|
||||
);
|
||||
if (!activeCompanion) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: state.companions.filter(companion => companion.npcId !== npcId),
|
||||
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);
|
||||
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)) {
|
||||
if (state.companions.some((companion) => companion.npcId === npcId)) {
|
||||
return {
|
||||
...state,
|
||||
roster: removeCompanion(state.roster, npcId),
|
||||
@@ -62,7 +83,9 @@ export function activateRosterCompanion(state: GameState, npcId: string, swapNpc
|
||||
}
|
||||
|
||||
if (!swapNpcId) return state;
|
||||
const swapIndex = state.companions.findIndex(companion => companion.npcId === swapNpcId);
|
||||
const swapIndex = state.companions.findIndex(
|
||||
(companion) => companion.npcId === swapNpcId,
|
||||
);
|
||||
if (swapIndex < 0) return state;
|
||||
|
||||
const swappedOut = state.companions[swapIndex];
|
||||
@@ -103,7 +126,9 @@ export function recruitCompanionToParty(
|
||||
};
|
||||
}
|
||||
|
||||
const replaceIndex = state.companions.findIndex(item => item.npcId === replacedNpcId);
|
||||
const replaceIndex = state.companions.findIndex(
|
||||
(item) => item.npcId === replacedNpcId,
|
||||
);
|
||||
if (replaceIndex < 0) {
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { type CustomWorldThemeMode,detectCustomWorldThemeMode } from '../services/customWorldTheme';
|
||||
import { type Character, type CustomWorldPlayableNpc, type CustomWorldProfile } from '../types';
|
||||
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'
|
||||
| 'name'
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
>;
|
||||
|
||||
type CustomWorldTagRole = Pick<
|
||||
CustomWorldPlayableNpc,
|
||||
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'combatStyle'
|
||||
| 'tags'
|
||||
>;
|
||||
|
||||
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
|
||||
@@ -31,7 +49,8 @@ const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
|
||||
|
||||
const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{
|
||||
pattern: /\u5f13|\u7bad|\u5f29|\u72d9|\u8fdc\u7a0b|\u8239|\u822a|\u5de1|\u730e|\u5c04/u,
|
||||
pattern:
|
||||
/\u5f13|\u7bad|\u5f29|\u72d9|\u8fdc\u7a0b|\u8239|\u822a|\u5de1|\u730e|\u5c04/u,
|
||||
tags: ['\u8fdc\u5c04', '\u673a\u52a8'],
|
||||
},
|
||||
{
|
||||
@@ -47,11 +66,13 @@ const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
tags: ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
|
||||
},
|
||||
{
|
||||
pattern: /\u836f|\u533b|\u7597|\u4e39|\u9732|\u8349|\u8c37|\u6108|\u8865\u7ed9/u,
|
||||
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,
|
||||
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'],
|
||||
},
|
||||
{
|
||||
@@ -59,21 +80,26 @@ const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
tags: ['\u96f7\u6cd5', '\u7206\u53d1'],
|
||||
},
|
||||
{
|
||||
pattern: /\u673a\u5173|\u5668\u4fee|\u953b|\u94f8|\u5de5\u574a|\u6cd5\u5668|\u673a\u5de7/u,
|
||||
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,
|
||||
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,
|
||||
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))];
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
];
|
||||
}
|
||||
|
||||
function inferBuildTagsFromTexts(values: string[]) {
|
||||
@@ -83,9 +109,9 @@ function inferBuildTagsFromTexts(values: string[]) {
|
||||
}
|
||||
|
||||
return normalizeBuildTags(
|
||||
TEXT_TAG_RULES
|
||||
.filter(rule => rule.pattern.test(source))
|
||||
.flatMap(rule => rule.tags),
|
||||
TEXT_TAG_RULES.filter((rule) => rule.pattern.test(source)).flatMap(
|
||||
(rule) => rule.tags,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +143,7 @@ export function deriveCustomWorldCombatTags(
|
||||
const inferredTags = inferBuildTagsFromTexts(sourceTexts);
|
||||
const themeTags = THEME_FALLBACK_TAGS[detectCustomWorldThemeMode(profile)];
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
? (TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? [])
|
||||
: [];
|
||||
|
||||
return normalizeBuildTags([
|
||||
@@ -140,7 +166,7 @@ export function mergeCustomWorldPlayableNpcTags(
|
||||
) {
|
||||
const combatTags = deriveCustomWorldCombatTags(profile, role, options);
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
? (TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? [])
|
||||
: [];
|
||||
|
||||
return uniqueStrings([
|
||||
|
||||
@@ -51,16 +51,46 @@ const STOP_PHRASES = new Set([
|
||||
]);
|
||||
|
||||
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'] },
|
||||
{
|
||||
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) {
|
||||
@@ -68,14 +98,16 @@ function normalizeExplicitItemCategory(category: string) {
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | null {
|
||||
function inferEquipmentSlotFromCategory(
|
||||
category: string,
|
||||
): EquipmentSlotId | null {
|
||||
const normalized = normalizeExplicitItemCategory(category);
|
||||
if (normalized === '武器') return 'weapon';
|
||||
if (normalized === '护甲') return 'armor';
|
||||
if (
|
||||
normalized === '饰品'
|
||||
|| normalized === '稀有品'
|
||||
|| normalized === '专属物品'
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic';
|
||||
}
|
||||
@@ -128,20 +160,26 @@ 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;
|
||||
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);
|
||||
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);
|
||||
const categoryDelta =
|
||||
(CATEGORY_ORDER.get(left.category) ?? 99) -
|
||||
(CATEGORY_ORDER.get(right.category) ?? 99);
|
||||
if (categoryDelta !== 0) {
|
||||
return categoryDelta;
|
||||
}
|
||||
@@ -150,16 +188,21 @@ function sortInventoryByCategory(items: InventoryItem[]) {
|
||||
}
|
||||
|
||||
function collectPhrases(sourceTexts: string[]) {
|
||||
return sourceTexts.flatMap(text =>
|
||||
return sourceTexts.flatMap((text) =>
|
||||
text
|
||||
.split(/[[\]\s,。、“”‘’;:?�?.!?:()()【�?]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 12)
|
||||
.filter(segment => !STOP_PHRASES.has(segment)),
|
||||
.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) {
|
||||
function collectChineseNgrams(
|
||||
value: string,
|
||||
minSize = 2,
|
||||
maxSize = 4,
|
||||
limit = 16,
|
||||
) {
|
||||
const source = value.replace(/[^\u4e00-\u9fa5]/g, '');
|
||||
const grams: string[] = [];
|
||||
|
||||
@@ -190,8 +233,12 @@ function buildKeywordBundle(
|
||||
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?.skills.map(
|
||||
(skill) => `${skill.name} ${skill.summary} ${skill.style}`,
|
||||
) ?? []),
|
||||
...(role?.initialItems.map(
|
||||
(item) => `${item.name} ${item.category} ${item.description}`,
|
||||
) ?? []),
|
||||
...(role?.tags ?? []),
|
||||
];
|
||||
const characterTexts = [
|
||||
@@ -207,7 +254,9 @@ function buildKeywordBundle(
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
];
|
||||
const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(Boolean);
|
||||
const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(
|
||||
Boolean,
|
||||
);
|
||||
const phrases = collectPhrases(sourceTexts);
|
||||
const ngrams = [
|
||||
...collectChineseNgrams(role?.title ?? '', 2, 4, 12),
|
||||
@@ -215,24 +264,30 @@ function buildKeywordBundle(
|
||||
...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);
|
||||
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),
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,9 +298,9 @@ function queryItems(
|
||||
) {
|
||||
const items = buildRuntimeCustomWorldInventoryItems(seedKey, baseOptions);
|
||||
const categoryFallbackTriggered = Boolean(
|
||||
fallbackOptions
|
||||
&& baseOptions.categories?.length
|
||||
&& items.some(item => !baseOptions.categories!.includes(item.category)),
|
||||
fallbackOptions &&
|
||||
baseOptions.categories?.length &&
|
||||
items.some((item) => !baseOptions.categories!.includes(item.category)),
|
||||
);
|
||||
if ((items.length > 0 && !categoryFallbackTriggered) || !fallbackOptions) {
|
||||
return items;
|
||||
@@ -257,7 +312,7 @@ function mergeUniqueItems(...groups: InventoryItem[][]) {
|
||||
const result: InventoryItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
groups.flat().forEach(item => {
|
||||
groups.flat().forEach((item) => {
|
||||
const key = `${item.category}:${item.name}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
@@ -285,11 +340,11 @@ export function buildCustomWorldStarterEquipmentItems(
|
||||
const role = resolveCustomWorldRole(profile, character);
|
||||
const explicitItems = buildExplicitRoleInventoryItems(role);
|
||||
const explicitWeapon =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null;
|
||||
explicitItems.find((item) => item.equipmentSlotId === 'weapon') ?? null;
|
||||
const explicitArmor =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'armor') ?? null;
|
||||
explicitItems.find((item) => item.equipmentSlotId === 'armor') ?? null;
|
||||
const explicitRelic =
|
||||
explicitItems.find(item => item.equipmentSlotId === 'relic') ?? null;
|
||||
explicitItems.find((item) => item.equipmentSlotId === 'relic') ?? null;
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const baseTextKeywords = bundle.keywords;
|
||||
const baseTags = bundle.preferredTags;
|
||||
@@ -299,28 +354,66 @@ export function buildCustomWorldStarterEquipmentItems(
|
||||
categories: ['武器'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'weapon', '战斗']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, role?.combatStyle ?? '', '武器', '战斗']),
|
||||
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, '信物', '关键']),
|
||||
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,
|
||||
@@ -345,11 +438,17 @@ export function buildCustomWorldStarterInventoryItems(
|
||||
count: 2,
|
||||
quantity: 2,
|
||||
categories: ['消耗品'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
|
||||
preferredTags: dedupeStrings([
|
||||
...bundle.preferredTags,
|
||||
'healing',
|
||||
'mana',
|
||||
'补给',
|
||||
'探索',
|
||||
]),
|
||||
keywords: dedupeStrings([
|
||||
...bundle.keywords,
|
||||
role?.combatStyle ?? '',
|
||||
...explicitItems.map(item => item.name),
|
||||
...explicitItems.map((item) => item.name),
|
||||
'调息',
|
||||
'续战',
|
||||
]),
|
||||
@@ -358,25 +457,64 @@ export function buildCustomWorldStarterInventoryItems(
|
||||
count: 1,
|
||||
quantity: 2,
|
||||
categories: ['材料'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'material', 'forge', 'alchemy']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.backstory ?? character.backstory, '材料']),
|
||||
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, '线索', '寻路']),
|
||||
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, '信物', '关键']),
|
||||
preferredTags: dedupeStrings([
|
||||
...bundle.preferredTags,
|
||||
'剧情关键',
|
||||
'异变',
|
||||
'旧史',
|
||||
'rare',
|
||||
]),
|
||||
keywords: dedupeStrings([
|
||||
...bundle.keywords,
|
||||
profile.playerGoal,
|
||||
profile.name,
|
||||
'信物',
|
||||
'关键',
|
||||
]),
|
||||
});
|
||||
|
||||
const merged = mergeUniqueItems(explicitItems, consumables, materials, rareUtility, signature);
|
||||
const merged = mergeUniqueItems(
|
||||
explicitItems,
|
||||
consumables,
|
||||
materials,
|
||||
rareUtility,
|
||||
signature,
|
||||
);
|
||||
if (merged.length >= 5) {
|
||||
return sortInventoryByCategory(merged.slice(0, 5));
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
title: '返乡守灯人',
|
||||
role: '主角代理',
|
||||
description: '追查旧案的人',
|
||||
visualDescription: '瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
|
||||
visualDescription:
|
||||
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
|
||||
actionDescription: '抬灯照出雾中航线,侧身抽出卷边海图迅速标记。',
|
||||
sceneVisualDescription: '旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
|
||||
sceneVisualDescription:
|
||||
'旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
@@ -24,9 +26,11 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
title: '群岛议长',
|
||||
role: '遮掩者',
|
||||
description: '压住旧档的人',
|
||||
visualDescription: '银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
|
||||
visualDescription:
|
||||
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
|
||||
actionDescription: '用印信压住卷宗,抬手示意巡海队封锁出口。',
|
||||
sceneVisualDescription: '议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
|
||||
sceneVisualDescription:
|
||||
'议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -35,7 +39,9 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.actionDescription).toContain('抬灯');
|
||||
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain('旧灯塔');
|
||||
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain(
|
||||
'旧灯塔',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.visualDescription).toBe(
|
||||
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
|
||||
);
|
||||
@@ -66,9 +72,7 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(profile?.worldHook).toBe(
|
||||
'在失真的海图上追查一场被篡改的沉船事故。',
|
||||
);
|
||||
expect(profile?.worldHook).toBe('在失真的海图上追查一场被篡改的沉船事故。');
|
||||
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
|
||||
});
|
||||
@@ -173,7 +177,8 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
openingCg: {
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
|
||||
storyboardImageSrc:
|
||||
'/generated-custom-world-scenes/opening/storyboard.png',
|
||||
storyboardAssetId: 'storyboard-1',
|
||||
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
|
||||
videoAssetId: 'video-1',
|
||||
@@ -358,9 +363,7 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_a: 8, axis_b: 7 },
|
||||
topTraits: ['星砂共鸣'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
|
||||
],
|
||||
evidence: [{ slotId: 'axis_a', reason: '能听见星砂潮汐。' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -374,9 +377,7 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
|
||||
schemaId: 'schema-stardust',
|
||||
values: { axis_c: 9 },
|
||||
topTraits: ['钟楼感知'],
|
||||
evidence: [
|
||||
{ slotId: 'axis_c', reason: '能辨认旧铃回声。' },
|
||||
],
|
||||
evidence: [{ slotId: 'axis_c', reason: '能辨认旧铃回声。' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -184,7 +184,9 @@ function preserveStructuredRecord<T>(value: unknown): T | null {
|
||||
|
||||
function preserveStructuredRecordArray<T>(value: unknown): T[] | null {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((entry): entry is Record<string, unknown> => isRecord(entry)) as T[])
|
||||
? (value.filter((entry): entry is Record<string, unknown> =>
|
||||
isRecord(entry),
|
||||
) as T[])
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -671,7 +673,8 @@ function normalizePlayableNpc(
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
|
||||
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
|
||||
const currentPressure =
|
||||
toText(value.currentPressure) || toText(value.hiddenHook);
|
||||
const relationToPlayer = toText(value.relationToPlayer);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -680,13 +683,14 @@ function normalizePlayableNpc(
|
||||
description: toText(value.description) || publicMask,
|
||||
backstory: toText(value.backstory) || currentPressure,
|
||||
personality: toText(value.personality) || publicMask,
|
||||
motivation:
|
||||
toText(value.motivation) || relationToPlayer || currentPressure,
|
||||
motivation: toText(value.motivation) || relationToPlayer || currentPressure,
|
||||
combatStyle: toText(value.combatStyle),
|
||||
relationshipHooks:
|
||||
relationshipHooks.length > 0
|
||||
? relationshipHooks
|
||||
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
|
||||
: [relationToPlayer, currentPressure, ...tags]
|
||||
.filter(Boolean)
|
||||
.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
@@ -696,8 +700,10 @@ function normalizePlayableNpc(
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
visualDescription: toText(value.visualDescription) || publicMask || undefined,
|
||||
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
|
||||
visualDescription:
|
||||
toText(value.visualDescription) || publicMask || undefined,
|
||||
actionDescription:
|
||||
toText(value.actionDescription) || currentPressure || undefined,
|
||||
sceneVisualDescription:
|
||||
toText(value.sceneVisualDescription) || currentPressure || undefined,
|
||||
backstory: fallbackSource.backstory,
|
||||
@@ -752,7 +758,8 @@ function normalizeStoryNpc(
|
||||
.slice(0, 8);
|
||||
const tags = toStringArray(value.tags);
|
||||
const publicMask = toText(value.publicMask) || toText(value.publicIdentity);
|
||||
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
|
||||
const currentPressure =
|
||||
toText(value.currentPressure) || toText(value.hiddenHook);
|
||||
const relationToPlayer = toText(value.relationToPlayer);
|
||||
const fallbackSource = {
|
||||
name,
|
||||
@@ -761,13 +768,14 @@ function normalizeStoryNpc(
|
||||
description: toText(value.description) || publicMask,
|
||||
backstory: toText(value.backstory) || currentPressure,
|
||||
personality: toText(value.personality) || publicMask,
|
||||
motivation:
|
||||
toText(value.motivation) || relationToPlayer || currentPressure,
|
||||
motivation: toText(value.motivation) || relationToPlayer || currentPressure,
|
||||
combatStyle: toText(value.combatStyle),
|
||||
relationshipHooks:
|
||||
relationshipHooks.length > 0
|
||||
? relationshipHooks
|
||||
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).slice(0, 3),
|
||||
: [relationToPlayer, currentPressure, ...tags]
|
||||
.filter(Boolean)
|
||||
.slice(0, 3),
|
||||
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
|
||||
} satisfies CustomWorldRoleFallbackSource;
|
||||
|
||||
@@ -777,8 +785,10 @@ function normalizeStoryNpc(
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
visualDescription: toText(value.visualDescription) || publicMask || undefined,
|
||||
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
|
||||
visualDescription:
|
||||
toText(value.visualDescription) || publicMask || undefined,
|
||||
actionDescription:
|
||||
toText(value.actionDescription) || currentPressure || undefined,
|
||||
sceneVisualDescription:
|
||||
toText(value.sceneVisualDescription) || currentPressure || undefined,
|
||||
backstory: fallbackSource.backstory,
|
||||
@@ -1034,8 +1044,8 @@ function normalizeSceneActBlueprint(
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: backgroundImageSrc || undefined,
|
||||
backgroundPromptText: backgroundPromptText || undefined,
|
||||
backgroundAssetId: backgroundAssetId || undefined,
|
||||
@@ -1079,12 +1089,7 @@ function normalizeSceneChapterBlueprints(
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(
|
||||
act,
|
||||
actIndex,
|
||||
sceneId,
|
||||
profileRoles,
|
||||
),
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId, profileRoles),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
@@ -1133,7 +1138,10 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
: null;
|
||||
const worldHook = toText(
|
||||
value.worldHook,
|
||||
toText(creatorIntentRecord?.worldHook, toText(value.summary, settingText || name)),
|
||||
toText(
|
||||
creatorIntentRecord?.worldHook,
|
||||
toText(value.summary, settingText || name),
|
||||
),
|
||||
);
|
||||
const playerPremise = toText(
|
||||
value.playerPremise,
|
||||
@@ -1182,9 +1190,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
const openingCg = preserveStructuredRecord<CustomWorldOpeningCgProfile>(
|
||||
value.openingCg,
|
||||
);
|
||||
const cover = preserveStructuredRecord<CustomWorldCoverProfile>(
|
||||
value.cover,
|
||||
);
|
||||
const cover = preserveStructuredRecord<CustomWorldCoverProfile>(value.cover);
|
||||
const normalizedProfile = {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
@@ -1217,22 +1223,24 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts:
|
||||
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
|
||||
threadContracts:
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
value.sceneChapterBlueprints,
|
||||
{
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
},
|
||||
),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
),
|
||||
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts: preserveStructuredRecordArray<KnowledgeFact>(
|
||||
value.knowledgeFacts,
|
||||
),
|
||||
threadContracts: preserveStructuredRecordArray<ThreadContract>(
|
||||
value.threadContracts,
|
||||
),
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
value.sceneChapterBlueprints,
|
||||
{
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
},
|
||||
),
|
||||
anchorContent: preserveStructuredRecord<EightAnchorContent>(
|
||||
value.anchorContent,
|
||||
),
|
||||
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
anchorPack:
|
||||
value.anchorPack && typeof value.anchorPack === 'object'
|
||||
|
||||
@@ -136,7 +136,9 @@ function scoreMonsterPresetWithArchetype(
|
||||
preset: HostileNpcPreset,
|
||||
sourceText: string,
|
||||
options: {
|
||||
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
|
||||
archetypeSignals?: ReturnType<
|
||||
typeof collectCreatureArchetypeSignals
|
||||
> | null;
|
||||
preferredWorldType?: WorldType | null;
|
||||
} = {},
|
||||
) {
|
||||
@@ -149,9 +151,13 @@ function scoreMonsterPresetWithArchetype(
|
||||
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))
|
||||
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;
|
||||
}
|
||||
@@ -171,9 +177,9 @@ function scoreMonsterPresetWithArchetype(
|
||||
}
|
||||
|
||||
if (
|
||||
preferredWorldType
|
||||
&& preferredWorldType !== WorldType.CUSTOM
|
||||
&& preset.worldType === preferredWorldType
|
||||
preferredWorldType &&
|
||||
preferredWorldType !== WorldType.CUSTOM &&
|
||||
preset.worldType === preferredWorldType
|
||||
) {
|
||||
score += 3;
|
||||
}
|
||||
@@ -184,7 +190,9 @@ function scoreMonsterPresetWithArchetype(
|
||||
export function getCustomWorldMonsterPresetPool(
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
const presets = getAllMonsterPresets();
|
||||
@@ -200,17 +208,20 @@ export function getCustomWorldMonsterPresetPool(
|
||||
: 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);
|
||||
const archetypeScore = creatureArchetypes.reduce(
|
||||
(bestScore, archetype) => {
|
||||
const nextScore = scoreMonsterPresetWithArchetype(
|
||||
preset,
|
||||
preset.name,
|
||||
{
|
||||
archetypeSignals: collectCreatureArchetypeSignals(archetype),
|
||||
preferredWorldType,
|
||||
},
|
||||
);
|
||||
return Math.max(bestScore, nextScore);
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
preset,
|
||||
@@ -231,7 +242,9 @@ export function resolveCustomWorldNpcMonsterPreset(
|
||||
worldType?: WorldType | null,
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
const sourceText = buildMonsterSourceText(npc);
|
||||
@@ -246,7 +259,7 @@ export function resolveCustomWorldNpcMonsterPreset(
|
||||
|
||||
const preferredWorldType = profile
|
||||
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
||||
: worldType ?? null;
|
||||
: (worldType ?? null);
|
||||
const referenceArchetype = resolveCreatureArchetypeForSource(
|
||||
profile as CustomWorldProfile | null | undefined,
|
||||
npc,
|
||||
@@ -255,7 +268,8 @@ export function resolveCustomWorldNpcMonsterPreset(
|
||||
? collectCreatureArchetypeSignals(referenceArchetype)
|
||||
: null;
|
||||
const candidates =
|
||||
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
|
||||
profile &&
|
||||
profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
|
||||
? getCustomWorldMonsterPresetPool(profile)
|
||||
: getMonsterPresetPool(worldType);
|
||||
if (candidates.length === 0) {
|
||||
@@ -284,8 +298,12 @@ export function resolveCustomWorldNpcMonsterPresetId(
|
||||
worldType?: WorldType | null,
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
|
||||
return (
|
||||
resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
+153
-49
@@ -3,11 +3,19 @@ import {
|
||||
detectCustomWorldThemeMode,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||
} from '../services/customWorldTheme';
|
||||
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldProfile,
|
||||
InventoryItem,
|
||||
WorldTemplateType,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
|
||||
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
|
||||
|
||||
export function setRuntimeCustomWorldProfile(profile: CustomWorldProfile | null) {
|
||||
export function setRuntimeCustomWorldProfile(
|
||||
profile: CustomWorldProfile | null,
|
||||
) {
|
||||
runtimeCustomWorldProfile = profile;
|
||||
}
|
||||
|
||||
@@ -17,7 +25,10 @@ export function getRuntimeCustomWorldProfile() {
|
||||
|
||||
export function resolveCompatibilityTemplateWorldType(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
||||
customWorldProfile:
|
||||
| CustomWorldProfile
|
||||
| null
|
||||
| undefined = runtimeCustomWorldProfile,
|
||||
): WorldTemplateType | null {
|
||||
if (!worldType) return null;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
@@ -41,22 +52,32 @@ function hashText(value: string) {
|
||||
}
|
||||
|
||||
function compactStrings(values: Array<string | null | undefined | false>) {
|
||||
return [...new Set(
|
||||
values
|
||||
.map(value => typeof value === 'string' ? value.trim() : '')
|
||||
.filter(Boolean),
|
||||
)];
|
||||
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) {
|
||||
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 {
|
||||
function toInventoryItem(
|
||||
item: CustomWorldItem,
|
||||
quantity: number,
|
||||
seedKey: string,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: normalizeInventoryItemId(item, quantity, seedKey),
|
||||
category: item.category,
|
||||
@@ -89,8 +110,22 @@ export interface RuntimeCustomWorldItemQueryOptions {
|
||||
rarityFloor?: CustomWorldItem['rarity'];
|
||||
}
|
||||
|
||||
const RARITY_ORDER: CustomWorldItem['rarity'][] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
||||
const DEFAULT_RUNTIME_CATEGORIES = ['武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物'] as const;
|
||||
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', '防护'],
|
||||
@@ -146,26 +181,49 @@ function buildRuntimeItemTags(
|
||||
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;
|
||||
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))) {
|
||||
if (
|
||||
category === '消耗品' &&
|
||||
preferredTags.some((tag) => /mana|法力|灵气|内息/u.test(tag))
|
||||
) {
|
||||
baseTags.push('mana');
|
||||
}
|
||||
if (category === '消耗品' && preferredTags.some(tag => /heal|疗|血|恢复/u.test(tag))) {
|
||||
if (
|
||||
category === '消耗品' &&
|
||||
preferredTags.some((tag) => /heal|疗|血|恢复/u.test(tag))
|
||||
) {
|
||||
baseTags.push('healing');
|
||||
}
|
||||
|
||||
return compactStrings([...baseTags, selectedPreferredTag, selectedKeywordTag]).slice(0, 5);
|
||||
return compactStrings([
|
||||
...baseTags,
|
||||
selectedPreferredTag,
|
||||
selectedKeywordTag,
|
||||
]).slice(0, 5);
|
||||
}
|
||||
|
||||
function inferRuntimeItemRarity(seed: number, rarityFloorValue: number): CustomWorldItem['rarity'] {
|
||||
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';
|
||||
}
|
||||
@@ -194,7 +252,10 @@ function inferRuntimeItemMechanics(
|
||||
equipmentSlotId: 'armor' as const,
|
||||
statProfile: {
|
||||
maxHpBonus: 10 * rarityTier + (seed % 8),
|
||||
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
||||
incomingDamageMultiplier: Math.max(
|
||||
0.72,
|
||||
Number((1 - rarityTier * 0.04).toFixed(2)),
|
||||
),
|
||||
},
|
||||
useProfile: null,
|
||||
value: 26 * rarityTier,
|
||||
@@ -218,7 +279,10 @@ function inferRuntimeItemMechanics(
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: tags.includes('mana')
|
||||
? { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 }
|
||||
? {
|
||||
manaRestore: 12 * rarityTier,
|
||||
cooldownReduction: rarityTier >= 3 ? 1 : 0,
|
||||
}
|
||||
: { hpRestore: 16 * rarityTier },
|
||||
value: 18 * rarityTier,
|
||||
};
|
||||
@@ -241,7 +305,11 @@ function buildProceduralRuntimeItem(
|
||||
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 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);
|
||||
@@ -251,7 +319,9 @@ function buildProceduralRuntimeItem(
|
||||
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 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}`;
|
||||
@@ -277,10 +347,16 @@ function matchesRuntimeQuery(
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
rarityFloorValue: number,
|
||||
) {
|
||||
if (options.categories?.length && !options.categories.includes(item.category)) {
|
||||
if (
|
||||
options.categories?.length &&
|
||||
!options.categories.includes(item.category)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (options.tags?.length && !options.tags.some(tag => item.tags.includes(tag))) {
|
||||
if (
|
||||
options.tags?.length &&
|
||||
!options.tags.some((tag) => item.tags.includes(tag))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (rarityFloorValue >= 0) {
|
||||
@@ -292,18 +368,26 @@ function matchesRuntimeQuery(
|
||||
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)));
|
||||
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 => {
|
||||
const preferredTags = [
|
||||
...new Set(
|
||||
(options.preferredTags ?? []).map(normalizeLookupText).filter(Boolean),
|
||||
),
|
||||
];
|
||||
preferredTags.forEach((tag) => {
|
||||
if (itemTags.has(tag)) {
|
||||
score += 10;
|
||||
return;
|
||||
@@ -313,8 +397,14 @@ function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldIt
|
||||
}
|
||||
});
|
||||
|
||||
const keywords = [...new Set((options.keywords ?? []).map(normalizeLookupText).filter(keyword => keyword.length >= 2))];
|
||||
keywords.forEach(keyword => {
|
||||
const keywords = [
|
||||
...new Set(
|
||||
(options.keywords ?? [])
|
||||
.map(normalizeLookupText)
|
||||
.filter((keyword) => keyword.length >= 2),
|
||||
),
|
||||
];
|
||||
keywords.forEach((keyword) => {
|
||||
if (!haystack.includes(keyword)) {
|
||||
return;
|
||||
}
|
||||
@@ -331,10 +421,15 @@ function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldIt
|
||||
return score;
|
||||
}
|
||||
|
||||
function rankItems(items: CustomWorldItem[], seedKey: string, options: RuntimeCustomWorldItemQueryOptions = {}) {
|
||||
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);
|
||||
const relevanceDelta =
|
||||
scoreItemRelevance(right, options) - scoreItemRelevance(left, options);
|
||||
if (relevanceDelta !== 0) {
|
||||
return relevanceDelta;
|
||||
}
|
||||
@@ -353,13 +448,20 @@ export function pickRuntimeCustomWorldItems(
|
||||
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 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));
|
||||
const filtered = sourceItems.filter((item) =>
|
||||
matchesRuntimeQuery(item, options, rarityFloorValue),
|
||||
);
|
||||
|
||||
return rankItems(filtered.length > 0 ? filtered : sourceItems, seedKey, options).slice(0, options.count ?? 1);
|
||||
return rankItems(
|
||||
filtered.length > 0 ? filtered : sourceItems,
|
||||
seedKey,
|
||||
options,
|
||||
).slice(0, options.count ?? 1);
|
||||
}
|
||||
|
||||
export function buildRuntimeCustomWorldInventoryItems(
|
||||
@@ -369,5 +471,7 @@ export function buildRuntimeCustomWorldInventoryItems(
|
||||
const count = options.count ?? 1;
|
||||
return pickRuntimeCustomWorldItems(seedKey, options)
|
||||
.slice(0, count)
|
||||
.map((item, index) => toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`));
|
||||
.map((item, index) =>
|
||||
toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
|
||||
function buildLandmarkLookup(
|
||||
landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>,
|
||||
) {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
@@ -170,8 +172,7 @@ export function getCustomWorldSceneRelativePositionLabel(
|
||||
export function normalizeCustomWorldSceneRelativePosition(
|
||||
value: unknown,
|
||||
): CustomWorldSceneRelativePosition {
|
||||
const normalizedValue =
|
||||
typeof value === 'string' ? normalizeKey(value) : '';
|
||||
const normalizedValue = typeof value === 'string' ? normalizeKey(value) : '';
|
||||
|
||||
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
|
||||
if (option.value === normalizedValue) {
|
||||
@@ -285,9 +286,10 @@ function resolveConnectionsForLandmark(
|
||||
relativePosition: normalizeCustomWorldSceneRelativePosition(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: typeof connection.summary === 'string'
|
||||
? connection.summary.trim()
|
||||
: '',
|
||||
summary:
|
||||
typeof connection.summary === 'string'
|
||||
? connection.summary.trim()
|
||||
: '',
|
||||
} satisfies CustomWorldSceneConnection;
|
||||
})
|
||||
.filter((connection): connection is CustomWorldSceneConnection =>
|
||||
@@ -299,7 +301,9 @@ 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]));
|
||||
const nameMap = new Map(
|
||||
landmarks.map((landmark) => [landmark.id, landmark.name]),
|
||||
);
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
landmark.connections.forEach((connection) => {
|
||||
|
||||
@@ -108,7 +108,17 @@ const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
},
|
||||
{
|
||||
name: '边关营地',
|
||||
keywords: ['营地', '驻地', '营火', '关隘', '边关', '据点', '归舍', '落脚', '住处'],
|
||||
keywords: [
|
||||
'营地',
|
||||
'驻地',
|
||||
'营火',
|
||||
'关隘',
|
||||
'边关',
|
||||
'据点',
|
||||
'归舍',
|
||||
'落脚',
|
||||
'住处',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '地宫通道',
|
||||
@@ -239,7 +249,8 @@ function collectWorldSceneImagePool(worldType: WorldTemplateType) {
|
||||
|
||||
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;
|
||||
const assignedWorld =
|
||||
globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
||||
if (assignedWorld === worldType) {
|
||||
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
@@ -255,12 +266,15 @@ export function normalizeOptionalImageSrc(value: unknown) {
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
|
||||
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] ?? [];
|
||||
const references =
|
||||
COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
||||
|
||||
return references.map((reference, index) => ({
|
||||
...reference,
|
||||
@@ -269,10 +283,7 @@ function buildSceneReferencePool(worldType: WorldTemplateType) {
|
||||
}
|
||||
|
||||
function buildOwnedSceneReferencePool(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
'id' | 'name' | 'ownedSettingLayers'
|
||||
>,
|
||||
profile: Pick<CustomWorldProfile, 'id' | 'name' | 'ownedSettingLayers'>,
|
||||
) {
|
||||
const sceneBuckets =
|
||||
profile.ownedSettingLayers?.referenceProfile.sceneBuckets ?? [];
|
||||
@@ -287,8 +298,8 @@ function buildOwnedSceneReferencePool(
|
||||
|
||||
return sceneBuckets.map((bucket, index) => {
|
||||
const offset =
|
||||
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`)
|
||||
% pool.length;
|
||||
hashText(`${profile.id || profile.name}:${bucket.id}:${bucket.label}`) %
|
||||
pool.length;
|
||||
|
||||
return {
|
||||
name: bucket.label,
|
||||
@@ -307,17 +318,19 @@ function buildSourceText(
|
||||
const profile = options.profile;
|
||||
const landmark = options.landmark;
|
||||
const themeHints = profile
|
||||
? ({
|
||||
mythic: '归处 旧痕 路途 异象 线索',
|
||||
martial: '刀剑 风尘 旧约 行路 关隘',
|
||||
arcane: '云阶 法纹 星辉 秘藏 回响',
|
||||
machina: '工坊 轨道 装置 核心 机械',
|
||||
tide: '潮雾 港湾 岸线 水路 回潮',
|
||||
rift: '裂痕 断层 前线 边界 异压',
|
||||
} as const)[detectCustomWorldThemeMode(profile)]
|
||||
: (worldType === WorldType.XIANXIA
|
||||
? '云阶 法纹 星辉 秘藏 回响'
|
||||
: '刀剑 风尘 旧约 行路 关隘');
|
||||
? (
|
||||
{
|
||||
mythic: '归处 旧痕 路途 异象 线索',
|
||||
martial: '刀剑 风尘 旧约 行路 关隘',
|
||||
arcane: '云阶 法纹 星辉 秘藏 回响',
|
||||
machina: '工坊 轨道 装置 核心 机械',
|
||||
tide: '潮雾 港湾 岸线 水路 回潮',
|
||||
rift: '裂痕 断层 前线 边界 异压',
|
||||
} as const
|
||||
)[detectCustomWorldThemeMode(profile)]
|
||||
: worldType === WorldType.XIANXIA
|
||||
? '云阶 法纹 星辉 秘藏 回响'
|
||||
: '刀剑 风尘 旧约 行路 关隘';
|
||||
|
||||
return uniqueStrings([
|
||||
profile?.name,
|
||||
@@ -344,7 +357,10 @@ function buildSignalChars(text: string) {
|
||||
];
|
||||
}
|
||||
|
||||
function scoreSceneReference(reference: SceneImageReference, sourceText: string) {
|
||||
function scoreSceneReference(
|
||||
reference: SceneImageReference,
|
||||
sourceText: string,
|
||||
) {
|
||||
let score = 0;
|
||||
|
||||
if (sourceText.includes(reference.name)) {
|
||||
@@ -380,10 +396,7 @@ function scoreSceneReference(reference: SceneImageReference, sourceText: string)
|
||||
return score;
|
||||
}
|
||||
|
||||
function getFirstUnusedImage(
|
||||
candidates: string[],
|
||||
usedImageSrcs: Set<string>,
|
||||
) {
|
||||
function getFirstUnusedImage(candidates: string[], usedImageSrcs: Set<string>) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && !usedImageSrcs.has(candidate)) {
|
||||
return candidate;
|
||||
@@ -394,7 +407,8 @@ function getFirstUnusedImage(
|
||||
}
|
||||
|
||||
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
|
||||
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
|
||||
const offset =
|
||||
hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
|
||||
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
|
||||
}
|
||||
|
||||
@@ -412,7 +426,9 @@ export function getDefaultCustomWorldSceneImage(
|
||||
? getAllCustomWorldSceneImages()
|
||||
: collectWorldSceneImagePool(worldType);
|
||||
if (pool.length === 0) {
|
||||
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
|
||||
return worldType === WorldType.WUXIA
|
||||
? '/scene_bg/45_PixelSky.png'
|
||||
: '/scene_bg/47_PixelSky.png';
|
||||
}
|
||||
|
||||
const usedImageSrcs = new Set(
|
||||
@@ -442,12 +458,10 @@ export function getDefaultCustomWorldSceneImage(
|
||||
.map((reference, referenceIndex) => ({
|
||||
imageSrc: reference.imageSrc,
|
||||
score:
|
||||
scoreSceneReference(reference, sourceText)
|
||||
+ (
|
||||
preferredSceneBucket && reference.name === preferredSceneBucket.label
|
||||
? 28
|
||||
: 0
|
||||
),
|
||||
scoreSceneReference(reference, sourceText) +
|
||||
(preferredSceneBucket && reference.name === preferredSceneBucket.label
|
||||
? 28
|
||||
: 0),
|
||||
tieBreaker: hashText(`${seedKey}:${reference.name}:${referenceIndex}`),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
@@ -469,11 +483,9 @@ export function getDefaultCustomWorldSceneImage(
|
||||
return matchedReferenceImage;
|
||||
}
|
||||
|
||||
const offset = hashText(`${seedKey}:scene:${index}:${sourceText}`) % pool.length;
|
||||
const rotatedPool = [
|
||||
...pool.slice(offset),
|
||||
...pool.slice(0, offset),
|
||||
];
|
||||
const offset =
|
||||
hashText(`${seedKey}:scene:${index}:${sourceText}`) % pool.length;
|
||||
const rotatedPool = [...pool.slice(offset), ...pool.slice(0, offset)];
|
||||
|
||||
return getFirstUnusedImage(rotatedPool, usedImageSrcs);
|
||||
}
|
||||
@@ -491,7 +503,10 @@ export function resolveCustomWorldLandmarkImage(
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'imageSrc'>,
|
||||
landmark: Pick<
|
||||
CustomWorldLandmark,
|
||||
'id' | 'name' | 'description' | 'imageSrc'
|
||||
>,
|
||||
index: number,
|
||||
usedImageSrcs?: Iterable<string>,
|
||||
) {
|
||||
|
||||
+10
-4
@@ -74,14 +74,20 @@ export function getInventoryItemValue(item: InventoryItem) {
|
||||
|
||||
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));
|
||||
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));
|
||||
const buybackMultiplier = 0.4 + discountTier * 0.06;
|
||||
return Math.max(
|
||||
4,
|
||||
Math.round(getInventoryItemValue(item) * buybackMultiplier),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCurrency(
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { GameState } from '../types';
|
||||
import { getFacingTowardPlayer, getMonsterGroupAnchorX, PLAYER_BASE_X_METERS } from './hostileNpcs';
|
||||
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));
|
||||
return roundMeters(start + (end - start) * progress);
|
||||
}
|
||||
|
||||
export function hasEncounterEntity(state: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>) {
|
||||
export function hasEncounterEntity(
|
||||
state: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
|
||||
) {
|
||||
return state.sceneHostileNpcs.length > 0 || Boolean(state.currentEncounter);
|
||||
}
|
||||
|
||||
@@ -21,7 +27,7 @@ export function buildEncounterEntryState(
|
||||
const anchorX = getMonsterGroupAnchorX(finalState.sceneHostileNpcs);
|
||||
return {
|
||||
...finalState,
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map((monster) => {
|
||||
const offset = monster.xMeters - anchorX;
|
||||
const xMeters = roundMeters(entryX + offset);
|
||||
return {
|
||||
@@ -54,10 +60,12 @@ export function buildEncounterTransitionState(
|
||||
sourceState: Pick<GameState, 'sceneHostileNpcs' | 'currentEncounter'>,
|
||||
): GameState {
|
||||
if (finalState.sceneHostileNpcs.length > 0) {
|
||||
const sourceById = new Map(sourceState.sceneHostileNpcs.map(monster => [monster.id, monster]));
|
||||
const sourceById = new Map(
|
||||
sourceState.sceneHostileNpcs.map((monster) => [monster.id, monster]),
|
||||
);
|
||||
return {
|
||||
...finalState,
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map((monster) => {
|
||||
const sourceMonster = sourceById.get(monster.id);
|
||||
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
|
||||
return {
|
||||
@@ -76,7 +84,9 @@ export function buildEncounterTransitionState(
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
|
||||
xMeters:
|
||||
sourceState.currentEncounter?.xMeters ??
|
||||
finalState.currentEncounter.xMeters,
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
};
|
||||
@@ -91,12 +101,18 @@ export function interpolateEncounterTransitionState(
|
||||
progress: number,
|
||||
): GameState {
|
||||
if (finalState.sceneHostileNpcs.length > 0) {
|
||||
const startById = new Map(startState.sceneHostileNpcs.map(monster => [monster.id, monster]));
|
||||
const startById = new Map(
|
||||
startState.sceneHostileNpcs.map((monster) => [monster.id, monster]),
|
||||
);
|
||||
return {
|
||||
...finalState,
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map(monster => {
|
||||
sceneHostileNpcs: finalState.sceneHostileNpcs.map((monster) => {
|
||||
const startMonster = startById.get(monster.id);
|
||||
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
|
||||
const xMeters = lerp(
|
||||
startMonster?.xMeters ?? monster.xMeters,
|
||||
monster.xMeters,
|
||||
progress,
|
||||
);
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
@@ -109,7 +125,10 @@ export function interpolateEncounterTransitionState(
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
const startX = startState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters ?? 0;
|
||||
const startX =
|
||||
startState.currentEncounter?.xMeters ??
|
||||
finalState.currentEncounter.xMeters ??
|
||||
0;
|
||||
const endX = finalState.currentEncounter.xMeters ?? startX;
|
||||
return {
|
||||
...finalState,
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Character, CustomWorldProfile, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
|
||||
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';
|
||||
import {
|
||||
getCharacterEquipment,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
} from './characterPresets';
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
maxHpBonus: number;
|
||||
@@ -88,9 +100,11 @@ function normalizePresetRarity(rarityText: string | undefined): ItemRarity {
|
||||
}
|
||||
|
||||
function inferSlotFromText(value: string) {
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const;
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value))
|
||||
return 'weapon' as const;
|
||||
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const;
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const;
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value))
|
||||
return 'relic' as const;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -123,7 +137,10 @@ function buildStarterEquipmentItem(
|
||||
};
|
||||
}
|
||||
|
||||
function inferStarterBuildProfile(slot: EquipmentSlotId, name: string): InventoryItem['buildProfile'] {
|
||||
function inferStarterBuildProfile(
|
||||
slot: EquipmentSlotId,
|
||||
name: string,
|
||||
): InventoryItem['buildProfile'] {
|
||||
const source = `${slot} ${name}`;
|
||||
|
||||
if (/弓|箭|矢/u.test(source)) {
|
||||
@@ -188,7 +205,9 @@ function inferStarterBuildProfile(slot: EquipmentSlotId, name: string): Inventor
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotFromItem(item: InventoryItem): EquipmentSlotId | null {
|
||||
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';
|
||||
@@ -209,12 +228,17 @@ export function buildInitialEquipmentLoadout(
|
||||
const starterEquipment = getCharacterEquipment(character, customWorldProfile);
|
||||
|
||||
starterEquipment.forEach((equipmentItem, index) => {
|
||||
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)
|
||||
?? EQUIPMENT_SLOTS[index]
|
||||
?? null;
|
||||
const inferredSlot =
|
||||
inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`) ??
|
||||
EQUIPMENT_SLOTS[index] ??
|
||||
null;
|
||||
if (!inferredSlot || loadout[inferredSlot]) return;
|
||||
|
||||
loadout[inferredSlot] = buildStarterEquipmentItem(character.id, equipmentItem, inferredSlot);
|
||||
loadout[inferredSlot] = buildStarterEquipmentItem(
|
||||
character.id,
|
||||
equipmentItem,
|
||||
inferredSlot,
|
||||
);
|
||||
});
|
||||
|
||||
return loadout;
|
||||
@@ -254,18 +278,23 @@ function getItemEquipmentBonuses(item: InventoryItem, slot: EquipmentSlotId) {
|
||||
return {
|
||||
maxHpBonus: statProfile?.maxHpBonus ?? fallback.maxHpBonus,
|
||||
maxManaBonus: statProfile?.maxManaBonus ?? fallback.maxManaBonus,
|
||||
outgoingDamageBonus: statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
|
||||
incomingDamageMultiplier: statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
|
||||
outgoingDamageBonus:
|
||||
statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
|
||||
incomingDamageMultiplier:
|
||||
statProfile?.incomingDamageMultiplier ??
|
||||
fallback.incomingDamageMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentBonuses(loadout: EquipmentLoadout): EquipmentBonuses {
|
||||
export function getEquipmentBonuses(
|
||||
loadout: EquipmentLoadout,
|
||||
): EquipmentBonuses {
|
||||
let maxHpBonus = 0;
|
||||
let maxManaBonus = 0;
|
||||
let outgoingDamageBonus = 0;
|
||||
let incomingDamageMultiplier = 1;
|
||||
|
||||
EQUIPMENT_SLOTS.forEach(slot => {
|
||||
EQUIPMENT_SLOTS.forEach((slot) => {
|
||||
const item = loadout[slot];
|
||||
if (!item) return;
|
||||
|
||||
@@ -297,7 +326,9 @@ export function applyEquipmentLoadoutToState(
|
||||
)
|
||||
: Math.max(1, state.playerMaxHp);
|
||||
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
|
||||
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;
|
||||
const nextMaxMana = state.playerCharacter
|
||||
? getCharacterMaxMana(state.playerCharacter)
|
||||
: state.playerMaxMana;
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -313,8 +344,12 @@ 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,
|
||||
bonuses.outgoingDamageMultiplier > 1
|
||||
? `伤害 x${bonuses.outgoingDamageMultiplier}`
|
||||
: null,
|
||||
bonuses.incomingDamageMultiplier < 1
|
||||
? `承伤 x${bonuses.incomingDamageMultiplier}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(',') : '暂无额外加成';
|
||||
|
||||
+212
-100
@@ -6,7 +6,11 @@ import type {
|
||||
ItemStatProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import {
|
||||
getSimilarBuildTags,
|
||||
normalizeBuildRole,
|
||||
normalizeBuildTags,
|
||||
} from './buildTags';
|
||||
import { formatCurrency } from './economy';
|
||||
import { getEquipmentSlotFromItem } from './equipmentEffects';
|
||||
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
|
||||
@@ -112,12 +116,21 @@ function buildEquipmentItem(params: {
|
||||
}) {
|
||||
return {
|
||||
id: createItemId(`forge-equip:${params.name}`),
|
||||
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
|
||||
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',
|
||||
params.slot === 'weapon'
|
||||
? 'weapon'
|
||||
: params.slot === 'armor'
|
||||
? 'armor'
|
||||
: 'relic',
|
||||
...normalizeBuildTags(params.tags),
|
||||
],
|
||||
description: params.description,
|
||||
@@ -137,32 +150,58 @@ function buildEquipmentItem(params: {
|
||||
}
|
||||
|
||||
function buildRefinedIngot() {
|
||||
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
|
||||
return buildMaterialItem(
|
||||
'精炼锭材',
|
||||
1,
|
||||
['工巧', '守御'],
|
||||
'rare',
|
||||
'经过二次锻压的通用金属锭材,可用于武器与护甲锻造。',
|
||||
);
|
||||
}
|
||||
|
||||
function buildCondensedSilk() {
|
||||
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
|
||||
return buildMaterialItem(
|
||||
'凝光纱',
|
||||
1,
|
||||
['工巧', '法力'],
|
||||
'rare',
|
||||
'适合饰品与法器类配方的高阶纤维材料。',
|
||||
);
|
||||
}
|
||||
|
||||
function buildTagEssence(tag: string) {
|
||||
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
|
||||
return buildMaterialItem(
|
||||
`${tag}精粹`,
|
||||
1,
|
||||
[tag, '工巧'],
|
||||
'rare',
|
||||
`从旧装备中提炼出的 ${tag} 构筑精粹。`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
|
||||
function buildAnyMaterialRequirement(
|
||||
id: string,
|
||||
label: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: item => item.tags.includes('material') || item.category.includes('材料'),
|
||||
matches: (item) =>
|
||||
item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
|
||||
function buildNamedMaterialRequirement(
|
||||
name: string,
|
||||
quantity: number,
|
||||
): ForgeRequirement {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: item => item.name === name,
|
||||
matches: (item) => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,9 +213,7 @@ const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 3),
|
||||
],
|
||||
requirements: [buildAnyMaterialRequirement('material:any', '任意材料', 3)],
|
||||
createResult: () => buildRefinedIngot(),
|
||||
},
|
||||
{
|
||||
@@ -192,7 +229,9 @@ const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
id: 'tag:mana',
|
||||
label: '含法力标签材料',
|
||||
quantity: 1,
|
||||
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
|
||||
matches: (item) =>
|
||||
(item.tags.includes('material') || item.category.includes('材料')) &&
|
||||
item.tags.includes('mana'),
|
||||
},
|
||||
],
|
||||
createResult: () => buildCondensedSilk(),
|
||||
@@ -209,22 +248,24 @@ const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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',
|
||||
@@ -238,24 +279,25 @@ const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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',
|
||||
@@ -269,34 +311,41 @@ const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
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,
|
||||
},
|
||||
}),
|
||||
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) {
|
||||
function countMatchingItems(
|
||||
inventory: InventoryItem[],
|
||||
requirement: ForgeRequirement,
|
||||
) {
|
||||
return inventory
|
||||
.filter(item => requirement.matches(item))
|
||||
.filter((item) => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
function consumeRequirement(
|
||||
inventory: InventoryItem[],
|
||||
requirement: ForgeRequirement,
|
||||
) {
|
||||
let remaining = requirement.quantity;
|
||||
let nextInventory = [...inventory];
|
||||
|
||||
@@ -312,7 +361,10 @@ function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequir
|
||||
return remaining === 0 ? nextInventory : null;
|
||||
}
|
||||
|
||||
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
|
||||
function applyRequirementsIfPossible(
|
||||
inventory: InventoryItem[],
|
||||
requirements: ForgeRequirement[],
|
||||
) {
|
||||
let nextInventory = [...inventory];
|
||||
for (const requirement of requirements) {
|
||||
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
||||
@@ -322,7 +374,10 @@ function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: F
|
||||
return nextInventory;
|
||||
}
|
||||
|
||||
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
|
||||
function buildDismantleBaseMaterials(
|
||||
item: InventoryItem,
|
||||
slot: EquipmentSlotId | null,
|
||||
) {
|
||||
const rarityScale: Record<ItemRarity, number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
@@ -333,15 +388,43 @@ function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId
|
||||
|
||||
const amount = rarityScale[item.rarity];
|
||||
if (slot === 'weapon') {
|
||||
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
return [
|
||||
buildMaterialItem(
|
||||
'武器残片',
|
||||
amount,
|
||||
['工巧', '重击'],
|
||||
item.rarity === 'common' ? 'common' : 'uncommon',
|
||||
),
|
||||
];
|
||||
}
|
||||
if (slot === 'armor') {
|
||||
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
return [
|
||||
buildMaterialItem(
|
||||
'甲片',
|
||||
amount,
|
||||
['工巧', '守御'],
|
||||
item.rarity === 'common' ? 'common' : 'uncommon',
|
||||
),
|
||||
];
|
||||
}
|
||||
if (slot === 'relic') {
|
||||
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
return [
|
||||
buildMaterialItem(
|
||||
'灵饰碎片',
|
||||
amount,
|
||||
['工巧', '法力'],
|
||||
item.rarity === 'common' ? 'common' : 'uncommon',
|
||||
),
|
||||
];
|
||||
}
|
||||
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
|
||||
return [
|
||||
buildMaterialItem(
|
||||
'零散材料',
|
||||
Math.max(1, Math.ceil(amount / 2)),
|
||||
['工巧'],
|
||||
'common',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildDismantleEssences(item: InventoryItem) {
|
||||
@@ -350,17 +433,26 @@ function buildDismantleEssences(item: InventoryItem) {
|
||||
item.buildProfile?.role ?? '',
|
||||
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
||||
|
||||
return buildTags.map(tag => buildTagEssence(tag));
|
||||
return buildTags.map((tag) => buildTagEssence(tag));
|
||||
}
|
||||
|
||||
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
|
||||
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));
|
||||
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));
|
||||
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;
|
||||
}
|
||||
@@ -375,7 +467,9 @@ function buildReforgedItem(item: InventoryItem) {
|
||||
const currentTags = normalizeBuildTags(item.buildProfile.tags);
|
||||
const primaryTag = currentTags[0];
|
||||
const replacement = primaryTag
|
||||
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
|
||||
? (getSimilarBuildTags(primaryTag, 0.6).find(
|
||||
(tag) => !currentTags.includes(tag),
|
||||
) ?? primaryTag)
|
||||
: null;
|
||||
|
||||
const nextTags = normalizeBuildTags([
|
||||
@@ -417,7 +511,7 @@ export function getForgeRecipeViews(
|
||||
playerCurrency = 0,
|
||||
worldType: WorldType | null = null,
|
||||
) {
|
||||
return FORGE_RECIPES.map(recipe => ({
|
||||
return FORGE_RECIPES.map((recipe) => ({
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
kind: recipe.kind,
|
||||
@@ -425,7 +519,7 @@ export function getForgeRecipeViews(
|
||||
resultLabel: recipe.resultLabel,
|
||||
currencyCost: recipe.currencyCost,
|
||||
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
||||
requirements: recipe.requirements.map(requirement => ({
|
||||
requirements: recipe.requirements.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
@@ -433,7 +527,10 @@ export function getForgeRecipeViews(
|
||||
})),
|
||||
canCraft:
|
||||
playerCurrency >= recipe.currencyCost &&
|
||||
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
|
||||
recipe.requirements.every(
|
||||
(requirement) =>
|
||||
countMatchingItems(inventory, requirement) >= requirement.quantity,
|
||||
),
|
||||
})) satisfies ForgeRecipeView[];
|
||||
}
|
||||
|
||||
@@ -443,10 +540,13 @@ export function executeForgeRecipe(
|
||||
worldType: WorldType | null,
|
||||
playerCurrency: number,
|
||||
): ForgeExecutionResult | null {
|
||||
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
|
||||
const recipe = FORGE_RECIPES.find((candidate) => candidate.id === recipeId);
|
||||
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
||||
const consumedInventory = applyRequirementsIfPossible(
|
||||
inventory,
|
||||
recipe.requirements,
|
||||
);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
const createdItem = recipe.createResult(worldType);
|
||||
@@ -457,8 +557,11 @@ export function executeForgeRecipe(
|
||||
};
|
||||
}
|
||||
|
||||
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
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);
|
||||
@@ -470,7 +573,10 @@ export function executeDismantleItem(inventory: InventoryItem[], itemId: string)
|
||||
];
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
||||
inventory: addInventoryItems(
|
||||
removeInventoryItem(inventory, itemId, 1),
|
||||
outputs,
|
||||
),
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
@@ -480,7 +586,7 @@ export function executeReforgeItem(
|
||||
itemId: string,
|
||||
playerCurrency: number,
|
||||
): ReforgeExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
const targetItem = inventory.find((item) => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
@@ -501,13 +607,16 @@ export function executeReforgeItem(
|
||||
};
|
||||
}
|
||||
|
||||
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
|
||||
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 => ({
|
||||
requirements: cost.requirements.map((requirement) => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
@@ -515,13 +624,16 @@ export function getReforgeCostView(item: InventoryItem, worldType: WorldType | n
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
|
||||
sourceItemName?: string;
|
||||
recipeName?: string;
|
||||
createdItemName?: string;
|
||||
outputNames?: string[];
|
||||
currencyText?: string;
|
||||
}) {
|
||||
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}` : ''}。`;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ function createModalState(overrides: Partial<GameState> = {}): GameState {
|
||||
|
||||
describe('functionCatalog', () => {
|
||||
it('keeps function documentation ids unique and source files resolvable', () => {
|
||||
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
|
||||
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
|
||||
expect(new Set(documentationIds).size).toBe(documentationIds.length);
|
||||
ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
|
||||
|
||||
@@ -37,9 +37,9 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
detailedDescription:
|
||||
'它会把当前互动引到礼物选择 modal,礼物列表、好感增益和不可选原因都读取后端 runtimeNpcInteraction view。',
|
||||
trigger: '后端判断当前 NPC 可接收礼物时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击只打开 gift modal,确认礼物后只提交 itemId 给后端结算。',
|
||||
result: '玩家可立即看到后端结算后的好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
execution: '首次点击只打开 gift modal,确认礼物后只提交 itemId 给后端结算。',
|
||||
result:
|
||||
'玩家可立即看到后端结算后的好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user