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).
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:
| Mode | What you get |
|---|---|
| Number | The plain input. Default; nothing changes if you never touch ƒx. |
| ƒx Formula | A 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 JSON | A 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
| Preset | Reads as | Params | Real 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
| Field | Evaluated when | Context bound |
|---|---|---|
Tracker starts_at / min / max | player init at setup; per_turn_reset_to_X at turn rollover | player = 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 copies | deck instantiation during setup | player count |
starting_hand_size, setup draw_n_cards.n | the opening deal | player count |
Zone auto_refill.to | every refill pass after actions | full 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.when | after every action (pre-existing) | player/opponent/trigger |
Evaluator operators available inside expr
Standard JSON Logic (+ - * / if == < > and or var …) plus the DeckCraft custom operators:
| Operator | Args | Returns |
|---|---|---|
count_cards | { zone | zones[], owner, where } — where is a per-card predicate with card bound | count (zones array sums) |
player_count | none | number 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_count | pre-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:
| # | File | What |
|---|---|---|
| 1 | php-includes/inc-expr-value.php | a case in dcCompileAuthored() (authored → JSON Logic), plus reference checks in dcValidateExprValue() if it names zones/trackers |
| 2 | php-includes/inc-game-expressions.php | only 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) |
| 3 | js/expr-widget.js | an <option> in the preset select, param inputs, a PRESET_PARAMS entry, and the read-back branch in read() |
| 4 | tests/test-expr-value.php | compile + 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 — seeengineApplyZoneRefills()or the cost loop inengineLegalActions()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
| Rule | Why |
|---|---|
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. |