Docs: Tactics

Tactics: players as a separate layer

A model describes the rules. A tactic describes how someone plays: at which decision they choose what. It is a separate YAML file next to the model, written in the same expression language. The engine runs it like any other player, so a tactic can be watched game by game, compared with another tactic, and get look-ahead on top.

On the site. The player has tactics for some of its built-in games: Pig ("stop at 8"), chess ("material") and the hex skirmish ("basic"). Pick one under Players and follow its decisions question by question. Loading your own tactic file is not yet available on the site, and neither are look-ahead or comparing tactics over many games. This page describes the tactic language, so that tactics can be written, read and shared now.

The JSON schema for editor completion is tactic.schema.json; put # yaml-language-server: $schema=https://tabletwin.com/models/docs/tactic.schema.json on the first line of a tactic file. The published tactics are pig-tactic.yaml, chess-material.yaml and hex-skirmish-basic.yaml.

The smallest example

For a Pig model with a step choice that offers roll and stop, a variable pot, a per-player score and a constant GOAL (see pig.yaml): keep rolling until the pot is 8, or until stopping wins.

tactic: pig-hold-at-8
game: pig
rules:
  - at: step choice
    when_x: "pot >= 8 or score[me] + pot >= GOAL"
    pick: stop
  - at: step choice
    pick: roll

Against a random player this tactic wins about 75% of games (measured over 2000 games). The finished example is pig-tactic.yaml: in the player, choose Pig and "tactic stop at 8 vs random" to watch it.

The file

KeyTypeDefaultMeaning
tactictext?Name of the tactic
gametextgame id of the model the tactic is for
label, summary, notetextFor people
extendspathBase tactic, relative to this file (see variants)
let{name: expression}{}Helper values, computed lazily per decision
ruleslist[]Rules, tried top to bottom
defaultrandom / firstrandomWhat happens when no rule decides
searchdict{}Places where look-ahead is used (see look-ahead)

The schema requires tactic, and rules or extends.

Decision places

The engine asks each decision at a place. The name follows from the model:

PlaceWhenOptions
step <id>A step with several legal actions in doaction names
action <name>A choose or search in an action's effect (also an ability on a content item)the options of the choice
action <name> (cost)A choose in an action's costidem
trigger <id>An optional trigger (use, skip), or a choose in a trigger's doidem
window <name>A priority windowthe playable fast actions and pass
content <id>A choose in a content item's effect run by execthe options of the choice

A subset choice (choose with min/max) asks one element at a time at <place> · 1, <place> · 2, ...; tactics match the place without that suffix. A choose inside the impact of an emit has the place <place of the emit> · <event>. The engine page has the full list.

To see the places of a model, open it in the player: every question shows its place, its options and who decides. A rule whose at names a place that never occurs is simply never used, so check the spelling against what the player shows.

Rules

Rules are tried from top to bottom; the first rule that decides, picks. If none decides, default does: random (a random option) or first (option 0).

FieldMeaning
atPlace, or list of places. Required. Shell-style wildcards: *, ?, [abc], [!abc] (window *, trigger *)
optionsThe rule only applies if the option labels are exactly this list, in order ([use, skip])
when_xThe rule only applies if this expression is true
pickPick the option with this label. If there is none, the rule does not decide
pick_xAn expression that returns an option or a label; the option that is the same object or has the same label is picked. No match: the rule does not decide
max_x / min_xScore each option o (index i); the highest (lowest) wins, the first on a tie. A tuple compares field by field
take_xFor subset choices: take candidate o? The first candidate for which it is true is picked; none: done; if done is not offered yet (fewer than min chosen): the first candidate
noteFor people

Every rule needs exactly one of pick, pick_x, max_x, min_x, take_x, and an at; otherwise the tactic cannot be loaded (NotExecutable: tactic <name>: rule 3 (...) has 2 pick fields).

rules:
  - at: trigger guard_dog
    options: [use, skip]
    pick: use
  - at: action attack
    max_x: "(o['damage'], -o['cost'])"          # most damage, then cheapest
  - at: window *
    when_x: "hp[me] <= 2"
    pick: heal
  - at: window *
    pick: pass

Subsets. The engine asks a subset one element at a time: each time the remaining candidates plus done (once the minimum is reached). take_x is computed per candidate, with pool (all options of the choice) and chosen (what is already chosen). Compute what you want once in a let and ask per candidate whether it belongs: take_x: "o['uid'] in questers".

What is in scope

NameWhat
Everything global in the modelZones, variables, derived values, constants, content, players, n_players, eliminated, results, board and own functions, modified(...)
opts, labelsThe options of this decision (for a subset: the items behind the labels, None for done) and their labels
o, iIn max_x, min_x, take_x: the option and its index
pool, chosenFor a subset: all options of the choice, and what is chosen so far. Otherwise pool is opts and chosen is []
where, meThe place (without · n) and the deciding player
names from letHelper values, computed only when a rule uses them (also through another let)

