Fixed failing action engine tests, moved more engine parts to ts

This commit is contained in:
Thaum Rystra
2024-02-18 23:39:16 +02:00
parent c721374278
commit 55a6b16c31
15 changed files with 177 additions and 100 deletions

View File

@@ -1,11 +1,13 @@
import { assert } from 'chai';
import {
allMutations,
allUpdates,
createTestCreature,
randomIds,
removeAllCreaturesAndProps,
runActionById
} from '/imports/api/engine/action/functions/actionEngineTest.testFn';
import { Mutation, Update } from '/imports/api/engine/action/tasks/TaskResult';
const [
creatureId, targetCreatureId, targetCreature2Id,
@@ -30,7 +32,7 @@ const actionTestCreature = {
{
_id: attackMissId,
type: 'action',
attackRoll: { calculation: '-20' },
attackRoll: { calculation: '-5' },
},
// Disable crits
{
@@ -105,48 +107,57 @@ describe('Apply Action Properties', function () {
targetCreatureId,
targetCreature2Id,
]);
assert.deepEqual(allMutations(action), [{
contents: [{
name: 'Action'
}],
targetIds: [
targetCreatureId,
targetCreature2Id,
]
}, {
contents: [{
inline: true,
name: 'Hit!',
value: '1d20 [10] + 10\n**20**',
}],
targetIds: [targetCreatureId],
}, {
contents: [{
inline: true,
name: 'Hit!',
value: '1d20 [10] + 10\n**20**',
}],
targetIds: [targetCreature2Id],
}]);
const expectedMutations: Mutation[] = [
{
contents: [{ name: 'Action' }],
targetIds: [targetCreatureId, targetCreature2Id]
}, {
contents: [{
inline: true,
name: 'Hit!',
value: '1d20 [10] + 10\n**20**',
}],
targetIds: [targetCreatureId],
}, {
contents: [{
inline: true,
name: 'Hit!',
value: '1d20 [10] + 10\n**20**',
}],
targetIds: [targetCreature2Id],
},
];
assert.deepEqual(allMutations(action), expectedMutations);
});
it('should make attack rolls that use uses', async function () {
const action = await runActionById(usesActionId, [targetCreatureId]);
const expectedUpdates: Update[] = [
{
propId: usesActionId,
type: 'action',
inc: { usesUsed: 1, usesLeft: -1 },
}
]
assert.deepEqual(allUpdates(action), expectedUpdates);
});
it('should make attack rolls that miss', async function () {
const action = await runActionById(attackMissId, [targetCreatureId]);
assert.deepEqual(allMutations(action), [{
contents: [{
name: 'Action'
}],
targetIds: [
targetCreatureId,
]
}, {
contents: [{
inline: true,
name: 'Miss!',
value: '1d20 [10] + 10\n**20**',
}],
targetIds: [targetCreatureId],
}]);
const expectedMutations: Mutation[] = [
{
contents: [{ name: 'Action' }],
targetIds: [targetCreatureId],
}, {
contents: [{
inline: true,
name: 'Miss!',
value: '1d20 [10] 5\n**5**', // DiceCloud uses unicode minus
}],
targetIds: [targetCreatureId],
}
];
assert.deepEqual(allMutations(action), expectedMutations);
});
});

View File

