Skip to content
Felucca Online

Docs

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:

  1. worn items: the catalog's ItemDef::mods, then the instance's ItemProps::mods (magic properties);
  2. 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

  • Skill is 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 the SkillDef: name, title ("Blacksmith"), category, usable (has an active use, UO's blue gem) and stat_gain. stat_gain is 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 = u16 in tenths (1000 = 100.0). Locks are up/down/locked, and the same Lock type serves stats.
  • Skills is 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:

rulevalue
stat cap / per stat225 / 100
skill cap / per skill700.0 / 100.0
max hits / stamina / manaStr / Dex / Int (pre-AOS)
carry capacity40 + 3.5 · Str stones
creation: statssum 80, each 10–60
creation: skills≤ 3, sum 100.0, each ≤ 50.0
creation: name2–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:

componentmeaning
weighttenths of a stone, per unit
stackableidentical units merge into one stack with an amount
equip: EquipDefslot, two_handed, requires: [Requirement]
weapon: WeaponDefcombat skill, damage, speed, range
armor: ArmorDefarmor rating
container: ContainerDefmax items (nested included), max content weight
tool: Skillthe skill it is a tool for
modsmodifiers 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), and consume(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} and use_skill, plus a character reply carrying this JSON. Command::Equip { item_id } and EntityKind::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.