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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ and is not reconstructed here.

### Changed

- Package API version 4 adds `actor::EffectKind`, the shared spell effect taxonomy. Existing
Actor byte constants keep their values. Package authors can use `EffectKind::try_from`
when reading effect rows; unknown bytes remain possible in authored content. The five API
roots from version 3 stay the same.

- Package API version 3 has five roots: `actor`, `hooks`, `package`, `tables`, and
`script_binding`. Actor operations and observations replace direct dependencies on `chat`,
`combat`, `creatures`, `faction`, `gameobject`, `group`, `helpers`, `items`, `loot`, `nav`, `quest`,
Expand Down
7 changes: 6 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A World of Warcraft 1.12.1 server. Game state lives in SpacetimeDB; the gateway

**D** [Datascript](#packages) · [Delivery Delay](#mail) · [Dismount](#mounts) · [Distraction](#creature-ai) · [Dormant Character](#accounts-characters-and-sessions) · [Durable Read](#gateway-and-module) · [Durable Request](#gateway-and-module)

**E** [Encounter Binding](#realm-topology) · [Encounter Signal](#realm-topology) · [Engagement](#creature-ai) · [Entity Handle](#runtime-scripts) · [Escrow](#sharding-and-transfer) · [Event Binding](#packages) · [EventAI Source Profile](#realm-topology)
**E** [Effect Kind](#gateway-and-module) · [Encounter Binding](#realm-topology) · [Encounter Signal](#realm-topology) · [Engagement](#creature-ai) · [Entity Handle](#runtime-scripts) · [Escrow](#sharding-and-transfer) · [Event Binding](#packages) · [EventAI Source Profile](#realm-topology)

**F** [Fake](#working-method) · [Fee Hold](#guilds) · [Fixed Rout](#creature-ai) · [Fixture-Reserved Identifier](#packages) · [Flat Cast](#creature-ai) · [Forced Death](#creature-ai) · [Foreground Action](#sharding-and-transfer) · [Fuel Budget](#runtime-scripts)

Expand Down Expand Up @@ -162,6 +162,11 @@ The Gateway's subscribed connection per shard, authenticated with the Owner Toke
**Owner Token**:
The credential that bypasses row-level security.

**Effect Kind**:
A known durable spell effect code. The shared `EffectKind` enum defines its byte value and whether
it applies an aura. Imported and authored table rows keep their byte encoding; unknown codes
remain possible and consumers handle them explicitly.

**Relay**:
Gateway code that turns a table change into a client message.
_Avoid_: forwarder, pusher
Expand Down
126 changes: 126 additions & 0 deletions crates/lyracore-shared/src/aura.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Pure folds over active aura values.

use crate::spell::taxonomy::*;

#[derive(Clone, Copy, Debug)]
pub struct AuraAmount {
pub kind: u8,
pub parameter: i32,
pub amount: i32,
pub stacks: u8,
}

/// Legacy zero-stack rows contribute once. Arithmetic follows the Module's signed aura fold.
pub fn stacked_amount(amount: i32, stacks: u8) -> i32 {
amount * i32::from(stacks.max(1))
}

pub fn moves_sheet(kind: u8, parameter: i32) -> bool {
(kind == A_MOD_STAT
&& [STAT_STR, STAT_AGI, STAT_STA, STAT_INT, STAT_SPI, STAT_ALL]
.into_iter()
.any(|stat| parameter == i32::from(stat)))
|| (kind == A_MOD_COMBAT
&& [COMBAT_ATTACK_POWER, COMBAT_CRIT]
.into_iter()
.any(|field| parameter == i32::from(field)))
}

pub fn speed_bonus(auras: impl IntoIterator<Item = AuraAmount>, speed_kind: u8) -> i32 {
auras
.into_iter()
.filter(|a| a.kind == A_MOD_SPEED && a.parameter == i32::from(speed_kind))
.map(|a| stacked_amount(a.amount, a.stacks))
.sum()
}

/// Movement retains at least ten percent of its base speed.
pub fn move_speed_with_pct(base: f32, pct: i32) -> f32 {
base * (100 + pct).max(10) as f32 / 100.0
}

/// Running combines movement auras with mounted speed only while a mount aura is active.
pub fn run_speed(base: f32, auras: impl IntoIterator<Item = AuraAmount>) -> f32 {
let (mut movement, mut mount, mut mounted) = (0, 0, false);
for aura in auras {
mounted |= aura.kind == A_MOUNTED;
if aura.kind == A_MOD_SPEED {
if aura.parameter == i32::from(SPEED_MOVE) {
movement += stacked_amount(aura.amount, aura.stacks);
} else if aura.parameter == i32::from(SPEED_MOUNTED) {
mount += stacked_amount(aura.amount, aura.stacks);
}
}
}
move_speed_with_pct(base, mounted_move_pct(movement, mount, mounted))
}

pub fn mounted_move_pct(movement: i32, mount: i32, mounted: bool) -> i32 {
movement + if mounted { mount } else { 0 }
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn sheet_changes_include_crit_and_only_valid_stats() {
assert!(moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_CRIT)));
assert!(moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_ATTACK_POWER)));
assert!(!moves_sheet(A_MOD_COMBAT, i32::from(COMBAT_HIT)));
assert!(moves_sheet(A_MOD_STAT, i32::from(STAT_ALL)));
assert!(moves_sheet(A_MOD_STAT, i32::from(STAT_SPI)));
assert!(!moves_sheet(A_MOD_STAT, 5));
assert!(!moves_sheet(A_MOD_STAT, -1));
}

#[test]
fn stacked_snare_reaches_the_movement_floor() {
let auras = [
AuraAmount {
kind: A_MOD_SPEED,
parameter: i32::from(SPEED_MOVE),
amount: -30,
stacks: 4,
},
AuraAmount {
kind: A_MOD_SPEED,
parameter: i32::from(SPEED_MOVE),
amount: 10,
stacks: 0,
},
AuraAmount {
kind: A_MOD_SPEED,
parameter: i32::from(SPEED_SWING),
amount: 50,
stacks: 1,
},
];
assert_eq!(speed_bonus(auras, SPEED_MOVE), -110);
assert!((move_speed_with_pct(7.0, speed_bonus(auras, SPEED_MOVE)) - 0.7).abs() < 0.0001);
}
#[test]
fn running_applies_mount_speed_only_while_mounted() {
let movement = AuraAmount {
kind: A_MOD_SPEED,
parameter: i32::from(SPEED_MOVE),
amount: -30,
stacks: 1,
};
let mount = AuraAmount {
kind: A_MOD_SPEED,
parameter: i32::from(SPEED_MOUNTED),
amount: 60,
stacks: 1,
};
let mounted = AuraAmount {
kind: A_MOUNTED,
parameter: 1147,
amount: 0,
stacks: 1,
};
assert_eq!(run_speed(7.0, [movement, mount, mounted]), 9.1);
assert_eq!(run_speed(7.0, [mount, mounted]), 11.2);
assert_eq!(run_speed(7.0, [movement, mount]), 4.9);
}
}
79 changes: 79 additions & 0 deletions crates/lyracore-shared/src/faction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,82 @@ mod tests {
assert!(!same_team(1, 2), "Human and Orc are not");
}
}