The local names of the engine at that moment (actor, stack and window in a priority window, names bound by let or choose in the action) are not in scope; use me for the deciding player. If a tactic needs to know what is on the stack, the model must keep it in a variable. A tactic reads the full state, including hidden zones and other players' hands: not reading what the player cannot know is up to the author.

The expression language has no lambda. Sort by a key with tuples: [t[2] for t in sorted((-c['willpower'], i, c) for i, c in enumerate(pool))] (the index i keeps the sort from comparing the cards themselves on equal keys).

A tactic can name places where a player looks ahead instead of following the rules blindly. Look-ahead is not yet available on the site; this section describes what the search key means.

At the places in search.at the player compares every other option with the option the rules pick:

  1. Each round takes n new random seeds (search.rollouts, default 8).
  2. For each option and each seed it makes a copy of the state in which what the player cannot know is redistributed (see below), plays that option, and plays the game out with the tactic for all players. All options use the same seeds (paired).
  3. After each round, per alternative a, with d the per-seed differences between a and the rules' option, m their mean and se its standard error:
    • m + z·se <= 0 (worse, or not distinguishable): a drops out;
    • m − z·se > 0 (proven better): a is a candidate. If there are candidates, the one with the highest mean is chosen.
  4. When no alternative is left, or after max_rollouts rollouts per option, the rules' choice stands.
search:
  at: [action commit, step defend]     # places (wildcards allowed)
  rollouts: 8          # seeds per round
  max_rollouts: 32     # default 4 × rollouts
  z: 2                 # standard errors of proof needed to deviate from the rules (default 2)

Decisions with fewer than two options, and places not in search.at, are never searched.

Why a test. In a game like LotR LCG good and less good options differ by a few percentage points of win rate. That is less than the noise of a few dozen rollouts. "Take the highest mean" then picks by chance, and chance makes a good player worse. Measured: without the test the standard LotR tactic dropped from 25% to 5% wins, while a diagnosis with 200 rollouts per option showed that the rules often already picked the best option.

No peeking. A copy of the state knows everything, face-down cards included. So the search first redistributes what the player cannot know. Drawing from a shuffled zone is already fair: the order is only fixed when a card is drawn. Face-down cards that are already on the table (shadow cards on an enemy) must be marked in the model with drawn_from:

shadow: {kind: set, visibility: hidden, drawn_from: encounter_deck}

The search then shuffles those cards together with the unknown part of the encounter deck and deals them back. Counts and identities (uid) stay the same; only which card each one is changes.

Comparing tactics

The fair way to compare two players is on the same rules and the same seeds (paired), over hundreds or thousands of games, with a confidence interval on the difference in win rate. A variant that changes one rule then measures exactly that rule. Running such comparisons is not yet available on the site.

Variants with extends

Do not copy a tactic to change one thing; build on it with extends:

tactic: lotr-quest-wide
game: lotr-lcg
extends: lotr-standard.yaml
let:
  quest_margin: "5"     # the base uses 2

How the files merge:

  • rules: the variant's rules come first, then the base's;
  • let: merged, the variant's values win;
  • every other key (label, default, search, ...): the variant's value replaces the base's (search as a whole, not per field);
  • extends paths are relative to the file that names them; a cycle is an error.

So put the numbers you want to vary in the base as let.

Testing a tactic

  • Expressions in let and in the rules must be valid expressions; every rule needs an at and exactly one pick field.
  • Every rule belongs to a place that exists: compare the at values with the places the player shows. Places without a rule of their own fall back to general rules (window *) or to default.
  • Watch it choose. For the built-in games with a tactic, pick the tactic under Players in the player and step through a game: at each question you see the options and what was picked. Play a seat yourself against it to try situations.
  • If the same strategy exists elsewhere (as another tactic, or written down as rules of thumb), check that both take the same decisions in the same positions.

Pitfalls

  1. Order. The first rule that decides wins. Put specific rules (one trigger) above general ones (trigger *).
  2. pick on a missing label does not decide. The next rule gets its turn; put a catch-all at the bottom if that is not intended.
  3. Optional triggers and choices in the same trigger share their place. Tell them apart with options: [use, skip].
  4. One option is not a decision. The engine asks nothing when there is only one option; a rule for that place is then never used.
  5. A tactic is not a rule. What the rulebook prescribes belongs in the model. A tactic only chooses among what the rules allow.
  6. No engine locals in a tactic. actor, stack and bound names give a NameError. Use me.

This page as Markdown