1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
use crate::context::AttributeContext;
use crate::modifier::{Modifier, TaggedModifier};
use crate::tags::TagMask;
/// How a attribute node's modifiers are reduced to produce a single value.
#[derive(Clone, Debug)]
pub enum ReduceFn {
/// Sum all modifier values. Default for "added"/"flat" style attributes.
Sum,
/// Multiply all modifier values. Default for "more"/"less" style multipliers.
/// The base is 1.0; each modifier is treated as `(1 + modifier_value)`.
Product,
/// User-defined reduction function.
Custom(fn(&[f32]) -> f32),
}
impl Default for ReduceFn {
fn default() -> Self {
ReduceFn::Sum
}
}
/// A attribute node - the fundamental unit of the attribute graph.
///
/// Holds a collection of tagged modifiers and a reduce function that combines
/// them into a single value. Each modifier carries a [`TagMask`] indicating
/// which attribute/damage types it applies to; see [`TaggedModifier`].
#[derive(Clone, Debug)]
pub struct AttributeNode {
/// How modifiers are combined.
pub reduce: ReduceFn,
/// Active tagged modifiers on this node.
pub modifiers: Vec<TaggedModifier>,
}
impl AttributeNode {
/// Create a new node with the given reduce function and no modifiers.
pub fn new(reduce: ReduceFn) -> Self {
Self {
reduce,
modifiers: Vec::new(),
}
}
/// Create a new Sum-reducing node.
pub fn sum() -> Self {
Self::new(ReduceFn::Sum)
}
/// Create a new Product-reducing node.
pub fn product() -> Self {
Self::new(ReduceFn::Product)
}
/// Add a modifier to this node (untagged - applies to every tag query).
pub fn add_modifier(&mut self, modifier: Modifier) {
self.modifiers.push(TaggedModifier::global(modifier));
}
/// Add a tagged modifier to this node.
pub fn add_tagged_modifier(&mut self, modifier: Modifier, tag: TagMask) {
self.modifiers.push(TaggedModifier::new(modifier, tag));
}
/// Remove the first modifier whose value matches (ignoring tags).
/// Returns true if found and removed.
pub fn remove_modifier(&mut self, modifier: &Modifier) -> bool {
if let Some(pos) = self
.modifiers
.iter()
.position(|tm| &tm.modifier == modifier)
{
self.modifiers.remove(pos);
true
} else {
false
}
}
/// Remove the first modifier that matches both value and tag.
/// Returns true if found and removed.
pub fn remove_tagged_modifier(&mut self, modifier: &Modifier, tag: TagMask) -> bool {
let target = TaggedModifier::new(modifier.clone(), tag);
if let Some(pos) = self.modifiers.iter().position(|tm| tm == &target) {
self.modifiers.remove(pos);
true
} else {
false
}
}
/// Evaluate this node: evaluate **all** modifiers (ignoring tags), then reduce.
pub fn evaluate(&self, context: &AttributeContext) -> f32 {
let iter = self.modifiers.iter().map(|tm| tm.modifier.evaluate(context));
self.reduce_iter(iter)
}
/// Evaluate only modifiers whose tags match the given query, then reduce.
///
/// A modifier matches if its tag is NONE (global) or its tag bits are a
/// subset of `query`. See [`TagMask::matches_query`].
pub fn evaluate_tagged(&self, context: &AttributeContext, query: TagMask) -> f32 {
let iter = self
.modifiers
.iter()
.filter(|tm| tm.tag.matches_query(query))
.map(|tm| tm.modifier.evaluate(context));
self.reduce_iter(iter)
}
/// Reduce an iterator of evaluated modifier values using this node's reduce function.
///
/// Sum and Product fold directly without allocating. Custom still requires
/// collecting into a Vec because its function signature takes `&[f32]`.
fn reduce_iter(&self, iter: impl Iterator<Item = f32>) -> f32 {
match &self.reduce {
ReduceFn::Sum => iter.sum(),
ReduceFn::Product => iter.map(|v| 1.0 + v).product(),
ReduceFn::Custom(f) => {
let values: Vec<f32> = iter.collect();
if values.is_empty() { 0.0 } else { f(&values) }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sum_node() {
let ctx = AttributeContext::new();
let mut node = AttributeNode::sum();
node.add_modifier(Modifier::Flat(10.0));
node.add_modifier(Modifier::Flat(5.0));
assert_eq!(node.evaluate(&ctx), 15.0);
}
#[test]
fn product_node() {
let ctx = AttributeContext::new();
let mut node = AttributeNode::product();
node.add_modifier(Modifier::Flat(0.5)); // 1.5x
node.add_modifier(Modifier::Flat(0.3)); // 1.3x
let result = node.evaluate(&ctx);
// 1.5 * 1.3 = 1.95
assert!((result - 1.95).abs() < 0.001);
}
#[test]
fn empty_sum_is_zero() {
let ctx = AttributeContext::new();
let node = AttributeNode::sum();
assert_eq!(node.evaluate(&ctx), 0.0);
}
#[test]
fn empty_product_is_one() {
let ctx = AttributeContext::new();
let node = AttributeNode::product();
assert_eq!(node.evaluate(&ctx), 1.0);
}
#[test]
fn remove_modifier() {
let ctx = AttributeContext::new();
let mut node = AttributeNode::sum();
node.add_modifier(Modifier::Flat(10.0));
node.add_modifier(Modifier::Flat(5.0));
assert!(node.remove_modifier(&Modifier::Flat(10.0)));
assert_eq!(node.evaluate(&ctx), 5.0);
}
#[test]
fn custom_reduce() {
let ctx = AttributeContext::new();
let mut node = AttributeNode::new(ReduceFn::Custom(|vals| {
vals.iter().copied().fold(f32::NEG_INFINITY, f32::max)
}));
node.add_modifier(Modifier::Flat(3.0));
node.add_modifier(Modifier::Flat(7.0));
node.add_modifier(Modifier::Flat(1.0));
assert_eq!(node.evaluate(&ctx), 7.0);
}
// --- Tagged modifier tests ---
#[test]
fn tagged_evaluate_filters_by_query() {
let ctx = AttributeContext::new();
let fire = TagMask::bit(0);
let physical = TagMask::bit(1);
let melee = TagMask::bit(2);
let mut node = AttributeNode::sum();
node.add_tagged_modifier(Modifier::Flat(25.0), physical | melee);
node.add_tagged_modifier(Modifier::Flat(10.0), fire | melee);
node.add_modifier(Modifier::Flat(5.0)); // global
// Unfiltered: all modifiers
assert_eq!(node.evaluate(&ctx), 40.0);
// PHYSICAL|MELEE: physical+melee modifier (25) + global (5) = 30
assert_eq!(node.evaluate_tagged(&ctx, physical | melee), 30.0);
// FIRE|MELEE: fire+melee modifier (10) + global (5) = 15
assert_eq!(node.evaluate_tagged(&ctx, fire | melee), 15.0);
// MELEE only: global (5) only - neither tagged modifier is a subset
assert_eq!(node.evaluate_tagged(&ctx, melee), 5.0);
// FIRE|PHYSICAL|MELEE: all three match = 25 + 10 + 5 = 40
assert_eq!(
node.evaluate_tagged(&ctx, fire | physical | melee),
40.0
);
}
#[test]
fn remove_tagged_modifier_matches_tag() {
let ctx = AttributeContext::new();
let fire = TagMask::bit(0);
let mut node = AttributeNode::sum();
node.add_tagged_modifier(Modifier::Flat(10.0), fire);
node.add_modifier(Modifier::Flat(10.0)); // same value, NONE tag
// Remove only the FIRE-tagged one
assert!(node.remove_tagged_modifier(&Modifier::Flat(10.0), fire));
assert_eq!(node.evaluate(&ctx), 10.0); // global remains
}
}