Skip to main content

shuyi_core/bots/
balance.rs

1//! An autobalance search: the game's [`Knob`]s change a stage a little at
2//! a time (one more enemy, a weaker trap, a later ambush), the
3//! [`playtest`] runner replays it with the bots, and the search stops when
4//! every rate check is inside its band. [`autobalance`] does it all and
5//! says what it changed, in the knobs' own words. [`autobalance_by`] is
6//! the same search with the caller playing each setting (on threads of
7//! its own) and saying when to stop (a time limit).
8//!
9//! **The bots never change.** Only the stage does: the search takes the
10//! entrants and the checks as they are and has no way to reach their
11//! settings, since bots tuned until they pass would prove nothing.
12//!
13//! The search is a pattern search over the knobs' integer steps, scored
14//! by [`distance`]. Every run plays the same seeds, so settings are
15//! compared on the same luck, and the same inputs always give the same
16//! changes. What it finds is the smallest change set *this search* found,
17//! not one proved the smallest.
18
19use alloc::collections::BTreeMap;
20use alloc::string::{String, ToString};
21use alloc::vec;
22use alloc::vec::Vec;
23use core::fmt;
24use core::ops::RangeInclusive;
25
26use serde::{Deserialize, Serialize};
27
28use super::Sides;
29use super::playtest::{self, Check, Entrant, Found, PlaytestError, PlaytestReport, Tries};
30
31/// One way the game lets the search change a stage definition `S`: a
32/// bounded integer step, 0 being the stage as authored.
33pub trait Knob<S> {
34    /// Its name, which the change set uses.
35    fn name(&self) -> &str;
36
37    /// The steps it may take. It holds 0, the stage as authored.
38    fn range(&self) -> RangeInclusive<i32>;
39
40    /// The first step size the search tries: a coarse knob (every enemy's
41    /// level) starts larger. The search halves it down to 1.
42    fn stride(&self) -> u32 {
43        1
44    }
45
46    /// `stage` with this knob moved `step`, or `None` when it can't apply
47    /// there (no free tile for one more piece). Knobs apply in the order
48    /// given, each to the result of the one before; a knob at 0 isn't
49    /// applied.
50    fn apply(&self, stage: &S, step: i32) -> Option<S>;
51
52    /// The change of `step` in plain words, such as `"the foe trips 10%
53    /// less often"`.
54    fn describe(&self, step: i32) -> String;
55}
56
57/// How hard the search works.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(deny_unknown_fields)]
61#[non_exhaustive]
62pub struct BalanceSettings {
63    /// The full run, which decides "in band": the stage as authored and
64    /// the stage found are both played this way.
65    pub tries: Tries,
66    /// The try count of the search's own runs, from the same first seed.
67    pub quick: u32,
68    /// The most runs of the playtest runner the search makes, the first
69    /// run of the stage as authored among them.
70    pub evaluations: u32,
71}
72
73impl BalanceSettings {
74    /// The full run `tries`, quick runs of `quick` tries, and at most
75    /// `evaluations` runs.
76    #[must_use]
77    pub const fn new(tries: Tries, quick: u32, evaluations: u32) -> Self {
78        Self {
79            tries,
80            quick,
81            evaluations,
82        }
83    }
84}
85
86/// How far `report`'s rates lie outside their bands: over every
87/// [`Check::Rate`],
88/// `max(0, min * count - successes * 100, successes * 100 - max * count)`,
89/// summed. 0 exactly when every rate is inside its band.
90/// A [`Check::Median`] doesn't count: it is about the bots, not the stage.
91///
92/// Distances of runs with different try counts don't compare.
93pub fn distance(report: &PlaytestReport) -> u64 {
94    report
95        .checks
96        .iter()
97        .map(|judged| match (&judged.check, &judged.found) {
98            (
99                Check::Rate { band, .. },
100                Found::Rate {
101                    successes, count, ..
102                },
103            ) => {
104                let share = u64::from(*successes) * 100;
105                let min = u64::from(band.min) * u64::from(*count);
106                let max = u64::from(band.max) * u64::from(*count);
107                min.saturating_sub(share).max(share.saturating_sub(max))
108            }
109            _ => 0,
110        })
111        .fold(0, u64::saturating_add)
112}
113
114/// Why the search stopped.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub enum Stop {
118    /// The stage as authored was in every band: nothing changed.
119    AlreadyInBands,
120    /// The change set puts every rate in its band. If the budget ran out,
121    /// or the caller said to stop, while it was being pruned, it may be
122    /// larger than it had to be.
123    InBands,
124    /// No knob's step brought the stage any nearer its bands: the change
125    /// set is the nearest the search came.
126    Stuck,
127    /// The budget of evaluations ran out first: the change set is the
128    /// best setting seen.
129    Budget,
130    /// The caller said to stop ([`autobalance_by`]'s `should_stop`, such
131    /// as the tools' time limit): the change set is the best setting seen.
132    Stopped,
133}
134
135impl fmt::Display for Stop {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.write_str(match self {
138            Stop::AlreadyInBands => "already in every band: nothing to change",
139            Stop::InBands => "in every band",
140            Stop::Stuck => "stuck: no knob brings it nearer its bands",
141            Stop::Budget => "out of evaluations: the best setting seen",
142            Stop::Stopped => "stopped: the best setting seen",
143        })
144    }
145}
146
147/// One knob the search moved.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
150#[serde(deny_unknown_fields)]
151#[non_exhaustive]
152pub struct Change {
153    /// The knob's name.
154    pub knob: String,
155    /// Its step.
156    pub step: i32,
157    /// The knob's words for the step.
158    pub what: String,
159}
160
161impl Change {
162    /// Knob `knob` moved by `step`, which it words as `what`.
163    pub fn new(knob: impl Into<String>, step: i32, what: impl Into<String>) -> Self {
164        Self {
165            knob: knob.into(),
166            step,
167            what: what.into(),
168        }
169    }
170}
171
172/// What the search found. Its `Display` says why it stopped, the changes
173/// one per line, then the reports before and after.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Balanced<S> {
176    /// The changed stage (the stage as authored when nothing changed).
177    pub stage: S,
178    /// Every knob not at 0, in knob order.
179    pub changes: Vec<Change>,
180    /// The stage as authored, at the full try count.
181    pub before: PlaytestReport,
182    /// The changed stage, at the full try count.
183    pub after: PlaytestReport,
184    /// Why the search stopped.
185    pub stop: Stop,
186    /// The runs of the playtest runner it made.
187    pub evaluations: u32,
188}
189
190impl<S> fmt::Display for Balanced<S> {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        writeln!(f, "{} ยท {} evaluations", self.stop, self.evaluations)?;
193        if self.changes.is_empty() {
194            writeln!(f, "no change")?;
195        }
196        for change in &self.changes {
197            writeln!(f, "{} {:+}: {}", change.knob, change.step, change.what)?;
198        }
199        write!(f, "\nbefore: {}\nafter: {}", self.before, self.after)
200    }
201}
202
203/// What one evaluation of a setting gave.
204enum Eval {
205    /// Its distance.
206    Distance(u64),
207    /// A knob can't apply there.
208    Inapplicable,
209    /// The search may run nothing more: [`Stop::Budget`] or
210    /// [`Stop::Stopped`].
211    Ended(Stop),
212}
213
214/// What a round over the knobs found.
215enum Move {
216    /// A step that lowered the distance to this, taken.
217    Better(u64),
218    /// No step lowers it.
219    None,
220    /// The search may run nothing more: [`Stop::Budget`] or
221    /// [`Stop::Stopped`].
222    Ended(Stop),
223}
224
225/// A search in progress: what it was given, and every run it made.
226struct Search<'a, S, F, G> {
227    stage: &'a S,
228    knobs: &'a [&'a dyn Knob<S>],
229    settings: &'a BalanceSettings,
230    /// Plays a setting: [`autobalance_by`]'s `evaluate`.
231    evaluate: F,
232    /// Whether to stop before the next run: [`autobalance_by`]'s
233    /// `should_stop`.
234    should_stop: G,
235    /// Whether `should_stop` has said so.
236    stopped: bool,
237    /// The distance of every setting run, by its steps and try count.
238    distances: BTreeMap<(Vec<i32>, u32), u64>,
239    /// The report of every setting run at the full try count.
240    reports: BTreeMap<Vec<i32>, PlaytestReport>,
241    /// The runs made.
242    used: u32,
243}
244
245impl<'a, S: Clone, E, F, G> Search<'a, S, F, G>
246where
247    F: FnMut(&S, &Tries) -> Result<PlaytestReport, E>,
248    G: FnMut() -> bool,
249{
250    /// A search of `stage` by `knobs` that has made no run yet.
251    fn new(
252        stage: &'a S,
253        knobs: &'a [&'a dyn Knob<S>],
254        settings: &'a BalanceSettings,
255        evaluate: F,
256        should_stop: G,
257    ) -> Self {
258        Self {
259            stage,
260            knobs,
261            settings,
262            evaluate,
263            should_stop,
264            stopped: false,
265            distances: BTreeMap::new(),
266            reports: BTreeMap::new(),
267            used: 0,
268        }
269    }
270
271    /// The full try count.
272    fn full(&self) -> u32 {
273        self.settings.tries.count
274    }
275
276    /// The stage with every knob moved as `point` says, or `None` if one
277    /// can't apply.
278    fn build(&self, point: &[i32]) -> Option<S> {
279        let mut stage = self.stage.clone();
280        for (knob, &step) in self.knobs.iter().zip(point) {
281            if step != 0 {
282                stage = knob.apply(&stage, step)?;
283            }
284        }
285        Some(stage)
286    }
287
288    /// Records a run of `point` at `count` tries.
289    fn record(&mut self, point: &[i32], count: u32, report: PlaytestReport) -> u64 {
290        let d = distance(&report);
291        self.distances.insert((point.to_vec(), count), d);
292        if count == self.full() {
293            self.reports.insert(point.to_vec(), report);
294        }
295        d
296    }
297
298    /// Plays `stage`, the setting `point`, at `count` tries, and records
299    /// it.
300    fn run(&mut self, point: &[i32], count: u32, stage: &S) -> Result<u64, E> {
301        let tries = Tries {
302            count,
303            ..self.settings.tries
304        };
305        let report = (self.evaluate)(stage, &tries)?;
306        self.used += 1;
307        Ok(self.record(point, count, report))
308    }
309
310    /// The distance of `point` at `count` tries, run unless it was run
311    /// before. A run at fewer tries than the full count keeps one
312    /// evaluation back, for the full run of the setting it ends on. Once
313    /// `should_stop` says so, it runs nothing more.
314    fn eval(&mut self, point: &[i32], count: u32) -> Result<Eval, E> {
315        if let Some(&d) = self.distances.get(&(point.to_vec(), count)) {
316            return Ok(Eval::Distance(d));
317        }
318        let Some(stage) = self.build(point) else {
319            return Ok(Eval::Inapplicable);
320        };
321        let kept = u32::from(count != self.full());
322        if self.used.saturating_add(kept) >= self.settings.evaluations {
323            return Ok(Eval::Ended(Stop::Budget));
324        }
325        self.stopped = self.stopped || (self.should_stop)();
326        if self.stopped {
327            return Ok(Eval::Ended(Stop::Stopped));
328        }
329        Ok(Eval::Distance(self.run(point, count, &stage)?))
330    }
331
332    /// Whether `point` is in every band at the full try count.
333    fn in_bands(&mut self, point: &[i32]) -> Result<bool, E> {
334        let full = self.full();
335        Ok(matches!(self.eval(point, full)?, Eval::Distance(0)))
336    }
337
338    /// One round over the knobs from `point`, which is `here` from its
339    /// bands: in knob order, `+stride` then `-stride` (clamped to the
340    /// knob's range), taking the first step that lowers the distance.
341    fn round(
342        &mut self,
343        point: &mut Vec<i32>,
344        strides: &[i32],
345        count: u32,
346        here: u64,
347    ) -> Result<Move, E> {
348        let knobs = self.knobs;
349        for ((k, knob), &stride) in knobs.iter().enumerate().zip(strides) {
350            let range = knob.range();
351            if range.is_empty() {
352                continue;
353            }
354            for stride in [stride, -stride] {
355                let to = point[k]
356                    .saturating_add(stride)
357                    .clamp(*range.start(), *range.end());
358                if to == point[k] {
359                    continue;
360                }
361                let mut next = point.clone();
362                next[k] = to;
363                match self.eval(&next, count)? {
364                    Eval::Distance(d) if d < here => {
365                        *point = next;
366                        return Ok(Move::Better(d));
367                    }
368                    Eval::Ended(stop) => return Ok(Move::Ended(stop)),
369                    Eval::Distance(_) | Eval::Inapplicable => {}
370                }
371            }
372        }
373        Ok(Move::None)
374    }
375
376    /// The pattern search from every knob at 0, at the quick try count
377    /// until a setting is in every band there, then at the full count:
378    /// the setting it ends on and why. A setting in every band at the
379    /// full count is pruned.
380    ///
381    /// Each pass of `level` halves the strides once more (a shift by
382    /// `level`). A move lowers the distance, so a pass makes at most one
383    /// move per evaluation; the passes and the moves are bounded loops,
384    /// so the search ends whatever its comparisons say.
385    fn pattern(&mut self) -> Result<(Vec<i32>, Stop), E> {
386        let full = self.full();
387        let mut count = self.settings.quick;
388        let mut point = vec![0; self.knobs.len()];
389        let bases: Vec<i32> = self
390            .knobs
391            .iter()
392            .map(|k| i32::try_from(k.stride()).unwrap_or(i32::MAX))
393            .collect();
394        let mut here = match self.eval(&point, count)? {
395            Eval::Distance(d) => d,
396            Eval::Ended(stop) => return Ok((point, stop)),
397            Eval::Inapplicable => return Ok((point, Stop::Budget)),
398        };
399        for level in 0..u32::BITS {
400            let strides: Vec<i32> = bases.iter().map(|&b| (b >> level).max(1)).collect();
401            for _ in 0..=self.settings.evaluations {
402                if here == 0 {
403                    match self.eval(&point, full)? {
404                        Eval::Distance(0) => return Ok((self.prune(point)?, Stop::InBands)),
405                        Eval::Distance(d) => {
406                            count = full;
407                            here = d;
408                        }
409                        Eval::Ended(stop) => return Ok((point, stop)),
410                        Eval::Inapplicable => return Ok((point, Stop::Budget)),
411                    }
412                }
413                match self.round(&mut point, &strides, count, here)? {
414                    Move::Better(d) => here = d,
415                    Move::None => break,
416                    Move::Ended(stop) => return Ok((point, stop)),
417                }
418            }
419            if strides.iter().all(|&s| s == 1) {
420                break;
421            }
422        }
423        Ok((point, Stop::Stuck))
424    }
425
426    /// `point`, in every band at the full count, with each changed knob
427    /// in order tried at 0, then nearer 0 one step at a time, keeping
428    /// each change that stays in every band.
429    fn prune(&mut self, mut point: Vec<i32>) -> Result<Vec<i32>, E> {
430        for k in 0..point.len() {
431            let step = point[k];
432            let mut trial = point.clone();
433            trial[k] = 0;
434            if self.in_bands(&trial)? {
435                point = trial;
436                continue;
437            }
438            for nearer in (1..step.unsigned_abs()).rev() {
439                trial[k] = step.signum() * i32::try_from(nearer).unwrap_or(i32::MAX);
440                if !self.in_bands(&trial)? {
441                    break;
442                }
443                point.clone_from(&trial);
444            }
445        }
446        Ok(point)
447    }
448
449    /// The distance of `point` at the full count, or `None` if a knob
450    /// can't apply there: the run kept back for the setting the search
451    /// ends on, which a stop doesn't stop. A setting the search ends on
452    /// was either run in full already or run at fewer tries, which kept
453    /// this evaluation back, so it never goes over the budget.
454    fn last(&mut self, point: &[i32]) -> Result<Option<u64>, E> {
455        let full = self.full();
456        if let Some(&d) = self.distances.get(&(point.to_vec(), full)) {
457            return Ok(Some(d));
458        }
459        match self.build(point) {
460            Some(stage) => Ok(Some(self.run(point, full, &stage)?)),
461            None => Ok(None),
462        }
463    }
464
465    /// What the search found, ending on `point`. A setting no nearer its
466    /// bands at the full count than the stage as authored is dropped.
467    fn finish(
468        mut self,
469        point: &[i32],
470        before: PlaytestReport,
471        stop: Stop,
472    ) -> Result<Balanced<S>, E> {
473        let origin = vec![0; self.knobs.len()];
474        let found = match self.last(point)? {
475            Some(d) if d < distance(&before) => self
476                .build(point)
477                .zip(self.reports.get(point).cloned())
478                .map(|(stage, after)| (point.to_vec(), stage, after)),
479            _ => None,
480        };
481        let (point, stage, after) =
482            found.unwrap_or_else(|| (origin, self.stage.clone(), before.clone()));
483        let changes = self
484            .knobs
485            .iter()
486            .zip(&point)
487            .filter(|&(_, &step)| step != 0)
488            .map(|(knob, &step)| Change {
489                knob: knob.name().to_string(),
490                step,
491                what: knob.describe(step),
492            })
493            .collect();
494        Ok(Balanced {
495            stage,
496            changes,
497            before,
498            after,
499            stop,
500            evaluations: self.used,
501        })
502    }
503}
504
505/// Changes `stage` with `knobs` until every rate check of `checks` puts
506/// every one of `entrants` inside its band, as `settings` says. The
507/// entrants and the checks are never changed: only the stage is.
508///
509/// All deterministic:
510/// 1. **Before:** the stage as authored plays at `settings.tries`. In
511///    every band already, it stops ([`Stop::AlreadyInBands`]).
512/// 2. **Pattern search** at `settings.quick` tries, from every knob at 0,
513///    each knob's stride its [`Knob::stride`]: in knob order, `+stride`
514///    then `-stride` (clamped to the range, skipping a step that doesn't
515///    move or that a knob can't apply). The first step that lowers the
516///    [`distance`] is taken and the knob order starts again. When none
517///    does, every stride above 1 is halved; when every stride is 1, it
518///    stops ([`Stop::Stuck`]).
519/// 3. **Confirm:** a setting in every band at `quick` plays at `tries`.
520///    In every band there too, it is pruned; if not, the pattern search
521///    carries on at `tries` from there.
522/// 4. **Prune:** each changed knob in order is tried at 0, then nearer 0
523///    one step at a time, keeping each change that stays in every band
524///    at `tries` ([`Stop::InBands`]). What is left is the smallest change
525///    set this search found, not one proved the smallest.
526/// 5. **Budget:** every run of the playtest runner is one evaluation, and
527///    a setting is never run twice at one try count. When the
528///    `evaluations` run out, it stops ([`Stop::Budget`]) with the best
529///    setting seen, kept back one evaluation to play it at `tries`.
530///
531/// A setting that ends no nearer its bands at `tries` than the stage as
532/// authored is dropped: the result is then the stage as authored.
533///
534/// A check naming an entrant not in the run fails before any try is
535/// played; a refused command stops the search.
536///
537/// It is [`autobalance_by`], each setting played by [`playtest::run`],
538/// never told to stop.
539pub fn autobalance<S: Clone, R: Sides>(
540    stage: &S,
541    knobs: &[&dyn Knob<S>],
542    entrants: &[&dyn Entrant<S, R>],
543    checks: &[Check],
544    settings: &BalanceSettings,
545) -> Result<Balanced<S>, PlaytestError<R>>
546where
547    R::Command: Clone,
548    R::Outcome: fmt::Debug,
549{
550    autobalance_by(
551        stage,
552        knobs,
553        settings,
554        |stage, tries| playtest::run(stage, entrants, tries, checks),
555        || false,
556    )
557}
558
559/// [`autobalance`]'s search, each setting played by `evaluate`: the
560/// report of the stage it is given, played as the [`Tries`] it is given
561/// say, its checks judged (a caller with threads plays the seeds on them
562/// and [`playtest::judge`]s them once). Its first error stops the search.
563///
564/// Before each run of the search, `should_stop` is asked whether to stop
565/// (a caller's clock: the core reads none). Once it says so, the search
566/// stops ([`Stop::Stopped`]) with the best setting seen, kept back one
567/// evaluation as for [`Stop::Budget`]: the stage as authored and the
568/// setting it ends on are always played at `settings.tries`, so a search
569/// told to stop at once makes the one run of the stage as authored.
570///
571/// Given the same reports from `evaluate` and the same answers from
572/// `should_stop`, it makes the same runs in the same order and finds the
573/// same changes.
574pub fn autobalance_by<S: Clone, E>(
575    stage: &S,
576    knobs: &[&dyn Knob<S>],
577    settings: &BalanceSettings,
578    evaluate: impl FnMut(&S, &Tries) -> Result<PlaytestReport, E>,
579    should_stop: impl FnMut() -> bool,
580) -> Result<Balanced<S>, E> {
581    let mut search = Search::new(stage, knobs, settings, evaluate, should_stop);
582    let origin = vec![0; knobs.len()];
583    let before = (search.evaluate)(stage, &settings.tries)?;
584    search.used = 1;
585    let (point, stop) = if search.record(&origin, settings.tries.count, before.clone()) == 0 {
586        (origin, Stop::AlreadyInBands)
587    } else {
588        search.pattern()?
589    };
590    search.finish(&point, before, stop)
591}
592
593#[cfg(test)]
594mod tests;