Skip to content

Crafting

Timed transformation of resources over machines:

txt
inputs → job → duration → outputs

Generic 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 +1

Recipes 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

OptionRequiredRole
recipes, machinesThe static definitions
getResources/setResourcesThe resource stock in your state
getJobs/setJobsThe active jobs in your state
jobIdCustom job id factory
onStartAfter a job started (inputs already consumed)
onCompleteAfter 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.