How-to  ›  Expressions

Expressions (ƒx) — author values that compute themselves

Point 1 of the model conclusions, implemented. Any numeric field marked ƒx can be a plain number or a formula evaluated against the live game state: "1 VP per 10 cards in your deck" (Gardens), "8 copies at two players, 12 at three" (Province), "damage equal to your Mastery" (Shards of Infinity).

written 2026-08-05 · shipped in commit 070dfaf

What this is, in one minute

A ƒx value is stored as a two-key object instead of a number:

{
  "expr":     { "floor_div": [ { "count_cards": { "zones": ["deck", "discard"], "owner": "player" } }, 10 ] },
  "authored": { "level": "formula", "preset": "count_per_n", "params": { "zones": "deck, discard", "n": 10 } }
}
  • expr — compiled JSON Logic. The engine and evaluator read only this.
  • authored — what the form (or the AI) actually wrote: a preset name plus its parameters. The form repopulates its dropdowns from this, and the server recompiles expr from authored on every save, so the two can never disagree.

Three authoring levels, by design: plain number (~90% of fields), preset formula (~9%), free JSON Logic (~1%, weaker validation, warned in the UI). The AI generator is only ever allowed to emit presets.

Authoring in the forms

Every ƒx-enabled field shows a small mode selector next to the number input:

ModeWhat you get
NumberThe plain input. Default; nothing changes if you never touch ƒx.
ƒx FormulaA preset dropdown plus parameter inputs (zone pickers, tracker selects, per-player-count number boxes). You cannot reference a zone or tracker that doesn't exist in your game.
ƒx Free JSONA raw JSON Logic textarea, for the 1% the presets don't cover. Types are validated; balance is not.

The Preview button evaluates your formula immediately and shows the result at 2, 3 and 4 players — e.g. 2p: 8 · 3p: 12 · 4p: 12. Preview runs against an empty table, so card-count formulas show 0 there; they count real cards during simulation.

Fields with the widget today: Step 2 · Trackers (Starts at, Min, Max), Anatomy · Setup (Starting hand size), Step 2 · Zones (Refill target count).

The preset catalog

PresetReads asParamsReal example
by_player_count "N at 2 players, M at 3…" a number per player count + default Province pile: 8 / 12 / 12. Curse: 10 / 20 / 30.
count_cards "how many cards in these zones" zones (comma list), owner (default: the player), optional filter_type / filter_family Race for the Galaxy military strength (count Military worlds in tableau)
count_per_n "1 per every N cards" (rounded down) same as count_cards + n Gardens: VP = deck size ÷ 10
sum_cards "total points printed on my cards" zones, field (default vp), optional filter_type Dominion endgame scoring; Gardens' own formula evaluates per card (G12)
tracker_value "the current value of a tracker" tracker id, of: self | opponent Shards of Infinity: damage equal to your Mastery
arithmetic "A op B", nesting the above a, b (number or nested formula), op: + − × ÷ "3 + 1 per Champion you control" (JSON only — not in the form dropdown yet)

The catalog is deliberately closed: an unknown preset throws at compile time — it is never silently coerced (the lesson from finding A2). Adding a preset is a code change; see Extending it.

Authoring in game-defs JSON (probes, import)

Three accepted spellings — all normalise to the canonical two-key shape on save/import:

/* 1 — canonical: expr + authored (what export writes) */
"copies": {
  "expr":     { "by_player_count": { "map": { "2": 8, "3": 12 }, "default": 12 } },
  "authored": { "level": "formula", "preset": "by_player_count",
                "params": { "2": 8, "3": 12, "default": 12 } }
}

/* 2 — authored only: expr is compiled server-side */
"copies": {
  "authored": { "level": "formula", "preset": "by_player_count",
                "params": { "2": 8, "3": 12, "default": 12 } }
}

/* 3 — bare expr (hand-written JSON Logic): gains authored { level: "free" } */
"copies": { "expr": { "by_player_count": { "map": { "2": 8 }, "default": 12 } } }

Live example: dominion.json — the Estate / Duchy / Province / Curse piles all carry spelling 1. php8.2 game-tool.php import validates every expression and reports problems in the gap report instead of failing the import.

Where expressions work

