Character system (stats, skills, equipment, inventory)
The gameplay model of a character, shared by every program that touches
one: the server (authoritative, later), the client (display, prediction),
and the SDK/MCP bridge (bots and agents read the same JSON). It lives in
crates/uo-proto/src/character/ plus the item catalog in
crates/uo-proto/src/items.rs.
Scope. This is data and invariants only. It defines what a character is and which states are legal. What skills do is server mechanics and comes later: using a skill, gain and atrophy, difficulty, combat, healing, regeneration, timers, starting kits. The same goes for the wire commands that change a character.
Shape
Character {
name,
stats: Stats, // base Str/Dex/Int + gain locks
skills: Skills, // base values of the 49 skills + locks (sparse)
vitals: Vitals, // current Hits/Stamina/Mana (maxima are derived)
items: Possessions, // worn items + container contents (flat tree)
effects: Vec<Effect>, // temporary modifiers (Bless, potions, curses…)
}
JSON (abridged):
{
"name": "Iolo",
"stats": {"str": {"base": 50, "lock": "up"}, "dex": {...}, "int": {...}},
"skills": {"swordsmanship": {"base": 500, "lock": "up"}, "tactics": {...}},
"vitals": {"hits": 50, "stamina": 20, "mana": 10},
"items": {
"1": {"def": 200, "amount": 1, "place": {"at": "equipped", "slot": "backpack"}},
"2": {"def": 301, "amount": 1, "props": {"quality": "exceptional", "crafter": "Dupre"},
"place": {"at": "equipped", "slot": "main_hand"}},
"3": {"def": 1, "amount": 7, "place": {"at": "in", "container": 1}}
},
"effects": [{"key": "bless", "mods": [{"attr": {"stat": "str"}, "op": "add", "value": 10}]}]
}
Base values vs effective values
Only base values are stored: trained stats, trained skills, and current vitals. Every effective number is computed on demand:
effective = (base + Σ flat) · (100 + Σ percent) / 100
The sum runs over every Modifier of that attribute from:
- worn items: the catalog's
ItemDef::mods, then the instance'sItemProps::mods(magic properties); - active
Effects.
Character::modifiers() yields each modifier with its ModSource
(item {uid} or effect {key}), which gives a breakdown for free ("why
is my Str 85?"). Nothing is cached, so unequipping an item or ending an
effect can never leave a stale bonus. Each call iterates about 20 worn
items, which is cheap. A cache can come later if profiling asks for it.
Accessors are stat(), skill() (may exceed 100.0 through bonuses),
max_vital(), carry_capacity(), armor_rating() (unweighted sum),
weapon(), combat_skill() (Wrestling when bare-handed) and title()
(from the highest base skill). Use attr(attr, base) for anything else.
Modifier targets (Attr, append-only) are stat, skill, max_vital,
armor_rating, weapon_damage (percent) and carry_capacity. A new
mechanic adds a variant instead of adding fields to the character.
Effects are keyed. Applying an effect whose key is already active replaces
it, as in UO where two Bless spells don't stack. expires_tick is only
stored; the server's timers act on it.
Skills
Skillis an enum of UO Renaissance's 49 skills. The discriminant is UO's own skill number (Alchemy 0 … Remove Trap 48), and the catalog is append-only, like items and terrain. The serde name is the snake_case key ("animal_lore").SKILLS[id]holds theSkillDef: name, title ("Blacksmith"), category,usable(has an active use, UO's blue gem) andstat_gain.stat_gainis the Str/Dex/Int split that using the skill trains, in percent. It is approximated from emulator tables and is for the future gain mechanics.- Values are
SkillValue = u16in tenths (1000 = 100.0). Locks areup/down/locked, and the sameLocktype serves stats. Skillsis sparse. Skills at 0.0 with the default lock aren't stored, so JSON stays short and future skills need no migration.
Ruleset
Ruleset is plain serializable data, so a shard can load its own.
Ruleset::RENAISSANCE is the default:
| rule | value |
|---|---|
| stat cap / per stat | 225 / 100 |
| skill cap / per skill | 700.0 / 100.0 |
| max hits / stamina / mana | Str / Dex / Int (pre-AOS) |
| carry capacity | 40 + 3.5 · Str stones |
| creation: stats | sum 80, each 10–60 |
| creation: skills | ≤ 3, sum 100.0, each ≤ 50.0 |
| creation: name | 2–16 ASCII letters, single spaces |
Formulas are linear: base + (str·s + dex·d + int·i) / div. AOS hits
(50 + Str/2), for example, is {base: 50, str: 1, div: 2}.
Character::validate(&rules) reports every broken cap, vitals above
their maximum, and any item-structure error (use it after loading a save).
Enforcing caps during gain, e.g. lowering a down skill to make room,
is mechanics.
Items: catalog vs instances
Catalog (items::ItemDef, static, append-only ids). An item kind is a
template built from optional components, so one item can be several
things at once:
| component | meaning |
|---|---|
weight | tenths of a stone, per unit |
stackable | identical units merge into one stack with an amount |
equip: EquipDef | slot, two_handed, requires: [Requirement] |
weapon: WeaponDef | combat skill, damage, speed, range |
armor: ArmorDef | armor rating |
container: ContainerDef | max items (nested included), max content weight |
tool: Skill | the skill it is a tool for |
mods | modifiers applied while worn |
The hatchet is a two-handed Swordsmanship weapon and the Lumberjacking tool. Id ranges: 1.. resources, 100.. tools, 200.. containers, 300.. weapons, 350.. shields, 400.. armor, 500.. clothing, 550.. jewelry. The numbers are approximated from UO Renaissance and get balanced with the mechanics. Catalog tests enforce:
- worn items and containers are never stackable;
- weapons go in the main hand;
- armor is wearable;
- every slot has at least one item.
Instance (character::Item): def, amount, props. Props are
quality (low/regular/exceptional), durability, tool uses, material (e.g.
a colored-ore ingot), crafter, and instance modifiers. Stacks merge only
when the def and the props are equal.
Equipment slots
EquipSlot follows UO's layers: one item per slot, and each slot is a
visual layer. It is append-only; the first four (main_hand, off_hand,
head, body) predate this system.
main_hand, off_hand, head, body (chest armor), neck, hands, arms, legs (leg armor), feet, shirt, pants, tunic (over the chest armor), robe, skirt, cloak, waist, ring, bracelet, earrings, backpack.
EquipSlot::uo_layer() gives the original client's layer number, as a
reference for paperdoll draw order. UO puts two-handed weapons on the
shield layer. We keep them in main_hand with two_handed, which also
occupies off_hand: a bow and a shield exclude each other in both
directions.
Possessions (inventory)
A flat table uid -> {item, place}. place is {"at": "equipped", "slot"} or {"at": "in", "container": uid}. This is UO's item tree with
parent links. The backpack is the item in the backpack slot and is
the inventory root; bags and pouches inside it are ordinary container
items. Item uids are world-unique and allocated by the server, so
operations that may create an item take an allocator closure.
Possessions keeps the structure valid, and every operation either
succeeds completely or changes nothing:
insert(uid, item, place): known kind; amount 1..=60000, and more than 1 only if stackable.add(item, container, alloc): merges into an equal stack directly inside the container if one exists.move_to(uid, place):- equipping: the item's own slot only, slot free, two-handed vs shield;
- containers: only into containers, no cycles;
- the backpack itself can't be moved.
- Container limits apply to the target and every enclosing container. A bag's contents count against the backpack: 125 items (a stack counts once, nested items included) and 400 stones. Moving an item within a container it is already in adds nothing.
split,remove(takes contents along), andconsume(def, amount)(crafting inputs, across stacks, all or nothing).- Queries:
equipment(),contents(),descendants(),ancestors(),item_count(),weight(),total_weight(),amount_of(),totals(). validate()checks loaded data: unknown kinds, bad amounts, wrong or double slots, missing parents, cycles, over-full containers.
Character::equip(uid) adds what needs the whole character. It checks
the item's Requirements (stat or skill minimums) against effective
values without the item's own bonuses: a Strength potion lets you put on
a plate helm. unequip(slot) puts the item back in the backpack.
Overloading isn't prevented here: overloaded(&rules) is a status, and
what it does (no running, stamina drain) is mechanics.
Character creation
NewCharacter { name, str, dex, int, skills: [(Skill, SkillValue)] }
mirrors UO's creation screen. validate(&rules) returns the first
CreationError. build(&rules, alloc) returns a character with full
vitals and an empty backpack. Profession and skill starting kits are
content for later.
The chain boundary (#7, #9)
The ledger owns how many of each economy item a character has.
Possessions owns where things are (worn, which bag) and per-instance
props. Invariant for the server (later): for every item kind on chain,
Possessions::totals() equals the on-chain stack. Unique items (props)
are off-chain until the SPL/NFT decision in #9.
Future work
- Wire protocol. Commands that act on uids, e.g.
equip {uid},move_item {uid, to},split {uid, amount}anduse_skill, plus acharacterreply carrying this JSON.Command::Equip { item_id }andEntityKind::Player.equipment: [(slot, item_id)]stay as they are until then. - Client. Worn items now pick the Quaternius parts, and offline debug
windows show the paperdoll, backpack and skills (
docs/characters.md› Items → appearance). Still missing: real (non-debug) windows, and sheets for online players, which need the wire commands above. - Mechanics on the server: skill use and gain, stat gain from
stat_gain, combat (weapon(),armor_rating(), coverage weighting), regeneration, effect timers, durability wear, starting kits. - More catalog content: reagents, colored ores and materials, the full weapon and armor tables.