dot-loadout

What a player takes into a match, as a bounded document of ids a dedicated server validates against a schema and entitlements without loading any content — plus deterministic world pickups.

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, and ensure_content() fetches the packs the catalogue’s items live in through DotContent — 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

validate for the trust boundary, conform for the way out of a store.

DotPickup / DotPickupField

Things on the floor. wants_fn and tags_fn decide 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.