diff --git a/crates/sds-bot/src/main.rs b/crates/sds-bot/src/main.rs index 36bf004..e366637 100644 --- a/crates/sds-bot/src/main.rs +++ b/crates/sds-bot/src/main.rs @@ -15,7 +15,7 @@ //! `SDS_LANCE_NODE=tcp:10.0.0.4:7000` moves lance command to another machine //! without changing a line here. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use anyhow::{Context, Result}; @@ -101,6 +101,18 @@ chosen {} generated / {} four-verb ({:.0}% verbs)", } } +/// One unit's standing order, and which generator produced it. +/// +/// The label is not carried: attribution is a boolean taken from +/// [`candidates::is_generated`] at the moment the order was chosen, and the +/// tally is bumped when the order is actually played rather than when it was +/// planned. A plan can be rebuilt several times in one movement phase, and +/// counting at plan time would count the same unit's turn more than once. +struct Order { + action: Action, + generated: bool, +} + /// The bot's memory between decisions. struct Bot { board: Option, @@ -108,14 +120,19 @@ struct Bot { lance_node: Arc, company_node: Arc, grit: Grit, - /// Orders chosen this round, by unit. Cleared when the round turns. - orders: HashMap, - /// Units the force could not give a hex to this round: every stand they - /// offered was in a hex another order had already claimed. They pass, and - /// the host counts a pass as defaulted. A stand-still nobody chose must not - /// be reported as an answer - see the `answered`/`decisions` invariant. + /// Orders standing for the units that have still to move, by unit. Rebuilt + /// whenever [`Bot::plan`] runs, which is whenever the world it was built on + /// changed - see [`Bot::settled`]. + orders: HashMap, + /// Units the force could not give a hex to: every stand they offered was in + /// a hex another order had already claimed. They pass, and the host counts a + /// pass as defaulted. A stand-still nobody chose must not be reported as an + /// answer - see the `answered`/`decisions` invariant. stalled: HashSet, - planned_round: Option, + /// The world the standing plan was built on: the round, and which enemies + /// had already settled. A plan is reused only for a decision taken in the + /// same world. + planned: Option<(i32, BTreeSet)>, /// What each lance decided, kept for the rationale on the wire. decisions: Vec, /// One training row per unit for this round's movement, by unit. @@ -208,7 +225,7 @@ impl Bot { grit, orders: HashMap::new(), stalled: HashSet::new(), - planned_round: None, + planned: None, decisions: Vec::new(), training: HashMap::new(), declared: Vec::new(), @@ -248,9 +265,38 @@ impl Bot { .unwrap_or_else(|| "all".to_string()) } - /// Ask everyone, then decide. Once per round. + /// Which enemies will not move again this round. + /// + /// `done` is MegaMek's own `Entity.isDone()`, cleared for every entity in + /// `TWGameManager.resetEntityPhase` at the top of a phase and set in + /// `endCurrentTurn`, so within the movement phase it reads "has taken its + /// turn". A destroyed enemy counts as settled for the same reason: it is + /// not going anywhere. + /// + /// This is the plan's cache key. Movement alternates by initiative, so this + /// set grows between our own decisions, and a plan built when it was + /// smaller was built against a wider enemy envelope than the one that is + /// now true. + fn settled(observation: &Observation) -> BTreeSet { + observation + .units + .iter() + .filter(|u| !u.friendly && (u.done || u.destroyed)) + .map(|u| u.id) + .collect() + } + + /// Ask everyone, then decide. + /// + /// Rebuilt per decision rather than once per round. Every enemy that + /// finishes its move collapses its `M` from a reachable set to a point, and + /// the next of our units to decide has to see the collapsed one: that is + /// the whole content of "score a position against where they could be". + /// Reusing a round-start plan would give our last mover the uncertainty our + /// first mover faced. async fn plan(&mut self, observation: &Observation) -> Result<()> { - if self.planned_round == Some(observation.round) { + let settled = Self::settled(observation); + if self.planned.as_ref() == Some(&(observation.round, settled.clone())) { return Ok(()); } self.orders.clear(); @@ -263,6 +309,13 @@ impl Bot { // the value never enters one. let started = std::time::Instant::now(); + let enemy_ids: BTreeSet = observation + .units + .iter() + .filter(|u| !u.friendly && !u.destroyed) + .map(|u| u.id) + .collect(); + let mine = Self::mine(observation); // What the other side has left, so a force can price finishing a // machine above hurting two. Built from the bot client's observation @@ -274,7 +327,7 @@ impl Bot { .collect(); let remaining = sds_core::reconcile::remaining(&enemies); if mine.is_empty() { - self.planned_round = Some(observation.round); + self.planned = Some((observation.round, settled)); return Ok(()); } @@ -360,13 +413,15 @@ impl Bot { if let Some(proposal) = thought.proposals.get(chosen.proposal) { // Which generator won, attributed by the label's own // marker rather than by an index into a list whose - // shape depends on the flag. - if candidates::is_generated(&proposal.label) { - self.tally.chosen_generated += 1; - } else { - self.tally.chosen_verbs += 1; - } - self.orders.insert(chosen.unit, proposal.action.clone()); + // shape depends on the flag. Recorded here, counted in + // `movement` when the order is played. + self.orders.insert( + chosen.unit, + Order { + action: proposal.action.clone(), + generated: candidates::is_generated(&proposal.label), + }, + ); } } } @@ -382,14 +437,27 @@ impl Bot { // Printed every round rather than only at the end. A match kills the // bot rather than closing its stdin, so an end-of-stream summary is // exactly the line that never reaches a log. + // Printed per plan, and a plan is per decision. `settled` is the thing + // that made this one necessary: it is how a reader sees the enemy + // envelope collapsing across a movement phase, and with it the + // asymmetry between our first mover and our last. + let enemies = observation + .units + .iter() + .filter(|u| !u.friendly && !u.destroyed) + .count(); eprintln!( - "[sds-bot] round {}: planned {} unit(s) in {spent:.0} ms; {}", + "[sds-bot] round {} for unit {}: planned {} unit(s) in {spent:.0} ms; \ + {}/{} enemies settled; {}", observation.round, + observation.actor.unwrap_or(-1), self.orders.len(), + settled.iter().filter(|id| enemy_ids.contains(id)).count(), + enemies, self.tally.summary() ); - self.planned_round = Some(observation.round); + self.planned = Some((observation.round, settled)); Ok(()) } @@ -414,16 +482,24 @@ impl Bot { async fn movement(&mut self, observation: &Observation) -> Result { self.plan(observation).await?; let actor = observation.actor.unwrap_or(-1); - if let Some(action) = self.orders.remove(&actor) { - return Ok(action); - } - // A unit whose every stand was in a hex somebody else claimed has no - // answer, and saying "stand still" would be counted as one. Pass: the - // host stands it still and counts the decision defaulted. - if self.stalled.contains(&actor) { - return Ok(Action::Pass); + let Some(order) = self.orders.remove(&actor) else { + // A unit whose every stand was in a hex somebody else claimed has + // no answer, and saying "stand still" would be counted as one. + // Pass: the host stands it still and counts the decision defaulted. + if self.stalled.contains(&actor) { + return Ok(Action::Pass); + } + // Ours, and no order for it. The inert default, not Princess. + return Ok(Action::Move { steps: Vec::new() }); + }; + // Counted when the order is played, not when it was planned: a rebuilt + // plan would otherwise count one turn several times. + if order.generated { + self.tally.chosen_generated += 1; + } else { + self.tally.chosen_verbs += 1; } - Ok(Action::Move { steps: Vec::new() }) + Ok(order.action) } /// Firing: build a menu of allocations, measure each, take the best. @@ -852,3 +928,65 @@ async fn main() -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn observation(units: serde_json::Value) -> Observation { + serde_json::from_value(serde_json::json!({ + "seq": 1, "round": 3, "phase": "MOVEMENT", "playerId": 1, "team": 1, + "actor": 1, "units": units, "shots": [], "deployHexes": [] + })) + .expect("observation parses") + } + + fn unit(id: i32, friendly: bool, done: bool, destroyed: bool) -> serde_json::Value { + serde_json::json!({ + "id": id, "name": format!("Mek {id}"), "ownerId": 1, "team": 1, + "friendly": friendly, "x": id, "y": 3, "facing": 0, "weight": 55.0, + "walkMp": 4, "runMp": 6, "armor": 100, "armorMax": 100, + "internal": 60, "internalMax": 60, "done": done, "destroyed": destroyed, + "locations": [], "weapons": [] + }) + } + + /// The plan's cache key is the enemies that will not move again, so a plan + /// is reused only for a decision taken in the world it was built for. This + /// is what makes the envelope collapse reach our next mover: if this set + /// did not change, the round-start plan would answer every decision and an + /// enemy that had already moved would still be scored as a reachable set. + #[test] + fn a_settled_enemy_changes_the_plan_key() { + let open = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, false, false, false), + unit(3, false, false, false), + ])); + assert!(Bot::settled(&open).is_empty()); + + let moved = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, false, true, false), + unit(3, false, false, false), + ])); + assert_eq!(Bot::settled(&moved), BTreeSet::from([2])); + assert_ne!(Bot::settled(&open), Bot::settled(&moved)); + + // Ours being done is not the enemy envelope and must not rebuild the + // plan on its own; a dead enemy is settled for the same reason a moved + // one is. + let ours = observation(serde_json::json!([ + unit(1, true, true, false), + unit(2, false, false, false), + unit(3, false, false, false), + ])); + assert!(Bot::settled(&ours).is_empty()); + let dead = observation(serde_json::json!([ + unit(1, true, false, false), + unit(2, false, false, true), + unit(3, false, false, false), + ])); + assert_eq!(Bot::settled(&dead), BTreeSet::from([2])); + } +}