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}