Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -763,3 +763,70 @@ An agent-sized slice of an issue: one context window of work, kept local.

**Tracer**:
The first Ticket of an issue. Establishes the Seam or pattern the other Tickets copy, and blocks them.

### Random properties

**Random Property**:
The suffix selected for one item instance at creation, held as `random_property_id`. Zero is a
plain item. Moving an existing item preserves this value, including zero.
_Avoid_: random enchant, item variant, roll

**Property Pool**:
The weighted set of Random Properties a template can produce, named by
`game_item_template.random_property`. Members sort by property ID. The importer stores ClassicDB
chance in exact hundredths and refuses more than two decimal places. Selection compares
`input * total_weight < cumulative_weight * 10000` using widened integer arithmetic, for inputs
0 through 9999. Thus each cumulative band endpoint rounds up. Both underweight and overweight
pools use their full total. Very small weights can receive no input at this resolution.
_Avoid_: random property group, suffix group

**Suffix**:
The name a Random Property adds to an item, such as "of the Bear". The Module keeps a copy for
diagnosis. The client renders its own.
_Avoid_: affix, postfix

**Stat Kind**:
An append-only code for an enchantment effect's contribution, independent of the client encoding.
The importer retains unmapped effects as kind 0, which contributes nothing. Amounts are flat
points, except crit, hit, dodge, parry and block, which use basis points, and regeneration, which
uses points per five seconds. Spell power retains a school mask.
_Avoid_: stat type, mod type, effect type

| Code | Contribution |
| --- | --- |
| 0 | Unknown |
| 1 | Strength |
| 2 | Agility |
| 3 | Stamina |
| 4 | Intellect |
| 5 | Spirit |
| 6 | Health |
| 7 | Mana |
| 8 | Holy resistance |
| 9 | Fire resistance |
| 10 | Nature resistance |
| 11 | Frost resistance |
| 12 | Shadow resistance |
| 13 | Arcane resistance |
| 14 | Armor |
| 15 | Weapon damage |
| 16 | Spell power |
| 17 | Healing power |
| 18 | Mana per five seconds |
| 19 | Health per five seconds |
| 20 | Crit |
| 21 | Hit |
| 22 | Defense |
| 23 | Dodge |
| 24 | Parry |
| 25 | Block |

Each enchantment effect has key `(u64(enchant_id) << 8) | effect_index`. Indices reserve 64 positions
per client enchantment slot. A referenced spell's three subeffects each reserve eight positions
for expanded stats or schools. The index is `slot * 64 + subeffect * 8 + expansion`, from 0 through
191. Direct effects use subeffect zero and expansion zero. `spell_id` retains the source spell
reference even when the Stat Kind is unknown.

Enchantment IDs 7745 and 7748 are authored compatibility entries, with +3 Strength and +3 Stamina.
They preserve existing stored item meanings. They are not rows from the build 5875 client
catalogue. The importer refuses a client catalogue that collides with either ID.
33 changes: 33 additions & 0 deletions crates/lyracore-shared/src/item_property.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Append-only Stat Kind codes used by item catalogue authors and the importer.

pub const UNKNOWN: u8 = 0;
pub const STRENGTH: u8 = 1;
pub const AGILITY: u8 = 2;
pub const STAMINA: u8 = 3;
pub const INTELLECT: u8 = 4;
pub const SPIRIT: u8 = 5;
pub const HEALTH: u8 = 6;
pub const MANA: u8 = 7;
pub const HOLY_RESISTANCE: u8 = 8;
pub const FIRE_RESISTANCE: u8 = 9;
pub const NATURE_RESISTANCE: u8 = 10;
pub const FROST_RESISTANCE: u8 = 11;
pub const SHADOW_RESISTANCE: u8 = 12;
pub const ARCANE_RESISTANCE: u8 = 13;
pub const ARMOR: u8 = 14;
pub const WEAPON_DAMAGE: u8 = 15;
pub const SPELL_POWER: u8 = 16;
pub const HEALING_POWER: u8 = 17;
pub const MANA_PER_FIVE: u8 = 18;
pub const HEALTH_PER_FIVE: u8 = 19;
pub const CRIT: u8 = 20;
pub const HIT: u8 = 21;
pub const DEFENSE: u8 = 22;
pub const DODGE: u8 = 23;
pub const PARRY: u8 = 24;
pub const BLOCK: u8 = 25;

