# Fencer
One combatant in the fence duel. Attaches to a bare entity — it asks
`fencer_body` for its rig in `awake`, so it needs no Model or mesh of its own.
Owns footwork along the piste (world X, depth and height held) and the
windup → active → recover state machine behind the four commitments: `thrust`,
`sweep`, `lunge` and `parry`. Every commitment carries the body over its own
`advance`, so distance and commitment can be made in one action; the step stops
at the fencer's own `minReach` rather than carrying it inside its own measure.
Footwork is bounded by the other fencer, not by the property line: a man who has
drawn back into his own garden is still reachable, which is the whole reason to
hold the measure. Breaking ground is slower than taking it.
A commitment that reached nobody — short of its reach, or crowded inside it, or
a guard that turned nothing aside — enters recovery from a debt. So does one
that was parried. The debt is the commitment's own (`duel.whiffOf` /
`duel.parriedOf`), so it scales with what the commitment bought: a thrust turned
aside costs half a second and a lunge turned aside costs the better part of two.
That debt is the opening the other fencer is meant to punish, and
`phaseTime < 0` in `recover` is how a reader tells a real opening from an
ordinary recovery.
**The lunge is recovered to the line it was thrown from.** It is the one
commitment marked `recoil`: whatever of its 1.30 m of `advance` it managed to
cover, it gives back over its own recovery, travelling backwards at the pace it
went out and unable to answer anything while it does. `recoilLeft` is the ground
still owed. So the lunge buys half a metre of reach nothing else has, and buys
no position at all — which is what stops it being a free way to close the
measure.
A commitment made while `riposte` is open comes out at `RIPOSTE_QUICK` of its
wind-up, reaches `RIPOSTE_REACH` further, cannot be turned aside, and plants
`RIPOSTE_POSTS` posts instead of one.
The component is input-agnostic except for one case: a `role = "player"` fencer
on a local entity reads the `move` axis and the `thrust` / `sweep` / `lunge` /
`parry` actions itself. A `role = "rival"` fencer is driven by `FencerAI`
through the same public methods a human's input reaches.
Fields worth reading from outside: `role`, `action`, `phase`, `phaseTime`,
`riposte`, `ripostePending`, `whiffed`, `frozen`, `touches`, `facing`.
`action` is `""` while idle, not `"guard"`; the idle `phase` is `"guard"`.
Methods: `commit(name)`, `walk(dir, dt, line)`, `resetTo(line)`,
`receiveTouch()`, `beParried()`, `clash()`, `openRiposte()`, `markContact()`,
`freeze(on)`, `isStriking()`, `currentReach()`, `currentMinReach()`.
Requires the `duel` and `fencer_body` modules and `@builtin::modules.zinput`.
`DuelMatch` umpires between two of these; it is what calls `receiveTouch` /
`beParried` / `clash` / `openRiposte` / `markContact`.
# duel
The rules of the Hedgerow Assizes, in one place: the ground plan of the walled
garden, the frame data of the four commitments, the measure each one works at,
the triangle that decides what a contact means, how far a post drives the
property line, and the roster of neighbours with the fencing each one keeps.
Every duel component reads its numbers from here, so the whole match is tuned in
one file.
Consumed by `Fencer`, `FencerAI`, `DuelMatch`, `DuelHud`, `DuelFx`, `garden` and
the main scene's entrypoint.
## The measure
Each action carries a band, not a range: `reach` is the furthest the blade
crosses and `minReach` the nearest — inside that, the panel is smothered and the
attack is *crowded out*. `advance` is the ground the commitment itself takes, so
every attack steps in and a defender cannot simply walk backwards out of the
sport. A commitment that reached nobody runs its recovery from a debt, and that
debt is the whole punishment for committing at the wrong distance.
| Action | windup | active | recover | reach | minReach | advance | whiff | parried |
|---|---|---|---|---|---|---|---|---|
| `thrust` | 0.22 | 0.10 | 0.42 | 2.55 | 1.55 | 0.35 | 0.35 | 0.45 |
| `sweep` | 0.34 | 0.20 | 0.58 | 2.15 | 0.00 | 0.25 | 0.55 | 0.65 |
| `lunge` | 0.30 | 0.16 | 0.95 | 3.05 | 1.75 | 1.30 † | 1.00 | 1.20 |
| `parry` | 0.05 | 0.34 | 0.40 | — | — | — | 0.30 | — |
† The lunge is marked `recoil`: whatever of its `advance` it managed to cover it
gives back over its own recovery, travelling backwards and unable to answer
anything while it does. It buys reach; it never buys position.
## The triangle
`parry` turns a `thrust` and a `lunge` aside and earns a riposte; a `sweep`
crashes through a held guard; a `thrust` arrives inside a `sweep`'s long
wind-up. A riposte cannot be turned at all.
**The guard answers from its first frame.** `resolveTouch` treats a `parry` in
`windup` exactly as it treats one in `active`, because a guard that only worked
once it had finished rising could be beaten by an attack thrown from further out
than the wind-up is long — which made a correct read lose to a committed attack,
the one outcome the triangle exists to prevent.
## What a post is worth
A post is not a fixed step. Three properties of the ground decide how far the
line moves, and between them they are what gives a bout its length:
| | | |
|---|---|---|
| `POST_STEP` | 1.15 | fresh mown lawn in the middle of a garden |
| `POST_ROOTED` | 0.60 | the last feet before a hedge are old rooted ground, and give up to this much of the step |
| `POST_RECLAIM` | 0.35 | lawn you already conceded holds somebody else's posts; they come out before the line passes back |
| `POST_CHURN` | 0.05 | each post already driven breaks the ground a little further, to `POST_CHURN_MAX` |
`postDrive(line, dir, posts, driven)` is the whole rule. Rooted ground stops a
garden being annexed by a short run of luck; conceded ground stops two even
fencers walking the line back and forth all afternoon. Chosen against a
simulation of the rule over 600 bouts a configuration, with the riposte rate and
touch cadence taken from played logs: a median bout of about 20 touches, 80 % of
bouts between 45 s and 120 s.
## The roster, and how each one fences
`NEIGHBOURS` is the roster — index, `key`, `skill` and `style`. `STYLES` is the
fencing attached to each key, and it is what makes three opponents rather than
one opponent at three settings:
| | `hold` | `think` | `patience` | `adapt` | `boundary` |
|---|---|---|---|---|---|
| `pettifer` | +0.55 | 1.45 | 2.4 | 0.6 | 0.0 |
| `wickham` | 0.00 | 0.90 | 1.0 | 2.2 | 0.15 |
| `ashcombe` | −0.35 | 0.75 | 0.6 | 1.1 | 1.0 |
`hold` moves the measure held at rest; `think` the pause between commitments;
`patience` how long an opponent who has offered nothing is indulged before the
touch is simply fetched; `adapt` how heavily a repeated stroke steers the answer
and how much faster the eye settles on one it has seen; `boundary` how far the
defence of one's own ground stiffens the fencing as the line comes back toward
one's own hedge. `w*` are the standing appetites for each commitment.
`style(key)` falls back to `wickham`'s for an unknown name rather than erroring.
## Surface
- `ACTIONS` — the table above.
- `isAttack(name)`, `reachOf(name, isRiposte)`, `windupOf(name, isRiposte)`,
`advanceSpeed(name)`, `whiffOf(name)`, `parriedOf(name)`.
- `resolveTouch(attack, defAction, defPhase, isRiposte)` — `"touch"` |
`"parried"` | `"nothing"`.
- `measureFault(name, gap, isRiposte)` — `"short"` | `"crowded"` | `nil`.
- `postDrive(line, dir, posts, driven)` — metres one touch moves the boundary.
- `resetX(line, side)` / `clampX(x, line, side, foeX)` — where a fencer stands,
and how far it may travel: bounded by the opponent, not by the line.
- `winner(line)` — `"player"` | `"rival"` | `nil` for the boundary's position.
- `NEIGHBOURS`, `neighbour(index)`, `neighbourIndexOf(key)`,
`neighbourNearestSkill(skill)`, `STYLES`, `style(key)`.
- `LAWN_HALF`, `LINE_LIMIT`, `POST_STEP`, `POST_ROOTED`, `POST_RECLAIM`,
`POST_CHURN`, `POST_CHURN_MAX`, `RIPOSTE_POSTS`, `STANCE`, `WALK_SPEED`,
`RETREAT_FACTOR`, `MIN_GAP`, `LEASH`, `RIPOSTE_WINDOW`, `RIPOSTE_QUICK`,
`RIPOSTE_REACH`, `CLASH_PUSHBACK`, `HIT_STUN`, `SALUTE_TIME`, `TOUCH_PAUSE`,
`SIDE`, `COLOR`.
# DuelMatch
The umpire nobody sees. One instance, on the `DuelDirector` entity.
Asks `garden` for the walled garden and the boundary fence, spawns the rival,
resolves every exchange between the two `Fencer`s through `duel.resolveTouch`,
plants a post and drives the property line into the loser's garden on a touch,
resets both fencers and runs the salute, and calls the match when the line
reaches a hedge.
The boundary is the health bar: `line` is the fence's leading post, and the
fence is rebuilt from the player's hedge out to it whenever `line` changes, so
the score is a physical object standing in the world.
## What a touch is worth
How far a post drives the line is `duel.postDrive`, and it is a property of the
ground rather than a constant: fresh lawn in the middle of a garden gives, the
last feet before a hedge are rooted and stubborn, lawn already conceded holds
somebody else's posts and has to be cleared before the line passes back over it,
and every post driven breaks the ground a little further for the next one.
`postsDriven` counts the posts this bout and `lastDrive` reports the metres the
last one bought. Between them these are what give a bout a length rather than
only a variance.
## Resolution
An attack whose blade is live inside its measure is an exchange. Two live
blades in the same tempo is a **clash**: neither scores and both pay for it,
which is what stops the umpire's own resolution order from deciding the bout.
An attack outside its measure is recorded as `SHORT` or `CROWDED` and never
touches its man, so the fencer who threw it pays the whiff debt.
A touch taken inside a riposte window plants `RIPOSTE_POSTS` posts rather than
one, and `lastPosts` reports which it was.
## The event bus
`fxEvent` names the moment and `fxSeq` moves on **every** write, including a
repeat of the same event — so a reader polls `fxSeq` against the last one it saw
rather than diffing fields for edges. Vocabulary: `salute`, `allez`,
`short:player`, `short:rival`, `clash`, `parry:player`, `parry:rival`,
`riposte:player`, `riposte:rival`, `touch:player`, `touch:rival`, `post`,
`over:player`, `over:rival`. `":player"` means *the player did it*.
`fxQueue` is the lossless read of the same vocabulary: every event since the
last frame, in the order it happened. `DuelMatch` empties it at the top of its
own update, and it runs before everything that reads it, so a reader gets the
whole of this frame's list — including a `touch:player` and the `post` that
follows it in the same frame, which a single slot cannot carry.
## The roster
`neighbour` (1..3), `neighbourKey` (`"pettifer"` | `"wickham"` | `"ashcombe"`)
and `skill` are one identity written three ways. Writing any of them settles the
other two off `duel.NEIGHBOURS` and pushes the matching `style` onto the live
rival's `FencerAI`, so a garden, a turnout and a verdict can all name the same
person without any of them guessing. `DuelHud` keeps ownership of the prose.
## Fields
`line`, `state` (`salute` | `fence` | `touch` | `over`), `lastTouch`, `winner`,
`playerTouches`, `rivalTouches`, `skill` (the rival's quality — writing it
retunes the live rival), `announce`, `lastPosts`, `postsDriven`, `lastDrive`,
`matchClock` (seconds since this bout's first `allez`), `neighbour`,
`neighbourKey`, `fxEvent` / `fxSeq` / `fxQueue`, and `lastExchange` /
`lastExchangeSeq` / `exchanges` — the tuning record, naming who struck into what
and what the triangle made of it, including attacks that fell short of their
reach or were smothered inside it.
## Method
`restart()` — a fresh assize, the line back to nought. Callable at any time,
including from `state == "over"`, where the `restart` action also reaches it.
Pairs with `DuelHud` on the same entity. Requires the `duel` and `garden`
modules and `@builtin::modules.zinput`.