@@ -10,11 +10,12 @@ import { applyAfterChildrenTriggers, applyAfterTriggers, applyChildren } from '/
import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import numberToSignedString from '/imports/api/utility/numberToSignedString';
import rollDice from '/imports/parser/rollDice';
import { getFromScope, getNumberFromScope } from '/imports/api/creature/creatures/CreatureVariables';
import { getNumberFromScope } from '/imports/api/creature/creatures/CreatureVariables';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
import { CalculatedField } from '/imports/api/properties/subSchemas/computedField';
export default async function applyActionProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider
): Promise<void> {
const prop = task.prop;
const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;
@@ -48,13 +49,13 @@ export default async function applyActionProperty(
spendResources(action, prop, targetIds, result, userInput);
const attack = prop.attackRoll || prop.attackRollBonus;
const attack: CalculatedField = prop.attackRoll || prop.attackRollBonus;
// Attack if there is an attack roll
if (attack && attack.calculation) {
if (targetIds.length) {
for (const targetId of targetIds) {
await applyAttackToTarget(task, action, attack, targetId, result);
await applyAttackToTarget(task, action, attack, targetId, result, userInput);
await applyAfterTriggers(action, prop, [targetId], userInput);
await applyChildren(action, prop, [targetId], userInput);
}
@@ -76,7 +77,8 @@ export default async function applyActionProperty(
}
async function applyAttackToTarget(
task: PropTask, action: EngineAction, attack, targetId, taskResult: TaskResult
task: PropTask, action: EngineAction, attack: CalculatedField, targetId: string,
taskResult: TaskResult, userInput: InputProvider
) {
taskResult.pushScope = {
'~attackHit': {},
@@ -86,7 +88,7 @@ async function applyAttackToTarget(
'~attackRoll': {},
}
await recalculateCalculation(attack, action, 'reduce');
await recalculateCalculation(attack, action, 'reduce', userInput);
const scope = await getEffectiveActionScope(action);
const contents: LogContent[] = [];
@@ -95,7 +97,7 @@ async function applyAttackToTarget(
result,
criticalHit,
criticalMiss,
} = await rollAttack(attack, scope, taskResult.pushScope);
} = await rollAttack(attack, scope, taskResult.pushScope, userInput);
const targetScope = getVariables(targetId);
const targetArmor = getNumberFromScope('armor', targetScope)
@@ -143,7 +145,7 @@ async function applyAttackToTarget(
}
}
async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskResult, userInput) {
async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskResult, userInput: InputProvider) {
taskResult.pushScope = {
'~attackHit': {},
'~attackMiss': {},
@@ -151,14 +153,14 @@ async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskRe
'~criticalMiss': {},
'~attackRoll': {},
}
await recalculateCalculation(attack, action, 'reduce');
await recalculateCalculation(attack, action, 'reduce', userInput);
const scope = await getEffectiveActionScope(action);
const {
resultPrefix,
result,
criticalHit,
criticalMiss,
} = await rollAttack(attack, scope, taskResult.pushScope);
} = await rollAttack(attack, scope, taskResult.pushScope, userInput);
let name = criticalHit ? 'Critical Hit!' : criticalMiss ? 'Critical Miss!' : 'To Hit';
if (scope['~attackAdvantage']?.value === 1) {
name += ' (Advantage)';
@@ -182,11 +184,11 @@ async function applyAttackWithoutTarget(action, prop, attack, taskResult: TaskRe
});
}
async function rollAttack(attack, scope, resultPushScope) {
async function rollAttack(attack, scope, resultPushScope, userInput: InputProvider) {
const rollModifierText = numberToSignedString(attack.value, true);
let value, resultPrefix;
if (scope['~attackAdvantage']?.value === 1) {
const [a, b] = await rollDice(2, 20);
const [[a, b]] = await userInput.rollDice(attack, [{ number: 2, diceSize: 20 }]);
if (a >= b) {
value = a;
resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`;
@@ -195,7 +197,7 @@ async function rollAttack(attack, scope, resultPushScope) {
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else if (scope['~attackAdvantage']?.value === -1) {
const [a, b] = await rollDice(2, 20);
const [[a, b]] = await userInput.rollDice(attack, [{ number: 2, diceSize: 20 }]);
if (a <= b) {
value = a;
resultPrefix = `1d20 [ ${a}, ~~${b}~~ ] ${rollModifierText}`;
@@ -204,7 +206,7 @@ async function rollAttack(attack, scope, resultPushScope) {
resultPrefix = `1d20 [ ~~${a}~~, ${b} ] ${rollModifierText}`;
}
} else {
value = await rollDice(1, 20)[0];
[[value]] = await userInput.rollDice(attack, [{ number: 1, diceSize: 20 }]);
resultPrefix = `1d20 [${value}] ${rollModifierText}`
}
resultPushScope['~attackDiceRoll'] = { value };
@@ -231,7 +233,7 @@ function applyCrits(value, scope, resultPushScope) {
return { criticalHit, criticalMiss };
}
async function resetProperties(action: EngineAction, prop: any, result: TaskResult, userInput) {
async function resetProperties(action: EngineAction, prop: any, result: TaskResult, userInput: InputProvider) {
const attributes = getPropertiesOfType(action.creatureId, 'attribute');
for (const att of attributes) {
if (att.removed || att.inactive) continue;

View File

@@ -1,4 +1,5 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
import { applyDefaultAfterPropTasks, applyTaskToEachTarget } from '/imports/api/engine/action/functions/applyTaskGroups';
import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation';
import { PropTask } from '/imports/api/engine/action/tasks/Task';
@@ -8,7 +9,7 @@ import { getSingleProperty, getVariables } from '/imports/api/engine/loadCreatur
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
export default async function applyAdjustmentProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider
): Promise<void> {
const prop = task.prop;
const damageTargetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;

View File

@@ -1,4 +1,5 @@
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 { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import recalculateCalculation from '/imports/api/engine/action/functions/recalculateCalculation';
@@ -8,7 +9,7 @@ import { getPropertyChildren } from '/imports/api/engine/loadCreatures';
import rollDice from '/imports/parser/rollDice';
export default async function applyBranchProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider
): Promise<void> {
const prop = task.prop;
const targets = task.targetIds;

View File

@@ -16,9 +16,10 @@ import recalculateInlineCalculations from '/imports/api/engine/action/functions/
import getPropertyTitle from '/imports/api/utility/getPropertyTitle';
import INLINE_CALCULATION_REGEX from '/imports/constants/INLINE_CALCULTION_REGEX';
import { applyAfterTasksSkipChildren } from '/imports/api/engine/action/functions/applyTaskGroups';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
export default async function applyBuffProperty(
task: PropTask, action: EngineAction, result: TaskResult, userInput
task: PropTask, action: EngineAction, result: TaskResult, userInput: InputProvider
) {
const prop = EJSON.clone(task.prop);
const targetIds = prop.target === 'self' ? [action.creatureId] : task.targetIds;

View File

@@ -0,0 +1,9 @@
import { EngineAction } from '/imports/api/engine/action/EngineActions';
type InputProvider = {
rollDice(
action: EngineAction, dice: { number: number, diceSize: number }[]
): Promise<number[][]>;
}
export default InputProvider;

View File

@@ -1,4 +1,3 @@
import { assert } from 'chai';
import '/imports/api/simpleSchemaConfig.js';
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
import { propsFromForest } from '/imports/api/properties/tests/propTestBuilder.testFn';
@@ -9,6 +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';
/**
* Removes all creatures, properties, and creatureVariable documents from the database
@@ -59,12 +59,12 @@ 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?, userInputFn = () => 0) {
export async function runActionById(propId, targetIds?, userInput = testInputProvider) {
const prop = await CreatureProperties.findOneAsync(propId);
const actionId = await createAction(prop, targetIds);
const action = await EngineActions.findOneAsync(actionId);
if (!action) throw 'Action is expected to exist';
await applyAction(action, userInputFn, { simulate: true });
await applyAction(action, userInput, { simulate: true });
return action;
}
@@ -148,3 +148,25 @@ 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 }

View File

@@ -1,7 +1,8 @@
import { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions';
import EngineActions, { EngineAction, ActionSchema } from '/imports/api/engine/action/EngineActions';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyTask from '/imports/api/engine/action/tasks/applyTask'
import { isEmpty } from 'lodash';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
// TODO create a function to get the effective value of a property,
// simulating all the result updates in the action so far
@@ -10,12 +11,12 @@ import { isEmpty } from 'lodash';
// This is run once as a simulation on the client awaiting all the various inputs or step through
// clicks from the user, then it is run as part of the runAction method, where it is expected to
// complete instantly on the client, and sent to the server as a method call
export async function applyAction(action: EngineAction, userInput?: any[] | Function, options?: {
export async function applyAction(action: EngineAction, userInput: InputProvider, options?: {
simulate?: boolean, stepThrough?: boolean
}) {
const { simulate, stepThrough } = options || {};
if (!simulate && stepThrough) throw 'Cannot step through unless simulating';
if (simulate && typeof userInput !== 'function') throw 'Must provide a function to get user input when simulating';
if (simulate && !userInput) throw 'Must provide a function to get user input when simulating';
action._stepThrough = stepThrough;
action._isSimulation = simulate;
@@ -37,6 +38,6 @@ function writeChangedAction(original: EngineAction, changed: EngineAction) {
}
}
if (!isEmpty($set) && original._id) {
return Actions.updateAsync(original._id, { $set });
return EngineActions.updateAsync(original._id, { $set });
}
}

View File

@@ -2,25 +2,33 @@ import { Context, toPrimitiveOrString } from '/imports/parser/resolve';
import {
aggregateCalculationEffects,
aggregateCalculationProficiencies,
resolveCalculationNode,
} from '/imports/api/engine/computation/computeComputation/computeByType/computeCalculation';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import resolve from '/imports/parser/resolve';
import { getEffectiveActionScope } from '/imports/api/engine/action/functions/getEffectiveActionScope';
import { CalculatedField } from '/imports/api/properties/subSchemas/computedField';
import { ResolveLevel } from '/imports/parser/parseTree/NodeFactory';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
import { EngineAction } from '/imports/api/engine/action/EngineActions';
// TODO Redo the work of
// imports/api/engine/computation/computeComputation/computeByType/computeCalculation.js
// But in the action scope
export default async function recalculateCalculation(
calcObj, action, parseLevel = 'reduce', context, scope
calcObj: CalculatedField,
action,
parseLevel: ResolveLevel = 'reduce',
userInput: InputProvider,
) {
if (!calcObj?.parseNode) return;
calcObj._parseLevel = parseLevel;
if (!scope) {
scope = await getEffectiveActionScope(action);
}
// Re-resolve the parse node
resolveCalculationNode(calcObj, calcObj.parseNode, scope, context);
const scope = await getEffectiveActionScope(action);
// Re-resolve the parse node before effects and proficiencies
const {
result: unaffectedResult,
context
} = resolve(parseLevel, calcObj.parseNode, scope);
calcObj.valueNode = unaffectedResult;
// store the unaffected value
if (calcObj.effectIds || calcObj.proficiencyIds) {
calcObj.unaffected = toPrimitiveOrString(calcObj.valueNode);
@@ -35,19 +43,28 @@ export default async function recalculateCalculation(
id => getSingleProperty(action.creatureId, id),
scope['proficiencyBonus']?.value || 0
);
// Resolve the modified valueNode
resolveCalculationNode(calcObj, calcObj.valueNode, scope, context);
// Store the primitive value
calcObj.value = toPrimitiveOrString(calcObj.valueNode);
// TODO log errors
// Resolve the modified valueNode, use the same context
const {
result: finalResult
} = resolve(parseLevel, calcObj.parseNode, scope, context);
// Store the errors
calcObj.errors = context.errors;
// Store the value and its primitive
calcObj.value = toPrimitiveOrString(finalResult);
calcObj.valueNode = finalResult;
}
export async function rollAndReduceCalculation(calcObj, action) {
export async function rollAndReduceCalculation(
calcObj: CalculatedField, action: EngineAction, userInput: InputProvider
) {
const context = new Context();
const scope = await getEffectiveActionScope(action);
// Compile
recalculateCalculation(calcObj, action, 'compile', context, scope);
recalculateCalculation(calcObj, action, 'compile', userInput);
const compiled = calcObj.valueNode;
// Roll

View File

@@ -5,8 +5,11 @@ import applyDamagePropTask from '/imports/api/engine/action/tasks/applyDamagePro
import applyItemAsAmmoTask from '/imports/api/engine/action/tasks/applyItemAsAmmoTask';
import { getSingleProperty } from '/imports/api/engine/loadCreatures';
import applyProperties from '/imports/api/engine/action/applyProperties';
import InputProvider from '/imports/api/engine/action/functions/InputProvider';
export default async function applyTask(action: EngineAction, task: Task, userInput?): Promise<void> {
export default async function applyTask(
action: EngineAction, task: Task, userInput: InputProvider
): Promise<void> {
action.taskCount += 1;
if (action.taskCount > 100) throw 'Only 100 properties can be applied at once';

View File

@@ -1,15 +1,20 @@
import SimpleSchema from 'simpl-schema';
import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS';
import ParseNode from '/imports/parser/parseTree/ParseNode';
import { ConstantValueType } from '/imports/parser/parseTree/constant';
export interface CalculatedField {
calculation?: string,
value?: string | number,
effectIds?: string[],
parseNode?: any,
parseError?: any,
hash?: number,
errors?: any[],
calculation?: string;
value?: ConstantValueType;
valueNode: ParseNode;
effectIds?: string[];
proficiencyIds?: string[];
unaffected?: ConstantValueType;
parseNode?: ParseNode;
parseError?: any;
hash?: number;
errors?: any[];
}
// Get schemas that apply fields directly so they can be gracefully extended

View File

@@ -1,7 +1,7 @@
import NodeFactory from '/imports/parser/parseTree/NodeFactory';
import { Context, ResolvedResult } from '/imports/parser/resolve';
type ConstantValueType = number | string | boolean | undefined
export type ConstantValueType = number | string | boolean | undefined
export type ConstantNode = {
parseType: 'constant';

View File

@@ -1,5 +1,6 @@
import nodeTypeIndex from './parseTree/_index';
import ParseNode from '/imports/parser/parseTree/ParseNode';
import { ConstantValueType } from '/imports/parser/parseTree/constant';
// Takes a parse node and computes it to a set detail level
// returns {result, context}
@@ -37,10 +38,10 @@ export function toString(node: ParseNode) {
return type.toString(node);
}
export function toPrimitiveOrString(node) {
export function toPrimitiveOrString(node: ParseNode): ConstantValueType {
if (!node) return '';
if (node.parseType === 'constant') return node.value;
if (node.parseType === 'error') return null;
if (node.parseType === 'error') return undefined;
return toString(node);
}

View File

@@ -1,9 +0,0 @@
export default function rollDice(number, diceSize){
let values = [];
let randomSrc = DDP.randomStream('diceRoller');
for (let i = 0; i < number; i++){
let roll = ~~(randomSrc.fraction() * diceSize) + 1
values.push(roll);
}
return values;
}

View File

@@ -0,0 +1,12 @@
export default function rollDice(number: number, diceSize: number): number[] {
const values: number[] = [];
const randomSrc = DDP.randomStream('diceRoller');
if (number > 100) {
throw new Meteor.Error('Too many dice', 'can only roll up to 100 dice at once');
}
for (let i = 0; i < number; i++) {
const roll = ~~(randomSrc.fraction() * diceSize) + 1
values.push(roll);
}
return values;
}