diff --git a/.vscode/settings.json b/.vscode/settings.json index 81f51528..dfc0d6d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "alea", "autorun", "blackbox", "Crits", diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.ts b/app/imports/api/creature/creatureProperties/CreatureProperties.ts index e5c2c3a9..fcf8d490 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.ts +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.ts @@ -7,12 +7,12 @@ import propertySchemasIndex from '/imports/api/properties/computedPropertySchema import { storedIconsSchema } from '/imports/api/icons/Icons'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; -const CreatureProperties: Mongo.Collection = new Mongo.Collection('creatureProperties'); +// TODO make this a union type of all CreatureProperty types +const CreatureProperties: Mongo.Collection = new Mongo.Collection('creatureProperties'); -export interface CreatureProperty extends TreeDoc { +export interface CreatureProperty { _id: string _migrationError?: string - type: string tags: string[] disabled?: boolean icon?: { diff --git a/app/imports/api/engine/action/ActionEngine.test.ts b/app/imports/api/engine/action/ActionEngine.test.ts index 84a42c7d..2c6fef7a 100644 --- a/app/imports/api/engine/action/ActionEngine.test.ts +++ b/app/imports/api/engine/action/ActionEngine.test.ts @@ -9,6 +9,7 @@ import { loadCreature } from '/imports/api/engine/loadCreatures'; import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions'; import { applyAction } from '/imports/api/engine/action/functions/applyAction'; import { LogContent, Removal, Update } from '/imports/api/engine/action/tasks/TaskResult'; +import inputProvider from '/imports/api/engine/action/functions/inputProviderForTests.testFn'; const creatureId = Random.id(); const targetId = Random.id(); @@ -81,11 +82,12 @@ describe('Interrupt action system', function () { [{ value: 'child 2 of index branch' }] ); }); - it('Halts execution of choice branches', async function () { - let userInputRequested = false; - const requestUserInput = () => { userInputRequested = true; return 0 }; - await runActionById(choiceBranchId, requestUserInput); - assert.isTrue(userInputRequested, 'User input should be requested when a choice branch is applied'); + it('Gets choices from choice branches', async function () { + const action = await runActionById(choiceBranchId); + assert.deepEqual( + allLogContent(action), + [{ value: 'child 1 of choice branch' }] + ); }); it('Applies adjustments', async function () { let action = await runActionById(adjustmentSetId); @@ -205,12 +207,12 @@ function createAction(prop, targetIds?) { return EngineActions.insertAsync(action); } -async function runActionById(propId, userInputFn = () => 0) { +async function runActionById(propId) { const prop = await CreatureProperties.findOneAsync(propId); const actionId = await createAction(prop); const action = await EngineActions.findOneAsync(actionId); if (!action) throw 'Action is expected to exist'; - await applyAction(action, userInputFn, { simulate: true }); + await applyAction(action, inputProvider, { simulate: true }); return action; } diff --git a/app/imports/api/engine/action/applyProperties/applyActionProperty.test.ts b/app/imports/api/engine/action/applyProperties/applyActionProperty.test.ts index 8a5ba16b..a6785ef0 100644 --- a/app/imports/api/engine/action/applyProperties/applyActionProperty.test.ts +++ b/app/imports/api/engine/action/applyProperties/applyActionProperty.test.ts @@ -8,6 +8,7 @@ import { runActionById } from '/imports/api/engine/action/functions/actionEngineTest.testFn'; import { Mutation, Update } from '/imports/api/engine/action/tasks/TaskResult'; +import Alea from 'alea'; const [ creatureId, targetCreatureId, targetCreature2Id, @@ -93,6 +94,14 @@ describe('Apply Action Properties', function () { await createTestCreature(actionTargetCreature2); }); + it('should generate random numbers reliably given consistent seeds', function () { + const aleaFraction = Alea('test', 'seeds'); + const randomNumbers = [aleaFraction(), aleaFraction(), aleaFraction()]; + assert.deepEqual(randomNumbers, [ + 0.19889510236680508, 0.9176857066340744, 0.042551583144813776 + ]); + }); + it('should run empty actions', async function () { const action = await runActionById(emptyActionId); assert.exists(action); diff --git a/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts b/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts index b3ae201e..09f38171 100644 --- a/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applyAdjustmentProperty.ts @@ -24,7 +24,7 @@ export default async function applyAdjustmentProperty( } // Evaluate the amount - await recalculateCalculation(prop.amount, action, 'reduce'); + await recalculateCalculation(prop.amount, action, 'reduce', userInput); const value = +prop.amount.value; if (!isFinite(value)) { return; diff --git a/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts index d935ce08..b12c93bb 100644 --- a/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts +++ b/app/imports/api/engine/action/applyProperties/applyBranchProperty.ts @@ -1,6 +1,7 @@ +import { filter } from 'lodash'; import { EngineAction } from '/imports/api/engine/action/EngineActions'; import InputProvider from '/imports/api/engine/action/functions/InputProvider'; -import { applyAfterPropTasksForSingleChild, applyAfterTasksSkipChildren, applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups'; +import { applyAfterPropTasksForSingleChild, applyAfterPropTasksForSomeChildren, applyAfterTasksSkipChildren, applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups'; import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope'; import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation'; import { PropTask } from '/imports/api/engine/action/tasks/Task'; @@ -16,7 +17,7 @@ export default async function applyBranchProperty( switch (prop.branchType) { case 'if': { - await recalculateCalculation(prop.condition, action, 'reduce'); + await recalculateCalculation(prop.condition, action, 'reduce', userInput); if (prop.condition?.value) { return applyDefaultAfterPropTasks(action, prop, targets, userInput); } else { @@ -28,7 +29,7 @@ export default async function applyBranchProperty( if (!children.length) { return applyAfterTasksSkipChildren(action, prop, targets, userInput); } - await recalculateCalculation(prop.condition, action, 'reduce'); + await recalculateCalculation(prop.condition, action, 'reduce', userInput); if (!isFinite(prop.condition?.value)) { result.appendLog({ name: 'Branch Error', @@ -110,21 +111,17 @@ export default async function applyBranchProperty( } return applyDefaultAfterPropTasks(action, prop, targets, userInput); case 'choice': { - let index; - if (action._isSimulation) { - index = await userInput(prop); - } else { - // TODO - throw 'Reading stored user input not implemented' - } const children = await getPropertyChildren(action.creatureId, prop); - if (!children.length) { + let choices: string[]; + let chosenChildren: typeof children = []; + if (children.length) { + choices = await userInput.choose(action, children); + chosenChildren = filter(children, child => choices.includes(child._id)); + } + if (!children.length || !chosenChildren.length) { return applyAfterTasksSkipChildren(action, prop, targets, userInput); } - if (!isFinite(index) || index < 0) index = 0; - if (index > children.length - 1) index = children.length - 1; - const child = children[index]; - return applyAfterPropTasksForSingleChild(action, prop, child, targets, userInput); + return applyAfterPropTasksForSomeChildren(action, prop, chosenChildren, targets, userInput); } } } \ No newline at end of file diff --git a/app/imports/api/engine/action/functions/InputProvider.ts b/app/imports/api/engine/action/functions/InputProvider.ts index 23c704eb..f9c1933d 100644 --- a/app/imports/api/engine/action/functions/InputProvider.ts +++ b/app/imports/api/engine/action/functions/InputProvider.ts @@ -4,6 +4,17 @@ type InputProvider = { rollDice( action: EngineAction, dice: { number: number, diceSize: number }[] ): Promise; + /** + * Choose from a provided selection + * @param action + * @param choices Options to choose from + * @param quantity Number of choices to make [min, max] inclusive, where -1 means no limit + */ + choose( + action: EngineAction, + choices: ({ _id: string } & Record)[], + quantity?: [min: number, max: number], + ): Promise; } export default InputProvider; \ No newline at end of file diff --git a/app/imports/api/engine/action/functions/actionEngineTest.testFn.ts b/app/imports/api/engine/action/functions/actionEngineTest.testFn.ts index 6288d629..1d5020d2 100644 --- a/app/imports/api/engine/action/functions/actionEngineTest.testFn.ts +++ b/app/imports/api/engine/action/functions/actionEngineTest.testFn.ts @@ -8,8 +8,7 @@ import { loadCreature } from '/imports/api/engine/loadCreatures'; import EngineActions, { EngineAction } from '/imports/api/engine/action/EngineActions'; import { applyAction } from '/imports/api/engine/action/functions/applyAction'; import { LogContent, Mutation, Removal, Update } from '/imports/api/engine/action/tasks/TaskResult'; -import InputProvider from '/imports/api/engine/action/functions/InputProvider'; - +import inputProvider from '/imports/api/engine/action/functions/inputProviderForTests.testFn'; /** * Removes all creatures, properties, and creatureVariable documents from the database */ @@ -59,7 +58,7 @@ export const randomIds = new Array(100).fill(undefined).map(() => Random.id()); * @param userInputFn A function that simulates user input * @returns The Engine Action with mutations resulting from running the action */ -export async function runActionById(propId, targetIds?, userInput = testInputProvider) { +export async function runActionById(propId, targetIds?, userInput = inputProvider) { const prop = await CreatureProperties.findOneAsync(propId); const actionId = await createAction(prop, targetIds); const action = await EngineActions.findOneAsync(actionId); @@ -148,25 +147,3 @@ export function allLogContent(action: EngineAction) { }); return contents; } - -const testInputProvider: InputProvider = { - /** - * For testing, randomness is hard to deal with - * rollDice function returns the average roll for every dice rolled - * [5d10, 1d4] => [[6,6,6,6,6], [3]] - */ - async rollDice(action, dice) { - const result: number[][] = []; - for (const diceRoll of dice) { - const averageRoll = Math.round(diceRoll.diceSize / 2); - // Return an array full of averagely rolled dice - result.push( - new Array(diceRoll.number) - .fill(averageRoll) - ) - } - return result; - } -} - -export { testInputProvider } \ No newline at end of file diff --git a/app/imports/api/engine/action/functions/applyTaskGroups.ts b/app/imports/api/engine/action/functions/applyTaskGroups.ts index 207ada37..2670453e 100644 --- a/app/imports/api/engine/action/functions/applyTaskGroups.ts +++ b/app/imports/api/engine/action/functions/applyTaskGroups.ts @@ -16,7 +16,6 @@ export async function applyChildren( action: EngineAction, prop, targetIds: string[], userInput ) { const children = await getPropertyChildren(action.creatureId, prop); - // Push the child tasks and related triggers to the stack for (const childProp of children) { await applyTask(action, { prop: childProp, targetIds }, userInput); } @@ -101,6 +100,25 @@ export async function applyAfterPropTasksForSingleChild( await applyAfterChildrenTriggers(action, prop, targetIds, userInput); } +/** + * Returns a list of tasks containing the following: + * After triggers + * After-children triggers + * @param action + * @param prop + * @param targetIds + * @returns + */ +export async function applyAfterPropTasksForSomeChildren( + action: EngineAction, prop, children, targetIds: string[], userInput +) { + await applyAfterTriggers(action, prop, targetIds, userInput); + for (const childProp of children) { + await applyTask(action, { prop: childProp, targetIds }, userInput); + } + await applyAfterChildrenTriggers(action, prop, targetIds, userInput); +} + /** * Get all the trigger tasks for a given trigger path * @param action diff --git a/app/imports/api/engine/action/functions/inputProviderForTests.testFn.ts b/app/imports/api/engine/action/functions/inputProviderForTests.testFn.ts new file mode 100644 index 00000000..c162f010 --- /dev/null +++ b/app/imports/api/engine/action/functions/inputProviderForTests.testFn.ts @@ -0,0 +1,34 @@ +import InputProvider from '/imports/api/engine/action/functions/InputProvider'; + +const inputProviderForTests: InputProvider = { + /** + * For testing, randomness is hard to deal with + * rollDice function returns the average roll for every dice rolled + * [5d10, 1d4] => [[6,6,6,6,6], [3]] + */ + async rollDice(action, dice) { + const result: number[][] = []; + for (const diceRoll of dice) { + const averageRoll = Math.round(diceRoll.diceSize / 2); + // Return an array full of averagely rolled dice + result.push( + new Array(diceRoll.number) + .fill(averageRoll) + ) + } + return result; + }, + /** + * For testing, always return the minimum number of choices, always choosing the first options + */ + async choose(action, choices, quantity = [1, 1]) { + const chosen: string[] = []; + const choiceQuantity = quantity[0] <= 0 ? 1 : quantity[0]; + for (let i = 0; i < choiceQuantity && i < choices.length; i += 1) { + chosen.push(choices[i]._id); + } + return chosen; + } +} + +export default inputProviderForTests; diff --git a/app/imports/api/properties/Actions.ts b/app/imports/api/properties/Actions.ts index 0f7c6510..068db2a6 100644 --- a/app/imports/api/properties/Actions.ts +++ b/app/imports/api/properties/Actions.ts @@ -6,12 +6,24 @@ import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX'; import { CreatureProperty } from '/imports/api/creature/creatureProperties/CreatureProperties'; import { InlineCalculation } from '/imports/api/properties/subSchemas/inlineCalculationField'; import { CalculatedField } from '/imports/api/properties/subSchemas/computedField'; +import Property from '/imports/api/properties/Properties.type'; +export type CreatureAction = Action & CreatureProperty & { + overridden?: boolean + insufficientResources?: boolean +} + +/* + * Actions are things a character can do + */ export interface Action extends ActionBase { type: 'action' } -export interface ActionBase extends CreatureProperty { +/** + * Base property type for both spells and actions + */ +export interface ActionBase extends Property { name?: string summary?: InlineCalculation description?: InlineCalculation @@ -23,9 +35,7 @@ export interface ActionBase extends CreatureProperty { usesUsed?: number reset?: string silent?: boolean - insufficientResources?: boolean usesLeft?: number - overridden?: boolean // Resources resources: { itemsConsumed: { diff --git a/app/imports/api/properties/Attributes.js b/app/imports/api/properties/Attributes.ts similarity index 77% rename from app/imports/api/properties/Attributes.js rename to app/imports/api/properties/Attributes.ts index 0bac605a..d9d749c1 100644 --- a/app/imports/api/properties/Attributes.js +++ b/app/imports/api/properties/Attributes.ts @@ -2,11 +2,57 @@ import SimpleSchema from 'simpl-schema'; import VARIABLE_NAME_REGEX from '/imports/constants/VARIABLE_NAME_REGEX'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; import createPropertySchema from '/imports/api/properties/subSchemas/createPropertySchema'; +import { CreatureProperty } from '/imports/api/creature/creatureProperties/CreatureProperties'; +import { CalculatedField } from '/imports/api/properties/subSchemas/computedField'; +import { InlineCalculation } from '/imports/api/properties/subSchemas/inlineCalculationField'; +import { ConstantValueType } from '/imports/parser/parseTree/constant'; +import Property from '/imports/api/properties/Properties.type'; + +export type CreatureAttribute = Attribute & CreatureProperty & { + total?: ConstantValueType; + value?: ConstantValueType; + modifier?: number; + proficiency?: 0 | 0.49 | 0.5 | 1 | 2; + advantage?: -1 | 0 | 1; + constitutionMod?: number; + hide?: true; + overridden?: true; + effectIds?: string[]; + proficiencyIds?: string[]; + definitions?: { _id: string, type: string, row?: number }[]; +} + +export interface Attribute extends Property { + type: 'attribute'; + name?: string; + variableName?: string; + attributeType: 'ability' | 'stat' | 'modifier' | 'hitDice' | 'healthBar' | 'resource' | + 'spellSlot' | 'utility'; + hitDiceSize?: 'd1' | 'd2' | 'd4' | 'd6' | 'd8' | 'd10' | 'd12' | 'd20' | 'd100'; + spellSlotLevel?: CalculatedField; + healthBarColorMid?: string; + healthBarColorLow?: string; + healthBarNoDamage?: true; + healthBarNoHealing?: true; + healthBarNoDamageOverflow?: true; + healthBarNoHealingOverflow?: true; + healthBarDamageOrder?: number; + healthBarHealingOrder?: number; + baseValue?: CalculatedField; + description?: InlineCalculation; + damage?: number; + decimal?: true; + ignoreLowerLimit?: true; + ignoreUpperLimit?: true; + hideWhenTotalZero?: true; + hideWhenValueZero?: true; + reset?: string; +} /* * Attributes are numbered stats of a character */ -let AttributeSchema = createPropertySchema({ +const AttributeSchema = createPropertySchema({ name: { type: String, optional: true, @@ -134,7 +180,7 @@ let AttributeSchema = createPropertySchema({ }, }); -let ComputedOnlyAttributeSchema = createPropertySchema({ +const ComputedOnlyAttributeSchema = createPropertySchema({ description: { type: 'computedOnlyInlineCalculationField', optional: true, diff --git a/app/imports/api/properties/Properties.type.ts b/app/imports/api/properties/Properties.type.ts new file mode 100644 index 00000000..e7aaa73f --- /dev/null +++ b/app/imports/api/properties/Properties.type.ts @@ -0,0 +1,12 @@ +import { TreeDoc } from '/imports/api/parenting/ChildSchema'; + +export default interface Property extends TreeDoc { + _id: string + _migrationError?: string + tags: string[] + icon?: { + name: string + shape: string + }, + slotQuantityFilled?: number +} \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index bbec14ff..5409c8fb 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1790,6 +1790,11 @@ "uri-js": "^4.2.2" } }, + "alea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/alea/-/alea-1.0.1.tgz", + "integrity": "sha512-QU+wv+ziDXaMxRdsQg/aH7sVfWdhKps5YP97IIwFkHCsbDZA3k87JXoZ5/iuemf4ntytzIWeScrRpae8+lDrXA==" + }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", diff --git a/app/package.json b/app/package.json index a97a85c4..1dcad558 100644 --- a/app/package.json +++ b/app/package.json @@ -24,6 +24,7 @@ "@chenfengyuan/vue-countdown": "^1.1.5", "@tozd/vue-observer-utils": "^0.5.0", "@types/meteor": "^2.9.8", + "alea": "^1.0.1", "aws-sdk": "^2.1559.0", "bcrypt": "^5.1.1", "chroma-js": "^2.4.2",