223 lines
6.7 KiB
TypeScript
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}在当前画像中最突出。`,
|
|
})),
|
|
};
|
|
}
|