Skip to content

Conditions, actions and interactions ​

Three tools for game rules that must be checked, applied safely and offered to the player:

  • A Condition answers "is this allowed?" and says why not.
  • An Action validates a request, stages its changes, and commits them all or none.
  • An interaction offers actions the player could take on a target ("Open chest"), enabled or not.

Conditions and actions ​

ts
import { Action, ActionContext, ActionRequest, ActionResult, Condition, ConditionResult } from "gamekit";

class Wallet {
    gold: int = 30;
}

class Price {
    cost: int;
    constructor(cost: int) { this.cost = cost; }
}

// Conditions explain themselves when they fail.
const canAfford: Condition<ActionContext<Price>> = new Condition<ActionContext<Price>>(
    "can-afford", "not enough gold",
    (ctx: ActionContext<Price>): boolean => (ctx.actor as Wallet).gold >= ctx.payload.cost);

// An action validates, stages mutations, then commits them all or none.
const buy: Action<Price, any> = new Action<Price, any>("buy", "buys an item");
buy.addCondition(canAfford);
buy.validate((ctx: ActionContext<Price>): void => {
    const wallet: Wallet = ctx.actor as Wallet;
    const cost: int = ctx.payload.cost;
    ctx.stageMutation("pay",
        (): void => { wallet.gold -= cost; },
        (): void => { wallet.gold += cost; });
});

const wallet: Wallet = new Wallet();
const first: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
const second: ActionResult<any> = buy.execute(new ActionRequest<Price>("buy", wallet, null, new Price(20)));
console.log("first: " + first.status + ", second: " + second.status + " (" + (second.reason ?? "") + ")");
console.log("gold left: " + wallet.gold);

// Conditions compose.
const positive: Condition<int> = new Condition<int>("positive", "must be above zero", (n: int): boolean => n > 0);
const even: Condition<int> = new Condition<int>("even", "must be even", (n: int): boolean => n % 2 == 0);
const both: ConditionResult = Condition.all<int>([positive, even]).evaluate(3);
console.log("3 passes: " + both.passed + ", reason: " + both.reason);
text
first: committed, second: denied (not enough gold)
gold left: 10
3 passes: false, reason: must be even

Conditions ​

new Condition<TContext>(id, description, evaluator) wraps a predicate over a context you pass in; it never looks anything up globally. The evaluator may return a boolean, a string (a failure reason) or a ConditionResult (passed, conditionId, reason, reasons). When a plain false comes back, the description is the reason.

CombinatorBehaviour
Condition.all(list, shortCircuit = true)In order. Stops at the first failure, or with false collects every reason.
Condition.any(list, shortCircuit = true)In order. Stops at the first pass; if all fail, collects the reasons.
Condition.not(c, reason)Inverts, with an explicit reason.
c.and(d), c.or(d), c.not()The same, written fluently.

Actions ​

An action runs a request through four steps:

  1. Request. new ActionRequest(actionId, actor, target, payload, metadata) states what the caller wants.
  2. Validate. Conditions (addCondition, precondition, guard) and stages (validate(stage)) run in the order you added them over an ActionContext. A stage may change the payload, stage changes with stageMutation(id, apply, rollback), or call deny(reason). A denial or a throw stops here with nothing applied.
  3. Commit. The staged mutations run. If one throws, the ones already applied are rolled back in reverse order.
  4. Notify. Events staged with emitOnCommit(scope, key, event) are emitted only after a successful commit, so listeners hear about facts, never about attempts.

execute returns an ActionResult with status (ActionStatus: committed, denied or failed), ok, reason, error and the applied mutations. onCommit(fn) adds commit-time work.

ActionSequence runs several actions in order. When one is denied or fails, the rest are skipped, and with rollbackOnFailure (the default) the committed ones are rolled back in reverse.

Interactions ​

ts
import { Action, ActionContext, ActionRequest, ActionResult, Condition, InteractionContext, InteractionDefinition, InteractionOffer, InteractionProvider, InteractionRegistry } from "gamekit";

class Player {
    keys: int = 0;
}
class Chest {
    open: boolean = false;
}

const openChest: Action<any, any> = new Action<any, any>("open-chest", "opens a chest");
openChest.validate((ctx: ActionContext<any>): void => {
    const chest: Chest = ctx.target as Chest;
    const player: Player = ctx.actor as Player;
    ctx.stageMutation("open",
        (): void => { chest.open = true; player.keys -= 1; },
        (): void => { chest.open = false; player.keys += 1; });
});

const chestDef: InteractionDefinition<string> = new InteractionDefinition<string>(
    "chest",
    openChest,
    (ctx: InteractionContext<Player, Chest, any>): ActionRequest<any> => new ActionRequest<any>("open-chest", ctx.actor, ctx.target),
    new Condition<any>("has-key", "you need a key", (ctx: any): boolean => (ctx.actor as Player).keys > 0),
    (ctx: InteractionContext<Player, Chest, any>, enabled: boolean, result: any): string => "Open chest");

const interactions: InteractionRegistry = new InteractionRegistry();
interactions.register(InteractionProvider.fromDefinition(chestDef));

const player: Player = new Player();
const chest: Chest = new Chest();
for (const offer of interactions.discover(player, chest)) {
    console.log(offer.metadata + " enabled: " + offer.enabled);
}
player.keys = 1;
const offers: InteractionOffer<any>[] = interactions.discover(player, chest);
const result: ActionResult<any> = offers[0].execute();
console.log("result " + result.status + ", chest open: " + chest.open);
text
Open chest enabled: false
result committed, chest open: true
  • An InteractionDefinition pairs an action with a request factory, an optional condition and an optional metadata factory. The metadata is yours: a prompt, a verb, an icon.
  • An InteractionProvider discovers offers for an InteractionContext(actor, target, environment). InteractionProvider.fromDefinition wraps a definition; new InteractionProvider(id, discover) takes your own discovery function.
  • An InteractionRegistry gathers providers. discover(actor, target, environment?) returns InteractionOffers. A disabled offer is still returned, with its conditionResult, so the game can show why it is greyed out. offer.execute() runs the action.

gamekit adds no prompts, ranges or verbs of its own. Two parts of a game can register unrelated interactions without knowing about each other.

Reference: Condition, ConditionResult, Action, ActionContext, ActionSequence, InteractionRegistry.