# 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](https://tabletwin.com/docs/expressions/). 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](https://tabletwin.com/play/) 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. In the [workshop](https://tabletwin.com/docs/workshop/), write your
own tactic in the Tactic tab: the Check pane checks it against the model (its expressions, and
which decision places have a rule of their own), the Play pane offers it as an opponent, and the
Simulate pane plays it over many games and compares it with other players. A tactic in the workshop
cannot use `extends`; look-ahead (`search`) is not available on the site.

The JSON schema for editor completion is
[tactic.schema.json](https://tabletwin.com/models/docs/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](https://tabletwin.com/models/docs/examples/pig-tactic.yaml),
[chess-material.yaml](https://tabletwin.com/models/tactics/chess-material.yaml) and
[hex-skirmish-basic.yaml](https://tabletwin.com/models/tactics/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](https://tabletwin.com/models/docs/examples/pig.yaml)):
keep rolling until the pot is 8, or until stopping wins.

```yaml
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](https://tabletwin.com/models/docs/examples/pig-tactic.yaml):
in the player, choose Pig and "tactic stop at 8 vs random" to watch it.

## The file

| Key | Type | Default | Meaning |
|---|---|---|---|
| `tactic` | text | `?` | Name of the tactic |
| `game` | text | | `game` id of the model the tactic is for |
| `label`, `summary`, `note` | text | | For people |
| `extends` | path | | Base tactic, relative to this file (see [variants](#variants-with-extends)) |
| `let` | `{name: expression}` | `{}` | Helper values, computed lazily per decision |
| `rules` | list | `[]` | Rules, tried top to bottom |
| `default` | `random` / `first` | `random` | What happens when no rule decides |
| `search` | dict | `{}` | Places where look-ahead is used (see [look-ahead](#look-ahead-search)) |

The schema requires `tactic`, and `rules` or `extends`.

## Decision places

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

| Place | When | Options |
|---|---|---|
| `step <id>` | A step with several legal actions in `do` | action 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 `cost` | idem |
| `trigger <id>` | An optional trigger (`use`, `skip`), or a `choose` in a trigger's `do` | idem |
| `window <name>` | A priority window | the playable fast actions and `pass` |
| `content <id>` | A `choose` in a content item's `effect` run by `exec` | the 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](https://tabletwin.com/docs/engine/#places-where) 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).

| Field | Meaning |
|---|---|
| `at` | Place, or list of places. Required. Shell-style wildcards: `*`, `?`, `[abc]`, `[!abc]` (`window *`, `trigger *`) |
| `options` | The rule only applies if the option labels are exactly this list, in order (`[use, skip]`) |
| `when_x` | The rule only applies if this expression is true |
| `pick` | Pick the option with this label. If there is none, the rule does not decide |
| `pick_x` | An 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_x` | Score each option `o` (index `i`); the highest (lowest) wins, the first on a tie. A tuple compares field by field |
| `take_x` | For 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 |
| `note` | For 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`).

```yaml
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

| Name | What |
|---|---|
| Everything global in the model | Zones, variables, derived values, constants, `content`, `players`, `n_players`, `eliminated`, `results`, board and own functions, `modified(...)` |
| `opts`, `labels` | The options of this decision (for a subset: the items behind the labels, `None` for `done`) and their labels |
| `o`, `i` | In `max_x`, `min_x`, `take_x`: the option and its index |
| `pool`, `chosen` | For a subset: all options of the choice, and what is chosen so far. Otherwise `pool` is `opts` and `chosen` is `[]` |
| `where`, `me` | The place (without ` · n`) and the deciding player |
| names from `let` | Helper 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).

## Look-ahead (`search`)

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.

```yaml
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`:

```yaml
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`:

```yaml
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`.
