A loadout is a mapping from slot ids to item ids. Nothing else — no scenes, no
meshes, no weapon resources. A server receives one from an untrusted client,
checks it against a DotLoadoutSchema and a DotLoadoutEntitlements, and hands
it to the game, on a machine that may have none of the content installed.
Requires dot-core.
var res := await loadouts.active_for(user_key)
for entry in loadouts.resolve(res.value):
arsenal.give(weapon_for(entry["item"]), entry["arsenal_slot"])
var published := await loadouts.publish(user_key, submitted)
if not published.ok:
DotLog.warn("game", "loadout refused: %s" % published.error)
The same trade dot-user-avatar makes,
for the same reason.
Two failure modes it is built around
Everyone owns everything. DotLoadoutEntitlements starts empty and
entitlement_source starts unset, so an unwired server permits only items
marked free. That is loud and gets fixed in a minute. The other default —
granting everything — is silent, and is a game where every unlock is free.
A schema change locks players out. Retiring an item or adding a required
slot makes every saved loadout invalid. conform repairs on the way out of
a store, so a player who has not logged in for a month gets a slightly different
gun rather than an error. validate refuses on the way in from a client,
because a client that can make the server repair its way to a legal loadout can
put anything anywhere.
Where a game plugs in
- DotItem
One thing a player can take: kind, slots, tags, cost, weight, entitlement.
- DotItemCatalogue + resolverCallable
Every item by id, plus the optional, client-side resolver that turns one into an asset. A server never calls it. A delivered item carries a
content_id, andensure_content()fetches the packs the catalogue’s items live in throughDotContent— called by a client, because a server deciding yes or no needs only the ids, and a download on the join path would decide nothing it cannot already decide.- DotLoadoutSlot
A position and what fits in it: kinds, required tags, forbidden tags.
- DotLoadoutSchema
The slots, the catalogue, and the point and weight budgets — a game mode’s equipment rules in one resource.
- DotLoadoutEntitlements + DotLoadoutManager.entitlement_source
What one player may take, and where that comes from.
- DotLoadoutStore subclass
_fetch,_store,_remove,_open,_close,_writable,_store_name. Memory and local-JSON ship.- DotLoadoutValidator
validatefor the trust boundary,conformfor the way out of a store.- DotPickup / DotPickupField
Things on the floor.
wants_fnandtags_fndecide who may take what.- Signals
loadouts_changed,loadout_refused,loadout_conformed,rules_changed,taken,collected,respawned.
A server owner’s rules (API 2)
The schema is the game’s design and entitlements are what one player unlocked; both are fixed when the game ships. What one server’s owner decided tonight is a third thing, and DotLoadoutRules holds it, so a cvar change never rebuilds the schema or re-conforms every saved loadout. Hand it to manager.rules (or set_rules(), which emits rules_changed):
# Snipers only: the primary is any scoped weapon, everything else as usual.
manager.rules = DotLoadoutRules.parse(true, "primary=tag:scoped", "").value
The text is the three cvars’ grammar, which DotLoadoutCommand registers for you:
mp_loadout_allow "primary=rifle,carbine secondary=pistol" # items by id
mp_loadout_allow "*=-tag:explosive" # a leading - forbids; * is every slot
mp_loadout_fixed "primary=marksman thrown=none" # the server fills these; none keeps a slot empty
mp_loadout_menu 0 # nobody chooses: defaults and fixed only
Entries are whitespace-separated and never use semicolons, because a server console splits lines on them. parse fails naming the entry it cannot read, and the caller keeps the rules it had: a typo must not unlock everything. The cvars refuse such a value at the console too, rather than keeping it and failing every later change.
The rules are applied as a copy on the way out of active_for, refused in publish and passed as the optional rules argument of DotLoadoutValidator.validate / conform. manager.offer_for(user_key) is what a loadout screen lists: per slot, the choices this player may make under tonight’s rules, as ids, names and numbers only (a dedicated server may have no art).
DotLoadoutCommand is the server half of the B menu. A client sends /loadout, the server answers with a loadout_menu notice carrying that offer, and choosing sends /loadout primary=rifle secondary=pistol, which goes through publish, so a hostile client typing the line gets exactly what the screen would have let it pick. It names no dot-server class: the host only needs add_command and add_cvar. A mode that hands out the weapons itself (a gun game, a weapon pool) sets closed_fn so the offer says the menu is closed and a choice is refused in the same words, without touching the owner’s rules.
A loadout’s digest() is a fingerprint two processes must agree on, so filled_slots(), the entitlements’ to_list() and the catalogue’s ids() come out in text order. Array.sort() on StringNames orders by where each was interned, which differed between processes.
Pickups are not Area3Ds
Respawn timers count ticks, not seconds, and reach is a distance test rather than a physics overlap. Both because a client predicts picking something up and the server re-runs it: an area callback fires on whichever frame the physics server got to it, which is not the same frame on two machines.
A publish appends unless it replaces
A player with nothing saved is handed a synthesised default. If publishing adds a second loadout rather than replacing that one, then on a schema with a cap of one, the only publish they would ever make is refused.
