essences/
kill_faucets.rs

1//! BAL-038 — persisted daily counter and band snapshot per kill faucet family.
2//!
3//! Hidden state: the player never sees the counter or the remaining budget. It
4//! stays available to telemetry, admin and debug.
5
6use crate::prelude::*;
7use chrono::{DateTime, Utc};
8
9/// A reward family with its own counter, band snapshot and cap.
10///
11/// Equipment Trigger and Effect stones deliberately share ONE counter: the kind
12/// is decided by an internal 50/50 roll after the drop is granted, so counting
13/// them apart would let a build farm twice the authored budget.
14#[derive(
15    Clone,
16    Copy,
17    Debug,
18    Default,
19    Eq,
20    PartialEq,
21    Hash,
22    Serialize,
23    Deserialize,
24    JsonSchema,
25    Tsify,
26    strum::Display,
27    strum::EnumString,
28    strum::EnumIter,
29)]
30pub enum KillFaucetFamily {
31    /// Five sequential tickets per eligible ordinary campaign-mob death.
32    #[default]
33    DirectCookies,
34    /// `D` is the reached chapter band's `R_value`, not a constant.
35    CoreEssence,
36    LawCopies,
37    /// Trigger + Effect combined, on purpose.
38    EquipmentStones,
39    /// Campaign mob + boss legs only. The dungeon-clear leg is a separate
40    /// faucet and does not consume this counter.
41    ArtifactStones,
42    /// Skill Crystals paid only by campaign-boss deaths. This family uses an
43    /// exact authored daily cap rather than the ordinary `D..2D` decay shape.
44    SkillChapters,
45    /// Gems paid only by campaign-boss deaths. The exact cap prevents a
46    /// repeatable boss farm from becoming an uncapped premium-currency route.
47    BossGems,
48}
49
50/// The hard cap that belongs to a snapshotted `D`: `ceil(2 × D)`.
51///
52/// A packet is clipped to the remaining budget rather than allowed to overshoot,
53/// so a large packet can never step past the cap.
54pub fn hard_cap(d: f64) -> i64 {
55    (2.0 * d.max(0.0)).ceil() as i64
56}
57
58/// Chance multiplier at `granted` units against a snapshotted `d`, for every
59/// family EXCEPT direct Cookies (which keep their own hyperbola).
60///
61/// Flat `1.0` while `granted <= d`, then continuous linear decay reaching `0` at
62/// `2d`. Continuity at `d` matters: a step there would read to a player as the
63/// faucet breaking rather than tapering.
64pub fn decay_multiplier(granted: f64, d: f64) -> f64 {
65    if d <= 0.0 {
66        return 0.0;
67    }
68    ((2.0 * d - granted) / d).clamp(0.0, 1.0)
69}
70
71/// One family's day.
72///
73/// The band (`d`, cap) is SNAPSHOTTED on the first eligible roll after the daily
74/// reset and then frozen until the next one. Chapter, mode, build or power
75/// progress during the day never reprices it and never restores a chance that
76/// has already decayed — otherwise pushing a few chapters mid-session would
77/// refresh a spent faucet, which is exactly the unattended-farm loop the cap
78/// exists to bound.
79///
80/// Stored in integers so `CharacterState` keeps its `Eq`: granted units are
81/// whole by construction (one Cookie, one stone, `packet` Essence), and `D` is
82/// held in micro-units the way BAL-021 holds Plinko tranches.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Tsify)]
84pub struct KillFaucetDailyState {
85    /// Units actually GRANTED today, not roll attempts. A proc clipped to the
86    /// remaining budget counts only what it paid out.
87    pub granted: i64,
88    /// Snapshotted `D` in micro-units (`D × 1_000_000`).
89    pub d_micro: i64,
90    /// Snapshotted `ceil(2 × D)`.
91    pub cap: i64,
92    pub last_reset_at: DateTime<Utc>,
93}
94
95/// Fixed-point scale for [`KillFaucetDailyState::d_micro`].
96pub const D_MICRO: i64 = 1_000_000;
97
98impl KillFaucetDailyState {
99    /// Open a fresh day for a family whose band resolves to `d`.
100    pub fn snapshot(d: f64, now: DateTime<Utc>) -> Self {
101        Self {
102            granted: 0,
103            d_micro: (d.max(0.0) * D_MICRO as f64).round() as i64,
104            cap: hard_cap(d),
105            last_reset_at: now,
106        }
107    }
108
109    /// The snapshotted `D` back in natural units.
110    pub fn d(&self) -> f64 {
111        self.d_micro as f64 / D_MICRO as f64
112    }
113
114    /// Budget still payable today. Zero means the family is done until reset.
115    pub fn remaining(&self) -> i64 {
116        (self.cap - self.granted).max(0)
117    }
118
119    /// Chance multiplier for a non-Cookie family at the current `granted`.
120    pub fn decay(&self) -> f64 {
121        decay_multiplier(self.granted as f64, self.d())
122    }
123
124    /// Clip `amount` to the remaining budget and bank it. Returns what was
125    /// actually paid, which is what the caller must grant — never the request.
126    pub fn take(&mut self, amount: i64) -> i64 {
127        let paid = amount.max(0).min(self.remaining());
128        self.granted += paid;
129        paid
130    }
131}
132
133pub type KillFaucetDailyMap = std::collections::HashMap<KillFaucetFamily, KillFaucetDailyState>;
134
135/// The family's state for TODAY, snapshotting the band if this is its first
136/// eligible roll since the daily reset.
137///
138/// The reset is lazy on purpose: it is a pure function of the stored
139/// `last_reset_at` against `now`, evaluated when a roll actually happens. That
140/// keeps it inside the deterministic handler, needs no separate reset event, and
141/// lines up with the authoritative quests/ads daily boundary because both
142/// compare calendar dates.
143///
144/// An already-open day is returned UNCHANGED even when `d_today` differs — that
145/// is the anti-repricing invariant. Progressing chapters mid-session must not
146/// raise `D`, refresh the cap, or restore a chance that has already decayed.
147pub fn band_for_today(
148    map: &mut KillFaucetDailyMap,
149    family: KillFaucetFamily,
150    d_today: f64,
151    now: DateTime<Utc>,
152) -> &mut KillFaucetDailyState {
153    let stale = map
154        .get(&family)
155        .is_none_or(|s| s.last_reset_at.date_naive() < now.date_naive());
156    if stale {
157        map.insert(family, KillFaucetDailyState::snapshot(d_today, now));
158    }
159    map.get_mut(&family).expect("just inserted")
160}
161
162/// The family's state for today with an exact hard cap.
163///
164/// Boss-only currency budgets are authored as a final daily amount, not as the
165/// knee of a diminishing `D..2D` curve. The value is still snapshotted on the
166/// first eligible boss death and never repriced within the day.
167pub fn exact_cap_for_today(
168    map: &mut KillFaucetDailyMap,
169    family: KillFaucetFamily,
170    cap_today: i64,
171    now: DateTime<Utc>,
172) -> &mut KillFaucetDailyState {
173    let stale = map
174        .get(&family)
175        .is_none_or(|s| s.last_reset_at.date_naive() < now.date_naive());
176    if stale {
177        let cap = cap_today.max(0);
178        map.insert(
179            family,
180            KillFaucetDailyState {
181                granted: 0,
182                d_micro: cap.saturating_mul(D_MICRO),
183                cap,
184                last_reset_at: now,
185            },
186        );
187    }
188    map.get_mut(&family).expect("just inserted")
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn t0() -> DateTime<Utc> {
196        DateTime::<Utc>::from_timestamp(0, 0).unwrap()
197    }
198
199    #[test]
200    fn a_packet_is_clipped_to_the_remaining_budget() {
201        // laws: D = 5.659 -> cap 12. A packet may never step past the cap.
202        let mut s = KillFaucetDailyState::snapshot(5.659, t0());
203        assert_eq!(s.cap, 12);
204        assert_eq!(s.take(11), 11);
205        assert_eq!(s.take(5), 1, "clipped to what is left, not the request");
206        assert_eq!(s.remaining(), 0);
207        assert_eq!(s.take(1), 0, "capped means capped");
208    }
209
210    #[test]
211    fn decay_starts_at_the_knee_not_at_zero() {
212        let mut s = KillFaucetDailyState::snapshot(42.14, t0());
213        assert_eq!(s.decay(), 1.0);
214        s.take(42);
215        assert!(s.decay() > 0.99, "flat through D, continuous at the knee");
216        s.take(43);
217        assert_eq!(s.decay(), 0.0, "zero at 2D");
218    }
219}
220
221#[cfg(test)]
222mod band_tests {
223    use super::*;
224
225    fn day(n: i64) -> DateTime<Utc> {
226        DateTime::<Utc>::from_timestamp(n * 86_400, 0).unwrap()
227    }
228
229    #[test]
230    fn an_open_day_is_never_repriced_by_chapter_progress() {
231        let mut map = KillFaucetDailyMap::new();
232        // First roll of the day in the ch21 band.
233        let s = band_for_today(&mut map, KillFaucetFamily::CoreEssence, 1_000.0, day(1));
234        assert_eq!(s.cap, 2_000);
235        s.take(1_500);
236
237        // The player pushes into the ch52 band the same day. D must NOT rise,
238        // the cap must not grow, and the spent budget must not come back.
239        let s = band_for_today(&mut map, KillFaucetFamily::CoreEssence, 1_500.0, day(1));
240        assert_eq!(
241            s.cap, 2_000,
242            "intraday band change must not reprice the day"
243        );
244        assert_eq!(s.granted, 1_500);
245        assert_eq!(s.remaining(), 500);
246    }
247
248    #[test]
249    fn the_next_day_snapshots_the_new_band() {
250        let mut map = KillFaucetDailyMap::new();
251        band_for_today(&mut map, KillFaucetFamily::CoreEssence, 1_000.0, day(1)).take(2_000);
252
253        let s = band_for_today(&mut map, KillFaucetFamily::CoreEssence, 1_500.0, day(2));
254        assert_eq!(s.cap, 3_000, "a fresh day takes the CURRENT band");
255        assert_eq!(s.granted, 0);
256    }
257
258    #[test]
259    fn an_exact_cap_never_uses_the_two_d_headroom() {
260        let mut map = KillFaucetDailyMap::new();
261        let s = exact_cap_for_today(&mut map, KillFaucetFamily::SkillChapters, 45, day(1));
262        assert_eq!(s.cap, 45);
263        assert_eq!(s.take(44), 44);
264        assert_eq!(s.take(2), 1);
265        assert_eq!(s.remaining(), 0);
266    }
267}