/// Authored compatibility entries, separate from client enchantment IDs.
/// Existing stored items and synthetic enchanting spells use these meanings.
pub const COMPATIBILITY_ENCHANTMENTS: [(u32, u8, i32); 2] =
[(7745, STRENGTH, 3), (7748, STAMINA, 3)];
2 changes: 2 additions & 0 deletions crates/lyracore-shared/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,5 @@ pub const ERR_ATTACK_TARGET_DEAD: &str = "target is dead";
/// maps it to `SMSG_ATTACKSWING_CANT_ATTACK` so the client leaves stance cleanly. Shared so producer
/// (module) and matcher (gateway) can't drift.
pub const ERR_ATTACK_FRIENDLY: &str = "target is friendly";

pub mod item_property;
87 changes: 68 additions & 19 deletions crates/lyracore-shared/src/loot_roll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,34 @@ pub mod vote_kind {
}

/// Encode a `ROLL_START` payload: `corpse_guid,slot,item_entry,countdown_ms`.
pub fn encode_start(corpse_guid: u64, slot: u8, item_entry: u32, countdown_ms: u32) -> String {
format!("{corpse_guid},{slot},{item_entry},{countdown_ms}")
pub fn encode_start(
corpse_guid: u64,
slot: u8,
item_entry: u32,
countdown_ms: u32,
random_property_id: u32,
) -> String {
format!("{corpse_guid},{slot},{item_entry},{countdown_ms},{random_property_id}")
}

/// Decode a `ROLL_START` payload back to `(corpse_guid, slot, item_entry, countdown_ms)`.
pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32)> {
pub fn decode_start(payload: &str) -> Option<(u64, u8, u32, u32, u32)> {
let mut p = payload.split(',');
let corpse_guid: u64 = p.next()?.parse().ok()?;
let slot: u8 = p.next()?.parse().ok()?;
let item_entry: u32 = p.next()?.parse().ok()?;
let countdown_ms: u32 = p.next()?.parse().ok()?;
let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0);
if p.next().is_some() {
return None; // trailing garbage — fail closed
}
Some((corpse_guid, slot, item_entry, countdown_ms))
Some((
corpse_guid,
slot,
item_entry,
countdown_ms,
random_property_id,
))
}

/// Encode a `ROLL_VOTE` payload: `corpse_guid,slot,item_entry,roll_number,vote_kind,auto_pass`.
Expand All @@ -116,26 +129,36 @@ pub fn encode_vote(
roll_number: u8,
vote: u8,
auto_pass: bool,
random_property_id: u32,
) -> String {
format!(
"{corpse_guid},{slot},{item_entry},{roll_number},{vote},{}",
"{corpse_guid},{slot},{item_entry},{roll_number},{vote},{},{random_property_id}",
auto_pass as u8
)
}

/// Decode a `ROLL_VOTE` payload back to `(corpse_guid, slot, item_entry, roll_number, vote_kind, auto_pass)`.
pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool)> {
pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool, u32)> {
let mut p = payload.split(',');
let corpse_guid: u64 = p.next()?.parse().ok()?;
let slot: u8 = p.next()?.parse().ok()?;
let item_entry: u32 = p.next()?.parse().ok()?;
let roll_number: u8 = p.next()?.parse().ok()?;
let vote: u8 = p.next()?.parse().ok()?;
let auto_pass = p.next()? == "1";
let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0);
if p.next().is_some() {
return None;
}
Some((corpse_guid, slot, item_entry, roll_number, vote, auto_pass))
Some((
corpse_guid,
slot,
item_entry,
roll_number,
vote,
auto_pass,
random_property_id,
))
}

