Ranged combat: Archery (bow) — projectile, line-of-sight, ranged range #111

Closed
opened 2026-07-20 18:37:36 +02:00 by marco · 2 comments
Owner

Part of the Alpha epic (pillar 3). Combat is melee-only (CombatSystem.InRange = melee range). Add ranged combat so PvP has real variety: a bow that hits at distance, requires line of sight, and is dodgeable.

Build a general ranged path in CombatSystem, not a bow special-case — a weapon declares its range and whether it is ranged. Reuse the projectile/targeting precedent from BoltSpell (targeted elemental projectile + LOS + range re-check at resolution) so spells and ranged weapons share the model.

Scope

  • Weapon range as data on BaseWeapon (melee weapons = 1; bow = N); CombatSystem uses it instead of a fixed melee range.
  • Add Archery skill and a bow (BaseWeapon subclass) driving hit chance via Archery.
  • Line-of-sight requirement for ranged swings, checked server-side against statics/map (a target behind a wall can't be hit — ties into the arena statics).
  • Re-check range/LOS at resolution so a target that breaks LOS or leaves range dodges (mirrors the melee ResolveDue behavior and BoltSpell).
  • Broadcast the projectile so clients can animate it within AoI (reuse the spell projectile broadcast if possible; bump ProtocolVersion.Current if the wire changes).

Out of scope

  • Ammo/quiver consumption, arrow items (assume infinite for alpha).
  • Cover/partial-cover mechanics beyond hard LOS.

Definition of Done

Base DoD applies on top.

  • A player with a bow hits another player at ranged distance; a melee weapon cannot reach that far (unit-tested against CombatSystem).
  • A target that steps behind a static/wall or out of range before resolution is not hit.
  • The projectile is visible to clients within AoI, over the versioned protocol.
Part of the Alpha epic (pillar 3). Combat is melee-only (`CombatSystem.InRange` = melee range). Add **ranged combat** so PvP has real variety: a bow that hits at distance, requires line of sight, and is dodgeable. > Build a general **ranged path** in `CombatSystem`, not a bow special-case — a weapon declares its range and whether it is ranged. Reuse the projectile/targeting precedent from `BoltSpell` (targeted elemental projectile + LOS + range re-check at resolution) so spells and ranged weapons share the model. ## Scope - Weapon range as data on `BaseWeapon` (melee weapons = 1; bow = N); `CombatSystem` uses it instead of a fixed melee range. - Add `Archery` skill and a bow (`BaseWeapon` subclass) driving hit chance via Archery. - Line-of-sight requirement for ranged swings, checked server-side against statics/map (a target behind a wall can't be hit — ties into the arena statics). - Re-check range/LOS at resolution so a target that breaks LOS or leaves range dodges (mirrors the melee `ResolveDue` behavior and `BoltSpell`). - Broadcast the projectile so clients can animate it within AoI (reuse the spell projectile broadcast if possible; **bump `ProtocolVersion.Current`** if the wire changes). ## Out of scope - Ammo/quiver consumption, arrow items (assume infinite for alpha). - Cover/partial-cover mechanics beyond hard LOS. ## Definition of Done _Base DoD applies on top._ - [ ] A player with a bow hits another player at ranged distance; a melee weapon cannot reach that far (unit-tested against `CombatSystem`). - [ ] A target that steps behind a static/wall or out of range before resolution is not hit. - [ ] The projectile is visible to clients within AoI, over the versioned protocol.
marco added this to the Alpha milestone 2026-07-20 18:37:36 +02:00
Author
Owner

Note (from the arena LoS discussion): server-side attack line-of-sight is now centralized for spells in the new TargetedSpell base (one place does target+range+LoS; bolts derive from it). When Archery lands here, build the ranged path to reuse a shared reach check (range + LoS) across melee / bolt / arrow rather than re-implementing it — the same 'no hitting through a wall' rule the arena cover depends on. Don't duplicate the LoS check per attack type.

Note (from the arena LoS discussion): server-side attack line-of-sight is now centralized for **spells** in the new `TargetedSpell` base (one place does target+range+LoS; bolts derive from it). When Archery lands here, build the ranged path to **reuse a shared reach check (range + LoS)** across melee / bolt / arrow rather than re-implementing it — the same 'no hitting through a wall' rule the arena cover depends on. Don't duplicate the LoS check per attack type.
Author
Owner

Design review — agreed (ranged combat / Archery)

Owner decisions: bow range 8 (≤ AoI radius); LOS checked unconditionally in the combat path (no-op for melee, since adjacent tiles have no interior cell); a single generic Projectile wire message serves both spells and ranged (replaces SpellBolt); ammo/arrow-item stays out of scope (infinite arrows for alpha) — we only ship the bow item art + the flying-arrow projectile effect art.

Recommendation (build)

  1. SkillName — append Archery; SkillCatalog — add its row (auto-seeds on new players; no save-version bump — enum appended at the end keeps ordinals stable).
  2. BaseWeapon — add abstract int Range. Sword/Greatsword return 1. New Bow : BaseWeapon (Kind "bow", TwoHanded, Skill => Archery, Range => 8, own damage/cooldown, real bow Graphic). Auto-discovered by ItemCatalog; /give bow works with no command change.
  3. Mobile — projected WeaponRange (unarmed baseline 1), set by Arm(...). Derived, re-applied by RestoreEquipped; not persisted.
  4. CombatSystem — inject TileMap; InRange compares attacker.WeaponRange; add unconditional HasLineOfSight gate in both TryStartSwing and ResolveDue. Carry a ranged/projectile marker on the attack outcome so transport emits the projectile.
  5. Shared/ProtocolProjectile(Guid SourceId, Guid TargetId, string Art, int Damage, bool Hit) replacing SpellBolt; art is a data-driven id string (no client type-switch). Bump ProtocolVersion.Current 10 → 11.
  6. WorldTick — on ranged swing resolution emit Projectile(Art="arrow_shot") to PlayersWithin(attackerPosition, radius); spells migrate SpellBoltProjectile (Art="fire_bolt"/"water_bolt"). Melee keeps Attacked.
  7. Client — dispatcher migrates SpellBoltProjectile, VFX draws the effect by art id; projectile timing/interp in Client.Core.
  8. Assets — extract real UO bow + arrow-effect art, descriptive names (wood_bow, arrow_shot), update docs/uo-asset-map.md.

Invariants Check

  1. Scope ✓ — only #111's ranged path; ammo/arrow-item deferred. Unifying the projectile is within the issue's "reuse the spell projectile broadcast" intent.
  2. Server-authoritative ✓ — client sends only Attack(TargetId); server decides range from equipped weapon, validates Chebyshev range + LOS at start AND re-validates at resolution. Pending-swing guard + cooldown throttle the intent.
  3. GM authorization N/A — no admin action; /give bow reuses admin-gated GiveCommand.
  4. Identity model N/A.
  5. Protocol versioned ✗→resolved — replacing SpellBolt with Projectile changes the spell wire and adds ranged ⇒ bump ProtocolVersion.Current 10→11 same change; type in Shared/Protocol.
  6. String catalog ✓ — no new player-facing copy (silent whiff like melee; reuse OutOfRange/NoLineOfSight if any feedback added).
  7. Single-threaded sim ✓ — range/LOS/resolution on the sim thread; CombatSystem gains no lock; injected TileMap read-only.
  8. World.cs HARD GATE ✓ — new logic in CombatSystem; World.TryStartSwing stays a delegate; map passed via ctor.
  9. Screen HARD GATE (client) ✓ — dispatcher migrates the handler SpellBoltProjectile; arrow animation in the VFX component; no case/draw pass in GameScreen.
  10. Client engine-independence ✓ — projectile timing/interp math in Client.Core, unit-tested; only the draw call in MonoGame Client.
  11. Gameplay/Networking separation ✓ — rules in CombatSystem/Spells; wire + AoI send in WorldTick/Networking.
  12. Act on the instance ✓ — WeaponRange projected onto the Mobile via Arm(...), no side dict; Archery in the mobile's SkillSet.
  13. Extend by type, not switch ✓ — Projectile carries a data-driven art-id string (client draws static/<art> generically, no per-type switch); bow = auto-discovered subclass; Archery = enum + one catalog row.
  14. Server-paced actions ✓ — shot advances on ticks via PendingSwing.ResolveAtTick; can't be client-compressed; projectile publicly observable within AoI.
  15. Persistence (GameServer) ✓ — bow persists as an Item (kind string, auto-discovered), no Item version bump; Archery serializes in the existing count-prefixed skill list, no PlayerMobile version bump (append enum at end; old saves seed default). No cross-player refs, no tick-loop IO.
  16. Persistence (Auth) N/A.
  17. Process separation N/A.
  18. Typed options ✓ — weapon range is a constant on the type; unarmed baseline in CombatOptions; no IConfiguration read.
  19. Broadcasts absolute / AoI / transient ✓ — Projectile is a transient event, sent only to PlayersWithin(sourcePos, AoiRadius) at emit time, absolute ids, no HP/deltas, never global. No new reconciled entity → InterestDiff unchanged.
  20. Multi-platform ✓ — pure .NET / MonoGame; no OS-specific API.
  21. Assets required ✗→must-resolve — bow item art + arrow projectile art must be real extracted UO art in assets.isoa; no placeholder shape; missing static/<id> simply not drawn.
  22. Asset naming HARD GATE ✗→must-resolve — descriptive names (wood_bow, arrow_shot), never bare bow/arrow; mirror in docs/uo-asset-map.md.
  23. ModernUO reference ✓ — weapon-arms-the-mobile + skill-vs-skill hit chance; diverge by keeping resolution-time range/LOS re-checks (server-paced honesty, #75).
  24. Docs & DoD same change ✓ — update combat doc + uo-asset-map.md; DoD already in issue; gm-commands.md unchanged (/give data-driven).

Server-side validation (trust boundary)

Intent: Attack(TargetId) (unchanged, no position/range/result). Server validates at swing start: both engageable & alive, no pending swing, ChebyshevDistance ≤ equipped-weapon range, HasLineOfSight vs authoritative TileMap. Re-validates at resolution: same range + LOS + liveness — a target that broke LOS/range whiffs (no arrow, no damage). Hit is a server RNG roll on Archery vs defender skill; damage applied server-side. Forged/out-of-range/no-LOS targets dropped with continue, tick intact.

Verification plan

  • Unit (against CombatSystem/World) — primary level, no networking:
    • Bow hits at range 8 (clear LOS); sword (Range 1) at that distance refused by TryStartSwing. [DoD #1]
    • Trust boundary: target leaves range between start and ResolveDue → whiff; target steps behind a blocking static → whiff; melee at range 1 still lands (LOS no-op). [DoD #2]
    • Shot blocked by a wall at start is refused at start. Archery drives hit chance (0 vs high ≈ min; high ≈ high).
    • Persistence round-trip: equip bow + gain Archery → serialize/deserialize → WeaponSkill == Archery, WeaponRange == 8 re-derived, Archery value restored; an old blob without Archery loads with the seeded default (no version bump).
    • Spell regression: Projectile replaces SpellBolt without breaking fire/water bolt resolution + damage display.
  • WebSocket flow (CreateWebSocketClient) — in-AoI observer receives Projectile (+ Attacked for melee); out-of-AoI receives nothing; stale-version client rejected 426. [DoD #3]
  • Screenshots (visible → required) — debug harness, fresh DB (delete world.sav* + Auth/game DBs). Two clients: /give bow → equip → /setskill archery 1000, position at range with clear LOS, Attack → capture (a) arrow in flight, (b) hit + floating damage; reposition behind the arena wall → capture (c) the shot whiffing (no arrow / no damage). Flag if the harness can't yet drive equip+attack across two clients.
## Design review — agreed (ranged combat / Archery) Owner decisions: bow range **8** (≤ AoI radius); LOS checked **unconditionally** in the combat path (no-op for melee, since adjacent tiles have no interior cell); a single **generic `Projectile`** wire message serves both spells and ranged (replaces `SpellBolt`); ammo/arrow-item stays **out of scope** (infinite arrows for alpha) — we only ship the bow item art + the flying-arrow projectile effect art. ### Recommendation (build) 1. `SkillName` — append `Archery`; `SkillCatalog` — add its row (auto-seeds on new players; no save-version bump — enum appended at the end keeps ordinals stable). 2. `BaseWeapon` — add `abstract int Range`. `Sword`/`Greatsword` return `1`. New `Bow : BaseWeapon` (`Kind "bow"`, `TwoHanded`, `Skill => Archery`, `Range => 8`, own damage/cooldown, real bow `Graphic`). Auto-discovered by `ItemCatalog`; `/give bow` works with no command change. 3. `Mobile` — projected `WeaponRange` (unarmed baseline `1`), set by `Arm(...)`. Derived, re-applied by `RestoreEquipped`; not persisted. 4. `CombatSystem` — inject `TileMap`; `InRange` compares `attacker.WeaponRange`; add unconditional `HasLineOfSight` gate in **both** `TryStartSwing` and `ResolveDue`. Carry a ranged/projectile marker on the attack outcome so transport emits the projectile. 5. `Shared/Protocol` — `Projectile(Guid SourceId, Guid TargetId, string Art, int Damage, bool Hit)` **replacing** `SpellBolt`; art is a data-driven id string (no client type-switch). **Bump `ProtocolVersion.Current` 10 → 11.** 6. `WorldTick` — on ranged swing resolution emit `Projectile(Art="arrow_shot")` to `PlayersWithin(attackerPosition, radius)`; spells migrate `SpellBolt`→`Projectile` (`Art="fire_bolt"/"water_bolt"`). Melee keeps `Attacked`. 7. Client — dispatcher migrates `SpellBolt`→`Projectile`, VFX draws the effect by art id; projectile timing/interp in `Client.Core`. 8. Assets — extract real UO bow + arrow-effect art, descriptive names (`wood_bow`, `arrow_shot`), update `docs/uo-asset-map.md`. ### Invariants Check 1. Scope ✓ — only #111's ranged path; ammo/arrow-item deferred. Unifying the projectile is within the issue's "reuse the spell projectile broadcast" intent. 2. Server-authoritative ✓ — client sends only `Attack(TargetId)`; server decides range from equipped weapon, validates Chebyshev range + LOS at start AND re-validates at resolution. Pending-swing guard + cooldown throttle the intent. 3. GM authorization N/A — no admin action; `/give bow` reuses admin-gated `GiveCommand`. 4. Identity model N/A. 5. Protocol versioned ✗→resolved — replacing `SpellBolt` with `Projectile` changes the spell wire and adds ranged ⇒ **bump `ProtocolVersion.Current` 10→11** same change; type in `Shared/Protocol`. 6. String catalog ✓ — no new player-facing copy (silent whiff like melee; reuse `OutOfRange`/`NoLineOfSight` if any feedback added). 7. Single-threaded sim ✓ — range/LOS/resolution on the sim thread; `CombatSystem` gains no lock; injected `TileMap` read-only. 8. World.cs HARD GATE ✓ — new logic in `CombatSystem`; `World.TryStartSwing` stays a delegate; map passed via ctor. 9. Screen HARD GATE (client) ✓ — dispatcher migrates the handler `SpellBolt`→`Projectile`; arrow animation in the VFX component; no `case`/draw pass in `GameScreen`. 10. Client engine-independence ✓ — projectile timing/interp math in `Client.Core`, unit-tested; only the draw call in MonoGame `Client`. 11. Gameplay/Networking separation ✓ — rules in `CombatSystem`/`Spells`; wire + AoI send in `WorldTick`/`Networking`. 12. Act on the instance ✓ — `WeaponRange` projected onto the `Mobile` via `Arm(...)`, no side dict; Archery in the mobile's `SkillSet`. 13. Extend by type, not switch ✓ — `Projectile` carries a data-driven art-id string (client draws `static/<art>` generically, no per-type switch); bow = auto-discovered subclass; Archery = enum + one catalog row. 14. Server-paced actions ✓ — shot advances on ticks via `PendingSwing.ResolveAtTick`; can't be client-compressed; projectile publicly observable within AoI. 15. Persistence (GameServer) ✓ — bow persists as an `Item` (kind string, auto-discovered), no `Item` version bump; Archery serializes in the existing count-prefixed skill list, **no `PlayerMobile` version bump** (append enum at end; old saves seed default). No cross-player refs, no tick-loop IO. 16. Persistence (Auth) N/A. 17. Process separation N/A. 18. Typed options ✓ — weapon range is a constant on the type; unarmed baseline in `CombatOptions`; no `IConfiguration` read. 19. Broadcasts absolute / AoI / transient ✓ — `Projectile` is a transient event, sent only to `PlayersWithin(sourcePos, AoiRadius)` at emit time, absolute ids, no HP/deltas, never global. No new reconciled entity → `InterestDiff` unchanged. 20. Multi-platform ✓ — pure .NET / MonoGame; no OS-specific API. 21. Assets required ✗→must-resolve — bow item art + arrow projectile art must be real extracted UO art in `assets.isoa`; no placeholder shape; missing `static/<id>` simply not drawn. 22. Asset naming HARD GATE ✗→must-resolve — descriptive names (`wood_bow`, `arrow_shot`), never bare `bow`/`arrow`; mirror in `docs/uo-asset-map.md`. 23. ModernUO reference ✓ — weapon-arms-the-mobile + skill-vs-skill hit chance; diverge by keeping resolution-time range/LOS re-checks (server-paced honesty, #75). 24. Docs & DoD same change ✓ — update combat doc + `uo-asset-map.md`; DoD already in issue; `gm-commands.md` unchanged (`/give` data-driven). ### Server-side validation (trust boundary) Intent: `Attack(TargetId)` (unchanged, no position/range/result). Server validates at swing start: both engageable & alive, no pending swing, `ChebyshevDistance ≤ equipped-weapon range`, `HasLineOfSight` vs authoritative `TileMap`. Re-validates at resolution: same range + LOS + liveness — a target that broke LOS/range whiffs (no arrow, no damage). Hit is a server RNG roll on Archery vs defender skill; damage applied server-side. Forged/out-of-range/no-LOS targets dropped with `continue`, tick intact. ### Verification plan - **Unit (against `CombatSystem`/`World`)** — primary level, no networking: - Bow hits at range 8 (clear LOS); sword (Range 1) at that distance refused by `TryStartSwing`. [DoD #1] - Trust boundary: target leaves range between start and `ResolveDue` → whiff; target steps behind a blocking static → whiff; melee at range 1 still lands (LOS no-op). [DoD #2] - Shot blocked by a wall at start is refused at start. Archery drives hit chance (0 vs high ≈ min; high ≈ high). - Persistence round-trip: equip bow + gain Archery → serialize/deserialize → `WeaponSkill == Archery`, `WeaponRange == 8` re-derived, Archery value restored; an old blob without Archery loads with the seeded default (no version bump). - Spell regression: `Projectile` replaces `SpellBolt` without breaking fire/water bolt resolution + damage display. - **WebSocket flow (`CreateWebSocketClient`)** — in-AoI observer receives `Projectile` (+ `Attacked` for melee); out-of-AoI receives nothing; stale-version client rejected 426. [DoD #3] - **Screenshots (visible → required)** — debug harness, fresh DB (delete `world.sav*` + Auth/game DBs). Two clients: `/give bow` → equip → `/setskill archery 1000`, position at range with clear LOS, `Attack` → capture (a) arrow in flight, (b) hit + floating damage; reposition behind the arena wall → capture (c) the shot whiffing (no arrow / no damage). Flag if the harness can't yet drive equip+attack across two clients.
marco closed this issue 2026-07-23 23:09:16 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#111
No description provided.