Skip to main content

shuyi_cli/contract/
autobalance.rs

1//! `autobalance`: the game's knobs change one of its stages until each of
2//! its named bots (its entrants) succeeds as often as its checks' bands
3//! want (`shuyi_core::autobalance_by`). The bots never change, only the
4//! stage. The game gives the plan ([`GameTools::autobalance_plan`]): its
5//! entrants and checks, as `playtest` has them, and the stage the search
6//! may change, with its knobs ([`Tunable`](crate::game::Tunable)). This
7//! runs the search, each setting's tries played on every core
8//! ([`crate::pool`]) and judged once in seed order, so what it finds
9//! never depends on the thread count. It
10//! stops at the budget of evaluations or the time limit with the best
11//! setting seen, says the changes in the knobs' words and, with `apply`,
12//! has the game write the changed stage. The answer passes when every
13//! rate is in its band.
14
15use std::fmt::Write as _;
16use std::time::{Duration, Instant};
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use shuyi_core::bots::balance::{Change, distance};
21use shuyi_core::bots::playtest::{PlayedTry, PlaytestReport, Tries};
22use shuyi_core::{BalanceSettings, Balanced, Stop};
23
24use super::codes::Code;
25use super::playtest::options_text;
26use super::playtest::{DEFAULT_TRIES, check_tries, checks_of, choose, played, seconds, threads};
27use super::{Answer, Op, codes};
28use crate::game::{GameArg, GameOption, GameTools, check_start, code_of, game_arg};
29
30/// The evaluations when the input doesn't say.
31pub const DEFAULT_BUDGET: u32 = 100;
32
33/// The most evaluations a search may make.
34pub const MAX_BUDGET: u32 = 10_000;
35
36/// What `autobalance` searches. A number left out is the default: the
37/// game's for the tries ([`GameTools::playtest_defaults`]), the engine's
38/// for the rest.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields, default)]
41// An input: built with `default()` and its fields assigned, it may grow
42// (ADR-0039).
43#[non_exhaustive]
44pub struct AutobalanceInput {
45    /// The stage, one of the game's; needed when it has several.
46    pub stage: Option<String>,
47    /// The entrants that play, by name or by a group's name; the game's
48    /// default ones if empty.
49    pub entrants: Vec<String>,
50    /// How many tries of each entrant decide whether a stage is in its
51    /// bands: the stage as authored and the one found play this many.
52    pub tries: Option<u32>,
53    /// The tries of each entrant in the search's own runs, from the same
54    /// first seed: a fifth of `tries` (at least 1) if left out.
55    pub quick: Option<u32>,
56    /// The most evaluations (each a run of every entrant's tries), the
57    /// stage as authored's among them: 100 if left out.
58    pub budget: Option<u32>,
59    /// Stop the search after this many seconds, with the best setting
60    /// seen (whose run at `tries` still follows); no limit if left out.
61    /// What it finds then depends on the machine.
62    pub time: Option<u64>,
63    /// The first try's seed; each next try takes the next.
64    pub seed: Option<u64>,
65    /// The turn a try stops at if it hasn't ended.
66    pub turns: Option<u32>,
67    /// The threads to play on; one a core if left out. What it finds never
68    /// depends on it.
69    pub threads: Option<u32>,
70    /// Have the game write the changed stage to its files. This writes
71    /// files of the game.
72    pub apply: bool,
73    /// The game's own options, in the order given.
74    pub start: Vec<GameArg>,
75}
76
77impl AutobalanceInput {
78    /// The tries it asks for, `defaults` filling in what it leaves out.
79    pub fn tries(&self, defaults: &Tries) -> Tries {
80        Tries::new(
81            self.tries.unwrap_or(defaults.count),
82            self.seed.unwrap_or(defaults.seed),
83            self.turns.unwrap_or(defaults.turns),
84        )
85    }
86
87    /// The search it asks for, `defaults` filling in the tries it leaves
88    /// out.
89    pub fn settings(&self, defaults: &Tries) -> BalanceSettings {
90        let tries = self.tries(defaults);
91        BalanceSettings::new(
92            tries,
93            self.quick.unwrap_or((tries.count / 5).max(1)),
94            self.budget.unwrap_or(DEFAULT_BUDGET),
95        )
96    }
97}
98
99/// What `autobalance` found.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102pub struct AutobalanceOutput {
103    /// The stage, if the game names its stages.
104    pub stage: Option<String>,
105    /// The plan's title.
106    pub title: String,
107    /// The search's numbers, the defaults filled in.
108    pub settings: BalanceSettings,
109    /// Whether every rate of the stage found is in its band.
110    pub in_bands: bool,
111    /// Why the search stopped.
112    pub stop: Stop,
113    /// The evaluations it made.
114    pub evaluations: u32,
115    /// Every knob it moved, in the knobs' order, with the knob's words.
116    pub changes: Vec<Change>,
117    /// The stage as authored, at the full tries.
118    pub before: PlaytestReport,
119    /// The stage found, at the full tries (the stage as authored when
120    /// nothing changed).
121    pub after: PlaytestReport,
122    /// With `apply`: the files the game wrote, none when nothing changed.
123    pub written: Option<Vec<String>>,
124    /// The search's wall time, in seconds: the one number that changes
125    /// with the machine and the thread count.
126    pub seconds: f64,
127}
128
129/// `autobalance`: `input`'s search of one of `game`'s stages, its tries
130/// played on threads and judged.
131pub fn autobalance(game: &dyn GameTools, input: &AutobalanceInput) -> Answer<AutobalanceOutput> {
132    let failed = |code: Code, error: String| Answer::failed(Op::Autobalance, code, error);
133    if let Err(e) = check_start(game.frame_options(), &input.start) {
134        return failed(codes::WRONG_ARGUMENTS, e);
135    }
136    let settings = input.settings(&game.playtest_defaults());
137    if let Err(e) = check_settings(&settings) {
138        return failed(codes::WRONG_ARGUMENTS, e);
139    }
140    let threads = match threads(input.threads) {
141        Ok(threads) => threads,
142        Err(e) => return failed(codes::WRONG_ARGUMENTS, e),
143    };
144    let stage = match one_stage(&game.playtest_stages(), input.stage.as_deref()) {
145        Ok(stage) => stage,
146        Err(e) => return failed(codes::WRONG_ARGUMENTS, e),
147    };
148    let given = AutobalanceInput {
149        tries: Some(settings.tries.count),
150        quick: Some(settings.quick),
151        budget: Some(settings.evaluations),
152        seed: Some(settings.tries.seed),
153        turns: Some(settings.tries.turns),
154        ..input.clone()
155    };
156    let plan = match game.autobalance_plan(stage.as_deref(), &given) {
157        Ok(plan) => plan,
158        Err(e) => return failed(code_of(&e), e),
159    };
160    let chosen = match choose(
161        &plan.entrants,
162        &plan.groups,
163        &plan.default_entrants,
164        &input.entrants,
165    ) {
166        Ok(chosen) => chosen,
167        Err((code, e)) => return failed(code, e),
168    };
169    let checks = match checks_of(&plan.checks, &plan.entrants, &chosen) {
170        Ok(checks) => checks,
171        Err(e) => return failed(codes::GAME_ERROR, e),
172    };
173    let started = Instant::now();
174    let limit = input.time.map(Duration::from_secs);
175    let mut should_stop = || limit.is_some_and(|limit| started.elapsed() >= limit);
176    let mut evaluate = |one: &(dyn Fn(usize, u64, u32) -> Result<PlayedTry, String> + Sync),
177                        tries: &Tries| {
178        played(&plan.entrants, &chosen, &checks, tries, threads, one).map(|p| p.report)
179    };
180    let searched = match plan
181        .stage
182        .search(&settings, &mut evaluate, &mut should_stop, input.apply)
183    {
184        Ok(searched) => searched,
185        Err(e) => return failed(codes::GAME_ERROR, e),
186    };
187    let seconds = started.elapsed().as_secs_f64();
188    let found = searched.balanced;
189    let limits = limits(&found, input.time, settings.tries.turns);
190    let (written, write_error) = match searched.written {
191        None => (None, None),
192        Some(Ok(files)) => (Some(files), None),
193        Some(Err(e)) => (None, Some(e)),
194    };
195    let in_bands = distance(&found.after) == 0;
196    let output = AutobalanceOutput {
197        stage,
198        title: plan.title,
199        settings,
200        in_bands,
201        stop: found.stop,
202        evaluations: found.evaluations,
203        changes: found.changes,
204        before: found.before,
205        after: found.after,
206        written,
207        seconds,
208    };
209    let answer = Answer::new(
210        Op::Autobalance,
211        in_bands && write_error.is_none(),
212        output,
213        limits,
214    );
215    match write_error {
216        Some(e) => {
217            let mut answer = answer.with_code(codes::GAME_ERROR);
218            answer.error = Some(format!("apply: the game couldn't write the stage: {e}"));
219            answer
220        }
221        None => answer,
222    }
223}
224
225/// What a search that ended as `found` could not determine: the setting
226/// a time limit of `time` seconds cut short, and the tries of the stage
227/// found that reached turn `turns` unfinished.
228fn limits(found: &Balanced<()>, time: Option<u64>, turns: u32) -> Vec<String> {
229    let mut limits = Vec::new();
230    if let (Some(time), Stop::Stopped) = (time, found.stop) {
231        limits.push(format!(
232            "the time limit of {time} s ran out after {} evaluations: the best setting seen, \
233             not what more time would find (it depends on the machine)",
234            found.evaluations
235        ));
236    }
237    let unfinished = found
238        .after
239        .entrants
240        .iter()
241        .flat_map(|e| &e.tries)
242        .filter(|t| t.outcome.is_none())
243        .count();
244    if unfinished > 0 {
245        limits.push(format!(
246            "after: {unfinished} of the tries reached turn {turns} without an end: their \
247             outcome is unknown"
248        ));
249    }
250    limits
251}
252
253/// Whether `settings` can be searched: tries that can be played, quick
254/// runs of 1 to that many, and 1 to [`MAX_BUDGET`] evaluations.
255fn check_settings(settings: &BalanceSettings) -> Result<(), String> {
256    check_tries(&settings.tries)?;
257    let count = settings.tries.count;
258    if settings.quick == 0 || settings.quick > count {
259        return Err(format!(
260            "quick: 1 to the tries ({count}), not {}",
261            settings.quick
262        ));
263    }
264    if settings.evaluations == 0 || settings.evaluations > MAX_BUDGET {
265        return Err(format!(
266            "budget: 1 to {MAX_BUDGET}, not {}",
267            settings.evaluations
268        ));
269    }
270    Ok(())
271}
272
273/// The stage `asked` for of the game's `stages` (`None`: the one unnamed
274/// stage), or why it names none.
275fn one_stage(stages: &[String], asked: Option<&str>) -> Result<Option<String>, String> {
276    match (asked, stages) {
277        (Some(stage), []) => Err(format!("stage: the game has no stages, not {stage}")),
278        (Some(stage), _) if stages.iter().any(|s| s == stage) => Ok(Some(stage.to_owned())),
279        (Some(stage), _) => Err(format!(
280            "stage: no {stage}; the stages: {}",
281            stages.join(", ")
282        )),
283        (None, []) => Ok(None),
284        (None, [one]) => Ok(Some(one.clone())),
285        (None, _) => Err(format!(
286            "stage: the game has {} stages, name one: {}",
287            stages.len(),
288            stages.join(", ")
289        )),
290    }
291}
292
293/// `autobalance`'s own options, for its usage.
294const OWN: [GameOption; 10] = [
295    GameOption {
296        flag: "--stage",
297        value: Some("ID"),
298        help: "the stage; needed when the game has several",
299    },
300    GameOption {
301        flag: "--entrant",
302        value: Some("NAME"),
303        help: "an entrant or a group, again for more (the game's choice if none)",
304    },
305    GameOption {
306        flag: "--tries",
307        value: Some("N"),
308        help: "the tries of each entrant that decide \"in band\", 1 to 10000",
309    },
310    GameOption {
311        flag: "--quick",
312        value: Some("N"),
313        help: "the tries of the search's own runs (a fifth of --tries)",
314    },
315    GameOption {
316        flag: "--budget",
317        value: Some("N"),
318        help: "the most evaluations, 1 to 10000 (100)",
319    },
320    GameOption {
321        flag: "--time",
322        value: Some("SECONDS"),
323        help: "stop after that long with the best setting seen (no limit)",
324    },
325    GameOption {
326        flag: "--seed",
327        value: Some("N"),
328        help: "the first try's seed; each next try takes the next",
329    },
330    GameOption {
331        flag: "--turns",
332        value: Some("N"),
333        help: "the turn a try stops at if it hasn't ended",
334    },
335    GameOption {
336        flag: "--threads",
337        value: Some("N"),
338        help: "the threads to play on (one a core; what it finds never changes)",
339    },
340    GameOption {
341        flag: "--apply",
342        value: None,
343        help: "have the game write the changed stage to its files",
344    },
345];
346
347/// `autobalance`'s usage, with the game's start `options`.
348pub fn usage(command: &str, options: &[GameOption]) -> String {
349    let mut out = format!(
350        "usage: {command} autobalance [--stage ID] [--entrant NAME]... [--tries N] [--quick N]\n\
351         \x20      [--budget N] [--time SECONDS] [--seed N] [--turns N] [--threads N] [--apply]\n\
352         \x20      [options] [--json]\n\n\
353         The game's knobs change its stage, never its bots, until each of its bots (its\n\
354         entrants) succeeds as often as its checks' bands want: the smallest change the\n\
355         search finds, in the knobs' words, with the reports before and after. Each\n\
356         setting's tries run on every core. It stops at --budget evaluations or after\n\
357         --time seconds with the best setting seen; --apply has the game write the\n\
358         changed stage. The tries are the game's: {} from seed {} to turn {} unless it\n\
359         says. It fails when a rate is still outside its band.\n\n",
360        DEFAULT_TRIES.count, DEFAULT_TRIES.seed, DEFAULT_TRIES.turns
361    );
362    out.push_str(&options_text(&OWN));
363    out.push_str(&options_text(options));
364    out
365}
366
367/// Reads `autobalance`'s arguments (not `--json`).
368pub fn parse_args(args: &[String], options: &[GameOption]) -> Result<AutobalanceInput, String> {
369    let mut input = AutobalanceInput::default();
370    let mut iter = args.iter();
371    while let Some(arg) = iter.next() {
372        let mut value = || {
373            iter.next()
374                .ok_or_else(|| format!("{arg} needs a value"))
375                .cloned()
376        };
377        let number = |text: &str| {
378            text.parse::<u64>()
379                .map_err(|_| format!("{arg}: not a whole number: {text}"))
380        };
381        let small = |n: u64| u32::try_from(n).map_err(|_| format!("{arg}: too big"));
382        match arg.as_str() {
383            "--stage" => input.stage = Some(value()?),
384            "--entrant" => input.entrants.push(value()?),
385            "--tries" => input.tries = Some(small(number(&value()?)?)?),
386            "--quick" => input.quick = Some(small(number(&value()?)?)?),
387            "--budget" => input.budget = Some(small(number(&value()?)?)?),
388            "--time" => input.time = Some(number(&value()?)?),
389            "--seed" => input.seed = Some(number(&value()?)?),
390            "--turns" => input.turns = Some(small(number(&value()?)?)?),
391            "--threads" => input.threads = Some(small(number(&value()?)?)?),
392            "--apply" => input.apply = true,
393            flag if flag.starts_with("--") => input.start.push(game_arg(flag, options, value)?),
394            extra => return Err(format!("no <games> here, only flags: {extra}")),
395        }
396    }
397    Ok(input)
398}
399
400/// What `autobalance` found, for people: the plan's title, why the search
401/// stopped, the changes and the reports before and after, then what was
402/// written and how long it took.
403pub fn text(output: &AutobalanceOutput) -> String {
404    let mut out = String::new();
405    // Writing to a `String` can't fail.
406    if !output.title.is_empty() {
407        let _ = writeln!(out, "{}\n", output.title);
408    }
409    let found = Balanced {
410        stage: (),
411        changes: output.changes.clone(),
412        before: output.before.clone(),
413        after: output.after.clone(),
414        stop: output.stop,
415        evaluations: output.evaluations,
416    };
417    let _ = write!(out, "{found}");
418    match output.written.as_deref() {
419        None => {}
420        Some([]) => out.push_str("\napply: nothing changed, nothing written\n"),
421        Some(files) => {
422            let _ = writeln!(out, "\napply: wrote {}", files.join(", "));
423        }
424    }
425    let _ = writeln!(out, "\ntook {} s", seconds(output.seconds));
426    out
427}
428
429#[cfg(test)]
430mod tests;