/// Encode a `ROLL_WON` payload: `corpse_guid,slot,item_entry,winning_roll,winning_vote`. The
Expand All @@ -148,22 +171,31 @@ pub fn encode_won(
item_entry: u32,
winning_roll: u8,
winning_vote: u8,
random_property_id: u32,
) -> String {
format!("{corpse_guid},{slot},{item_entry},{winning_roll},{winning_vote}")
format!("{corpse_guid},{slot},{item_entry},{winning_roll},{winning_vote},{random_property_id}")
}

/// Decode a `ROLL_WON` payload back to `(corpse_guid, slot, item_entry, winning_roll, winning_vote)`.
pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8)> {
pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> {
let mut p = payload.split(',');
let corpse_guid: u64 = p.next()?.parse().ok()?;
let slot: u8 = p.next()?.parse().ok()?;
let item_entry: u32 = p.next()?.parse().ok()?;
let winning_roll: u8 = p.next()?.parse().ok()?;
let winning_vote: u8 = p.next()?.parse().ok()?;
let random_property_id = p.next().map(str::parse).transpose().ok()?.unwrap_or(0);
if p.next().is_some() {
return None;
}
Some((corpse_guid, slot, item_entry, winning_roll, winning_vote))
Some((
corpse_guid,
slot,
item_entry,
winning_roll,
winning_vote,
random_property_id,
))
}

/// Encode a `MASTER_LIST` payload: `corpse_guid|guid,guid,...` (the eligible-recipient set).
Expand Down Expand Up @@ -198,31 +230,48 @@ pub fn decode_money_share(payload: &str) -> Option<u32> {
mod tests {
use super::*;

#[test]
fn saved_properties_cross_events_and_old_events_stay_plain() {
assert_eq!(
decode_start(&encode_start(1, 2, 3, 4000, 117)),
Some((1, 2, 3, 4000, 117))
);
assert_eq!(decode_start("1,2,3,4000"), Some((1, 2, 3, 4000, 0)));
assert_eq!(
decode_vote(&encode_vote(1, 2, 3, 99, 1, false, 117)),
Some((1, 2, 3, 99, 1, false, 117))
);
assert_eq!(
decode_won(&encode_won(1, 2, 3, 99, 1, 117)),
Some((1, 2, 3, 99, 1, 117))
);
}

#[test]
fn start_round_trips() {
let wire = encode_start(555, 2, 1234, 60_000);
assert_eq!(decode_start(&wire), Some((555, 2, 1234, 60_000)));
let wire = encode_start(555, 2, 1234, 60_000, 0);
assert_eq!(decode_start(&wire), Some((555, 2, 1234, 60_000, 0)));
}

#[test]
fn start_fails_closed_on_garbage() {
assert!(decode_start("").is_none());
assert!(decode_start("1,2,3").is_none()); // missing countdown
assert!(decode_start("1,2,3,4,5").is_none()); // trailing garbage
assert!(decode_start("1,2,3,4,5,6").is_none()); // trailing garbage
assert!(decode_start("x,2,3,4").is_none());
}

#[test]
fn vote_round_trips_including_auto_pass() {
let wire = encode_vote(555, 2, 1234, 87, vote_kind::NEED, false);
let wire = encode_vote(555, 2, 1234, 87, vote_kind::NEED, false, 0);
assert_eq!(
decode_vote(&wire),
Some((555, 2, 1234, 87, vote_kind::NEED, false))
Some((555, 2, 1234, 87, vote_kind::NEED, false, 0))
);
let auto = encode_vote(555, 2, 1234, 0, vote_kind::PASS, true);
let auto = encode_vote(555, 2, 1234, 0, vote_kind::PASS, true, 0);
assert_eq!(
decode_vote(&auto),
Some((555, 2, 1234, 0, vote_kind::PASS, true))
Some((555, 2, 1234, 0, vote_kind::PASS, true, 0))
);
}

Expand All @@ -237,10 +286,10 @@ mod tests {

#[test]
fn won_round_trips() {
let wire = encode_won(555, 2, 1234, 96, vote_kind::GREED);
let wire = encode_won(555, 2, 1234, 96, vote_kind::GREED, 0);
assert_eq!(
decode_won(&wire),
Some((555, 2, 1234, 96, vote_kind::GREED))
Some((555, 2, 1234, 96, vote_kind::GREED, 0))
);
}

Expand Down
11 changes: 8 additions & 3 deletions crates/lyracore-shared/src/trade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub struct OfferSlot {
pub enchantment: u32,
pub durability: u32,
pub max_durability: u32,
pub random_property_id: u32,
}

/// Encode one side's whole offer for an `OFFER_*` event payload:
Expand All @@ -83,14 +84,15 @@ pub fn encode_offer(gold: u32, slots: &[OfferSlot]) -> String {
.iter()
.map(|s| {
format!(
"{},{},{},{},{},{},{}",
"{},{},{},{},{},{},{},{}",
s.trade_slot,
s.entry,
s.display_id,
s.stack_count,
s.enchantment,
s.durability,
s.max_durability
s.max_durability,
s.random_property_id
)
})
.collect();
Expand All @@ -105,7 +107,7 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec<OfferSlot>)> {
let mut slots = Vec::new();
for part in body.split(';').filter(|p| !p.is_empty()) {
let f: Vec<&str> = part.split(',').collect();
if f.len() != 7 {
if f.len() != 7 && f.len() != 8 {
return None;
}
slots.push(OfferSlot {
Expand All @@ -116,6 +118,7 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec<OfferSlot>)> {
enchantment: f[4].parse().ok()?,
durability: f[5].parse().ok()?,
max_durability: f[6].parse().ok()?,
random_property_id: f.get(7).map(|v| v.parse()).transpose().ok()?.unwrap_or(0),
});
}
Some((gold, slots))
Expand All @@ -139,6 +142,7 @@ mod tests {
enchantment: 0,
durability: 0,
max_durability: 0,
random_property_id: 117,
},
OfferSlot {
trade_slot: 6,
Expand All @@ -148,6 +152,7 @@ mod tests {
enchantment: 2564,
durability: 34,
max_durability: 40,
random_property_id: 0,
},
];
let payload = encode_offer(1_2345, &slots);
Expand Down
6 changes: 4 additions & 2 deletions gateway/src/codec/auction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub struct AuctionView {
pub highest_bidder_guid: u64,
pub highest_bid: u32,
pub expires_at_micros: i64,
pub random_property_id: u32,
}

pub fn build_auction_list_item(view: &AuctionView, now_micros: i64) -> AuctionListItem {
Expand All @@ -30,7 +31,7 @@ pub fn build_auction_list_item(view: &AuctionView, now_micros: i64) -> AuctionLi
id: view.id,
item: view.item_entry,
item_enchantment: view.item_enchant_id,
item_random_property_id: 0,
item_random_property_id: view.random_property_id,
item_suffix_factor: 0,
item_count: view.item_stack_count,
item_charges: 0,
Expand Down Expand Up @@ -103,6 +104,7 @@ mod tests {
highest_bidder_guid: 20,
highest_bid: 201,
expires_at_micros: 3_500_000,
random_property_id: 509_0101,
}
}

Expand All @@ -112,7 +114,7 @@ mod tests {
assert_eq!(mapped.id, 9);
assert_eq!(mapped.item, 25);
assert_eq!(mapped.item_enchantment, 7);
assert_eq!(mapped.item_random_property_id, 0);
assert_eq!(mapped.item_random_property_id, 509_0101);
assert_eq!(mapped.item_suffix_factor, 0);
assert_eq!(mapped.item_count, 2);
assert_eq!(mapped.item_charges, 0);
Expand Down
Loading