FieldEvaluated whenContext bound
Tracker starts_at / min / maxplayer init at setup; per_turn_reset_to_X at turn rolloverplayer = the owner (zones are empty at setup — card counts are 0 there)
Card cost.*legal-action generation (can I afford this?)player = the buyer, full live state
Card / deck copiesdeck instantiation during setupplayer count
starting_hand_size, setup draw_n_cards.nthe opening dealplayer count
Zone auto_refill.toevery refill pass after actionsfull live state (computed lazily, only when the target really is a formula)
Card attributes.*stored + validated today; consumed when score-from-cards lands (G12)
ending.whenafter every action (pre-existing)player/opponent/trigger

Evaluator operators available inside expr

Standard JSON Logic (+ - * / if == < > and or var …) plus the DeckCraft custom operators:

OperatorArgsReturns
count_cards{ zone | zones[], owner, where }where is a per-card predicate with card boundcount (zones array sums)
player_countnonenumber of players (falls back to config.player_count)
floor_div[numerator, denominator] — both sides may nest custom ops⌊a/b⌋, 0 on divide-by-zero
by_player_count{ map: {"2": n…}, default }table lookup on player count
sum_cards{ zones[], owner, field, where? }sums a card attribute; per-card ƒx attributes evaluate with the owner bound (G12)
count_players_where · for_each_player · lookup_player · card_in · objective_countpre-existing — see inc-game-expressions.php headers

The preview endpoint

POST /ajax-expr-preview.php
Content-Type: application/json

{ "value": { "authored": { "level": "formula", "preset": "by_player_count",
                           "params": { "2": 8, "3": 12, "default": 12 } } } }

→ { "success": true,
    "preview": { "2": 8, "3": 12, "4": 12 },
    "compiled": { "by_player_count": { "map": { "2": 8, "3": 12 }, "default": 12 } },
    "note": "Zones are empty in preview: …" }

Also accepts literals (echoed at every player count) and bare-expr values. Invalid formulas return success: false with the compile error — the same error the save endpoint would give.

Extending it (developer how-to)

Adding a preset — four places, in order:

#FileWhat
1php-includes/inc-expr-value.phpa case in dcCompileAuthored() (authored → JSON Logic), plus reference checks in dcValidateExprValue() if it names zones/trackers
2php-includes/inc-game-expressions.phponly if it needs a new operator: add to CUSTOM_OPS + an op_* method. Operators receive RAW args and sub-evaluate what they need (the library's add_operation is unusable — see the file header)
3js/expr-widget.jsan <option> in the preset select, param inputs, a PRESET_PARAMS entry, and the read-back branch in read()
4tests/test-expr-value.phpcompile + evaluate + validate cases. Run the whole suite: php8.2 tests/test-*.php

Consuming the union at a new engine read site — the pattern:

$v = $def['some_field'] ?? 0;
if (dcIsExprValue($v)) {
    $v = dcResolveValue($v, $state, ['player' => $me], $fallback);
}
  • Guard with dcIsExprValue() first — literals must stay free.
  • If the read site is hot (per-action), compute $g->toArray() lazily — see engineApplyZoneRefills() or the cost loop in engineLegalActions() for the pattern.
  • dcZoneRefillTarget() shows the callable-state variant for shared helpers.

Adding the widget to another form — three lines:

html += ExprWidget.field('my_field', 'Label', value, { zones: [...], trackers: [...] });
/* after innerHTML: */  ExprWidget.bind(formEl);
/* on save:         */  var v = ExprWidget.read(formEl, 'my_field', 0);
                        var err = ExprWidget.validate(v);   // free-JSON syntax check

Then add the section/field to the compile-on-save block in ajax-step2-save.php ($EXPR_SECTIONS + a $normaliseAt call), and make sure the page loads js/expr-widget.js before the section script.

Invariants & gotchas

RuleWhy
expr is the only thing the engine reads; authored is the only thing the form reads.Either side can evolve without breaking the other; save recompiles so they never drift.
Unknown preset / bad free JSON throws at compile; save and import surface the error.Never silently coerce (finding A2). A wrong formula must be loud.
An expression evaluated with no game state returns the fallback and logs [expr-value].A broken formula degrades to a number instead of killing a simulation batch.
Setup-time context has empty zones.starts_at/copies/hand-size formulas should use player-count / tracker presets; card counts are 0 until cards exist.
Expressions stay deterministic.They read state; they never roll dice. Seed → bit-identical replay still holds.
The AI generator emits presets only, never free JSON Logic.Model-written DSL is undebuggable; presets compile through the same safe path as the form.
Per the working rule: a new ƒx field ships with its form, import/export round-trip, and a tracker row.No import-only fields, no invisible engine debt.