game-maker / suite (push) Failing after 8m59s
This reverses the design of the previous commit. `PropertyDef.scope` is gone without replacement: a behavior variable is an object property again, without exception, and sharing is something you point at rather than something you declare twice. A **Variables asset** is declarations and nothing else -- no graph, no position, never placed, no tab of its own (`TAB_OF_ASSET` is already `Partial<>`, so that costs no line). A `Variables` node names one through its new `variablesAsset` setting, empty meaning the behavior's own variables as before, and then reads and writes one value per key for the whole run. Its property list follows the chosen source, which was the actual request: a value another graph declared is now offerable, where before every participant had to declare the name itself. That deletes more than it adds (-72 lines net): `globalConflicts` and its run-stopping kind clash, `SetVariableScopeCommand`, the scope picker, both object-inspector filters and the "shared by the whole run" note on the placed object. A typo is no longer a silent second variable -- naming an asset is picking from a list. And two Variables assets may carry the same key without meeting, because the asset is the key, not the name. The one shape it copies is the one that already existed: `Set state` picks its machine the same way. Lifetime is unchanged. The bag is built at `BehaviorSystem.start()` from the declarations and never written back, so playing in the editor cannot change the project -- which is the whole reason a counter must not be an object property (`World.syncToModel` would `commit()` it, and the coin counter would stand at 7 for good). Full symmetry across that gap, without mirroring anything. Two named wires: `Inspector.liveVariables` shows the values as they stand while a run is on and falls back to the declarations when it ends, and `adoptDeclarations()` takes a key into the run *only where the document changed it since the last sync*. Without that condition any unrelated project edit would snap a running counter back to its starting value; a counter-test bends exactly that and falls over. Nothing was ever cached, incidentally: `GraphInterpreter` only pulse-caches nodes without a `read`, and the variable node has one. The wire was missing. The state machine `Scope` row is now an ordinary `enum` row through `createFieldRow`. It was hand-built from an `HStack` with a ghost dropdown and began ~34px left of every value above it; the explanations beside `Local` and `Global` are gone from the menu, the sentence below the row keeps saying it. Machine scope is otherwise untouched -- `StateMachineAsset.scope`, `SetMachineScopeCommand`, `stateBag()` and its persistence all stand, so #59 stays closed as built and `Scope` as a type now carries machines alone. Six counter-proofs, each biting at its own guard. One of them did not bite at first: the suggestion list was computed twice, in `nodeSection` and `portSection`, so only the browser test covered the request itself. It is one named function now, `variableChoices`, with a unit test of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
// Importing the graph package index is what registers the built-in nodes.
|
||
import {
|
||
createEmptyGraph,
|
||
type DeclaredPort,
|
||
type DeclaredPortKind,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isValidPortName,
|
||
fixedPortKeys,
|
||
type NodeDef,
|
||
nodeDef,
|
||
STATE,
|
||
} from '../graph';
|
||
import { isParticleNodeDef } from '../particles/nodes';
|
||
import { isShaderNodeDef } from '../shader/nodes';
|
||
import type { Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
|
||
import { isAssetType } from '../model/AssetTypes';
|
||
import { isFontFile } from '../model/font';
|
||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||
import {
|
||
type FieldValue,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
tagOf,
|
||
} from '../model/Properties';
|
||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||
import { readScope } from '../model/Scope';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import { isTextureFile, normalizeFrameRect } from '../model/texture';
|
||
import { hasParentCycle } from '../model/walk';
|
||
import {
|
||
DECLARED_PORT_KINDS,
|
||
FIELD_KINDS,
|
||
type FieldKind,
|
||
isTaggedKind,
|
||
VARIABLE_KINDS,
|
||
type VariableKind,
|
||
} from '../properties/kinds';
|
||
import { type FlatItem, makeFlatItem } from '../properties/listItems';
|
||
import {
|
||
coerceValue,
|
||
isValidFieldName,
|
||
isValidVariableName,
|
||
type PropertyDef,
|
||
type PropertySchema,
|
||
rowsNotClaimedBy,
|
||
schemaFor,
|
||
type StructFieldDef,
|
||
variableDef,
|
||
} from '../properties/PropertySchema';
|
||
import { particleParamDefs } from '../properties/particles';
|
||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||
|
||
/**
|
||
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
|
||
* missing ones get defaults, values are coerced against the schema, dangling objects are removed.
|
||
*/
|
||
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||
const r = asRecord(raw);
|
||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
||
|
||
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
|
||
const schemas = new Map<string, PropertySchema>();
|
||
const variables = new Map<string, PropertyDef[]>();
|
||
const rawProps = new Map<string, Record<string, unknown>>();
|
||
const shaderGraphs = new Map<string, Graph>();
|
||
const shaderRows = new Map<string, PropertyDef[]>();
|
||
const particleGraphs = new Map<string, Graph>();
|
||
const wearers = new Map<string, 'sprite' | 'particles'>();
|
||
for (const a of rawAssets) {
|
||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||
rawProps.set(a.id, asRecord(a.props));
|
||
if (a.type === 'sprite' || a.type === 'particles') wearers.set(a.id, a.type);
|
||
if (a.type === 'behavior' || a.type === 'variables') {
|
||
const vars = normalizeVariables(a.variables);
|
||
variables.set(a.id, vars);
|
||
// The full schema, not just the common rows: this is what pass 2 coerces `props` against, and
|
||
// a behavior's own `runInEditor` would be dropped as undeclared.
|
||
schemas.set(a.id, [...schemaFor(a.type), ...vars]);
|
||
} else if (a.type === 'particles') {
|
||
// A source's rows end with its own graph's parameters, so the graph is needed before the
|
||
// overrides of the objects placed from it can be coerced.
|
||
const graph = normalizeGraph(a.graph, isParticleNodeDef);
|
||
particleGraphs.set(a.id, graph);
|
||
schemas.set(a.id, [...schemaFor('particles'), ...particleParamDefs(graph)]);
|
||
} else {
|
||
if (a.type === 'shader') {
|
||
const graph = normalizeGraph(a.graph, isShaderNodeDef);
|
||
shaderGraphs.set(a.id, graph);
|
||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||
}
|
||
schemas.set(a.id, schemaFor(a.type));
|
||
}
|
||
}
|
||
/**
|
||
* A sprite's or source's rows end with the parameters of the shader its bag names – an override's,
|
||
* else the asset's. A source already carries its own graph's rows, so a name it has stays its own.
|
||
*/
|
||
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
|
||
const schema = schemas.get(assetId);
|
||
const wearer = wearers.get(assetId);
|
||
if (!schema || !wearer) return schema;
|
||
const own = bag[SHADER_KEY];
|
||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
|
||
const rows = shaderRows.get(typeof shader === 'string' ? shader : '') ?? [];
|
||
return [...schema, ...(wearer === 'particles' ? rowsNotClaimedBy(rows, schema) : rows)];
|
||
};
|
||
|
||
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
|
||
// is handed the ids already spoken for.
|
||
const assets: Asset[] = [];
|
||
const takenIds = new Set(schemas.keys());
|
||
for (const a of rawAssets) {
|
||
const id = typeof a.id === 'string' ? a.id : '';
|
||
const schema = schemaOf(id, asRecord(a.props));
|
||
if (!schema || !isAssetType(a.type)) continue;
|
||
const type = a.type;
|
||
const name = typeof a.name === 'string' ? a.name : type;
|
||
const props = normalizeBag(a.props, schema, false);
|
||
if (type === 'scene' || type === 'container') {
|
||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemaOf) });
|
||
} else if (type === 'behavior') {
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
variables: variables.get(id) ?? [],
|
||
graph: normalizeGraph(a.graph),
|
||
});
|
||
} else if (type === 'variables') {
|
||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [] });
|
||
} else if (type === 'statemachine') {
|
||
const machine: StateMachineAsset = {
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
graph: normalizeGraph(a.graph, (def) => def.type === STATE),
|
||
};
|
||
if (readScope(a.scope) === 'global') machine.scope = 'global';
|
||
assets.push(machine);
|
||
} else if (type === 'shader') {
|
||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'particles') {
|
||
assets.push({ id, type, name, props, graph: particleGraphs.get(id) ?? createEmptyGraph() });
|
||
} else if (type === 'texture') {
|
||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||
} else if (type === 'font') {
|
||
const file = typeof a.file === 'string' && isFontFile(a.file) ? a.file : '';
|
||
assets.push({ id, type, name, props, file });
|
||
} else if (type === 'animation') {
|
||
// Kept even when they dangle, like a sprite's `texture`: a frame reads as no picture.
|
||
const frames = Array.isArray(a.frames) ? a.frames : [];
|
||
assets.push({
|
||
id,
|
||
type,
|
||
name,
|
||
props,
|
||
frames: frames.filter((f): f is string => typeof f === 'string' && f !== ''),
|
||
});
|
||
} else {
|
||
assets.push({ id, type, name, props });
|
||
}
|
||
}
|
||
|
||
let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : '';
|
||
if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) {
|
||
const first = assets.find((a) => a.type === 'scene');
|
||
activeSceneId = first ? first.id : '';
|
||
}
|
||
|
||
const project: Project = {
|
||
formatVersion: PROJECT_FORMAT_VERSION,
|
||
id: typeof r.id === 'string' && r.id ? r.id : fallbackId,
|
||
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||
assets,
|
||
activeSceneId,
|
||
};
|
||
return project;
|
||
}
|
||
|
||
/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */
|
||
export function serializeProject(project: Project): unknown {
|
||
return JSON.parse(JSON.stringify(project));
|
||
}
|
||
|
||
function normalizeObjects(
|
||
raw: unknown,
|
||
schemaOf: (assetId: string, overrides: Record<string, unknown>) => PropertySchema | undefined,
|
||
): SceneObject[] {
|
||
const objects: SceneObject[] = [];
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const o = asRecord(item);
|
||
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
||
const schema = schemaOf(o.assetId, asRecord(o.overrides));
|
||
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
||
const p = asRecord(o.position);
|
||
const offset = asRecord(o.editorOffset);
|
||
objects.push({
|
||
id: o.id,
|
||
assetId: o.assetId,
|
||
name: typeof o.name === 'string' ? o.name : '',
|
||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||
editorOffset: { x: toNumber(offset.x), y: toNumber(offset.y) },
|
||
overrides: normalizeBag(o.overrides, schema, true),
|
||
parentId: typeof o.parentId === 'string' ? o.parentId : '',
|
||
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
|
||
});
|
||
}
|
||
// Parents must exist in the same scene, differ from the object itself and not form cycles.
|
||
const byId = new Map(objects.map((o) => [o.id, o]));
|
||
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
|
||
// Cut one link per cycle: an object already unlinked here is a root for the objects checked after it.
|
||
const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined);
|
||
for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = '';
|
||
return objects;
|
||
}
|
||
|
||
/**
|
||
* Deviations of container content, keyed by template path. Values are not coerced against a schema:
|
||
* which asset a path leads to depends on container assets that may not be built yet, and resolving
|
||
* drops what the schema does not declare anyway.
|
||
*/
|
||
function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
|
||
const edits: Record<string, InstanceEdit> = {};
|
||
for (const [path, value] of Object.entries(asRecord(raw))) {
|
||
if (!path) continue;
|
||
const e = asRecord(value);
|
||
const edit = emptyEdit();
|
||
if (typeof e.name === 'string') edit.name = e.name;
|
||
const position = asRecord(e.position);
|
||
for (const axis of ['x', 'y', 'z'] as const)
|
||
if (typeof position[axis] === 'number' && Number.isFinite(position[axis]))
|
||
edit.position[axis] = position[axis];
|
||
const offset = asRecord(e.editorOffset);
|
||
if (typeof offset.x === 'number' || typeof offset.y === 'number')
|
||
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
|
||
for (const [key, v] of Object.entries(asRecord(e.overrides))) {
|
||
const value = asStoredValue(v);
|
||
if (value !== undefined) edit.overrides[key] = value;
|
||
}
|
||
if (!isEmptyEdit(edit)) edits[path] = edit;
|
||
}
|
||
return edits;
|
||
}
|
||
|
||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||
const defs: PropertyDef[] = [];
|
||
const seen = new Set<string>();
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const v = asRecord(item);
|
||
const key = typeof v.key === 'string' ? v.key : '';
|
||
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind))
|
||
? (v.kind as VariableKind)
|
||
: 'number';
|
||
// Common property keys are reserved – a variable of that name would shadow the switch.
|
||
if (!isValidVariableName(key) || seen.has(key)) continue;
|
||
seen.add(key);
|
||
const fields = normalizeStructFields(v.fields);
|
||
const def = variableDef(key, kind, undefined, fields);
|
||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
|
||
defs.push(def);
|
||
}
|
||
return defs;
|
||
}
|
||
|
||
/** An unreadable field is dropped rather than defaulted: an invented key would claim a value. */
|
||
function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||
const fields: StructFieldDef[] = [];
|
||
const seen = new Set<string>();
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const f = asRecord(item);
|
||
const key = typeof f.key === 'string' ? f.key : '';
|
||
if (!isValidFieldName(key) || seen.has(key)) continue;
|
||
const field = readField(key, f);
|
||
if (!field) continue;
|
||
seen.add(key);
|
||
fields.push(field);
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
|
||
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
|
||
if (f.kind === 'list') return { key, kind: 'list' };
|
||
return isFieldKind(f.kind) ? { key, kind: f.kind as FieldKind } : null;
|
||
}
|
||
|
||
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
|
||
const ports: DeclaredPort[] = [];
|
||
const seen = new Set(fixed);
|
||
for (const item of Array.isArray(raw) ? raw : []) {
|
||
const p = asRecord(item);
|
||
const key = typeof p.key === 'string' ? p.key : '';
|
||
if (!isValidPortName(key) || seen.has(key)) continue;
|
||
seen.add(key);
|
||
const kind = (DECLARED_PORT_KINDS as readonly string[]).includes(String(p.kind))
|
||
? (p.kind as DeclaredPortKind)
|
||
: 'number';
|
||
ports.push(writable && p.write === true ? { key, kind, write: true } : { key, kind });
|
||
}
|
||
return ports;
|
||
}
|
||
|
||
/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */
|
||
function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
|
||
const g = asRecord(raw);
|
||
const graph = createEmptyGraph();
|
||
const nodeIds = new Set<string>();
|
||
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
|
||
const n = asRecord(item);
|
||
const id = typeof n.id === 'string' ? n.id : '';
|
||
const type = typeof n.type === 'string' ? n.type : '';
|
||
const def = nodeDef(type);
|
||
if (!id || nodeIds.has(id) || !def || (keep !== undefined && !keep(def))) continue;
|
||
nodeIds.add(id);
|
||
// Params carry both settings and inline input values, so there is no schema to coerce against.
|
||
const params: PropertyBag = {};
|
||
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
||
const stored = asStoredValue(value);
|
||
if (stored !== undefined) params[key] = stored;
|
||
}
|
||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||
const ports = normalizePorts(n.ports, fixedPortKeys(def), def.declaresPorts === 'both');
|
||
if (ports.length > 0) node.ports = ports;
|
||
if (n.collapsed === true) node.collapsed = true;
|
||
graph.nodes.push(node);
|
||
}
|
||
const edgeIds = new Set<string>();
|
||
for (const item of Array.isArray(g.edges) ? g.edges : []) {
|
||
const e = asRecord(item);
|
||
const from = asRecord(e.from);
|
||
const to = asRecord(e.to);
|
||
const id = typeof e.id === 'string' ? e.id : '';
|
||
if (!id || edgeIds.has(id)) continue;
|
||
if (
|
||
typeof from.node !== 'string' ||
|
||
typeof from.port !== 'string' ||
|
||
typeof to.node !== 'string' ||
|
||
typeof to.port !== 'string'
|
||
)
|
||
continue;
|
||
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
|
||
edgeIds.add(id);
|
||
const edge: GraphEdge = {
|
||
id,
|
||
from: { node: from.node, port: from.port },
|
||
to: { node: to.node, port: to.port },
|
||
};
|
||
if (e.folded === true) edge.folded = true;
|
||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||
if (label) edge.label = label;
|
||
graph.edges.push(edge);
|
||
}
|
||
return graph;
|
||
}
|
||
|
||
/**
|
||
* A file name that is not the server's content-addressed form is dropped: a request built from it
|
||
* would 404, and the texture then loads as "no picture".
|
||
*/
|
||
function normalizeTexture(
|
||
raw: Record<string, unknown>,
|
||
takenIds: Set<string>,
|
||
): { file: string; width: number; height: number; frames: TextureFrame[] } {
|
||
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
|
||
const size = (value: unknown): number =>
|
||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||
const width = size(raw.width);
|
||
const height = size(raw.height);
|
||
const frames: TextureFrame[] = [];
|
||
for (const item of Array.isArray(raw.frames) ? raw.frames : []) {
|
||
const f = asRecord(item);
|
||
const id = typeof f.id === 'string' ? f.id : '';
|
||
// Frame ids share the reference space with asset ids, so a duplicate would make one
|
||
// reference mean two pictures.
|
||
if (!id || takenIds.has(id)) continue;
|
||
takenIds.add(id);
|
||
const rect = normalizeFrameRect(
|
||
{ x: toNumber(f.x), y: toNumber(f.y), w: toNumber(f.w), h: toNumber(f.h) },
|
||
width,
|
||
height,
|
||
);
|
||
frames.push({ id, name: typeof f.name === 'string' ? f.name : '', ...rect });
|
||
}
|
||
return { file, width, height, frames };
|
||
}
|
||
|
||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||
const r = asRecord(raw);
|
||
const bag: PropertyBag = {};
|
||
for (const def of schema) {
|
||
const value = r[def.key];
|
||
if (value === undefined || value === null) {
|
||
if (!sparse) bag[def.key] = def.default;
|
||
continue;
|
||
}
|
||
bag[def.key] = coerceValue(def, value as PropertyValue);
|
||
}
|
||
return bag;
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||
}
|
||
|
||
/**
|
||
* A value from an untyped slot (node params, instance edits). Lists and structs are kept but
|
||
* flattened one level, so nothing deeper than the model allows can enter behind the type's back.
|
||
*/
|
||
function asStoredValue(value: unknown): PropertyValue | undefined {
|
||
if (isPrimitive(value)) return value;
|
||
if (Array.isArray(value)) return value.map(asStoredItem).filter((item) => item !== undefined);
|
||
return asStoredStruct(value, asStoredField);
|
||
}
|
||
|
||
const isPrimitive = (value: unknown): value is number | string | boolean =>
|
||
typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean';
|
||
|
||
/** A primitive, or the one object a flat position may hold: a tagged item. */
|
||
const asFlatItem = (value: unknown): FlatItem | undefined => {
|
||
if (isPrimitive(value)) return value;
|
||
const tag = tagOf(value);
|
||
return tag !== undefined && isTaggedKind(tag) ? makeFlatItem(tag, value as PropertyValue) : undefined;
|
||
};
|
||
|
||
/** A list item: a flat one, or a struct of flat ones. */
|
||
const asStoredItem = (value: unknown): number | string | boolean | StructValue | undefined =>
|
||
asFlatItem(value) ?? asStoredStruct(value, asFlatItem);
|
||
|
||
/** A struct field: a flat item or a flat list. A field that is itself a struct is dropped. */
|
||
const asStoredField = (value: unknown): FieldValue | undefined =>
|
||
Array.isArray(value) ? value.map(asFlatItem).filter((item) => item !== undefined) : asFlatItem(value);
|
||
|
||
function asStoredStruct(
|
||
value: unknown,
|
||
ofField: (item: unknown) => FieldValue | undefined,
|
||
): StructValue | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||
const fields: Record<string, FieldValue> = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const stored = ofField(item);
|
||
if (stored !== undefined) fields[key] = stored;
|
||
}
|
||
return fields;
|
||
}
|
||
|
||
function toNumber(value: unknown): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|