统一 Rust 与 TypeScript 格式化门禁

纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
This commit is contained in:
2026-09-01 16:26:20 +08:00
parent ae79d50b14
commit 071faa482c
1051 changed files with 28412 additions and 19834 deletions
+4 -8
View File
@@ -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',
},
+4 -3
View File
@@ -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),
+123 -41
View File
@@ -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,
};
}
+43 -21
View File
@@ -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;
+55 -27
View File
@@ -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}在当前画像中最突出。`,
})),
+59 -25
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -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',
);
File diff suppressed because it is too large Load Diff
+41 -16
View File
@@ -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,
+42 -16
View File
@@ -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([
+214 -76
View File
@@ -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));
}
+16 -15
View File
@@ -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: '能辨认旧铃回声。' }],
},
},
],
+49 -41
View File
@@ -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'
+42 -24
View File
@@ -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
View File
@@ -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}`),
);
}
+11 -7
View File
@@ -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) => {
+55 -40
View File
@@ -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
View File
@@ -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(
+30 -11
View File
@@ -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,
+52 -17
View File
@@ -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
View File
@@ -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) => {
+3 -3
View File
@@ -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