The server owns the weapon inventory and reconciles it only downward
Status: Accepted (2026-07-03)
Context
The gamemode needs to read a player's weapons back (which weapon is held, how much ammo is left), and the server needs to validate a WeaponShot against what the player really owns, not only against its geometry and rate. That requires a server-side inventory.
Keeping that inventory live is the hard part. The client switches weapons and spends ammo on its own; the server sees this only through the owner's PlayerSync frames (the held weapon, its clip and total) and through accepted shots. A store that only the gamemode's commands change is stale after the first shot. Copying the reported ammo into the store as is would keep it live, but then a client that freezes its ammo counter has legal infinite ammo: there is no client-side anti-cheat to stop it, and the project does not build one. The protection has to come from validating the client against state the server owns.
The facade verbs follow ADR 0009; weapon gives are delivered through the grant primitive of ADR 0013. Weapon skills are already server-authoritative, so the server knows the skill tier a shot is fired at.
Decision
Each player has a server-owned inventory: the game's 13 weapon slots, each {weapon_type, total_ammo, clip_ammo}, plus the held weapon (0 = fists). Only the server's commands add to it (give, take, set ammo, clear, set armed weapon); each is committed to the store after its packet is queued. A give follows the game's own ammo rules, so the store matches what the client will have: giving the same weapon again adds its ammo; giving a different weapon into a slot whose weapons share ammo (shotguns, submachine guns, assault rifles) carries the displaced weapon's total over; any other slot is replaced. Without the carry-over a swap inside a shared slot would leave the store below the client's pooled total, and the downward-only merge below could never raise it again. Totals are capped at 65535 (MAX_WEAPON_AMMO), so every legal total fits the u16 total of a frame without clamping; otherwise the merge would read the clamp as spent ammo.
The store stays live through three rules:
- Inventory revision. Every queued weapon command increments a per-player
rev(u16) and carries it (GiveWeapon,TakeWeapon,SetWeaponAmmo,ClearWeapons,SetArmedWeapon). The client echoes the last revision it applied successfully inFrame.weapon_rev; a failed apply does not move the echo, so the server keeps ignoring a client that has diverged. Frames that echo an older revision are not used for reconciliation. Without this gate a grant in flight breaks the merge for good: the server gives 100 rounds, the client still sends frames with 3, the merge pulls the store down to 3, and the honest frame with 100 that follows would be discarded as an increase. The revision never grows without a packet: the gate is strict equality, and a revision the client never received would close it forever. - Downward-only merge within the current revision. A frame of the current life and revision can move the held weapon only within the inventory and the held weapon's ammo only down (
stored = min(observed, stored)); the clip moves freely under the total (a reload). An observation can spend the player's own ammo, never grant a weapon or ammo. - The server's own spend. An accepted
WeaponShot(one packet is one trigger pull, a shotgun's pellets included) takes one round from the store and itself makes the shot's weapon the held one; an accepted projectile thrown or launched on foot takes one round of the weapon behind it. The flamethrower, the spraycan and the extinguisher send no shots: while the owner's frames show their fire held, the server spends at least 10 rounds per second of the store itself, far below the game's real consumption, so a client that freezes the counter of a continuous weapon still runs dry.
Invariant: the server's count is never below the client's real one. Every decrement matches a shot the client really sent; a lost datagram leaves the server's count higher, in the player's favor, and the next frame's absolute value (frames come at least every 100 ms) brings it back down. Nothing is deducted twice, because a frame carries an absolute value and the merge takes the lower of two views of the same reality. A false rejection "the server thinks 0, the client has ammo" cannot happen by construction.
Lifetime. A death does not clear the store: the corpse keeps its weapons. The respawn grant clears it, without a revision bump: an empty store is safe against stale frames anyway, since an observation cannot add a slot, and a spawn loadout bumps the revision itself. Clearing at the grant keeps one delivery order on both sides, because the client clears its inventory on the revival the grant triggers: a give sent between the grant and the client's confirmation of the new life is committed after the server's clear and applied after the client's. A new connection starts with an empty store; the store goes away with the player (ADR 0021).
Shot validation uses the store: the shooter is spawned and alive, holds the weapon with total_ammo > 0, and stays within the per-weapon range and minimum interval of a conservative server table (src/weapons.rs). There is no check that the shot's weapon equals the stored held weapon: a shot routinely overtakes the frame that announces a weapon switch, and membership plus ammo already prove the player can fire it. These checks are sanity checks: a false rejection costs more than a missed spam shot.
Consequences
- Wire:
Frame.weapon_rev(u16) and arev(u16) on all five weapon commands;GiveWeapon.set_as_current(a give may leave the held weapon alone);SetWeaponAmmo.clipwith the sentinel 0xFFFF for "not set". - The readback is exact up to one frame interval.
- The remaining hole is deliberate: a client that never reloads still fires within its finite total and the weapon's cadence.
Alternatives considered
- Trust the client's reported ammo. Keeps the store live with no extra rules, but makes a frozen ammo counter legal infinite ammo when there is no client-side anti-cheat. The downward merge and the per-shot decrement close this for the price of one subtraction and one
min. - Require the shot's weapon to equal the stored held weapon. Rejects honest players who switch and fire at once.
- A u8 revision. A burst of 256 commands in one tick (a loadout script) could wrap it while a frame is in flight, and the stale echo would pass the equality gate.
- Clear the store when the client confirms the new life instead of at the respawn grant. A give that lands between the grant and the confirmation would be erased on the server only: the client would hold the weapon and every shot would be rejected as not held.
- Bump the revision on the respawn clear. No packet carries that revision, so the client could never echo it and the gate would stay closed.