pub fn reputation_rank(standing: i32) -> u8 {
match standing {
42000.. => 7,
21000.. => 6,
9000.. => 5,
3000.. => 4,
0.. => 3,
-3000.. => 2,
-6000.. => 1,
_ => 0,
}
}

/// Explicit relations take precedence over group masks. Faction zero has no explicit relation.
pub fn relation_holds(
asserting: &[u32; 4],
vetoing: &[u32; 4],
mask: u32,
other_faction: u32,
other_group: u32,
) -> bool {
if other_faction != 0 {
if asserting.contains(&other_faction) {
return true;
}
if vetoing.contains(&other_faction) {
return false;
}
}
mask & other_group != 0
}

/// A standing row for a faction with a reputation bar overrides template hostility.
pub fn refuses_interaction(reputation: Option<(i32, bool)>, template_hostile: bool) -> bool {
reputation.map_or(template_hostile, |(standing, at_war)| {
at_war || reputation_rank(standing) <= 2
})
}

#[cfg(test)]
mod rule_tests {
use super::*;

#[test]
fn reputation_ranks_change_at_each_vanilla_threshold() {
for (standing, rank) in [
(-6000, 1),
(-3000, 2),
(0, 3),
(3000, 4),
(9000, 5),
(21000, 6),
(42000, 7),
] {
assert_eq!(reputation_rank(standing), rank);
assert_eq!(reputation_rank(standing - 1), rank - 1);
}
assert_eq!(reputation_rank(i32::MIN), 0);
assert_eq!(reputation_rank(i32::MAX), 7);
}

#[test]
fn explicit_hostility_beats_friendship_and_friendship_beats_the_mask() {
assert!(relation_holds(&[7, 0, 0, 0], &[7, 0, 0, 0], 0, 7, 1));
assert!(!relation_holds(&[0; 4], &[7, 0, 0, 0], 1, 7, 1));
assert!(relation_holds(&[0; 4], &[0; 4], 1, 0, 1));
assert!(!relation_holds(&[0; 4], &[0; 4], 0, 0, 1));
}

#[test]
fn reputation_and_at_war_override_template_hostility() {
assert!(refuses_interaction(Some((42000, true)), false));
assert!(refuses_interaction(Some((-1, false)), false));
assert!(!refuses_interaction(Some((0, false)), true));
assert!(refuses_interaction(None, true));
assert!(!refuses_interaction(None, false));
}
}
64 changes: 64 additions & 0 deletions crates/lyracore-shared/src/item.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
//! Item rules shared by the module and gateway.

