1use crate::bitboard::Bitboard;
10use crate::constants::WIN_MASKS;
11use crate::game::current_player;
12use crate::moves::generate_legal_moves;
13
14pub const FEATURE_NAMES: [&str; 6] = [
16 "threat_own",
17 "threat_opp",
18 "threat_shared",
19 "mobility_diff",
20 "build_two",
21 "build_one",
22];
23
24const SEEDED_WEIGHTS: [f64; 6] = [100.0, -100.0, 20.0, 3.0, 2.0, 0.0];
25
26#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct EvalConfig {
33 pub weights: [f64; 6],
34 pub win: f64,
35}
36
37impl Default for EvalConfig {
38 fn default() -> Self {
39 Self {
40 weights: SEEDED_WEIGHTS,
41 win: 10_000.0,
42 }
43 }
44}
45
46fn placement_is_legal(bb: &Bitboard, player: u8, shape: u8, position: u8) -> bool {
52 let opponent_shape_bits = bb.planes[Bitboard::plane_index(1 - player, shape)];
53 let position_mask = 1u16 << position;
54 for &mask in &WIN_MASKS {
55 if (position_mask & mask != 0) && (opponent_shape_bits & mask != 0) {
56 return false;
57 }
58 }
59 true
60}
61
62pub fn count_legal_moves(bb: &Bitboard, player: u8) -> usize {
67 if current_player(bb) != Some(player) {
68 return 0;
69 }
70 generate_legal_moves(bb).len()
71}
72
73pub fn features(bb: &Bitboard, player: u8) -> [f64; 6] {
79 let counts: [[u32; 4]; 2] =
80 std::array::from_fn(|p| std::array::from_fn(|s| bb.shape_piece_count(p as u8, s as u8)));
81 let side_to_move = current_player(bb).unwrap_or(0);
82 let sign = if side_to_move == player { 1.0 } else { -1.0 };
83
84 let union_all = bb.occupied();
85 let shape_unions: [u16; 4] = std::array::from_fn(|s| bb.planes[s] | bb.planes[s + 4]);
86
87 let mut threat_own = 0.0;
88 let mut threat_opp = 0.0;
89 let mut threat_shared = 0.0;
90 let mut build_two = 0.0;
91 let mut build_one = 0.0;
92
93 for &mask in &WIN_MASKS {
94 let present: Vec<u8> = (0..4u8)
95 .filter(|&s| shape_unions[s as usize] & mask != 0)
96 .collect();
97 let occupied = (union_all & mask).count_ones();
98
99 if (present.len() as u32) < occupied {
100 continue; }
102
103 if occupied == 3 {
104 let missing_shape = (0..4u8).find(|s| !present.contains(s)).unwrap();
105 let empty_position = (mask & !union_all).trailing_zeros() as u8;
106 let completable: [bool; 2] = std::array::from_fn(|side| {
107 counts[side][missing_shape as usize] < 2
108 && placement_is_legal(bb, side as u8, missing_shape, empty_position)
109 });
110 if completable[player as usize] {
111 threat_own += 1.0;
112 }
113 if completable[1 - player as usize] {
114 threat_opp += 1.0;
115 }
116 if completable[0] && completable[1] {
117 threat_shared += sign;
118 }
119 } else if occupied == 2 {
120 build_two += sign;
121 } else if occupied == 1 {
122 build_one += sign;
123 }
124 }
125
126 let mobility_diff =
127 count_legal_moves(bb, player) as f64 - count_legal_moves(bb, 1 - player) as f64;
128
129 [
130 threat_own,
131 threat_opp,
132 threat_shared,
133 mobility_diff,
134 build_two,
135 build_one,
136 ]
137}
138
139pub fn evaluate(bb: &Bitboard, player: u8, cfg: &EvalConfig) -> f64 {
141 features(bb, player)
142 .iter()
143 .zip(cfg.weights.iter())
144 .map(|(f, w)| f * w)
145 .sum()
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn empty_board_features_are_zero_except_nothing() {
154 let feats = features(&Bitboard::EMPTY, 0);
156 assert_eq!(feats[0], 0.0); assert_eq!(feats[1], 0.0); assert_eq!(feats[2], 0.0); assert_eq!(feats[3], 64.0); assert_eq!(feats[4], 0.0); assert_eq!(feats[5], 0.0); }
163
164 fn one_sided_threat_board() -> Bitboard {
167 Bitboard::EMPTY
168 .with_move(0, 0, 0)
169 .with_move(1, 3, 8)
170 .with_move(0, 2, 2)
171 .with_move(1, 3, 13)
172 .with_move(0, 1, 1)
173 }
174
175 #[test]
176 fn one_sided_threat_counts() {
177 let bb = one_sided_threat_board();
178 assert_eq!(current_player(&bb), Some(1));
180 let n = generate_legal_moves(&bb).len() as f64;
181
182 let feats_p0 = features(&bb, 0);
183 assert_eq!(feats_p0[0], 1.0, "threat_own for p0");
186 assert_eq!(feats_p0[1], 0.0, "threat_opp for p0");
187 assert_eq!(feats_p0[2], 0.0, "threat_shared");
188 assert_eq!(feats_p0[3], -n, "mobility: not p0's turn");
189 assert_eq!(feats_p0[4], -3.0, "build_two with sign -1");
190 assert_eq!(feats_p0[5], -4.0, "build_one with sign -1");
191
192 let feats_p1 = features(&bb, 1);
193 assert_eq!(feats_p1[0], 0.0, "threat_own for p1");
194 assert_eq!(feats_p1[1], 1.0, "threat_opp for p1");
195 assert_eq!(feats_p1[3], n, "mobility: p1's turn");
196 assert_eq!(feats_p1[4], 3.0);
197 assert_eq!(feats_p1[5], 4.0);
198 }
199
200 #[test]
201 fn features_do_not_mutate_input() {
202 let bb = one_sided_threat_board();
203 let before = bb;
204 let _ = features(&bb, 0);
205 assert_eq!(bb, before);
206 }
207
208 #[test]
209 fn evaluate_matches_hand_computed_dot_product() {
210 let bb = one_sided_threat_board();
211 let cfg = EvalConfig::default();
212 let feats = features(&bb, 0);
213 let expected: f64 = feats
214 .iter()
215 .zip(cfg.weights.iter())
216 .map(|(f, w)| f * w)
217 .sum();
218 assert_eq!(evaluate(&bb, 0, &cfg), expected);
219 let n = generate_legal_moves(&bb).len() as f64;
221 assert_eq!(expected, 100.0 - 3.0 * n - 6.0);
222 }
223
224 #[test]
225 fn count_legal_moves_zero_off_turn() {
226 assert_eq!(count_legal_moves(&Bitboard::EMPTY, 1), 0);
227 assert_eq!(count_legal_moves(&Bitboard::EMPTY, 0), 64);
228 }
229
230 #[test]
231 fn shared_threat_is_counted_for_both() {
232 let bb = Bitboard::EMPTY
234 .with_move(0, 0, 0)
235 .with_move(1, 1, 1)
236 .with_move(0, 2, 2);
237 let feats = features(&bb, 1);
239 assert_eq!(feats[0], 1.0);
240 assert_eq!(feats[1], 1.0);
241 assert_eq!(feats[2], 1.0);
242 }
243}