1use 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
30pub const DEFAULT_BUDGET: u32 = 100;
32
33pub const MAX_BUDGET: u32 = 10_000;
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields, default)]
41#[non_exhaustive]
44pub struct AutobalanceInput {
45 pub stage: Option<String>,
47 pub entrants: Vec<String>,
50 pub tries: Option<u32>,
53 pub quick: Option<u32>,
56 pub budget: Option<u32>,
59 pub time: Option<u64>,
63 pub seed: Option<u64>,
65 pub turns: Option<u32>,
67 pub threads: Option<u32>,
70 pub apply: bool,
73 pub start: Vec<GameArg>,
75}
76
77impl AutobalanceInput {
78 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields)]
102pub struct AutobalanceOutput {
103 pub stage: Option<String>,
105 pub title: String,
107 pub settings: BalanceSettings,
109 pub in_bands: bool,
111 pub stop: Stop,
113 pub evaluations: u32,
115 pub changes: Vec<Change>,
117 pub before: PlaytestReport,
119 pub after: PlaytestReport,
122 pub written: Option<Vec<String>>,
124 pub seconds: f64,
127}
128
129pub 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
225fn 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
253fn 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
273fn 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
293const 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
347pub 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
367pub 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
400pub fn text(output: &AutobalanceOutput) -> String {
404 let mut out = String::new();
405 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;