Appearance
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/economyThey 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:
| Export | Role |
|---|---|
producerResourceId | producer:<id> resource id convention |
producerResources | Declare one resource per producer tier, backed by total[index] |
producerOutput | Bridge an Economy resource into producers' resource seam |
economyPurchase | Implement the scalar producers purchase seam through Economy |
producerPurchase | Build 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:
| Export | Role |
|---|---|
cardShardResourceId | card:<id>:shards |
cardLevelResourceId | card:<id>:level |
cardResources | Declare writable shard and read-only level resources |
cardLevelAtLeast | Requirement over read-only level |
cardLevelBelow | Requirement for "not maxed" gates |
hasCardShards | Non-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:
| Export | Role |
|---|---|
craftingResources | Declare a ResourceBag stock as Economy resources |
bagToAmounts | Convert ResourceBag to amount lines |
amountsToBag | Convert amount lines back to ResourceBag |
recipeCost | Recipe inputs as Cost |
recipeReward | Recipe 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:
startconsumes inputs and creates a job;cancelrefunds or discards inputs based on options;updatecompletes 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 field | Mapping |
|---|---|
id | transaction.id |
affordable | economy.canExecute(state, transaction) |
effect | economy.execute(state, transaction) |
cost | formatted transaction cost unless overridden |
trigger | options.trigger or always visible |
repeatable | options.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:
| Export | Role |
|---|---|
boostTokenResourceId | boost-token:<id> token convention |
activateBoost | Transaction that spends a token and calls boosts.grant |
Pattern:
- A reward can credit a token resource.
- A transaction can consume the token.
applygrants the boost.- 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:
| Export | Role |
|---|---|
containerContentResources | Expose container contents as writable resources |
containerFreeSpaceResource | Expose free capacity as a read-only resource |
containerHasSpace | Requirement 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:
| Export | Role |
|---|---|
pickupAvailable | Requirement that the item exists and is ready |
collectPickup | Transaction 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:
| Export | Role |
|---|---|
containerHasSpace | Requirement: the content fits right now |
containerContentResources | Container content amounts as Economy resources |
containerFreeSpaceResource | Free 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:
| Export | Role |
|---|---|
pickupAvailable | Requirement: the item exists and is not expired |
collectPickup | Transaction 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.