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;