overlord_event_system/behaviors/
arena.rs

1//! Arena rating-change math (code-dispatched by the matchmaking display and
2//! fight-result paths).
3//!
4//! Asymmetric point exchange: the opponent's KIND is decided by the honest
5//! power ratio `opponent_power / player_power` at the moment of the fight —
6//! below the prey threshold it's «добыча» (small reward), above the stretch
7//! threshold «дерзость» (big reward), otherwise «ровня». Wins pay by kind
8//! plus a capped win-streak bonus; losses also cost by kind (losing to a
9//! stretch is nearly free, losing to prey is expensive) and cost NOTHING
10//! below the newcomer-protection rating — the ladder cushions its floor
11//! without capping the top (whales keep dominating by design).
12
13use configs::fighting::ArenaSettings;
14
15/// Matchup inputs for the rating math. `player_win_streak` is the streak
16/// BEFORE the fight being scored (so the Nth consecutive win pays
17/// `base + min((N-1)·step, max)`).
18pub struct RatingChangeCtx<'a> {
19    pub player_power: i64,
20    pub player_rating: i64,
21    pub player_win_streak: i64,
22    pub opponent_power: i64,
23    pub settings: &'a ArenaSettings,
24}
25
26/// Opponent kind by honest power ratio (see module docs).
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum OpponentKind {
29    Prey,
30    Peer,
31    Stretch,
32}
33
34pub fn opponent_kind(
35    player_power: i64,
36    opponent_power: i64,
37    settings: &ArenaSettings,
38) -> OpponentKind {
39    if player_power <= 0 {
40        return OpponentKind::Peer;
41    }
42    let ratio = opponent_power as f64 / player_power as f64;
43    if ratio < settings.rating_prey_power_ratio {
44        OpponentKind::Prey
45    } else if ratio > settings.rating_stretch_power_ratio {
46        OpponentKind::Stretch
47    } else {
48        OpponentKind::Peer
49    }
50}
51
52/// Rating gained on an arena win: kind base + capped win-streak bonus.
53pub fn win_rating_increase(ctx: &RatingChangeCtx) -> i64 {
54    let s = ctx.settings;
55    let base = match opponent_kind(ctx.player_power, ctx.opponent_power, s) {
56        OpponentKind::Prey => s.win_points_prey,
57        OpponentKind::Peer => s.win_points_peer,
58        OpponentKind::Stretch => s.win_points_stretch,
59    };
60    let streak_bonus = (ctx.player_win_streak.max(0) * s.win_streak_bonus_step)
61        .min(s.win_streak_bonus_max)
62        .max(0);
63    base + streak_bonus
64}
65
66/// Rating change on an arena loss: kind-based cost (losing to a stronger
67/// opponent costs less), or 0 under the newcomer-protection rating (the
68/// ladder's no-point-loss floor).
69pub fn lose_rating_decrease(ctx: &RatingChangeCtx) -> i64 {
70    let s = ctx.settings;
71    if ctx.player_rating < s.lose_protection_rating {
72        return 0;
73    }
74    let cost = match opponent_kind(ctx.player_power, ctx.opponent_power, s) {
75        OpponentKind::Prey => s.lose_points_prey,
76        OpponentKind::Peer => s.lose_points_peer,
77        OpponentKind::Stretch => s.lose_points_stretch,
78    };
79    -cost.max(0)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn settings() -> ArenaSettings {
87        // Only the rating fields matter here; build via serde defaults from a
88        // minimal JSON so the test doesn't hand-construct unrelated fields.
89        serde_json::from_value(serde_json::json!({
90            "base_rating": 1000,
91            "matches_history_size": 10,
92            "leaderboard_size": 100,
93            "arena_tickets_currency_id": "0199958f-61b0-72f3-97ba-16dff38a93d0",
94            "matchmaking_character_rating_delta": 100,
95            "pvp_cooldown_secs": 60,
96            "win_reward_bundle_id": "019f2358-e971-7d1f-827d-f722bea66b8c",
97            "lose_reward_bundle_id": "019f2358-e971-7d1f-827d-f722bea66b8c",
98            "arena_ticket_buy_currency_id": "0194d64e-2162-76d3-8449-3e850f6e39e9",
99            "arena_ticket_price": {"currency_id": "0194d64e-2162-76d3-8449-3e850f6e39e9", "amount": 20},
100            "arena_matches_ttl_days": 30,
101            "rematch_max_rating_increase": 10,
102            "rematch_max_rating_decrease": 10
103        }))
104        .expect("arena settings from defaults")
105    }
106
107    fn ctx(
108        s: &ArenaSettings,
109        power: i64,
110        opp: i64,
111        rating: i64,
112        streak: i64,
113    ) -> RatingChangeCtx<'_> {
114        RatingChangeCtx {
115            player_power: power,
116            player_rating: rating,
117            player_win_streak: streak,
118            opponent_power: opp,
119            settings: s,
120        }
121    }
122
123    #[test]
124    fn asymmetric_points_by_power_kind() {
125        let s = settings();
126        // prey (0.7×) pays the small base, peer (1.0×) the middle, stretch (1.3×) the big one.
127        assert_eq!(
128            win_rating_increase(&ctx(&s, 1000, 700, 1500, 0)),
129            s.win_points_prey
130        );
131        assert_eq!(
132            win_rating_increase(&ctx(&s, 1000, 1000, 1500, 0)),
133            s.win_points_peer
134        );
135        assert_eq!(
136            win_rating_increase(&ctx(&s, 1000, 1300, 1500, 0)),
137            s.win_points_stretch
138        );
139    }
140
141    #[test]
142    fn win_streak_bonus_is_capped() {
143        let s = settings();
144        let base = s.win_points_peer;
145        assert_eq!(win_rating_increase(&ctx(&s, 1000, 1000, 1500, 1)), base + 1);
146        assert_eq!(
147            win_rating_increase(&ctx(&s, 1000, 1000, 1500, 50)),
148            base + s.win_streak_bonus_max
149        );
150    }
151
152    #[test]
153    fn loss_is_free_under_protection_rating() {
154        let s = settings();
155        assert_eq!(
156            lose_rating_decrease(&ctx(&s, 1000, 1000, s.lose_protection_rating - 1, 0)),
157            0
158        );
159        assert_eq!(
160            lose_rating_decrease(&ctx(&s, 1000, 1000, s.lose_protection_rating, 0)),
161            -s.lose_points_peer
162        );
163    }
164
165    #[test]
166    fn asymmetric_loss_by_power_kind() {
167        let s = settings();
168        // Losing to prey (0.7×) costs the most, to a peer the middle,
169        // to a stretch (1.3×) the least.
170        assert_eq!(
171            lose_rating_decrease(&ctx(&s, 1000, 700, 1500, 0)),
172            -s.lose_points_prey
173        );
174        assert_eq!(
175            lose_rating_decrease(&ctx(&s, 1000, 1000, 1500, 0)),
176            -s.lose_points_peer
177        );
178        assert_eq!(
179            lose_rating_decrease(&ctx(&s, 1000, 1300, 1500, 0)),
180            -s.lose_points_stretch
181        );
182        assert!(s.lose_points_prey > s.lose_points_peer);
183        assert!(s.lose_points_peer > s.lose_points_stretch);
184    }
185
186    #[test]
187    fn zero_power_player_defaults_to_peer() {
188        let s = settings();
189        assert_eq!(opponent_kind(0, 500, &s), OpponentKind::Peer);
190    }
191}