Files
Genarrative/src/data/attributeValidation.ts
2026-04-28 20:25:37 +08:00

223 lines
6.7 KiB
TypeScript

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