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