pub const fn is_carried_slot(slot: u8) -> bool {
slot <= 38 || (slot >= 120 && slot <= 191)
}

pub fn carried_quantity(entry: u32, items: impl IntoIterator<Item = (u8, u32, u32)>) -> u32 {
items
.into_iter()
.filter(|&(slot, item_entry, _)| is_carried_slot(slot) && item_entry == entry)
.map(|(_, _, count)| count)
.sum()
}

/// Any projectile class is suitable; launcher subclass pairing is not enforced by the Module.
pub fn loaded_ammo<T>(
items: impl IntoIterator<Item = T>,
fields: impl Fn(&T) -> (u8, u32, Option<u8>),
) -> Option<T> {
items
.into_iter()
.filter_map(|item| {
let (slot, count, class) = fields(&item);
(is_carried_slot(slot) && count > 0 && class == Some(6)).then_some((slot, item))
})
.min_by_key(|(slot, _)| *slot)
.map(|(_, item)| item)
}

pub const fn grants_equipped_stats(slot: u8, max_durability: u32, durability: u32) -> bool {
slot <= 18 && !(max_durability > 0 && durability == 0)
}

/// Why the Module refused an item Durable Request. The tag is the whole reducer error text, so
/// neither tier matches on human prose; the detail stays in the Module log.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -248,6 +279,39 @@ mod tests {

use armor_subclass::*;

#[test]
fn carried_quantity_excludes_bank_and_unknown_slots() {
assert_eq!(
carried_quantity(
7,
[
(0, 7, 1),
(38, 7, 2),
(39, 7, 40),
(119, 7, 40),
(120, 7, 3),
(191, 7, 4),
(192, 7, 40),
(23, 8, 50)
]
),
10
);
}

#[test]
fn loaded_ammo_uses_the_lowest_carried_nonempty_projectile() {
let items = [
(39, 20, Some(6)),
(120, 5, Some(6)),
(191, 7, Some(6)),
(23, 0, Some(6)),
(24, 8, Some(2)),
(25, 1, None),
];
assert_eq!(loaded_ammo(items, |item| *item), Some((120, 5, Some(6))));
}

/// The nine playable classes (6 and 10 are unused in 1.12).
const PLAYABLE: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11];

Expand Down
Loading
Loading