From 15ecc05e21bee62e4dbf6410e311604fb4aa63d1 Mon Sep 17 00:00:00 2001 From: Thaum Rystra <9525416+ThaumRystra@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:36:26 +0200 Subject: [PATCH] The type system starts to infect the computation engine --- .vscode/settings.json | 2 + .../creatureProperties/CreatureProperties.ts | 3 +- .../api/creature/creatures/Creatures.ts | 84 +++---------------- .../engine/computation/CreatureComputation.ts | 37 +++++--- .../computation/buildCreatureComputation.ts | 15 +--- ...ation.js => computeCreatureComputation.ts} | 25 ++++-- ...{computeCreature.js => computeCreature.ts} | 18 ++-- app/imports/api/engine/loadCreatures.ts | 66 ++++++++------- .../api/properties/PropertyType.type.ts | 3 + app/tsconfig.json | 1 - 10 files changed, 106 insertions(+), 148 deletions(-) rename app/imports/api/engine/computation/{computeCreatureComputation.js => computeCreatureComputation.ts} (80%) rename app/imports/api/engine/{computeCreature.js => computeCreature.ts} (78%) create mode 100644 app/imports/api/properties/PropertyType.type.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 51325d3d..a34eb69c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,7 +20,9 @@ "multigraph", "nearley", "ngraph", + "nonreactive", "ostrio", + "pather", "recomputation", "Ruleset", "snackbars", diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.ts b/app/imports/api/creature/creatureProperties/CreatureProperties.ts index b0bbf1cf..f4df696d 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.ts +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.ts @@ -8,8 +8,7 @@ import { storedIconsSchema } from '/imports/api/icons/Icons'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; import { ConvertToUnion, InferType, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; import { Simplify } from 'type-fest'; - -type PropertyType = Exclude; +import type { PropertyType } from '/imports/api/properties/PropertyType.type'; const PreComputeCreaturePropertySchema = TypedSimpleSchema.from({ _id: { diff --git a/app/imports/api/creature/creatures/Creatures.ts b/app/imports/api/creature/creatures/Creatures.ts index af8121d6..e05148cb 100644 --- a/app/imports/api/creature/creatures/Creatures.ts +++ b/app/imports/api/creature/creatures/Creatures.ts @@ -1,59 +1,11 @@ import SimpleSchema from 'simpl-schema'; -import ColorSchema, { Colored } from '/imports/api/properties/subSchemas/ColorSchema'; -import SharingSchema, { Shared } from '/imports/api/sharing/SharingSchema'; +import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema'; +import SharingSchema from '/imports/api/sharing/SharingSchema'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; +import { InferType, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; +import type { Simplify } from 'type-fest'; -export type Creature = Colored & Shared & { - // Strings - _id: string, - name?: string, - alignment?: string, - gender?: string, - picture?: string, - avatarPicture?: string, - - // Libraries - allowedLibraries?: string[], - allowedLibraryCollections?: string[], - - // Stats that are computed and denormalized outside of recomputation - denormalizedStats?: { - xp: number, - milestoneLevels: number, - }, - propCount?: number, - // Does the character need a recompute? - dirty?: boolean, - // Version of computation engine that was last used to compute this creature - computeVersion?: string, - type: 'pc' | 'npc' | 'monster', - computeErrors?: { - type: string, - details?: any, - }[], - - // Tabletop - tabletopId?: string, - initiativeRoll?: number, - - settings: { - useVariantEncumbrance?: true, - hideSpellcasting?: true, - hideRestButtons?: true, - swapStatAndModifier?: true, - hideUnusedStats?: true, - showTreeTab?: true, - hideSpellsTab?: true, - hideCalculationErrors?: true, - hitDiceResetMultiplier?: number, - discordWebhook?: string, - }, -}; - -//set up the collection for creatures -const Creatures = new Mongo.Collection('creatures'); - -const CreatureSettingsSchema = new SimpleSchema({ +const CreatureSettingsSchema = TypedSimpleSchema.from({ //slowed down by carrying too much? useVariantEncumbrance: { type: Boolean, @@ -108,24 +60,7 @@ const CreatureSettingsSchema = new SimpleSchema({ }, }); -const IconGroupSchema = new SimpleSchema({ - name: { - type: String, - max: STORAGE_LIMITS.name, - optional: true, - }, - iconIds: { - type: Array, - max: 4, - defaultValue: [], - }, - 'iconIds.$': { - type: String, - max: STORAGE_LIMITS.variableName, - }, -}); - -const CreatureSchema = new SimpleSchema({ +const CreatureSchema = TypedSimpleSchema.from({ // Strings name: { type: String, @@ -246,10 +181,13 @@ const CreatureSchema = new SimpleSchema({ CreatureSchema.extend(ColorSchema); CreatureSchema.extend(SharingSchema); +export type Creature = Simplify<{ _id: string } & InferType>; + +//set up the collection for creatures +const Creatures = new Mongo.Collection('creatures'); + //@ts-expect-error attachSchema not defined Creatures.attachSchema(CreatureSchema); - - export default Creatures; export { CreatureSchema }; diff --git a/app/imports/api/engine/computation/CreatureComputation.ts b/app/imports/api/engine/computation/CreatureComputation.ts index b4b4fa0c..c704b6b1 100644 --- a/app/imports/api/engine/computation/CreatureComputation.ts +++ b/app/imports/api/engine/computation/CreatureComputation.ts @@ -1,40 +1,53 @@ import { EJSON } from 'meteor/ejson'; import createGraph, { Graph } from 'ngraph.graph'; import getEffectivePropTags from '/imports/api/engine/computation/utility/getEffectivePropTags'; +import type { Creature } from '/imports/api/creature/creatures/Creatures'; +import type { CreatureProperty } from '/imports/api/creature/creatureProperties/CreatureProperties'; -interface CreatureProperty { - _id: string; - type: string; -} +type ComputationProperty = CreatureProperty & { + _computationDetails: { + calculations: any[], + emptyCalculations: any[], + inlineCalculations: any[], + toggleAncestors: any[], + } +}; export default class CreatureComputation { originalPropsById: Record; propsById: Record; propsWithTag: Record; scope: Record; - props: Array; + props: ComputationProperty[]; dependencyGraph: Graph; errors: Array; - creature: object; + creature: Creature; variables: object; - constructor(properties: Array, creature: object, variables: object) { + constructor(properties: Array, creature: Creature, variables: object) { // Set up fields this.originalPropsById = {}; this.propsById = {}; this.propsWithTag = {}; this.scope = {}; - this.props = properties; this.dependencyGraph = createGraph(); this.errors = []; this.creature = creature; this.variables = variables; - // Store properties for easy access later - properties.forEach(prop => { + // Store properties and index for easy access later + this.props = properties.map(originalProp => { + const prop: ComputationProperty = Object.assign(EJSON.clone(originalProp), { + _computationDetails: { + calculations: [], + emptyCalculations: [], + inlineCalculations: [], + toggleAncestors: [], + } + }); // Store a copy of the unmodified prop // EJSON clone is ~4x faster than lodash cloneDeep for EJSONable objects - this.originalPropsById[prop._id] = EJSON.clone(prop); + this.originalPropsById[prop._id] = originalProp; // Store by id this.propsById[prop._id] = prop; @@ -50,6 +63,8 @@ export default class CreatureComputation { // Store the prop in the dependency graph this.dependencyGraph.addNode(prop._id, prop); + + return prop; }); } } diff --git a/app/imports/api/engine/computation/buildCreatureComputation.ts b/app/imports/api/engine/computation/buildCreatureComputation.ts index d9c4bd2f..e7541f29 100644 --- a/app/imports/api/engine/computation/buildCreatureComputation.ts +++ b/app/imports/api/engine/computation/buildCreatureComputation.ts @@ -14,6 +14,7 @@ import linkTypeDependencies from './buildComputation/linkTypeDependencies'; import computeSlotQuantityFilled from './buildComputation/computeSlotQuantityFilled'; import CreatureComputation from './CreatureComputation'; import removeSchemaFields from './buildComputation/removeSchemaFields'; +import type { Creature } from '/imports/api/creature/creatures/Creatures'; /** * Store index of properties @@ -31,6 +32,7 @@ import removeSchemaFields from './buildComputation/removeSchemaFields'; export default function buildCreatureComputation(creatureId: string) { const creature = getCreature(creatureId); + if (!creature) return; const variables = getVariables(creatureId); const properties = getProperties(creatureId); const computation = buildComputationFromProps(properties, creature, variables); @@ -38,7 +40,7 @@ export default function buildCreatureComputation(creatureId: string) { } export function buildComputationFromProps( - properties: CreatureProperty[], creature, variables + properties: CreatureProperty[], creature: Creature, variables: any ) { const computation = new CreatureComputation(properties, creature, variables); @@ -67,24 +69,15 @@ export function buildComputationFromProps( } // Process the properties one by one - properties.forEach(prop => { + computation.props.forEach(prop => { // The prop has been processed, it's no longer dirty delete prop.dirty; const computedSchema = computedOnlySchemas[prop.type]; removeSchemaFields([computedSchema, denormSchema], prop); - // Add a place to store all the computation details - prop._computationDetails = { - calculations: [], - emptyCalculations: [], - inlineCalculations: [], - toggleAncestors: [], - }; - // Parse all the calculations parseCalculationFields(prop, computedSchemas); - }); // Get all the properties as a forest, with their nested set properties set diff --git a/app/imports/api/engine/computation/computeCreatureComputation.js b/app/imports/api/engine/computation/computeCreatureComputation.ts similarity index 80% rename from app/imports/api/engine/computation/computeCreatureComputation.js rename to app/imports/api/engine/computation/computeCreatureComputation.ts index f74b4368..7c9ffe08 100644 --- a/app/imports/api/engine/computation/computeCreatureComputation.js +++ b/app/imports/api/engine/computation/computeCreatureComputation.ts @@ -3,13 +3,20 @@ import computeByType from '/imports/api/engine/computation/computeComputation/co import embedInlineCalculations from './utility/embedInlineCalculations'; import { removeEmptyCalculations } from './buildComputation/parseCalculationFields'; import path from 'ngraph.path'; +import type CreatureComputation from './CreatureComputation'; +import type { Graph, Node, NodeId } from 'ngraph.graph'; -export default async function computeCreatureComputation(computation) { - const stack = []; +type TraversedNode = Node & { + _visited?: boolean, + _visitedChildren?: boolean, +} + +export default async function computeCreatureComputation(computation: CreatureComputation) { + const stack: (TraversedNode)[] = []; // Computation scope of {variableName: prop} const graph = computation.dependencyGraph; // Add all nodes to the stack - graph.forEachNode(node => { + graph.forEachNode((node: TraversedNode) => { node._visited = false; node._visitedChildren = false; stack.push(node) @@ -22,7 +29,7 @@ export default async function computeCreatureComputation(computation) { // Depth first traversal of nodes while (stack.length) { - let top = stack[stack.length - 1]; + const top = stack[stack.length - 1]; if (top._visited) { // The object has already been computed, skip stack.pop(); @@ -45,21 +52,21 @@ export default async function computeCreatureComputation(computation) { } } -async function compute(computation, node) { +async function compute(computation: CreatureComputation, node: TraversedNode) { // Determine the prop's active status by its toggles computeToggles(computation, node); // Compute the property by type await computeByType[node.data?.type || '_variable']?.(computation, node); } -function pushDependenciesToStack(nodeId, graph, stack, computation) { - graph.forEachLinkedNode(nodeId, linkedNode => { +function pushDependenciesToStack(nodeId: NodeId, graph: Graph, stack: TraversedNode[], computation: CreatureComputation) { + graph.forEachLinkedNode(nodeId, (linkedNode: TraversedNode) => { if (linkedNode._visitedChildren && !linkedNode._visited) { // This is a dependency loop, find a path from the node to itself // and store that path as a dependency loop error const pather = path.nba(graph, { oriented: true }); - let loop = []; - // Pather doesn't like going from a node to iteself, so find all the + const loop: TraversedNode[] = []; + // Pather doesn't like going from a node to itself, so find all the // paths going from the next node back to the original node // and return the shortest one graph.forEachLinkedNode(nodeId, nextNode => { diff --git a/app/imports/api/engine/computeCreature.js b/app/imports/api/engine/computeCreature.ts similarity index 78% rename from app/imports/api/engine/computeCreature.js rename to app/imports/api/engine/computeCreature.ts index 96362d7c..8cfdbac5 100644 --- a/app/imports/api/engine/computeCreature.js +++ b/app/imports/api/engine/computeCreature.ts @@ -3,34 +3,32 @@ import computeCreatureComputation from './computation/computeCreatureComputation import writeAlteredProperties from './computation/writeComputation/writeAlteredProperties'; import writeScope from './computation/writeComputation/writeScope'; import writeErrorsAndPropCount from './computation/writeComputation/writeErrorsAndPropCount'; +import type CreatureComputation from './computation/CreatureComputation'; -export default async function computeCreature(creatureId) { +export default async function computeCreature(creatureId: string) { if (Meteor.isClient) return; // console.log('compute ' + creatureId); const computation = buildCreatureComputation(creatureId); await computeComputation(computation, creatureId); } -async function computeComputation(computation, creatureId) { +async function computeComputation(computation: CreatureComputation, creatureId: string) { try { await computeCreatureComputation(computation); const writePromise = writeAlteredProperties(computation); const scopeWritePromise = writeScope(creatureId, computation); await Promise.all([writePromise, scopeWritePromise]); - } catch (e) { + } catch (e: any) { const errorText = e.reason || e.message || e.toString(); computation.errors.push({ type: 'crash', details: { error: errorText }, }); - const logError = { + console.error({ creatureId, computeError: errorText, - }; - if (e.stack) { - logError.location = e.stack.split('\n')[1]; - } - console.error(logError); + ...e.stack && { location: e.stack.split('\n')[1] }, + }); } finally { checkPropertyCount(computation) writeErrorsAndPropCount(creatureId, computation.errors, computation.props.length); @@ -38,7 +36,7 @@ async function computeComputation(computation, creatureId) { } const MAX_PROPS = 1000; -function checkPropertyCount(computation) { +function checkPropertyCount(computation: CreatureComputation) { const count = computation.props.length; if (count <= MAX_PROPS) return; computation.errors.push({ diff --git a/app/imports/api/engine/loadCreatures.ts b/app/imports/api/engine/loadCreatures.ts index 14e81735..56519e46 100644 --- a/app/imports/api/engine/loadCreatures.ts +++ b/app/imports/api/engine/loadCreatures.ts @@ -1,10 +1,10 @@ import { debounce } from 'lodash'; -import Creatures from '/imports/api/creature/creatures/Creatures'; +import Creatures, { Creature } from '/imports/api/creature/creatures/Creatures'; import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables'; -import CreatureProperties, { CreatureProperty, CreaturePropertyByType } from '/imports/api/creature/creatureProperties/CreatureProperties'; +import CreatureProperties, { CreatureProperty, CreaturePropertyTypes } from '/imports/api/creature/creatureProperties/CreatureProperties'; import computeCreature from './computeCreature'; import { getFilter } from '/imports/api/parenting/parentingFunctions'; -import { ComputedPropertyTypeMap } from '../properties/Property.type'; +import type { PropertyType } from '/imports/api/properties/PropertyType.type'; const COMPUTE_DEBOUNCE_TIME = 100; // ms export const loadedCreatures: Map = new Map(); // creatureId => {creature, properties, etc.} @@ -34,7 +34,7 @@ export function loadCreature(creatureId: string, subscription: Tracker.Computati // logLoadedCreatures() } -function unloadCreature(creatureId, subscription) { +function unloadCreature(creatureId: string, subscription: Tracker.Computation) { if (!creatureId) throw 'creatureId is required'; const creature = loadedCreatures.get(creatureId); if (!creature) return; @@ -82,13 +82,13 @@ export function getProperties(creatureId: string): CreatureProperty[] { return props; } -export function getPropertiesOfType(creatureId, propType: T): CreaturePropertyByType[] { +export function getPropertiesOfType(creatureId: string, propType: T): CreaturePropertyTypes[T][] { const creature = loadedCreatures.get(creatureId); if (creature) { const props = Array.from(creature.properties.values()) .filter(prop => !prop.removed && prop.type === propType) .sort((a, b) => a.left - b.left); - return EJSON.clone(props) as unknown as CreaturePropertyByType[]; + return EJSON.clone(props) as unknown as CreaturePropertyTypes[T][]; } // console.time(`Cache miss on creature properties: ${creatureId}`) const props = CreatureProperties.find({ @@ -99,7 +99,7 @@ export function getPropertiesOfType(cre sort: { left: 1 }, }).fetch(); // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); - return props as unknown as CreaturePropertyByType[]; + return props as unknown as CreaturePropertyTypes[T][]; } /** @@ -108,7 +108,11 @@ export function getPropertiesOfType(cre * @param filterFn A function that returns true if the given prop matches the filter * @param mongoFilter A mongo selector that is exactly equal to the above function */ -export function getPropertiesByFilter(creatureId, filterFn: (any) => boolean, mongoFilter: Mongo.Selector) { +export function getPropertiesByFilter( + creatureId: string, + filterFn: (value: CreatureProperty, index: number, array: CreatureProperty[]) => unknown, + mongoFilter: Mongo.Selector +) { const creature = loadedCreatures.get(creatureId); if (creature) { const props: CreatureProperty[] = Array.from(creature.properties.values()) @@ -121,7 +125,7 @@ export function getPropertiesByFilter(creatureId, filterFn: (any) => boolean, mo 'root.id': creatureId, 'removed': { $ne: true }, ...mongoFilter - }, { + } as any, { sort: { left: 1 }, }).fetch(); // console.timeEnd(`Cache miss on creature properties: ${creatureId}`); @@ -152,7 +156,7 @@ export function getVariables(creatureId: string) { return variables; } -export function replaceLinkedVariablesWithProps(variables) { +export function replaceLinkedVariablesWithProps(variables: any) { for (const key in variables) { const propId = variables[key]?._propId; if (!propId) continue; @@ -185,7 +189,7 @@ export function getPropertyAncestors(creatureId: string, propertyId: string) { } } -export function getPropertyDescendants(creatureId, propertyId) { +export function getPropertyDescendants(creatureId: string, propertyId: string) { const property = getSingleProperty(creatureId, propertyId); if (!property) return []; if (loadedCreatures.has(creatureId)) { @@ -219,7 +223,7 @@ export function getPropertyDescendants(creatureId, propertyId) { * @param {string | any} property prop or prop ID to get children of * @returns {any[]} An array of child properties in tree order */ -export function getPropertyChildren(creatureId, property) { +export function getPropertyChildren(creatureId: string, property: string | CreatureProperty | undefined) { if (typeof property === 'string') { property = getSingleProperty(creatureId, property); } @@ -247,15 +251,15 @@ export function getPropertyChildren(creatureId, property) { } class LoadedCreature { - subs: Set; - propertyObserver: Meteor.LiveQueryHandle; - creatureObserver: Meteor.LiveQueryHandle; - variablesObserver: Meteor.LiveQueryHandle; - properties: Map; - creature: any; + subs!: Set; + propertyObserver!: Meteor.LiveQueryHandle; + creatureObserver!: Meteor.LiveQueryHandle; + variablesObserver!: Meteor.LiveQueryHandle; + properties!: Map; + creature?: Creature; variables: any; - constructor(sub, creatureId) { + constructor(sub: Tracker.Computation, creatureId: string) { const self = this; // This may be called from a subscription, but we don't want the observers // to be destroyed with it, so use a non-reactive context to observe @@ -271,7 +275,7 @@ class LoadedCreature { self.propertyObserver = CreatureProperties.find({ 'root.id': creatureId, }).observeChanges({ - added(id, fields) { + added(id, fields: CreatureProperty) { fields._id = id; self.addProperty(fields); if (fields.dirty) compute(); @@ -290,7 +294,7 @@ class LoadedCreature { self.creatureObserver = Creatures.find({ _id: creatureId, }).observeChanges({ - added(id, fields) { + added(id, fields: Creature) { fields._id = id; self.addCreature(fields) if (fields.dirty) compute(); @@ -310,7 +314,7 @@ class LoadedCreature { }, { fields: { _creatureId: 0 }, }).observeChanges({ - added(id, fields) { + added(id, fields: any) { fields._id = id; self.addVariables(fields) }, @@ -328,38 +332,38 @@ class LoadedCreature { this.creatureObserver.stop(); this.variablesObserver.stop(); } - addProperty(prop) { + addProperty(prop: CreatureProperty) { this.properties.set(prop._id, prop); } - changeProperty(id, fields) { + changeProperty(id: string, fields: Partial) { LoadedCreature.changeMap(id, fields, this.properties); } - removeProperty(id) { + removeProperty(id: string) { this.properties.delete(id) } - addCreature(creature) { + addCreature(creature: Creature) { this.creature = creature; } - changeCreature(id, fields) { + changeCreature(id: string, fields: Partial) { LoadedCreature.changeDoc(this.creature, fields); } removeCreature() { delete this.creature; } - addVariables(variables) { + addVariables(variables: any) { this.variables = variables; } - changeVariables(id, fields) { + changeVariables(id: string, fields: any) { LoadedCreature.changeDoc(this.variables, fields); } removeVariables() { delete this.variables; } - static changeMap(id, fields, map) { + static changeMap(id: string, fields: any, map: any) { const doc = map.get(id); LoadedCreature.changeDoc(doc, fields); } - static changeDoc(doc, fields) { + static changeDoc(doc: any, fields: any) { if (!doc) return; for (const key in fields) { if (key === undefined) { diff --git a/app/imports/api/properties/PropertyType.type.ts b/app/imports/api/properties/PropertyType.type.ts new file mode 100644 index 00000000..2cb4604c --- /dev/null +++ b/app/imports/api/properties/PropertyType.type.ts @@ -0,0 +1,3 @@ +import propertySchemasIndex from './computedPropertySchemasIndex'; + +export type PropertyType = Exclude; diff --git a/app/tsconfig.json b/app/tsconfig.json index 06622c64..f81d14b9 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -16,7 +16,6 @@ "preserveSymlinks": true, "allowJs": true, "checkJs": true, - "noImplicitAny": false, "noErrorTruncation": true, "outDir": "build", "paths": {