Skip to content

Economy Adapters

Official bridges between @idlekitjs/economy and the mechanics package.

They are optional and subpath-only:

txt
@idlekitjs/mechanics/producers/economy
@idlekitjs/mechanics/collections/economy
@idlekitjs/mechanics/crafting/economy
@idlekitjs/mechanics/projects/economy
@idlekitjs/mechanics/boosts/economy
@idlekitjs/mechanics/containers/economy
@idlekitjs/mechanics/pickups/economy

They are not exported by the @idlekitjs/mechanics barrel and they do not live under @idlekitjs/economy/adapters.

Why They Live In Mechanics

Economy is domain-free: it knows resources, amounts, requirements, transactions and cost curves. It must not learn what a producer column, card entry, recipe bag, project or boost timer is.

Each adapter depends on both sides: the mechanic's state shape and Economy's resource/transaction vocabulary. Putting the adapter beside the mechanic avoids a dependency cycle (economy -> mechanics -> economy) and keeps the core mechanic files Economy-free.

Boundary summary:

txt
@idlekitjs/economy does not depend on @idlekitjs/core or @idlekitjs/mechanics.
@idlekitjs/mechanics may depend on @idlekitjs/economy only inside */economy subfolders.
Core mechanic modules stay Economy-free.
Adapters are subpath-only and not re-exported by the main barrel.

Mechanics remain usable without Economy. Games opt into these bridges when they want common resource declarations, transaction costs, reward tokens or view-model formatting around a mechanic.

Producers

ts
import {
  economyPurchase,
  producerOutput,
  producerPurchase,
  producerResourceId,
  producerResources,
} from "@idlekitjs/mechanics/producers/economy";

Exports:

ExportRole
producerResourceIdproducer:<id> resource id convention
producerResourcesDeclare one resource per producer tier, backed by total[index]
producerOutputBridge an Economy resource into producers' resource seam
economyPurchaseImplement the scalar producers purchase seam through Economy
producerPurchaseBuild a composite producer purchase Transaction from a CostCurve

producerOutput connects continuous tier-0 production to an Economy resource through { get, add }. Production remains a mechanics concern; Economy only observes and credits the same balance.

economyPurchase fills the existing scalar purchase seam. It does not replace the seam and does not make Economy required. It reads the spendable balance of resourceByTier(index) and debits it with economy.spend.

AdVenture Communist style wiring:

ts
const purchase = economyPurchase(economy, (index) =>
  index === 0
    ? "currency:potatoes"
    : producerResourceId(PRODUCERS[index - 1].id),
);

const farm = producers<State>({
  definitions: PRODUCERS,
  getColumn: column.getColumn,
  setColumn: column.setColumn,
  resource: producerOutput(economy, "currency:potatoes"),
  purchase,
});

For tier unit currencies, pass wholeUnits:

ts
const purchase = economyPurchase(economy, resourceByTier, {
  wholeUnits: (index) => index > 0,
});

That floors budgets and ceils payments for whole-unit tiers.

Composite multi-resource purchases should use CostCurve + Transaction, not the scalar seam:

ts
const factoryCost = costCurve<State>({
  getOwned: (state) => state.owned[2] ?? 0,
  lines: [
    flat(producerResourceId("collective"), 1),
    geometric("currency:coins", { baseAmount: 1000, growth: 1.15, round: "ceil" }),
  ],
});

const result = economy.execute(
  state,
  producerPurchase({ producers: farm, index: 2, curve: factoryCost }),
);

producerPurchase grants through producers.grant(index, state, quantity, { owned: true }), so the producer cost curve advances exactly as if the units had been bought.

producers.grant

grant(index, state, quantity, { owned? }) adds whole units without paying through the scalar seam.

Use it for:

  • rewards;
  • crates;
  • debug/admin tools;
  • purchases already validated and paid by an Economy transaction;
  • other composite actions where payment is external to producers.

Default behavior is a pure gift: only total increases, like starter units. With { owned: true }, both owned and total increase, so the cost curve advances.

grant does not replace purchase. It bypasses costs only because the caller intentionally decided to grant or has already paid elsewhere.

Collections

ts
import {
  cardLevelAtLeast,
  cardLevelBelow,
  cardLevelResourceId,
  cardResources,
  cardShardResourceId,
  hasCardShards,
} from "@idlekitjs/mechanics/collections/economy";

Exports:

ExportRole
cardShardResourceIdcard:<id>:shards
cardLevelResourceIdcard:<id>:level
cardResourcesDeclare writable shard and read-only level resources
cardLevelAtLeastRequirement over read-only level
cardLevelBelowRequirement for "not maxed" gates
hasCardShardsNon-consuming shard requirement

cardResources declares two resources per card:

  • shards are writable and integer; credits create missing entries;
  • levels are read-only and integer; upgrades remain owned by the collections mechanic or game code.

The adapter does not duplicate collection upgrade logic. If collections is already the source of truth for levels and duplicates, keep it that way and use Economy for surrounding transactions, gates and formatting.

Upgrade transaction pattern:

ts
const result = economy.execute(state, {
  id: "upgrade-card:collective-manager",
  requirements: [cardLevelBelow("collective-manager", 5)],
  cost: [
    [cardShardResourceId("collective-manager"), 10],
    ["currency:science", 500],
  ],
  apply: (state) => {
    upgradeCard(state, "collective-manager");
  },
});

Current id convention: pass unprefixed business ids such as red-star to cardResources. Passing an id that already starts with card: produces resource ids like card:card:red-star:shards.

Crafting

ts
import {
  amountsToBag,
  bagToAmounts,
  craftingResources,
  recipeCost,
  recipeReward,
} from "@idlekitjs/mechanics/crafting/economy";

Exports:

