Appearance
Crafting
Timed transformation of resources over machines:
txt
inputs → job → duration → outputsGeneric enough for kitchens, workshops, refineries or sci-fi fabricators. The mechanic owns the job lifecycle — input consumption, progression by dt, output crediting, save healing; recipes and machines are data you supply. One active job per machine, no queue (a deliberate V1 boundary).
ts
import { crafting } from "@idlekitjs/mechanics/crafting";Minimal example
ts
import { crafting, type CraftingJob, type ResourceBag } from "@idlekitjs/mechanics/crafting";
interface State {
resources: ResourceBag; // Record<string, number>
crafting: { jobs: CraftingJob[] };
}
const kitchen = crafting<State>({
recipes: [
{ id: "sandwich", inputs: { bread: 2, cheese: 1 }, outputs: { sandwich: 1 }, duration: 10 },
],
machines: [{ id: "kitchen-1" }],
getResources: (s) => s.resources,
setResources: (s, resources) => {
s.resources = resources;
},
getJobs: (s) => s.crafting.jobs,
setJobs: (s, jobs) => {
s.crafting.jobs = jobs;
},
});
engine.use(kitchen);
kitchen.start(engine.state, "sandwich", "kitchen-1");
// 10 simulated seconds later: bread -2, cheese -1, sandwich +1Recipes and machines
ts
interface RecipeDef {
id: string;
inputs: ResourceBag; // consumed at start
outputs: ResourceBag; // credited at completion
duration: number; // seconds, finite > 0
machineType?: string; // restrict to machines of this type
metadata?: Record<string, unknown>; // names/icons — game-side
}
interface MachineDef {
id: string;
type?: string; // matched against recipe.machineType
metadata?: Record<string, unknown>;
}A recipe without machineType runs anywhere; one with it only runs on machines of that exact type. Invalid definitions throw at wiring time — duplicate ids, non-positive durations, negative amounts are bugs, not game states.
Statuses: UI-ready answers
status(state, recipeId, machineId) answers "can this run here, and if not, why?" as a discriminated union — no exceptions for runtime refusals:
ts
const status = kitchen.status(engine.state, "sandwich", "kitchen-1");
switch (status.kind) {
case "ready":
break; // enable the craft button
case "missing-inputs":
break; // status.missing: exact shortfall per resource
case "machine-busy":
break; // running another recipe (status.jobId)
case "crafting":
break; // running THIS recipe (status.job, status.progress)
case "wrong-machine-type":
break; // status.required vs status.actual
case "unknown-recipe":
break; // static-data problem
case "unknown-machine":
break;
}start runs the same check and returns the same union: on success you get { kind: "crafting", job, progress: 0 }, on refusal the blocking status — render it directly. canStart is the boolean shorthand.
The job lifecycle
ts
kitchen.start(state, "sandwich", "kitchen-1"); // consume inputs, create job
kitchen.activeJobs(state); // CraftingJob[] snapshot
kitchen.jobFor(state, "kitchen-1"); // this machine's job, if any
kitchen.progressFraction(state, "kitchen-1"); // [0, 1] for a progress bar
kitchen.cancel(state, "kitchen-1"); // refund inputs (default)
kitchen.cancel(state, "kitchen-1", { refund: false });Jobs are plain serializable data in your state:
ts
interface CraftingJob {
id: string; // default "<machineId>:<recipeId>"; override via the jobId option
recipeId: string;
machineId: string;
elapsed: number; // advanced in place by update
duration: number; // copied from the recipe at start
}Each update(state, dt) advances every job; completed jobs credit their outputs, fire onComplete(job, outputs, state) and disappear. Since inputs were consumed at start, a completed job never double-spends — and a big offline dt completes every due job in one pass. There is no auto-restart: chaining jobs is a game-side policy (see the recipe).
ResourceBag helpers
Pure, non-mutating helpers for the Record<string, number> currency shape — exported from the same subpath:
ts
import {
addResources,
subtractResources,
canAfford,
missingResources,
} from "@idlekitjs/mechanics/crafting";
canAfford({ bread: 3 }, { bread: 2, cheese: 1 }); // false
missingResources({ bread: 3 }, { bread: 2, cheese: 1 }); // { cheese: 1 }
addResources({ bread: 3 }, { bread: 2 }); // { bread: 5 } (new bag)Missing keys count as zero; every helper returns a fresh bag, so reassigning through your setResources keeps reactivity intact.
Economy adapter
The optional Economy bridge lives at @idlekitjs/mechanics/crafting/economy:
ts
import {
craftingResources,
recipeCost,
recipeReward,
} from "@idlekitjs/mechanics/crafting/economy";craftingResources(keys, accessors) declares entries in the same ResourceBag as Economy resources. ResourceBag remains the crafting state shape and the source of truth.
ts
const economy = createEconomy<State>().resources(
craftingResources(["bread", "cheese", "sandwich"], {
getResources: (state) => state.resources,
setResources: (state, resources) => {
state.resources = resources;
},
}),
);
const costLines = economy.formatCost(state, recipeCost(sandwichRecipe));
const rewardLines = economy.formatReward(recipeReward(sandwichRecipe));The adapter converts bags to Economy amounts for display and transactions; it does not replace start, cancel or complete. Crafting still consumes inputs at start, refunds on cancel and credits outputs on completion.
Save & load
Nothing to serialize manually — jobs ride in your state. On the loaded event the mechanic heals stale saves: jobs referencing removed recipes or machines are dropped, durations are re-derived from the current definitions, and corrupt elapsed values are reset. Shipping a balance patch cannot strand a player mid-job.
Options
| Option | Required | Role |
|---|---|---|
recipes, machines | ✓ | The static definitions |
getResources/setResources | ✓ | The resource stock in your state |
getJobs/setJobs | ✓ | The active jobs in your state |
jobId | Custom job id factory | |
onStart | After a job started (inputs already consumed) | |
onComplete | After a job completed (outputs already credited) |
Limits
- One job per machine; no queue; no multi-step chains.
- No auto-restart after completion — automation is a game policy.
- No speed multipliers in the current API.
- Out of scope: UI, customers/sales, workers, ads — those decide when to call
start, nothing more. - Economy can observe the same stock and format recipe costs/rewards, but job lifecycle remains in crafting.
Related
- Crafting machine recipe — progress bar, auto-restart pattern.
- Economy adapters.
- Mechanics API — crafting.