ADR-0015: Combat Model — ICombatState Ownership, the Dual-Run Player Machine, Strike Resolution, Enemy Damage Phase 0, Downed/Revive, and the Durability Events
Status
Accepted
Accepted 2026-08-28 —
architecture-review-2026-08-28.md(verdict CONCERNS; review amendments applied before acceptance).
Date
2026-08-28
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · NGO 2.x · C# (pure logic in Driftlands.Rules) |
| Domain | Core (with one Networking touch-point: which combat variables replicate — the representation is ADR-0008's) |
| Knowledge Risk | LOW for everything this ADR decides — engine-free C#. HIGH for the replication rows it names but does not decide (NGO 2.x, ADR-0008). Specialist-verified facts used here: NetworkTickSystem.Tick is a bare event Action with no documented subscriber-ordering guarantee (hence one orchestrator, §1); clientId is documented as unstable across reconnects and may be reassigned to a different player (NGO 2.4 session-management manual — hence PlayerKey, §1); enums are natively supported NetworkVariable<T> types (no custom serializer for EnemyPhase); MidpointRounding.AwayFromZero is BCL-standard and identical under Mono/IL2CPP but silently becomes banker's rounding under Burst (Unity-confirmed bug) — Rules cannot reference Burst, and must never be moved into a Burst path. |
| References Consulted | docs/engine-reference/unity/VERSION.md, modules/networking.md (1.x-flavoured — not relied on); live: NGO 2.5 "NetworkTime and ticks", 2.7 NetworkTickSystem API, 2.6 NetworkUpdateLoop, 2.4 NetworkVariable + "Session management"; ADR-0001/0002/0003/0005/0006/0007; design/gdd/combat-system.md (complete), harvesting Rules 1–2, enemy-ai, netcode Rule 5, movement Rules 3–6, crafting Rule 9/F3 |
| Post-Cutoff APIs Used | None. MidpointRounding.AwayFromZero (BCL) for every round(). |
| Verification Required | (1) With ADR-0008's tick rate, ResolveTick = IntentTick(host-stamped) + ceil((windup + active) / tickDt) sits ≥ 1 RTT after the owner's Windup entry at 140 ms RTT (the ≥ 0.18 s guard assumes tick granularity ≤ 20 ms — re-check if not 50 Hz). Two standalone builds under the Multiplayer Tools Network Simulator, not MPPM (ADR-0002 Verification 5). (2) Same rig: the host mirror's three legality verdicts (§2) agree with the owner's except where EC16 applies. (3) EditMode host-step harness asserts the four-step host order (§1) and that a PendingEnemyDamage produced in step 3 is consumed by the same tick's step 1b — our own call order; no NGO probe measures it. |
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0002 | CommandKind gains four members: StrikeRevoke, DashStarted, ReviveStart, ReviveCancel. IntentTick is host-stamped at envelope receipt for every intent — supersedes the worked HarvestResolveCommand's / intent tick + … / parenthetical (harvesting inherits the same rule). HostContext is built once per host tick and used by steps 2–4 (was "once per drain"). |
| ADR-0001 | §4: Advance has exactly one caller — HostTickOrchestrator step 1, once per NetworkTickSystem.Tick; the Awaitable timer is Presentation-role / IPreloadSchedule-only (architecture review C1, 2026-08-28). |
| ADR-0003 | ICombatState gets its shape (§Key Interfaces). IInstanceMetadata is declared to expose IGearHooks (the trailing comment becomes a type relationship). ReasonCode gains NotDowned, AlreadyBeingRevived, ReviverIneligible. StrikeIntent/SwingId/TargetKind are shared vocabulary in Driftlands.Data (owned by neither combat nor harvesting). |
docs/architecture/architecture.md | §2 Combat row; §3.1 host frame path gains CombatState.Tick before EnemyAI.Tick under one orchestrator; §4 IStrikeResolver retired, ICombatActor → ICombatState + IPlayerCombatView, IEnemyCombat.Meter : float + Phase, StrikeIntent home stated; Required-ADR row 15 written. |
design/quick-specs/player-movement-2026-08-27.md | Rule 5 gains the Downed carve-out: crawl is a locomotion mode (downedCrawlSpeed knob), not a modifier. |
design/gdd/combat-system.md (proposed, via /quick-design before the first combat story) | Tuning table gains maxTargets (default 1), reviveReach (1.5 u, 1.0–2.5), and a 4th revive cancel cause (reviver leaves reach); Rule 6's home heal rate recorded as an open question (instant at MVP). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0002 (ICommand/HostContext/drain phases 1–1b–2–3–4; arrivalSeq; Now), ADR-0003 (ICombatState member, the two event records, IHostPositions, IGearHooks, Rules.Combat home), ADR-0007 (InputFrame edges and Revive.HeldFor; edge consumers run in Update), ADR-0006 (combat.json — stage 4 rows already mechanised) |
| Enables | ADR-0008 (replication rows: player Hp/State/BleedOutRemaining, enemy Phase; intent encodings; late-join actor snapshot; the concrete source of PlayerKey), ADR-0009 (dash and knockback are movement-rail displacements this ADR requests; downedCrawlSpeed mode), ADR-0012 (EnemyAttack → PendingEnemyDamage contract, IEnemyCombat, attack shapes), ADR-0014 (HUD data from IPlayerCombatView and the two records only) |
| Blocks | Every combat story; the harvest-resolve story (shared StrikeIntent); EnemyAI attack stories |
| Ordering Note | Architecture Required-ADR row 15 = ADR-0015. Accept after ADR-0002/0003/0007. The model is decidable now; ADR-0008 decides wire shapes and cannot invalidate it. |
Context
Problem Statement
HostContext.Combat : ICombatState is the largest surface in the architecture with no owner: who holds HP, state and stagger per actor; how the owner-predicted verb machine relates to the host's authoritative HP/Downed/Dead; how a unified strike intent becomes a StrikeResolveCommand with a deterministic, host-owned resolution instant; how EnemyAI's attack cycle becomes the PendingEnemyDamage list phase 0 applies; how the revive channel, session-scoped Downed state and home stabilisation work across a rejoin whose clientId changes; and who raises SwingLanded/DamageAbsorbed. The combat GDD fixes every rule; this ADR fixes the mechanism — and names, rather than decides, the three design values the GDD lacks.
Constraints
- Two rails (ADR-0002): move/dash/aim/swing-visuals owner-authoritative; damage, HP, stagger, Downed/Dead, revive host-authoritative on the one channel. No third rail: every owner→host combat fact is an
ICommandonHostCommitChannel. - No rollback, no position history, no host wait (Rule 7); EC16 accepted. Phase 0 before the envelope loop (AC-32); SoftClose first (Rule 13).
- F1–F5 pure,
AwayFromZero, bit-identical (AC-9); no RNG; no kill credit. - Gates re-sampled at resolution:
Docked, attacker alive, target valid, positions (Rule 7, EC15, AC-36). - Enemy state ephemeral; player combat state session-scoped (netcode Rule 5), keyed by a stable identity, reset only at session end.
- Home: Downed cannot exist (EC8); HP restores to full while present (Rule 6).
- Only
SwingLanded.DamageDealt/DamageAbsorbed.DamageTakenmay be shown; no enemy HP bars (OQ6). - Gear: two hooks read through
IGearHooks.DamageModifier(InstanceId)/.DamageReduction(InstanceId)(crafting owns values; dull → baseline);HarvestModifieris Harvesting's and combat never calls it.HostContext.Gear : IInstanceMetadataexposesIGearHooks(ADR-0003 amended). No instance (unarmed / no armor) → DM 1.0 / DR 0.0, never a hook call. - Input:
Strike/Dashedges andRevive.HeldForfromInputFrame, consumed inUpdate; Downed/Dead suppression lives here, in the machine.
Requirements
- One pure verb machine, run twice (owner predicted; host mirrored) — identical code, bounded divergence.
StrikeResolveCommandresolves at a host-stampedResolveTickagainst current host positions with fixed tolerances; cardinality is a config value the GDD owns.EnemyAttackResolverproducesPendingEnemyDamagein host step 3; the drain applies it in phase 1b — the four-step host order has one owner and one subscriber.- Downed/Dead/revive/bleed-out/home transitions host-only, in one place, keyed by
PlayerKey, surviving disconnect. - Two durability events raised from confirmed outcomes only.
Decision
Alternative A: Driftlands.Rules.Combat holds the pure model — PlayerCombatMachine (dual-run), HitPipeline (F1–F5 + targetIsValid), ActorStateRules, the commands StrikeResolve/StrikeRevoke/DashStarted/ReviveStart/ReviveCancel, and EnemyAttackResolver; Driftlands.Runtime.Combat.CombatState : ICombatState is one host-owned actor table keyed by PlayerKey (players) and EnemyId (enemies), the only writer of HP/state/meters; a single HostTickOrchestrator subscribes once to NetworkTickSystem.Tick and runs tide Advance → CombatState.Tick → EnemyAI.Tick → Drain in sequence; the owner runs its own PlayerCombatMachine from InputFrame for prediction; players' Hp/State/BleedOutRemaining and enemies' Phase are the only replicated combat variables (representation → ADR-0008); enemies' HP and meters are host-only.
1. The actor table and the host tick
PlayerKey = session-stable identity (Steam ID or a client-persisted GUID sent in the ConnectionApproval payload — SOURCE: ADR-0008 — a client-persisted GUID carried in the ConnectionApproval payload via `IPlayerIdentity`; Steam ID provider at ship (2026-09-11); existence + stability: this ADR). clientId is a transport handle mapped to PlayerKey on connect, remapped on rejoin; never a row key.
ActorId = PlayerId(PlayerKey) | EnemyActorId(SocketId socket, ushort index) // renamed by ADR-0012 (Data.EnemyId is the archetype key)
PlayerActor { Hp, State, VerbPhaseEndsTick, DashCooldownEndsTick, BleedOutEndsTick?, ReviveChannel?{Reviver:PlayerKey, EndsTick}, OnLand:SocketId?, Equipped:{Weapon:InstanceId?, Armor:InstanceId?}, RevokedSwings:set<SwingId> }
EnemyActor { Hp, Phase, PhaseEndsTick, StaggerMeter, LastHitTick, ImmuneUntilTick, Interruptible, QueuedStagger, CurrentAttack?, Target:PlayerKey?, OnLand, KnockbackAccumulator }
- Host step order (owned here, registered as
host_tick_step_order). OneHostTickOrchestrator(Runtime) is the sole subscriber toNetworkTickSystem.Tick— NGO documents no ordering between multiple subscribers, so there are none. Per tick, synchronously: (1) tideAdvance(ADR-0001; the tide-edge carve-out runs here — ADR-0005) → (2)CombatState.Tick(now, ctx)— bleed-out expiry, enemy phase advance, revive completion, home heal → (3)EnemyAI.Tick(ctx)— steering,EnterTelegraph,EnemyAttackResolver→PendingEnemyDamage→ (4)HostCommitQueue.Drain(ctx). Steps 2–3 are host code, not drain phases; the drain's five steps (ADR-0002) are unchanged.HostContextis built once at the top of the sequence and passed to steps 2–4; never held across ticks (ADR-0003 amended: "once per host tick"). - Single writer. Every mutation happens in step 2 (time-driven), phase 1b, or a command's
Execute. Nothing outsideRuntime.Combatwrites an actor. - Session-scoped, keyed by
PlayerKey. A disconnect despawns the body; the row stays and its timers keep running (EC2, netcode Rule 5). Rejoin maps the newclientIdto the samePlayerKeyand reads the row: Downed-with-timer → Downed at last position; expired → Dead → home respawn at full HP; live → last HP. Rows die at session end. Enemies die with their land (LandTornDown).SwingLanded.AttackerClientId/DamageAbsorbed.AttackerClientIdresolve through the same map. - Reads.
ICombatStateexposes views plus the mutation verbs commands need;IPlayerCombatViewis the read-only slice for HUD/presenters (own/party HP + state, never enemy HP).
2. The verb machine — one pure type, two instances, three questions
PlayerCombatMachine.Step(state, inputs, nowTick, phases) → (state', events) in Rules.Combat. VerbInputs is plain data {DashPressed, StrikePressed, RevivePressed, ReviveReleased, Downed, Dead, UiCaptured}; PhaseConstants is resolved by the caller from the swing kind — combat's swingWindup/Active/Recovery for an enemy-kind swing, harvesting's own constants (HarvestConfig) for a node-kind swing (harvesting Rule 1: independently owned) — Step never reads a bare CombatConfig for a node swing.
Transitions are the GDD table verbatim (Idle→Dashing on dash off-cooldown, i-frames on, cooldown from dash start; Idle→Windup on strike; Windup→Dashing revokes the swing; Windup→Active→Recovery→Idle by timers; Active uncancellable; Hurt is an overlay flag). Downed/Dead suppress Strike/Dash/Demolish inside Step (ADR-0007: the adapter never gates on combat state); UiCaptured blocks verb starts.
- Owner instance (predicted; runs in
UpdatefromInputFrame): drives animation, whiff SFX, the dash displacement request to Movement (ADR-0009), and issues commands on the one channel: Windup entry →StrikeIntent(swingId, aim, kind, nodeId?)afterReferent.Choose(harvesting Rule 2, enemies win, decided once); Dashing entry →DashStartedCommand(dashId); Windup→Dashing →StrikeRevokeCommand(swingId);Revive.Pressedwith a Downed ally in reach →ReviveStartCommand(target);Revive.Released→ReviveCancelCommand(target).SwingIdis per-owner monotonic, unique only within a client; every lookup, revoke and event correlation keys on(PlayerKey, SwingId)— a bareSwingIdis never a key. - Host mirror — fed by the same
Stepfrom the intent stream at arrival, so it is phase-shifted from the owner's machine by ~½ RTT. What the mirror is and is not: it is authoritative for exactly three questions — is this actor Dashing (EC16), is this actor alive and not Downed (EC15), did the reviver dash (EC6) — and for nothing else. It is not a source of truth for phase timing;ResolveTick(host-stamped, §3) is. Verification 2's claim is "identical verdicts on these three questions", not identical phase. - Why every owner→host fact is a command (no notification rail): the mirror takes transitions in the order facts arrive; a
DashStartedobserved before its precedingStrikeIntentwould take Idle→Dashing instead of Windup→Dashing. One reliable-ordered channel per client (ADR-0002) makes the arrival order the owner's emission order.DashStartedCommand/StrikeRevokeCommand.Executemutate no HP — they feed the mirror;DashStartedalso callsCancelRevive(reviver, ReviverDashed). An owner-writtenNetworkVariablefor the same fact would ride delta replication with no per-tick ordering and would evade ADR-0002'sSendTo.Serverreflection test — forbidden (registered).
3. Strike resolution — StrikeResolveCommand
IntentTickis host-assigned, not client-trusted (ruled 2026-08-28). The host stampsIntentTick = ctx.NowTickat envelope receipt; the payload carries no tick.ResolveTick = IntentTick + ceil((windup + active) / tickDt)from the host clock. Consequence, stated: the host's resolution instant trails the owner's animation by ≤ 1 RTT — the same accepted direction as EC16. The≥ 0.18 sguard is a guard on host-side windup+active, which is what Rule 7 requires. The same rule governsHarvestResolveCommand(ADR-0002 amended). A client cannot delay or collapse its own window.- Executes on the drain whose
Now ≥ ResolveTick(ADR-0002's drop rule handles≥ SoftCloseTick). Fresh insideExecute:ctx.Tide.Evaluate(socket).Docked(AC-36); attacker row alive/not Downed andSwingId ∉ RevokedSwings(EC15; a revoke that arrives after resolution is a no-op — the hit stands, EC16's sibling, stated);Kind == Enemy(aNodeintent was routed toHarvestResolveCommandat receipt — never here). - Candidates:
ctx.Positions.HostKnown(attacker)and every enemy on that land;HitPipeline.ArcTestper candidate (baseArc/2 + 12°,baseRange + 0.2 u,targetIsValid: alive, same land, never a player — AC-11/12/29). Cardinality is the GDD's, not this ADR's (ruled 2026-08-28):ArcTestis a per-target predicate; the command orders confirmed candidates by angle-to-aim (tiebreakEnemyId) and applies damage to the firstmaxTargets— acombat.jsonvalue the GDD must add; implementation default 1 (no AC, example or balance check in the GDD exercises cleave). Sweep is a one-value change with no interface difference. - Per hit:
DM = ctx.Gear.DamageModifier(weapon)if a weapon instance is equipped else 1.0 (dull → 1.0 by crafting);damageDealt = HitPipeline.DamageDealt(unarmed 14 | weaponBase, DM);ctx.Combat.ApplyHit(enemy, damageDealt, staggerContribution, attackerKey, knockback)— F4 stagger applied in place (diminish window, decay, immunity, EC11 inclusive, EC18 queue-during-Active); the knockback contribution is recorded on the target's per-tick accumulator. - F5 within the five steps (no new phase). At the last statement of the envelope loop (step 2's end — not a phase),
CombatStateemits one displacement request to Movement (ADR-0009) per enemy with a non-empty accumulator: the argmax contribution, lowestOwnerClientId/PlayerKeyon an exact tie (AC-17/17b). This is a movement request, not a combat-table mutation, so no command's observable state changes after itsExecutereturns (ADR-0002 §7 atomicity). The accumulator is cleared at the top of every drain; non-empty at drain entry is an assertion failure. ctx.Broadcast(new SwingLanded(...))per hit (buffered — phase 4); nothing persisted. A miss is silent (Rule 8).Successiff ≥ 1 hit.
4. Enemy attacks — phase 0
Host step 3 (EnemyAI.Tick) advances each EnemyActor; on Telegraph → Active it calls EnemyAttackResolver.Resolve(enemy, attackDef, players) → PendingEnemyDamage[] for every player inside the attack's shape on that land whose host mirror is not Dashing (i-frames, host view — EC16). Phase 1b applies each: DR = ctx.Gear.DamageReduction(armor) if an armor instance is equipped else 0.0; damageTaken = HitPipeline.DamageTaken(incoming, DR); HP clamped to 0 first (EC9); ActorStateRules.OnZeroHp(...); DamageAbsorbed(...) buffered — all before any player command (AC-32). SoftClose (step 1) cancels attacks mid-Telegraph/Active on closed lands (AC-34): EnemyAI reads the soft-closed set the drain publishes and never resolves those. IEnemyCombat.EnterTelegraph(attackId) is the only EnemyAI→combat call; the density cap is EnemyAI's check before calling; Phase is exposed for it. EnemyActor.Phase is the one replicated enemy variable (EC17).
5. Downed, Dead, revive, home — ActorStateRules (pure) + CombatState.Tick
OnZeroHp: another living player on the same land → Downed (BleedOutEndsTick = Now + bleedOut); else Dead →Snap(DeathRespawn, homeSpawn)(Movement's closed list), HP = max, State = Idle, CARRY untouched (AC-28).- Crawl is a locomotion mode, not a speed modifier (ruled 2026-08-28):
PlayerState.Downedselects a Downed-only locomotion branch in Movement (downedCrawlSpeed, a new movement knob); nothing multipliesmoveSpeed, so movement Rule 5's schema-negative holds literally. The movement quick-spec is amended to name the knob and carve Downed out of Rule 5's absolute wording. ReviveStartCommand(target): gates — reviver alive/not Downed/not Dashing (mirror), target Downed on the same land, target has no channel (EC7: firstarrivalSeqwins →AlreadyBeingRevived), reviver withinreviveReach(1.5 u, 1.0–2.5 — a knob this ADR requires and the GDD lacks; proposed to the GDD). SetsReviveChannel{Reviver, EndsTick = Now + ceil(reviveChannelTime / tickDt)}.CombatState.Tickcompletes it atEndsTick→ Idle atround(maxHp × reviveHPFraction). Cancel paths:DamageAbsorbedfor the reviver (EC5),DashStartedCommandfrom the reviver (EC6),ReviveCancelCommand(owner'sRevive.Released—HeldForis only the owner's hold, never a claim of completion), and the reviver leavingreviveReach(host-known positions each tick — a proposed 4th cause consistent with EC5/6's "anything other than standing-still-channeling cancels", needing the GDD's assent). No partial progress.- Bleed-out expiry (
Tick): Downed → Dead → respawn as above. - Home (EC8, Rule 13): on
SoftCloseDeposit/EvictionExpired/death respawn/rejoin-after-departure: if Downed → Idle atreviveHPFraction, timer cleared;OnZeroHpat home stabilises (AC-42). WhileOnLand == nullHP → max immediately on arrival — Rule 6 says "restores to full while present" without a rate; this ADR implements the simplest reading and flags it (ahomeHealRateramp is a one-lineTickchange + a GDD knob row if the playtest prefers it). No regen anywhere else, ever.
6. Events and presentation
SwingLanded/DamageAbsorbed are raised only from the drain's buffered flush (phase 4) — never from prediction, never from Tick. Crafting drains durability (F3); presenters/HUD get DamageDealt/DamageTaken, StaggerFired, Lethal, Downed, Dead. Predicted feedback from the owner machine's events; confirmed feedback (impact, local hitstop, damage number, stagger/knockback, death) from the records. A rejected strike produces nothing.
Architecture Diagram
OWNER (Update) HOST — HostTickOrchestrator, sole Tick subscriber
InputFrame → VerbInputs → PlayerCombatMachine.Step ─┐ (1) tide Advance
Windup: Referent.Choose → StrikeIntent ═══════════╪═(one channel, ordered per client)═► envelopes: IntentTick = Now on receipt
Dashing: DashStartedCommand · Windup→Dash: StrikeRevokeCommand · Revive: ReviveStart/Cancel
(2) CombatState.Tick — bleed-out, enemy phases, revive complete, home heal
(3) EnemyAI.Tick — EnterTelegraph · EnemyAttackResolver → PendingEnemyDamage (mirror: not Dashing)
(4) Drain: 1 SoftClose · 1b apply PendingEnemyDamage (F2 → OnZeroHp)
2 envelopes (mirror Step · StrikeResolve @ ResolveTick: ArcTest×maxTargets, F1, ApplyHit F4)
└ end of step 2: F5 argmax → displacement request (ADR-0009)
3 teardown release · 4 flush SwingLanded / DamageAbsorbed → Crafting F3, Presenters, HUD
replicated (ADR-0008 rows): Player Hp/State/BleedOutRemaining · Enemy Phase — nothing else. Rows keyed by PlayerKey, never clientId.
Key Interfaces
namespace Driftlands.Data // shared vocabulary — owned by neither combat nor harvesting (architecture §4 shape retained)
{
public readonly record struct PlayerKey(ulong Value); // session-stable; source: ADR-0008
public readonly record struct StrikeIntent(uint SwingId, Vector2 Aim, TargetKind Kind, NodeId? Node); // NO tick — host stamps IntentTick on receipt
public enum TargetKind : byte { Enemy, Node }
}
namespace Driftlands.Rules.Combat
{
public enum PlayerState : byte { Idle, Dashing, Windup, Active, Recovery, Downed, Dead }
public enum EnemyPhase : byte { Idle, Telegraph, Active, Recovery, Stagger, Dead }
public readonly record struct VerbInputs(bool DashPressed, bool StrikePressed, bool RevivePressed, bool ReviveReleased, bool Downed, bool Dead, bool UiCaptured);
public readonly record struct PhaseConstants(long WindupTicks, long ActiveTicks, long RecoveryTicks, long DashTicks, long DashCooldownTicks); // resolved by kind: combat's or harvesting's
public readonly record struct PlayerVerbState(PlayerState State, long PhaseEndsTick, long DashCooldownEndsTick, bool Hurt, uint? PendingSwingId);
public static class PlayerCombatMachine
{ /// Pure. Owner (from InputFrame) and host mirror (from the intent stream). Downed/Dead suppress verbs here.
public static (PlayerVerbState next, VerbEvents events) Step(PlayerVerbState s, VerbInputs i, long nowTick, PhaseConstants phases); }
public static class HitPipeline
{
public static bool ArcTest(Vector2 attacker, Vector2 aim, Vector2 target, CombatConfig cfg); // F3 geometry incl. +12° / +0.2 u
public static int DamageDealt(int weaponBase, float damageModifier); // F1, AwayFromZero (never Burst)
public static int DamageTaken(int incoming, float damageReduction); // F2, floored 0
public static (float meter, bool fired, bool queued) Stagger(float meter, float contribution, long lastHitTick, long nowTick, long immuneUntil, EnemyPhase phase, CombatConfig cfg); // F4 + EC11/EC18/AC-19
public static (PlayerKey owner, Vector2 vector) Knockback(IReadOnlyList<(PlayerKey owner, Vector2 v)> sameTick); // F5 argmax, lowest key on tie
}
public static class ActorStateRules
{ public static PlayerState OnZeroHp(bool alliesAliveOnLand, bool atHome); public static int ReviveHp(int maxHp, float fraction); }
// Commands — all on HostCommitChannel (CommandKind: StrikeResolve, StrikeRevoke, DashStarted, ReviveStart, ReviveCancel)
public sealed record StrikeResolveCommand(uint SwingId, Vector2 Aim, long ResolveTick) : ICommand; // ResolveTick from the host-stamped IntentTick
public sealed record StrikeRevokeCommand(uint SwingId) : ICommand; // mirror + RevokedSwings; no-op after resolution (hit stands)
public sealed record DashStartedCommand(uint DashId) : ICommand; // mirror Idle/Windup→Dashing; CancelRevive(reviver, ReviverDashed)
public sealed record ReviveStartCommand(PlayerKey Target) : ICommand;
public sealed record ReviveCancelCommand(PlayerKey Target) : ICommand;
public readonly record struct PendingEnemyDamage(PlayerKey Victim, int Incoming, EnemyId Attacker, Vector2 Knockback);
public static class EnemyAttackResolver
{ public static IReadOnlyList<PendingEnemyDamage> Resolve(EnemyActorView e, AttackDef a, IReadOnlyList<(PlayerKey key, Vector2 pos, bool hostSeesDashing, bool alive)> players); }
/// The two questions harvesting asks — segregated so harvesting never sees the combat surface (core-harvesting story-004; the ADR-0003 precedent IInstanceMetadata : IGearHooks). Declared in Driftlands.Rules.Commands.
public interface ISwingerStateQuery { bool CanResolveHarvest(PlayerKey key); bool AcceptsHarvestIntent(PlayerKey key); }
/// HostContext.Combat — owner: this ADR. Implemented by Runtime.Combat.CombatState (single writer), which also implements both ISwingerStateQuery members:
/// CanResolveHarvest is false when Dead or Downed at RESOLUTION (harvesting Rule 9, AC-20); AcceptsHarvestIntent is true only from Idle/Move (AC-38, AC-21 — Downed has no carve-out).
public interface ICombatState : ISwingerStateQuery
{
PlayerActorView Player(PlayerKey key); EnemyActorView Enemy(EnemyActorId id); IEnumerable<EnemyActorView> EnemiesOn(SocketId land); // EnemyActorId — ADR-0012
bool HostSeesDashing(PlayerKey key); // mirror view (EC16)
void MirrorStep(PlayerKey key, VerbInputs i); // fed by StrikeIntent / DashStarted / StrikeRevoke commands
void ApplyHit(EnemyId target, int damage, float staggerContribution, PlayerKey attacker, Vector2 knockback); // F4 in place; knockback → accumulator
void ApplyEnemyDamage(PendingEnemyDamage d, float damageReduction); // phase 1b: F2, clamp, OnZeroHp
bool TryStartRevive(PlayerKey reviver, PlayerKey target, long endsTick); void CancelRevive(PlayerKey target, ReviveCancelReason why);
void RevokeSwing(PlayerKey owner, uint swingId);
}
public interface IPlayerCombatView { int Hp { get; } int MaxHp { get; } PlayerState State { get; } float BleedOutRemaining { get; } } // HUD — never enemy HP
}
namespace Driftlands.Runtime.Combat
{
public sealed class HostTickOrchestrator : NetworkBehaviour { /* sole NetworkTickSystem.Tick subscriber: Advance → CombatState.Tick → EnemyAI.Tick → Drain, one HostContext per tick */ }
public sealed class CombatState : ICombatState { public void Tick(long now, HostContext ctx); public void EmitKnockbacks(IDisplacementSink m); // ADR-0009: Rules.Movement.IDisplacementSink over the enemy's KinematicMover2D /* end of drain step 2 */ }
public interface IEnemyCombat { void EnterTelegraph(AttackId id); void LeashReset(); bool Interruptible { get; } float Meter { get; } EnemyPhase Phase { get; } } // exactly TWO EnemyAI→combat calls: EnterTelegraph and LeashReset (ADR-0012, user decision 2026-09-11)
}
Replication rows for ADR-0008 (named, not decided): PlayerActor.Hp (int), .State (enum), .BleedOutRemaining; EnemyActor.Phase (enum — natively supported); the five command encodings; the late-join actor snapshot (netcode Rule 4 "current player states" = these rows, keyed by PlayerKey); the concrete PlayerKey source (Steam ID vs client GUID in the ConnectionApproval payload — a GUID fallback keeps non-Steam local testing working).
Alternatives Considered
Alternative B: Per-actor NetworkBehaviour owning HP with its own RPCs
- Rejection Reason: NGO orders reliable RPCs per
NetworkObject; two players' strikes on one enemy could apply in different orders on different runs —host_rail_rpc_outside_channel. ADR-0002's one channel exists to prevent this.
Alternative C: Host-only player machine, client animates blind
- Rejection Reason: Dash-cancel and cooldown rejection gain a round trip; i-frames could not start on input; violates the owner rail.
Alternative D: Client-timed revive
- Rejection Reason: The host would validate a 3 s client timer after the fact — rewind-shaped; EC7 becomes racy. Ruled host-timed.
Alternative E: Replicate enemy HP/meters now
- Rejection Reason: The GDD forbids enemy HP bars at MVP; a hidden
NetworkVariableper enemy is bandwidth for nothing. Ruled host-only.
Alternative F: DashStarted as an owner-written NetworkVariable "notification"
- Description: Owner writes
Dashinginto a replicated variable the host reads for i-frames and revive cancel. - Cons: Delta replication carries no per-tick order relative to the same client's intents on the channel — the mirror could see the dash before the strike it cancels; a boolean flip-flop inside one collection window can coalesce; and it evades ADR-0002's
SendTo.Serverreflection test while doing the job of a command (ADR-0002's rejected Alternative C in miniature). - Rejection Reason: Every owner→host combat fact is a command on the one channel; forbidden pattern registered.
Alternative G: Three independent NetworkTickSystem.Tick subscribers
- Rejection Reason:
Tickis a bareevent Action; NGO documents no ordering between subscribers — a script-execution-order change could apply last tick's enemy attacks with no compiler error and no test failure. One orchestrator, one subscription.
Consequences
Positive
- One writer for every combat number; every GDD rule maps to one pure function or one command with a
[UNIT]test. - The verb machine is the same code on both ends; divergences are the GDD's accepted ones and are bounded to three verdicts.
- Phase 0 has a producer and a consumer sharing one record; the host step order has one owner and one subscriber.
- Session-scoped rows keyed by a stable identity make EC2/AC-41 fall out of "the row outlives the body" — and survive
clientIdreuse. - The three design values the GDD lacks (
maxTargets,reviveReach, home heal rate) are named as GDD questions, not decided in an ADR.
Negative
- Two machine instances per player; must stay labelled (like ADR-0001's two clocks).
ResolveTicktrails the owner's animation by ≤ 1 RTT + 1 tick (host-stamped); accepted, same direction as EC16.CommandKindgrows by four; ADR-0002/0003 amendments; the movement quick-spec and combat GDD gain a knob each.- Per-tick knockback accumulator — one invariant to test (empty at drain entry/exit).
PlayerKeytouches everyclientId-keyed surface (ADR-0003's records,IHostPositions) — a mapping, not a retype, but it must exist before the first PlayMode rejoin test.
Risks
- Mirror drift beyond the three verdicts → Verification 2; any divergence on those three is a bug, not a tolerance.
- Late revoke (revoke arrives after resolution): the hit stands — stated; a playtest fairness item beside EC16.
- Rounding backend:
AwayFromZerois identical under Mono/IL2CPP but silently banker's under Burst —HitPipelinemust never move into a Burst path (Rules has no Burst reference; a reflection test asserts none is added). AC-9 covers the rounding step; the pre-rounding arithmetic is scalar float math with no documented divergence at this magnitude. PlayerKeysource undecided until ADR-0008: development uses the GUID fallback; a Steam ID swap is a mapping change only.- Cleave if
maxTargetsis later raised: the TTK bands and the "mob-shredding lever is density, never per-hit damage" position must be re-checked by the GDD first.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| combat | Rules 1–4, state table, AC-1–5 | PlayerCombatMachine.Step (owner instance); DashStartedCommand |
| combat | Rule 5, AC-6–9: flat stats, two hooks, AwayFromZero, bit-identical | HitPipeline over ints; IGearHooks.DamageModifier/Reduction; no other stat input exists |
| combat | Rule 7, F3, AC-10–14, AC-36: unified intent, host resolves at the fixed instant vs current positions, fixed tolerances, Docked re-sampled | StrikeIntent → StrikeResolveCommand at host-stamped ResolveTick; fresh ctx reads; maxTargets (GDD value, default 1) |
| combat | Rule 8, feedback table | owner events vs the two records from the flush; hitstop local |
| combat | Rule 9, F6, EC4–8, AC-26–28, AC-42: Downed/Dead/revive/bleed-out/home; keep everything | ActorStateRules, Revive*Command, CombatState.Tick; crawl as a locomotion mode |
| combat | Rule 10, AC-11/29 | targetIsValid never a player |
| combat | Rule 11, F4/F5, EC11/18, AC-15–19 | ApplyHit + Stagger; F5 argmax at end of step 2 as a displacement request |
| combat | Rule 13, AC-32–35 | drain phases 1/1b; EnemyAI skips soft-closed lands; home stabilise on deposit |
| combat | EC1–3, AC-40–41: disconnect; rejoin restores | rows keyed by PlayerKey outlive bodies |
| combat | EC9/10/12, AC-30/37–39 | clamp-first; validation in ADR-0006 stage 4 |
| combat | EC15/16 | mirror re-sampled in Execute; HostSeesDashing in EnemyAttackResolver |
| combat | EC17 | EnemyActor.Phase replicated (ADR-0008) |
| harvesting | Rules 1–2: own phase constants; referent decided once; node intents never hits | PhaseConstants by kind; Referent.Choose on the owner; Node routed to HarvestResolveCommand |
| enemy-ai | Rules 2/6, regression AC | IEnemyCombat.EnterTelegraph only; Phase exposed for the cap check; resolver from step 3 |
| crafting | Rule 9 / F3 | events from the flush only; dull → baseline via the hooks |
| netcode-session | Rules 2/4/5 | commands only (no third rail); actor snapshot rows named; PlayerKey for session-scoped state |
| player-movement | Rules 3–6 | dash/knockback as displacement requests; Snap(DeathRespawn); Downed = locomotion mode (amended) |
| ui-hud | HP/party/Downed data; damage numbers | IPlayerCombatView + the two records' numbers only |
Deferred, with owners
| Item | Owner | Trigger |
|---|---|---|
maxTargets (default 1), reviveReach (1.5 u) + 4th cancel cause, home heal rate | combat GDD (/quick-design) | before the first combat story |
downedCrawlSpeed + Rule 5 carve-out | movement quick-spec | same change as this ADR |
PlayerKey concrete source (Steam ID vs GUID); replication representation; encodings; snapshot | ADR-0008 | playtest |
Dash / knockback displacement, Snap(DeathRespawn), crawl mode | ADR-0009 | pre-playtest |
AttackDef shapes, steering | ADR-0012 | enemy simulation ADR |
Performance Implications
- CPU: per strike, an arc test over ≤ ~15 enemies; per tick,
Tickwalks ≤ 5 players + ≤ 15 enemies.Stepis struct in/out, no allocation. - Memory: one row per actor; per-enemy knockback accumulator.
- Network: ≤ 3 replicated fields per player + 1 per enemy; five command kinds on the existing channel.
Migration Plan
No production code. (0) Fold Driftlands.Rules.Harvest.SwingerState into PlayerState and delete the harvesting-local enum — the harvest phase machine keeps its own phases (its Dashing exists only as a revocation target) and must not grow combat's (review 2026-09-07 #4); (1) Data: PlayerKey, StrikeIntent, TargetKind; Rules.Combat pure types + F1–F5 UNIT tests (AC-7–19 verbatim); (2) PlayerCombatMachine + AC-1–5; PhaseConstants from both configs; (3) ActorStateRules + AC-26/27/30/42; (4) the five commands + EnemyAttackResolver on ADR-0002's pure drain harness (AC-31/32/33/36, accumulator invariants, late revoke); (5) HostTickOrchestrator + EditMode host-step harness; CombatState + IEnemyCombat; ADR-0002/0003 amendments applied; (6) movement quick-spec carve-out + combat GDD quick-design for the three values; (7) two standalone builds under Network Simulator: AC-14/40/41, mirror verdicts; (8) presenters/HUD (ADR-0014).
Validation Criteria
- UNIT: every combat
[UNIT]AC maps to one test overRules.Combat; AC-9 bit-identity 1,000×;HitPipelinereferenced from no Burst-compiled assembly (reflection). - UNIT (drain harness): mutual-kill tick — 1b first, then the strike, both apply; SoftClose tick —
StrikeResolve ≥ φ+Wdropped, enemy mid-Telegraph cancelled; EC7 firstarrivalSeqwins; accumulator empty at drain entry and exit; two same-tick strikes on one enemy → exactly one displacement request, lowest key on tie; aStrikeRevokeafter resolution is a no-op. - UNIT (host-step harness):
Advance → CombatState.Tick → EnemyAI.Tick → Drainorder; aPendingEnemyDamageproduced in step 3 is consumed in the same tick's 1b; exactly oneTicksubscriber exists (reflection). - UNIT: owner vs mirror
Stepagree on the three verdicts for equivalent inputs; the mirror's phase offset is ≤ the fed arrival delay and never affectsResolveTick. - UNIT (schema-negative): no combat table in any save; no kill-credit field; no
MoveSpeedModifier;StrikeIntenthas no tick field; noSendTo.ServerRPC and no owner-writtenNetworkVariableoutsideHostCommitChannel/the ADR-0008-named rows. - Standalone-builds (Network Simulator 140 ms): AC-14; AC-41 disconnect-while-Downed with a new
clientIdon rejoin → same row; revive cancelled by damage / dash / release / leaving reach; AC-35 home deposit stabilises. - Playtest: AC-24/24b/25 feel + EC16/late-revoke fairness log;
maxTargetsquestion.
Related Decisions
- ADR-0002 (amended:
CommandKind+4; host-stampedIntentTick;HostContextonce per host tick), ADR-0003 (amended:ICombatStateshape;IInstanceMetadataexposesIGearHooks;ReasonCode+3; shared vocabulary inData), ADR-0005 (tide-edge carve-out runs in step 1), ADR-0007 (InputFrame), ADR-0008/0009/0012/0014 (enabled) design/gdd/combat-system.md(three proposed knob additions),design/quick-specs/player-movement-2026-08-27.md(amended), harvesting Rules 1–2, enemy-ai, netcode Rule 5, crafting Rule 9/F3docs/architecture/architecture.md§2/§3.1/§4, Required-ADR row 15
Amendments — 2026-09-07 (architecture-review-2026-09-07.md)
- C3 / #5 §Key Interfaces:
ICombatStatenow extendsISwingerStateQuerywith both members declared — the combat epic implementing from the previous list would not have compiled againstHostContext.Combat. - #4 Migration step (0): fold
SwingerStateintoPlayerState. - Still open: this ADR registers no per-frame ms budget (carried from 2026-08-28; the review suggests ≤ 2.0 ms combined
CombatState.Tick+EnemyAI.Tick+Drainat 5 × 15, measured withAllocatingGCMemory+ a control allocation, neverGC.GetAllocatedBytesForCurrentThread()). The figure is the user's call.
Amendments — 2026-09-11 (ADR-0008 netcode wire)
PlayerKeysource decided: a client-persisted GUID (Application.persistentDataPath/Driftlands/player-key) sent as the 16-byteConnectionApprovalpayload throughIPlayerIdentity; Steam ID is a second provider at ship time (ADR-0008 §1.3).- Replication rows get their representation (ADR-0008 §5): one server-written
NetworkVariable<PlayerActorNet>{Hp, State, BleedOutRemainingTicks}onPlayerNet;NetworkVariable<byte> Phase(+AnimFrame) onEnemyNet. The late-join actor snapshot is thesessionActorssection ofSnapshotDocument(ADR-0008 §6) — includes disconnected players' retained rows. - State lags outcomes by one tick (ADR-0008 §2):
SwingLanded/DamageAbsorbedmay reach a client before thePlayerActorNet/Phasedelta of the same drain; presenters act on the event payload, never on the variable inside the handler. - The
owner_written_network_variableban this ADR called "registered" was absent fromdocs/registry/architecture.yaml; ADR-0008 registers it.
Amendments — 2026-09-11 (ADR-0009 player transform authority)
IMovementDisplacement→Driftlands.Rules.Movement.IDisplacementSink(Displace(DisplacementRequest{Velocity, RemainingSecs, Kind})); the owner machine's dash request and the F5 knockback both go through it into the sameKinematicMover2Dsweep (walls stop both).Snap(DeathRespawn, homeSpawn)isIPlayerSnapper.Snap(key, SnapReason.DeathRespawn, homeSpawn)(host):HostPositions.Overridefirst, thenBroadcast(PlayerSnapped); the owner honours it withTeleportand raises the camera'sISnapSource.Snapped. The host never touches a player transform (NGO throws on non-authorityTeleport).HostKnownis a single-tick predicate input that may jump discontinuously on a snap; the revive-reach cancel is unaffected (evaluated per tick).
Amendments — 2026-09-11 (ADR-0010 inventory replication)
PlayerActor.Equipped{Weapon, Armor}is written only byEquipCommand/UnequipCommand.ExecutethroughICombatState.SetEquipped(key, slot, instanceId?)(single writer holds); the stack itself lives inCarryInventory.Equipmentand is persisted in thecarriestable'sequipmentarray. Equipping moves the tagged stack out of the bag (user decision 2026-09-11);IGearHooksreads byInstanceIdare unchanged.
Amendments — 2026-09-11 (ADR-0012 enemy simulation model)
EnemyActorId(SocketId, ushort Index)replaces the per-actorEnemyId(landSocket, index)—Data.EnemyIdis the archetype key. Closed list of sites that change type:ActorId, theEnemyActorrow key,ICombatState.Enemy(EnemyActorId),ApplyHit(EnemyActorId target, …),PendingEnemyDamage.Attacker : EnemyActorId, §3 step 2's angle tiebreak onEnemyActorId.Index;EnemiesOn(SocketId)unchanged. The combat epic sweeps them in one pass (compile-time-catchable).EnemyActor.Targetmoves to the AI'sBrainState(combat never reads a target — the resolver hits every player in the shape).IEnemyCombatgainsLeashReset()(HP to max, stagger meter and immunity cleared): the AI→combat boundary is exactly two calls.ICombatStategainsAddEnemy(EnemyActorId, EnemyId archetype)/RemoveEnemiesOn(SocketId).- Single writer, fourth site: mutations happen in step 2, phase 1b, a command's
Execute, or the deferred-spawn flush (phase 4) forAddEnemy/RemoveEnemiesOnonly — row creation/destruction, never a field write on a live row. - Phase advance is step 2:
CombatState.Tickadvances enemy phase timers and itsTelegraph → Activetransition firesEnemyAttackResolver; §4's "host step 3 advances eachEnemyActor" is superseded — step 3 (EnemyAI.Tick) reads the same tick's post-advance phase and only calls the two boundary members.PendingEnemyDamagefrom step 2 is consumed in phase 1b the same tick (Verification 3 holds).
Amendments — 2026-09-13 (NetcodeSession story-006b — session-scoped row store)
ICombatStategains two members,ActorRow GetOrCreate(PlayerKey)andbool TryGet(PlayerKey, out ActorRow), alongside the two inherited fromISwingerStateQuery. The closed set is now exactly{GetOrCreate, TryGet, CanResolveHarvest, AcceptsHarvestIntent}— fenced (Net_CombatSeamIsMinimal).Driftlands.Runtime.Session.SessionActorRowsis a NetcodeSession-owned, session-scoped minimal implementation (per the epic's confirmed scope decision, ADR-0008 §6):GetOrCreateanswersHas(key)for the two inherited swinger questions, and creates a defaultActorRow{Hp=100, State=0, BleedOutRemainingTicks=0, OnLand=null}the first time a key is seen. A row is never dropped on disconnect — only theSessionStateclientId→PlayerKey mapping releases;GetOrCreateafter a rejoin returns the exact pre-existing row (the rejoin rule, TR-net-004: rejoin = late join).- This is the exact replacement point for the Combat epic's real
CombatState: that epic implementsICombatStateover real HP/state/stagger logic rather than widening the interface — harvesting and session code are unaffected by the swap. SessionReadyCommand.Execute(ADR-0008 §6) is the one production call site that turns "no row" intoReasonCode.ActorDeadfor every OTHER command reachingHarvestResolveCommand's existing gate — no new rejection mechanism was added.