Conditions, actions and interactions
Three tools for game rules that must be checked, applied safely and offered to the player:
- A
Conditionanswers "is this allowed?" and says why not. - An
Actionvalidates 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 evenConditions
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.
| Combinator | Behaviour |
|---|---|
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:
- Request.
new ActionRequest(actionId, actor, target, payload, metadata)states what the caller wants. - Validate. Conditions (
addCondition,precondition,guard) and stages (validate(stage)) run in the order you added them over anActionContext. A stage may change the payload, stage changes withstageMutation(id, apply, rollback), or calldeny(reason). A denial or a throw stops here with nothing applied. - Commit. The staged mutations run. If one throws, the ones already applied are rolled back in reverse order.
- 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
InteractionDefinitionpairs 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
InteractionProviderdiscovers offers for anInteractionContext(actor, target, environment).InteractionProvider.fromDefinitionwraps a definition;new InteractionProvider(id, discover)takes your own discovery function. - An
InteractionRegistrygathers providers.discover(actor, target, environment?)returnsInteractionOffers. A disabled offer is still returned, with itsconditionResult, 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.