ExportRole
craftingResourcesDeclare a ResourceBag stock as Economy resources
bagToAmountsConvert ResourceBag to amount lines
amountsToBagConvert amount lines back to ResourceBag
recipeCostRecipe inputs as Cost
recipeRewardRecipe outputs as Reward

ResourceBag remains the mechanic's state shape. Economy observes and can trade the same numbers; it does not replace the crafting lifecycle.

Start/cancel/complete stay in crafting:

  • start consumes inputs and creates a job;
  • cancel refunds or discards inputs based on options;
  • update completes jobs and credits outputs.

The adapter avoids duplicated conversion code:

ts
const stock = {
  getResources: (state: State) => state.resources,
  setResources: (state: State, resources: ResourceBag) => {
    state.resources = resources;
  },
};

const economy = createEconomy<State>().resources(
  craftingResources(["ore", "coal", "iron"], stock),
);

const costLines = economy.formatCost(state, recipeCost(RECIPE));

Projects

ts
import { projectFromTransaction } from "@idlekitjs/mechanics/projects/economy";

projectFromTransaction(economy, transaction, options) wraps an Economy transaction as a Project<T>:

Project fieldMapping
idtransaction.id
affordableeconomy.canExecute(state, transaction)
effecteconomy.execute(state, transaction)
costformatted transaction cost unless overridden
triggeroptions.trigger or always visible
repeatableoptions.repeatable

Projects keep completed/repeatable/visible/trigger behavior. Economy supplies the action's affordability, cost formatting and execution.

ts
const project = projectFromTransaction(
  economy,
  {
    id: "project:better-plows",
    cost: [["currency:potatoes", 100]],
    apply: (state) => {
      state.upgrades.push("better-plows");
    },
  },
  {
    title: "Better plows",
    description: "Till twice the soil.",
  },
);

Boosts

ts
import { activateBoost, boostTokenResourceId } from "@idlekitjs/mechanics/boosts/economy";

Exports:

ExportRole
boostTokenResourceIdboost-token:<id> token convention
activateBoostTransaction that spends a token and calls boosts.grant

Pattern:

  1. A reward can credit a token resource.
  2. A transaction can consume the token.
  3. apply grants the boost.
  4. Boost timers, stacking and modifiers remain in boosts.
ts
const result = economy.execute(
  state,
  activateBoost(boostsExt, { boostId: "double-production" }),
);

Custom token ids and counts are supported:

ts
activateBoost(boostsExt, {
  boostId: "double-production",
  tokenId: "currency:stars",
  tokens: 3,
});

Containers

ts
import {
  containerContentResources,
  containerFreeSpaceResource,
  containerHasSpace,
} from "@idlekitjs/mechanics/containers/economy";

Exports:

ExportRole
containerContentResourcesExpose container contents as writable resources
containerFreeSpaceResourceExpose free capacity as a read-only resource
containerHasSpaceRequirement that a content amount fits

Containers stay the source of truth for capacity and contents. Economy can format or trade the same numbers, while deposits should gate capacity with containerHasSpace before crediting a content resource.

ts
const resources = [
  ...containerContentResources(bin, "scrap-bin", ["scrap", "wire"]),
  containerFreeSpaceResource(bin, "scrap-bin"),
];

const collectScrap = collectPickup(pickupsExt, {
  itemId: "scrap#1",
  requirements: [containerHasSpace(bin, "scrap-bin", "scrap", 1)],
  apply: (state) => {
    bin.fill(state, "scrap-bin", "scrap", 1);
  },
});

Pickups

ts
import {
  collectPickup,
  pickupAvailable,
} from "@idlekitjs/mechanics/pickups/economy";

Exports:

ExportRole
pickupAvailableRequirement that the item exists and is ready
collectPickupTransaction for a pickup collect action

Pickups spawn temporary opportunities. Rewards exist only when the collect transaction succeeds. collectPickup checks availability in preview, then takes the item, runs the optional domain effect and credits any reward.

ts
const result = economy.execute(
  state,
  collectPickup(pickupsExt, {
    itemId: "coin#4",
    reward: [["currency:coins", 25]],
  }),
);

Containers

ts
import {
  containerContentResources,
  containerFreeSpaceResource,
  containerHasSpace,
} from "@idlekitjs/mechanics/containers/economy";

Exports:

ExportRole
containerHasSpaceRequirement: the content fits right now
containerContentResourcesContainer content amounts as Economy resources
containerFreeSpaceResourceFree capacity as a read-only resource

The container stays the source of truth for capacity — Economy never owns it. containerHasSpace is the natural gate for collect/deposit transactions, with used/capacity progress for the UI:

ts
requirements: [containerHasSpace(bin, "trash-bin", "scrap", 1)];

containerContentResources credits go through the all-or-nothing fill, so an over-capacity credit throws as a wiring error — gate transactions with containerHasSpace instead of relying on clamping.

Pickups

ts
import { collectPickup, pickupAvailable } from "@idlekitjs/mechanics/pickups/economy";

Exports:

ExportRole
pickupAvailableRequirement: the item exists and is not expired
collectPickupTransaction that takes the item and settles cost/reward

Spawning a pickup creates an opportunity, never a reward. collectPickup enforces that boundary: availability (and any extra requirement, typically container space) is checked in the pure preview; only then is the item taken, the optional apply(state, item) run and the reward credited.

ts
const result = economy.execute(
  state,
  collectPickup(litter, {
    itemId,
    reward: [["currency:money", 5]],
    requirements: [containerHasSpace(bin, "trash-bin", "scrap")],
    apply: (state) => {
      bin.fill(state, "trash-bin", "scrap");
    },
  }),
);

A failed requirement (expired item, full container, missing cost) leaves the pickup in place and credits nothing.