Skip to main content

shuyi_core/bots/
weigh.rs

1//! Weighted features: a bot's score as the game's named counts times
2//! weights from a file, and personas (how a bot thinks and what it cares
3//! about) as data.
4//!
5//! A game declares a fixed list of feature names (`["won", "lost", "hp
6//! lost"]`) and a function that counts each of them between the state a
7//! decision began in and where a copy got to. [`Weights`] holds what one
8//! kind of player cares about each, read by name, and its
9//! [`score`](Weights::score) is the sum of count × weight, in integers
10//! that saturate rather than wrap. [`Weighted`] pairs the two into an
11//! [`Evaluate`] for the bots that plan. What a feature means and how it is
12//! counted stay the game's.
13//!
14//! A [`Persona`] is one kind of player: its [`PlanSettings`], its weights
15//! by name, and whatever the game adds of its own (a game mode, a
16//! difficulty). A map of them by name is a personas file:
17//!
18//! ```ron
19//! {
20//!     "careful": (
21//!         settings: (width: 4, candidates: 10, depth: 6, samples: 2, temperature: 0),
22//!         weights: {"won": 1000, "hp lost": -4},
23//!         game: (mode: Classic),
24//!     ),
25//! }
26//! ```
27//!
28//! A weight naming a feature the game didn't declare is an error
29//! ([`UnknownFeature`]); a feature with no weight weighs 0.
30
31use alloc::collections::BTreeMap;
32use alloc::string::String;
33use core::fmt;
34
35use serde::{Deserialize, Serialize};
36
37use super::Sides;
38use super::determinize::Evaluate;
39use super::plan::PlanSettings;
40
41/// What a kind of player cares about each of a game's `N` features, in
42/// the order of the game's names.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Weights<const N: usize>(pub [i32; N]);
45
46impl<const N: usize> Weights<N> {
47    /// The weights of `by_name`, put in the order of `names`. A feature
48    /// `by_name` leaves out weighs 0.
49    ///
50    /// # Errors
51    ///
52    /// [`UnknownFeature`] for the first name of `by_name` (in its order)
53    /// that isn't one of `names`.
54    pub fn from_named(
55        names: &[&str; N],
56        by_name: &BTreeMap<String, i32>,
57    ) -> Result<Self, UnknownFeature> {
58        let mut weights = [0; N];
59        for (name, &weight) in by_name {
60            let Some(index) = names.iter().position(|known| known == name) else {
61                return Err(UnknownFeature {
62                    name: name.clone(),
63                    known: names.join(", "),
64                });
65            };
66            weights[index] = weight;
67        }
68        Ok(Self(weights))
69    }
70
71    /// The sum of each count times its weight, clamped into `i32`: never
72    /// a wrap. It allocates nothing.
73    pub fn score(&self, counts: &[i64; N]) -> i32 {
74        // In i128 a product (under 2^94) never overflows and a sum of any
75        // real number of them neither, so a large term that a later one
76        // cancels still counts in full; the adds saturate all the same.
77        let sum = counts
78            .iter()
79            .zip(self.0)
80            .fold(0_i128, |sum, (&count, weight)| {
81                sum.saturating_add(i128::from(count) * i128::from(weight))
82            });
83        let clamped = sum.clamp(i128::from(i32::MIN), i128::from(i32::MAX));
84        i32::try_from(clamped).unwrap_or_default()
85    }
86
87    /// The weight of the feature `name` among `names`, or `None` when it
88    /// isn't one of them.
89    pub fn get(&self, names: &[&str; N], name: &str) -> Option<i32> {
90        names
91            .iter()
92            .position(|known| *known == name)
93            .map(|index| self.0[index])
94    }
95}
96
97/// A weight naming a feature the game didn't declare.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct UnknownFeature {
100    /// The name that isn't a feature.
101    pub name: String,
102    /// The game's feature names, joined with ", ".
103    pub known: String,
104}
105
106impl fmt::Display for UnknownFeature {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        write!(f, "no feature {:?} (there: {})", self.name, self.known)
109    }
110}
111
112impl core::error::Error for UnknownFeature {}
113
114/// An [`Evaluate`] made of the game's counts and a kind of player's
115/// weights: it scores `weights.score(&counts(start, end, side))`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Weighted<F, const N: usize> {
118    /// What the player cares about each feature.
119    pub weights: Weights<N>,
120    /// The game's counts of its features, between the state a decision
121    /// began in and where a copy got to, for a side.
122    pub counts: F,
123}
124
125impl<R, F, const N: usize> Evaluate<R> for Weighted<F, N>
126where
127    R: Sides,
128    F: Fn(&R, &R, R::Side) -> [i64; N],
129{
130    fn score(&self, start: &R, end: &R, side: R::Side) -> i32 {
131        self.weights.score(&(self.counts)(start, end, side))
132    }
133}
134
135/// One kind of player: how it thinks, what it cares about, and the
136/// game's own extras `X` (a game mode, a difficulty; `()` for none).
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(
139    deny_unknown_fields,
140    bound(
141        serialize = "X: Serialize",
142        deserialize = "X: Default + Deserialize<'de>"
143    )
144)]
145pub struct Persona<X> {
146    /// How its planner thinks.
147    pub settings: PlanSettings,
148    /// What it cares about each feature, by the game's names. A feature
149    /// left out weighs 0.
150    #[serde(default)]
151    pub weights: BTreeMap<String, i32>,
152    /// The game's own extras; their default when left out.
153    #[serde(default)]
154    pub game: X,
155}
156
157impl<X> Persona<X> {
158    /// Its weights over the game's feature `names`.
159    ///
160    /// # Errors
161    ///
162    /// [`UnknownFeature`] when a weight names a feature not in `names`.
163    pub fn weights<const N: usize>(&self, names: &[&str; N]) -> Result<Weights<N>, UnknownFeature> {
164        Weights::from_named(names, &self.weights)
165    }
166}
167
168#[cfg(test)]
169mod tests;