use crate::board::{BOARD_SIZE, BitBoard, Board, Move, NUM_CELLS, RuleSet, Stone};
use crate::heuristic::{DIR, scan_line};
use crate::pattern_table::{
PATTERN_RARE_ID, WindowThreat, pattern_threat_after_my_play, pattern_threat_after_my_play_caro,
pattern_threat_after_my_play_exact5, read_window, swap_mapped_id, visit_gap_four_gaps,
window_has_gap_four, window_has_jump_three,
};
use noru::trainer::SimpleRng;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
static ZOBRIST_KEYS: OnceLock<[[u64; 2]; NUM_CELLS]> = OnceLock::new();
const ZOBRIST_SIDE_WHITE: u64 = 0x5A5A_5A5A_A5A5_A5A5;
fn zobrist_keys() -> &'static [[u64; 2]; NUM_CELLS] {
ZOBRIST_KEYS.get_or_init(|| {
let mut rng = SimpleRng::new(0xDEAD_BEEF_CAFE_BABE);
let mut arr = [[0u64; 2]; NUM_CELLS];
for slot in arr.iter_mut() {
slot[0] = rng.next_u64();
slot[1] = rng.next_u64();
}
arr
})
}
fn zobrist_hash(board: &Board) -> u64 {
let keys = zobrist_keys();
let mut h = 0u64;
for idx in 0..NUM_CELLS {
if board.black.get(idx) {
h ^= keys[idx][0];
}
if board.white.get(idx) {
h ^= keys[idx][1];
}
}
if board.side_to_move == Stone::White {
h ^= ZOBRIST_SIDE_WHITE;
}
h
}
#[derive(Clone, Copy)]
struct TtEntry {
depth: u32,
result: TtResult,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TtResult {
AttackerWins,
Fails,
}
type TransTable = HashMap<u64, TtEntry>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum LineThreat {
None,
OpenTwo, ClosedThree, OpenThree, ClosedFour, OpenFour, Five, }
fn classify_line(count: u32, open_ends: u32, rule_set: RuleSet, side: Stone) -> LineThreat {
if rule_set.line_wins(side, count, open_ends) {
return LineThreat::Five;
}
match (count, open_ends) {
(4, 2) => LineThreat::OpenFour,
(4, 1) => LineThreat::ClosedFour,
(3, 2) => LineThreat::OpenThree,
(3, 1) => LineThreat::ClosedThree,
(2, 2) => LineThreat::OpenTwo,
_ => LineThreat::None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum ThreatKind {
None = 0,
ClosedFour = 1,
OpenThree = 2,
Five = 3,
OpenFour = 4,
DoubleFour = 5,
FourThree = 6,
DoubleThree = 7,
JumpThree = 8,
}
pub const THREAT_KIND_COUNT: usize = 9;
impl ThreatKind {
pub fn is_winning(self) -> bool {
matches!(
self,
ThreatKind::Five
| ThreatKind::OpenFour
| ThreatKind::DoubleFour
| ThreatKind::FourThree
| ThreatKind::DoubleThree
)
}
pub fn is_forcing(self) -> bool {
matches!(
self,
ThreatKind::ClosedFour | ThreatKind::OpenThree | ThreatKind::JumpThree
) || self.is_winning()
}
}
fn is_vct_terminal_win(kind: ThreatKind) -> bool {
matches!(
kind,
ThreatKind::Five | ThreatKind::OpenFour | ThreatKind::DoubleFour
)
}
pub fn classify_move(my_bb: &BitBoard, opp_bb: &BitBoard, mv: Move, exact5: bool) -> ThreatKind {
let rule_set = if exact5 {
RuleSet::Standard
} else {
RuleSet::Freestyle
};
classify_move_rules(my_bb, opp_bb, mv, Stone::Black, rule_set)
}
pub fn classify_move_rules(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
) -> ThreatKind {
classify_move_rules_with_flags(my_bb, opp_bb, mv, side, rule_set, false, false)
}
#[cfg(test)]
fn classify_move_rules_with_jump_three(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
) -> ThreatKind {
classify_move_rules_with_flags(my_bb, opp_bb, mv, side, rule_set, enable_jump_three, false)
}
fn classify_move_rules_with_flags(
my_bb: &BitBoard,
opp_bb: &BitBoard,
mv: Move,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
let mut my_tmp = *my_bb;
my_tmp.set(mv);
let mut fours = 0u32;
let mut open_fours = 0u32;
let mut open_threes = 0u32;
let mut closed_fours = 0u32;
let mut fives = 0u32;
let mut jump_threes = 0u32;
for &(dr, dc) in &DIR {
let info = scan_line(&my_tmp, opp_bb, row, col, dr, dc);
let open_ends = info.open_front as u32 + info.open_back as u32;
let mut line_threat = classify_line(info.count, open_ends, rule_set, side);
let mut window = None;
if enable_gap_four
&& !matches!(
line_threat,
LineThreat::Five | LineThreat::OpenFour | LineThreat::ClosedFour
)
{
let w = read_window(&my_tmp, opp_bb, row, col, dr, dc);
if window_has_gap_four(&w) {
line_threat = LineThreat::ClosedFour;
}
window = Some(w);
}
match line_threat {
LineThreat::Five => fives += 1,
LineThreat::OpenFour => {
open_fours += 1;
fours += 1;
}
LineThreat::ClosedFour => {
closed_fours += 1;
fours += 1;
}
LineThreat::OpenThree => open_threes += 1,
_ => {}
}
if enable_jump_three
&& !matches!(
line_threat,
LineThreat::Five | LineThreat::OpenFour | LineThreat::ClosedFour
)
{
let w = window.unwrap_or_else(|| read_window(&my_tmp, opp_bb, row, col, dr, dc));
if window_has_jump_three(&w) {
jump_threes += 1;
}
}
}
if fives >= 1 {
return ThreatKind::Five;
}
if open_fours >= 1 {
return ThreatKind::OpenFour;
}
if fours >= 2 {
return ThreatKind::DoubleFour;
}
if closed_fours >= 1 && open_threes >= 1 {
return ThreatKind::FourThree;
}
if open_threes >= 2 {
return ThreatKind::DoubleThree;
}
if closed_fours >= 1 {
return ThreatKind::ClosedFour;
}
if open_threes >= 1 {
return ThreatKind::OpenThree;
}
if enable_jump_three && jump_threes >= 1 {
return ThreatKind::JumpThree;
}
ThreatKind::None
}
pub fn classify_move_fast(board: &Board, mv: Move, side: Stone) -> ThreatKind {
classify_move_fast_with_flags(board, mv, side, false, false)
}
#[cfg(test)]
fn classify_move_fast_with_jump_three(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
) -> ThreatKind {
classify_move_fast_with_flags(board, mv, side, enable_jump_three, false)
}
fn classify_move_fast_with_flags(
board: &Board,
mv: Move,
side: Stone,
enable_jump_three: bool,
enable_gap_four: bool,
) -> ThreatKind {
let row = (mv / BOARD_SIZE) as i32;
let col = (mv % BOARD_SIZE) as i32;
let side_is_black = matches!(side, Stone::Black);
let (mine, opp) = if side_is_black {
(&board.black, &board.white)
} else {
(&board.white, &board.black)
};
let rule_set = board.effective_rule_set();
let mut fours = 0u32;
let mut open_fours = 0u32;
let mut closed_fours = 0u32;
let mut open_threes = 0u32;
let mut fives = 0u32;
let mut jump_threes = 0u32;
for (dir_idx, &(dr, dc)) in DIR.iter().enumerate() {
let pid_black = board.line_pattern_ids[mv][dir_idx];
let pid_my = if side_is_black {
pid_black
} else {
swap_mapped_id(pid_black)
};
let mut threat = if pid_my == PATTERN_RARE_ID {
let mut w = read_window(mine, opp, row, col, dr, dc);
debug_assert_eq!(w[5], 0, "candidate move cell must be empty");
w[5] = 1;
let mut count = 1u32;
let mut open_front = false;
for off in 1usize..=5 {
match w[5 + off] {
1 => count += 1,
0 => {
open_front = true;
break;
}
_ => break,
}
}
let mut open_back = false;
for off in 1usize..=5 {
match w[5 - off] {
1 => count += 1,
0 => {
open_back = true;
break;
}
_ => break,
}
}
let open_ends = open_front as u32 + open_back as u32;
if rule_set.line_wins(side, count, open_ends) {
WindowThreat::Five
} else {
match (count, open_ends) {
(4, 2) => WindowThreat::OpenFour,
(4, 1) => WindowThreat::ClosedFour,
(3, 2) => WindowThreat::OpenThree,
(3, 1) => WindowThreat::ClosedThree,
_ if enable_gap_four && window_has_gap_four(&w) => WindowThreat::ClosedFour,
_ if enable_jump_three && window_has_jump_three(&w) => WindowThreat::JumpThree,
(2, 2) => WindowThreat::OpenTwo,
_ => WindowThreat::None,
}
}
} else {
match rule_set {
RuleSet::Caro => pattern_threat_after_my_play_caro(pid_my),
RuleSet::Standard => pattern_threat_after_my_play_exact5(pid_my),
RuleSet::Renju if matches!(side, Stone::Black) => {
pattern_threat_after_my_play_exact5(pid_my)
}
_ => pattern_threat_after_my_play(pid_my),
}
};
if enable_gap_four
&& !matches!(
threat,
WindowThreat::Five | WindowThreat::OpenFour | WindowThreat::ClosedFour
)
{
let mut w = read_window(mine, opp, row, col, dr, dc);
debug_assert_eq!(w[5], 0, "candidate move cell must be empty");
w[5] = 1;
if window_has_gap_four(&w) {
threat = WindowThreat::ClosedFour;
}
}
match threat {
WindowThreat::Five => fives += 1,
WindowThreat::OpenFour => {
open_fours += 1;
fours += 1;
}
WindowThreat::ClosedFour => {
closed_fours += 1;
fours += 1;
}
WindowThreat::OpenThree => open_threes += 1,
WindowThreat::JumpThree if enable_jump_three => jump_threes += 1,
_ => {}
}
let _ = (dr, dc);
}
if fives >= 1 {
return ThreatKind::Five;
}
if open_fours >= 1 {
return ThreatKind::OpenFour;
}
if fours >= 2 {
return ThreatKind::DoubleFour;
}
if closed_fours >= 1 && open_threes >= 1 {
return ThreatKind::FourThree;
}
if open_threes >= 2 {
return ThreatKind::DoubleThree;
}
if closed_fours >= 1 {
return ThreatKind::ClosedFour;
}
if open_threes >= 1 {
return ThreatKind::OpenThree;
}
if enable_jump_three && jump_threes >= 1 {
return ThreatKind::JumpThree;
}
ThreatKind::None
}
pub struct VctConfig {
pub max_depth: u32,
pub time_budget: Option<Duration>,
pub enable_jump_three: bool,
pub enable_jump_three_attack_defense: bool,
pub enable_jump_three_counter: bool,
pub enable_jump_three_kind_scoped_defense: bool,
pub jump_attack_max_or_levels: u32,
pub enable_gap_four: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct VctJumpThreeFlags {
attack_defense: bool,
counter: bool,
kind_scoped_defense: bool,
attack_max_or_levels: u32,
gap_four: bool,
}
impl VctConfig {
fn jump_three_flags(&self) -> VctJumpThreeFlags {
VctJumpThreeFlags {
attack_defense: self.enable_jump_three || self.enable_jump_three_attack_defense,
counter: self.enable_jump_three || self.enable_jump_three_counter,
kind_scoped_defense: self.enable_jump_three_kind_scoped_defense,
attack_max_or_levels: self.jump_attack_max_or_levels,
gap_four: self.enable_gap_four,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct VctSearchStats {
pub nodes: u64,
pub deadline_hits: u64,
}
impl VctSearchStats {
fn enter_node(&mut self) {
self.nodes += 1;
}
fn mark_deadline(&mut self) {
self.deadline_hits += 1;
}
pub fn hit_deadline(&self) -> bool {
self.deadline_hits > 0
}
}
#[derive(Clone, Debug)]
pub struct VctSearchResult {
pub sequence: Option<Vec<Move>>,
pub stats: VctSearchStats,
}
impl VctSearchResult {
pub fn termination_reason(&self) -> &'static str {
if self.sequence.is_some() {
"proved"
} else if self.stats.hit_deadline() {
"deadline"
} else {
"exhausted"
}
}
}
impl Default for VctConfig {
fn default() -> Self {
Self {
max_depth: 16,
time_budget: Some(Duration::from_millis(500)),
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
}
}
}
pub fn search_vct(board: &mut Board, cfg: &VctConfig) -> Option<Vec<Move>> {
search_vct_with_stats(board, cfg).sequence
}
pub fn search_vct_with_stats(board: &mut Board, cfg: &VctConfig) -> VctSearchResult {
let deadline = cfg.time_budget.map(|d| Instant::now() + d);
let attacker = board.side_to_move;
let mut sequence = Vec::with_capacity(cfg.max_depth as usize * 2);
let mut tt: TransTable = HashMap::with_capacity(65536);
let mut stats = VctSearchStats::default();
let flags = cfg.jump_three_flags();
let hit = vct_or(
board,
attacker,
cfg.max_depth,
0,
deadline,
flags,
&mut sequence,
&mut tt,
&mut stats,
);
VctSearchResult {
sequence: if hit { Some(sequence) } else { None },
stats,
}
}
pub fn search_vct_audit_json(board: &mut Board, cfg: &VctConfig) -> Value {
let deadline = cfg.time_budget.map(|d| Instant::now() + d);
let attacker = board.side_to_move;
let mut sequence = Vec::with_capacity(cfg.max_depth as usize * 2);
let mut tt: TransTable = HashMap::with_capacity(65536);
let mut audit = VctAuditLog::default();
let mut stats = VctSearchStats::default();
let flags = cfg.jump_three_flags();
let hit = vct_or_audit(
board,
attacker,
cfg.max_depth,
0,
deadline,
flags,
&mut sequence,
&mut tt,
&mut audit,
&mut stats,
);
let result = VctSearchResult {
sequence: if hit { Some(sequence.clone()) } else { None },
stats,
};
json!({
"format": "vct-proof-audit-v1",
"hit": hit,
"attacker": stone_json(attacker),
"max_depth": cfg.max_depth,
"time_budget_ms": cfg.time_budget.map(|d| d.as_millis() as u64),
"termination_reason": result.termination_reason(),
"nodes": result.stats.nodes,
"deadline_hits": result.stats.deadline_hits,
"jump_three_attack_defense": flags.attack_defense,
"jump_three_counter": flags.counter,
"jump_three_kind_scoped_defense": flags.kind_scoped_defense,
"jump_attack_max_or_levels": flags.attack_max_or_levels,
"gap_four": flags.gap_four,
"sequence": if hit { Some(sequence.iter().map(|&mv| move_json(mv)).collect::<Vec<_>>()) } else { None },
"and_nodes": audit.and_nodes,
"terminal_event_count": audit.terminal_event_count,
"terminal_event_counts": audit.terminal_event_counts,
"terminal_event_samples": audit.terminal_event_samples,
"tt_hit_count": audit.tt_hit_count,
"tt_hit_events": audit.tt_hit_events,
})
}
#[derive(Default)]
struct VctAuditLog {
and_nodes: Vec<Value>,
terminal_event_count: usize,
terminal_event_counts: BTreeMap<String, usize>,
terminal_event_samples: Vec<Value>,
tt_hit_count: usize,
tt_hit_events: Vec<Value>,
}
impl VctAuditLog {
fn record_terminal(&mut self, kind: &str, event: Value) {
self.terminal_event_count += 1;
*self
.terminal_event_counts
.entry(kind.to_string())
.or_default() += 1;
if self.terminal_event_samples.len() < 64 {
self.terminal_event_samples.push(event);
}
}
}
fn vct_or(
board: &mut Board,
attacker: Stone,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
jump_three: VctJumpThreeFlags,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
stats: &mut VctSearchStats,
) -> bool {
stats.enter_node();
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_eq!(board.side_to_move, attacker);
let hash = zobrist_hash(board);
if let Some(entry) = tt.get(&hash) {
if entry.depth >= depth {
if matches!(entry.result, TtResult::Fails) {
return false;
}
}
}
let (my, opp) = bb_pair(board, attacker);
let rule_set = board.effective_rule_set();
let opp_has_immediate_five = has_immediate_five(opp, my, attacker.opponent(), rule_set);
let enable_jump_three_attack =
jump_three.attack_defense && or_level < jump_three.attack_max_or_levels;
let attack_moves = gather_attack_moves(
my,
opp,
attacker,
rule_set,
enable_jump_three_attack,
jump_three.gap_four,
);
if attack_moves.is_empty() {
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
return false;
}
for (mv, kind) in attack_moves {
if is_vct_terminal_win(kind) {
if opp_has_immediate_five && kind != ThreatKind::Five {
continue;
}
sequence.push(mv);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
return true;
}
if opp_has_immediate_five {
continue;
}
sequence.push(mv);
board.make_move(mv);
let won = vct_and(
board,
attacker,
kind,
depth - 1,
or_level,
deadline,
jump_three,
sequence,
tt,
stats,
);
board.undo_move();
if stats.hit_deadline() {
sequence.pop();
return false;
}
if won {
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
return true;
}
sequence.pop();
}
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
false
}
fn vct_and(
board: &mut Board,
attacker: Stone,
attack_kind: ThreatKind,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
jump_three: VctJumpThreeFlags,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
stats: &mut VctSearchStats,
) -> bool {
stats.enter_node();
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_ne!(board.side_to_move, attacker);
let (def_my, def_opp) = bb_pair(board, board.side_to_move);
let rule_set = board.effective_rule_set();
if has_immediate_five(def_my, def_opp, board.side_to_move, rule_set) {
return false;
}
let defenses = match board.last_move {
Some(attack_mv) => find_defenses_with_counters(board, attack_mv, attack_kind, jump_three),
None => board.candidate_moves(),
};
if defenses.is_empty() {
return false;
}
let checkpoint = sequence.len();
for mv in defenses {
sequence.truncate(checkpoint);
sequence.push(mv);
board.make_move(mv);
let attacker_still_wins = vct_or(
board,
attacker,
depth - 1,
or_level + 1,
deadline,
jump_three,
sequence,
tt,
stats,
);
board.undo_move();
if stats.hit_deadline() {
sequence.truncate(checkpoint);
return false;
}
if !attacker_still_wins {
sequence.truncate(checkpoint);
return false;
}
}
true
}
fn vct_or_audit(
board: &mut Board,
attacker: Stone,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
jump_three: VctJumpThreeFlags,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
audit: &mut VctAuditLog,
stats: &mut VctSearchStats,
) -> bool {
stats.enter_node();
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_eq!(board.side_to_move, attacker);
let hash = zobrist_hash(board);
if let Some(entry) = tt.get(&hash) {
if entry.depth >= depth {
audit.tt_hit_count += 1;
audit.tt_hit_events.push(json!({
"node": "or",
"hash": hash,
"requested_depth": depth,
"entry_depth": entry.depth,
"result": tt_result_json(entry.result),
"side_to_move": stone_json(board.side_to_move),
"history": history_json(board),
}));
if matches!(entry.result, TtResult::Fails) {
return false;
}
}
}
let (my, opp) = bb_pair(board, attacker);
let rule_set = board.effective_rule_set();
let opp_has_immediate_five = has_immediate_five(opp, my, attacker.opponent(), rule_set);
let enable_jump_three_attack =
jump_three.attack_defense && or_level < jump_three.attack_max_or_levels;
let attack_moves = gather_attack_moves(
my,
opp,
attacker,
rule_set,
enable_jump_three_attack,
jump_three.gap_four,
);
if attack_moves.is_empty() {
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
return false;
}
for (mv, kind) in attack_moves {
if is_vct_terminal_win(kind) {
if opp_has_immediate_five && kind != ThreatKind::Five {
audit.record_terminal(
"winning_attack_skipped_opp_immediate_five",
json!({
"kind": "winning_attack_skipped_opp_immediate_five",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
}),
);
continue;
}
sequence.push(mv);
audit.record_terminal(
"winning_attack_accepted",
json!({
"kind": "winning_attack_accepted",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
"opp_has_immediate_five": opp_has_immediate_five,
}),
);
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
return true;
}
if opp_has_immediate_five {
audit.record_terminal(
"forcing_attack_skipped_opp_immediate_five",
json!({
"kind": "forcing_attack_skipped_opp_immediate_five",
"depth": depth,
"move": move_json(mv),
"threat": threat_json(kind),
"history": history_json(board),
}),
);
continue;
}
sequence.push(mv);
board.make_move(mv);
let won = vct_and_audit(
board,
attacker,
kind,
depth - 1,
or_level,
deadline,
jump_three,
sequence,
tt,
audit,
stats,
);
board.undo_move();
if stats.hit_deadline() {
sequence.pop();
return false;
}
if won {
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::AttackerWins,
},
);
return true;
}
sequence.pop();
}
tt.insert(
hash,
TtEntry {
depth,
result: TtResult::Fails,
},
);
false
}
fn vct_and_audit(
board: &mut Board,
attacker: Stone,
attack_kind: ThreatKind,
depth: u32,
or_level: u32,
deadline: Option<Instant>,
jump_three: VctJumpThreeFlags,
sequence: &mut Vec<Move>,
tt: &mut TransTable,
audit: &mut VctAuditLog,
stats: &mut VctSearchStats,
) -> bool {
stats.enter_node();
if depth == 0 {
return false;
}
if timed_out(deadline) {
stats.mark_deadline();
return false;
}
debug_assert_ne!(board.side_to_move, attacker);
let node_history = history_json(board);
let last_attack = board.last_move.map(move_json);
let last_attack_threat = threat_json(attack_kind);
let defender = board.side_to_move;
let (def_my, def_opp) = bb_pair(board, defender);
let rule_set = board.effective_rule_set();
let defender_has_immediate_five = has_immediate_five(def_my, def_opp, defender, rule_set);
if defender_has_immediate_five {
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": true,
"defenses": [],
"result": false,
"terminal_reason": "defender_immediate_five",
}));
return false;
}
let defenses = match board.last_move {
Some(attack_mv) => find_defenses_with_counters(board, attack_mv, attack_kind, jump_three),
None => board.candidate_moves(),
};
if defenses.is_empty() {
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": [],
"result": false,
"terminal_reason": "no_defenses",
}));
return false;
}
let checkpoint = sequence.len();
let mut defense_results = Vec::with_capacity(defenses.len());
for mv in defenses {
sequence.truncate(checkpoint);
sequence.push(mv);
let tt_before = audit.tt_hit_count;
board.make_move(mv);
let attacker_still_wins = vct_or_audit(
board,
attacker,
depth - 1,
or_level + 1,
deadline,
jump_three,
sequence,
tt,
audit,
stats,
);
board.undo_move();
if stats.hit_deadline() {
sequence.truncate(checkpoint);
return false;
}
let tt_after = audit.tt_hit_count;
let continuation = sequence[checkpoint..]
.iter()
.map(|&mv| move_json(mv))
.collect::<Vec<_>>();
defense_results.push(json!({
"move": move_json(mv),
"attacker_still_wins": attacker_still_wins,
"tt_hits_delta": tt_after - tt_before,
"sequence_after_len": sequence.len(),
"continuation": continuation,
}));
if !attacker_still_wins {
sequence.truncate(checkpoint);
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": defense_results,
"result": false,
"terminal_reason": "defense_refutes",
}));
return false;
}
}
audit.and_nodes.push(json!({
"node": "and",
"depth": depth,
"or_level": or_level,
"attacker": stone_json(attacker),
"defender": stone_json(defender),
"history": node_history,
"last_attack": last_attack,
"last_attack_threat": last_attack_threat,
"defender_has_immediate_five": false,
"defenses": defense_results,
"result": true,
}));
true
}
fn gather_attack_moves(
my: &BitBoard,
opp: &BitBoard,
side: Stone,
rule_set: RuleSet,
enable_jump_three: bool,
enable_gap_four: bool,
) -> Vec<(Move, ThreatKind)> {
let mut out = Vec::new();
let cells = my.count_ones() + opp.count_ones();
if cells == 0 {
return out;
}
for idx in 0..(BOARD_SIZE * BOARD_SIZE) {
if my.get(idx) || opp.get(idx) {
continue;
}
let kind = classify_move_rules_with_flags(
my,
opp,
idx,
side,
rule_set,
enable_jump_three,
enable_gap_four,
);
if kind.is_forcing() {
out.push((idx, kind));
}
}
out.sort_by_key(|(_, k)| threat_priority(*k));
out
}
fn threat_priority(k: ThreatKind) -> i32 {
match k {
ThreatKind::Five => 0,
ThreatKind::OpenFour => 1,
ThreatKind::DoubleFour => 2,
ThreatKind::FourThree => 3,
ThreatKind::DoubleThree => 4,
ThreatKind::ClosedFour => 5,
ThreatKind::OpenThree => 6,
ThreatKind::JumpThree => 7,
ThreatKind::None => 100,
}
}
fn has_immediate_five(my: &BitBoard, opp: &BitBoard, side: Stone, rule_set: RuleSet) -> bool {
for idx in 0..(BOARD_SIZE * BOARD_SIZE) {
if my.get(idx) || opp.get(idx) {
continue;
}
if classify_move_rules(my, opp, idx, side, rule_set) == ThreatKind::Five {
return true;
}
}
false
}
#[inline]
fn in_board(r: i32, c: i32) -> bool {
r >= 0 && r < BOARD_SIZE as i32 && c >= 0 && c < BOARD_SIZE as i32
}
fn find_defenses_with_counters(
board: &Board,
attack_move: Move,
attack_kind: ThreatKind,
jump_three: VctJumpThreeFlags,
) -> Vec<Move> {
let direct_jump_three_defense = jump_three.attack_defense
&& (!jump_three.kind_scoped_defense || attack_kind == ThreatKind::JumpThree);
let mut defenses = find_defenses(
board,
attack_move,
direct_jump_three_defense,
jump_three.gap_four,
);
let mut seen = BitBoard::EMPTY;
for &d in &defenses {
seen.set(d);
}
let (def_my, def_opp) = bb_pair(board, board.side_to_move);
let rule_set = board.effective_rule_set();
for idx in 0..NUM_CELLS {
if def_my.get(idx) || def_opp.get(idx) || seen.get(idx) {
continue;
}
let kind = classify_move_rules_with_flags(
def_my,
def_opp,
idx,
board.side_to_move,
rule_set,
jump_three.counter,
jump_three.gap_four,
);
if kind.is_forcing() {
seen.set(idx);
defenses.push(idx);
}
}
defenses
}
fn find_defenses(
board: &Board,
attack_move: Move,
enable_jump_three: bool,
enable_gap_four: bool,
) -> Vec<Move> {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let mut seen = BitBoard::EMPTY;
let mut out = Vec::with_capacity(24);
for dr in -2..=2 {
for dc in -2..=2 {
if dr == 0 && dc == 0 {
continue;
}
let nr = row + dr;
let nc = col + dc;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + (nc as usize);
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
for &(dr, dc) in &DIR {
for step in [-4i32, -3, 3, 4] {
let nr = row + dr * step;
let nc = col + dc * step;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + (nc as usize);
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
if enable_jump_three {
append_jump_three_defenses(board, attack_move, &mut seen, &mut out);
}
if enable_gap_four {
append_gap_four_defenses(board, attack_move, &mut seen, &mut out);
}
out
}
fn append_jump_three_defenses(
board: &Board,
attack_move: Move,
seen: &mut BitBoard,
out: &mut Vec<Move>,
) {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let attacker = board.side_to_move.opponent();
let (my, opp) = bb_pair(board, attacker);
for &(dr, dc) in &DIR {
let w = read_window(my, opp, row, col, dr, dc);
for start in 0..=5 {
if !(start <= 5 && 5 < start + 6) {
continue;
}
let s = &w[start..start + 6];
if s != [0, 1, 0, 1, 1, 0] && s != [0, 1, 1, 0, 1, 0] {
continue;
}
for i in 0..6 {
if s[i] != 0 {
continue;
}
let off = (start + i) as i32 - 5;
let nr = row + dr * off;
let nc = col + dc * off;
if !in_board(nr, nc) {
continue;
}
let idx = (nr as usize) * BOARD_SIZE + nc as usize;
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
}
}
}
}
fn append_gap_four_defenses(
board: &Board,
attack_move: Move,
seen: &mut BitBoard,
out: &mut Vec<Move>,
) {
let row = (attack_move / BOARD_SIZE) as i32;
let col = (attack_move % BOARD_SIZE) as i32;
let attacker = board.side_to_move.opponent();
let (my, opp) = bb_pair(board, attacker);
for &(dr, dc) in &DIR {
let w = read_window(my, opp, row, col, dr, dc);
visit_gap_four_gaps(&w, |off| {
let nr = row + dr * off;
let nc = col + dc * off;
if !in_board(nr, nc) {
return;
}
let idx = (nr as usize) * BOARD_SIZE + nc as usize;
if board.is_empty(idx) && !seen.get(idx) {
seen.set(idx);
out.push(idx);
}
});
}
}
fn bb_pair(board: &Board, side: Stone) -> (&BitBoard, &BitBoard) {
match side {
Stone::Black => (&board.black, &board.white),
Stone::White => (&board.white, &board.black),
}
}
fn timed_out(deadline: Option<Instant>) -> bool {
if let Some(d) = deadline {
if Instant::now() >= d {
return true;
}
}
false
}
fn move_json(mv: Move) -> Value {
json!({"x": mv % BOARD_SIZE, "y": mv / BOARD_SIZE})
}
fn history_json(board: &Board) -> Value {
let mut side = Stone::Black;
let moves = board
.history
.iter()
.map(|&mv| {
let out = json!({
"x": mv % BOARD_SIZE,
"y": mv / BOARD_SIZE,
"color": stone_json(side),
});
side = side.opponent();
out
})
.collect::<Vec<_>>();
json!(moves)
}
fn stone_json(side: Stone) -> &'static str {
match side {
Stone::Black => "B",
Stone::White => "W",
}
}
fn threat_json(kind: ThreatKind) -> &'static str {
match kind {
ThreatKind::None => "None",
ThreatKind::ClosedFour => "ClosedFour",
ThreatKind::OpenThree => "OpenThree",
ThreatKind::Five => "Five",
ThreatKind::OpenFour => "OpenFour",
ThreatKind::DoubleFour => "DoubleFour",
ThreatKind::FourThree => "FourThree",
ThreatKind::DoubleThree => "DoubleThree",
ThreatKind::JumpThree => "JumpThree",
}
}
fn tt_result_json(result: TtResult) -> &'static str {
match result {
TtResult::AttackerWins => "attacker_wins",
TtResult::Fails => "fails",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::board::to_idx;
use noru::trainer::SimpleRng;
#[test]
fn pattern4_fast_classify_matches_baseline() {
let mut rng = SimpleRng::new(0xCAFE_BABE);
for trial in 0..1500 {
let mut board = Board::new();
let ply_target = 6 + rng.next_usize(45);
for _ in 0..ply_target {
if !matches!(board.game_result(), crate::board::GameResult::Ongoing) {
break;
}
let candidates = board.candidate_moves();
if candidates.is_empty() {
break;
}
let idx = rng.next_usize(candidates.len());
board.make_move(candidates[idx]);
}
if !matches!(board.game_result(), crate::board::GameResult::Ongoing) {
continue;
}
let side = board.side_to_move;
for &exact5 in &[false, true] {
board.exact5 = exact5;
let (my, opp) = match side {
Stone::Black => (&board.black, &board.white),
Stone::White => (&board.white, &board.black),
};
for cell in 0..NUM_CELLS {
if board.black.get(cell) || board.white.get(cell) {
continue;
}
let baseline = classify_move(my, opp, cell, exact5);
let fast = classify_move_fast(&board, cell, side);
assert_eq!(
baseline, fast,
"mismatch at trial {trial} cell {cell} side {side:?} exact5 {exact5}"
);
}
}
}
}
#[test]
fn test_classify_move_five() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(7, 6));
let k1 = classify_move(&board.black, &board.white, to_idx(7, 2), false);
let k2 = classify_move(&board.black, &board.white, to_idx(7, 7), false);
assert_eq!(k1, ThreatKind::Five, "(7,2) should complete Five");
assert_eq!(k2, ThreatKind::Five, "(7,7) should complete Five");
}
#[test]
fn test_classify_move_open_four() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 7), false);
assert_eq!(k, ThreatKind::OpenFour);
assert_ne!(
k,
ThreatKind::Five,
"open four must not be classified as Five"
);
}
#[test]
fn test_classify_move_jump_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
let mv = to_idx(7, 3);
let slow = classify_move_rules_with_jump_three(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
true,
);
let fast = classify_move_fast_with_jump_three(&board, mv, Stone::Black, true);
assert_eq!(slow, ThreatKind::JumpThree);
assert_eq!(fast, ThreatKind::JumpThree);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::None
);
assert_eq!(
classify_move_fast(&board, mv, Stone::Black),
ThreatKind::None
);
assert!(ThreatKind::JumpThree.is_forcing());
assert!(!ThreatKind::JumpThree.is_winning());
assert!(!is_vct_terminal_win(ThreatKind::JumpThree));
}
#[test]
fn rq560_jump_three_flag_on_off_preserves_legacy_classification() {
let mut left_gap = Board::new();
left_gap.make_move(to_idx(7, 5));
left_gap.make_move(to_idx(0, 0));
left_gap.make_move(to_idx(7, 6));
left_gap.make_move(to_idx(0, 14));
let left_mv = to_idx(7, 3);
assert_eq!(
classify_move_rules_with_jump_three(
&left_gap.black,
&left_gap.white,
left_mv,
Stone::Black,
left_gap.effective_rule_set(),
false,
),
ThreatKind::None
);
assert_eq!(
classify_move_rules_with_jump_three(
&left_gap.black,
&left_gap.white,
left_mv,
Stone::Black,
left_gap.effective_rule_set(),
true,
),
ThreatKind::JumpThree
);
assert_eq!(
classify_move_fast_with_jump_three(&left_gap, left_mv, Stone::Black, false),
ThreatKind::None
);
assert_eq!(
classify_move_fast_with_jump_three(&left_gap, left_mv, Stone::Black, true),
ThreatKind::JumpThree
);
let mut right_gap = Board::new();
right_gap.make_move(to_idx(7, 3));
right_gap.make_move(to_idx(0, 0));
right_gap.make_move(to_idx(7, 4));
right_gap.make_move(to_idx(0, 14));
let right_mv = to_idx(7, 6);
assert_eq!(
classify_move_rules_with_jump_three(
&right_gap.black,
&right_gap.white,
right_mv,
Stone::Black,
right_gap.effective_rule_set(),
false,
),
ThreatKind::None
);
assert_eq!(
classify_move_rules_with_jump_three(
&right_gap.black,
&right_gap.white,
right_mv,
Stone::Black,
right_gap.effective_rule_set(),
true,
),
ThreatKind::JumpThree
);
}
fn board_with_black_stones(stones: &[(usize, usize)]) -> Board {
let fillers = [(0, 0), (0, 14), (14, 0), (14, 14), (1, 0), (1, 14)];
let mut board = Board::new();
for (i, &(row, col)) in stones.iter().enumerate() {
board.make_move(to_idx(row, col));
board.make_move(to_idx(fillers[i].0, fillers[i].1));
}
board
}
#[test]
fn rq567_gap_four_flag_on_off_preserves_legacy_classification() {
let cases = [
(&[(7, 5), (7, 6), (7, 7)][..], to_idx(7, 3)),
(&[(7, 3), (7, 6), (7, 7)][..], to_idx(7, 4)),
(&[(7, 3), (7, 4), (7, 7)][..], to_idx(7, 5)),
];
for (stones, mv) in cases {
let board = board_with_black_stones(stones);
let legacy = classify_move(&board.black, &board.white, mv, false);
assert_eq!(
classify_move_rules_with_flags(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
false,
false,
),
legacy,
"gap-four flag off must preserve legacy classification"
);
assert_ne!(legacy, ThreatKind::ClosedFour);
assert_eq!(
classify_move_rules_with_flags(
&board.black,
&board.white,
mv,
Stone::Black,
board.effective_rule_set(),
false,
true,
),
ThreatKind::ClosedFour
);
assert_eq!(
classify_move_fast_with_flags(&board, mv, Stone::Black, false, true),
ThreatKind::ClosedFour
);
}
assert!(!ThreatKind::ClosedFour.is_winning());
assert!(!is_vct_terminal_win(ThreatKind::ClosedFour));
}
#[test]
fn test_jump_three_is_attack_candidate() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
let moves = gather_attack_moves(
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
false,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == to_idx(7, 3) && kind == ThreatKind::JumpThree),
"jump-three move must enter attack candidates: {:?}",
moves
);
}
#[test]
fn rq560_g90_jump_three_attack_candidate() {
let mut board = Board::new();
for mv in [
to_idx(7, 7),
to_idx(8, 7),
to_idx(5, 8),
to_idx(10, 7),
to_idx(5, 9),
to_idx(6, 8),
to_idx(5, 7),
to_idx(5, 6),
to_idx(4, 7),
to_idx(6, 7),
to_idx(6, 9),
to_idx(7, 10),
] {
board.make_move(mv);
}
let rapfi = to_idx(3, 9);
assert_eq!(
classify_move_fast_with_jump_three(&board, rapfi, Stone::Black, true),
ThreatKind::JumpThree
);
assert_eq!(
classify_move_fast(&board, rapfi, Stone::Black),
ThreatKind::None
);
let moves = gather_attack_moves(
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
false,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == rapfi && kind == ThreatKind::JumpThree),
"g90 rapfi move (9,3) must be a JumpThree attack candidate: {:?}",
moves
);
}
#[test]
fn rq567_g58_gap_four_attack_candidate() {
let mut board = Board::new();
for mv in [
to_idx(7, 7),
to_idx(6, 8),
to_idx(8, 9),
to_idx(11, 10),
to_idx(8, 8),
to_idx(8, 7),
to_idx(9, 9),
to_idx(10, 10),
to_idx(7, 9),
to_idx(10, 9),
to_idx(9, 8),
to_idx(9, 10),
to_idx(8, 10),
to_idx(10, 8),
] {
board.make_move(mv);
}
let rapfi = to_idx(5, 9);
assert_ne!(
classify_move_fast(&board, rapfi, Stone::Black),
ThreatKind::ClosedFour
);
assert_eq!(
classify_move_fast_with_flags(&board, rapfi, Stone::Black, true, true),
ThreatKind::ClosedFour
);
let moves = gather_attack_moves(
&board.black,
&board.white,
Stone::Black,
board.effective_rule_set(),
true,
true,
);
assert!(
moves
.iter()
.any(|&(mv, kind)| mv == rapfi && kind == ThreatKind::ClosedFour),
"g58 rapfi move (9,5) must enter attack candidates as gap-four ClosedFour: {:?}",
moves
);
board.make_move(rapfi);
let defenses = find_defenses(&board, rapfi, false, true);
assert!(
defenses.contains(&to_idx(6, 9)),
"gap-four defense set must include the gap cell (9,6); got {:?}",
defenses
);
}
#[test]
fn test_jump_three_defenses_include_gap_and_completion_cells() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 3));
let defenses = find_defenses(&board, to_idx(7, 3), true, false);
for expected in [to_idx(7, 2), to_idx(7, 4), to_idx(7, 7)] {
assert!(
defenses.contains(&expected),
"jump-three defense set must include {:?}; got {:?}",
expected,
defenses
);
}
}
#[test]
fn classify_move_exact5_overline_not_five() {
let mut board = Board::new();
for (b, w) in [
((7, 2), (0, 0)),
((7, 3), (0, 14)),
((7, 4), (14, 0)),
((7, 5), (14, 14)),
((7, 7), (3, 3)),
] {
board.make_move(to_idx(b.0, b.1));
board.make_move(to_idx(w.0, w.1));
}
let mv = to_idx(7, 6);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::Five,
"freestyle: overline still counts as a win"
);
assert_eq!(
classify_move(&board.black, &board.white, mv, true),
ThreatKind::None,
"standard: overline is not a win"
);
}
#[test]
fn classify_move_exact5_exact_five_still_wins() {
let mut board = Board::new();
for (b, w) in [
((7, 2), (0, 0)),
((7, 3), (0, 14)),
((7, 4), (14, 0)),
((7, 5), (14, 14)),
] {
board.make_move(to_idx(b.0, b.1));
board.make_move(to_idx(w.0, w.1));
}
let mv = to_idx(7, 6);
assert_eq!(
classify_move(&board.black, &board.white, mv, true),
ThreatKind::Five,
"standard: exactly five is a win"
);
assert_eq!(
classify_move(&board.black, &board.white, mv, false),
ThreatKind::Five
);
}
#[test]
fn test_vct_open_four_mate_in_1() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(14, 14));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find mate");
let seq = seq.unwrap();
assert_eq!(seq.len(), 1, "mate in 1");
assert!(
[to_idx(7, 2), to_idx(7, 7)].contains(&seq[0]),
"got {:?}",
seq[0]
);
}
#[test]
fn test_classify_move_double_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(6, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 6), false);
assert_eq!(
k,
ThreatKind::DoubleThree,
"should be double three, got {:?}",
k
);
}
#[test]
fn test_classify_move_four_three() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 14));
board.make_move(to_idx(6, 6));
let k = classify_move(&board.black, &board.white, to_idx(7, 6), false);
assert_eq!(k, ThreatKind::OpenFour, "open four dominates; got {:?}", k);
}
#[test]
fn test_vct_double_three_mate_in_3() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(5, 6));
board.make_move(to_idx(14, 0));
board.make_move(to_idx(6, 6));
board.make_move(to_idx(14, 14));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find VCT mate");
let seq = seq.unwrap();
assert!(seq.len() >= 1, "non-empty sequence");
assert_eq!(seq[0], to_idx(7, 6), "first move must be (7,6) DoubleThree");
}
#[test]
fn test_vct_no_winning_sequence() {
let mut board = Board::new();
board.make_move(to_idx(7, 7));
board.make_move(to_idx(6, 6));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(100)),
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
};
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_none(), "no VCT should exist, got {:?}", seq);
}
#[test]
fn test_vct_loses_to_faster_counter_threat() {
let mut board = Board::new();
board.make_move(to_idx(7, 3));
board.make_move(to_idx(8, 0));
board.make_move(to_idx(7, 4));
board.make_move(to_idx(8, 1));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(8, 2));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(8, 3));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "Five wins before opponent's 4");
let seq = seq.unwrap();
assert_eq!(seq.len(), 1);
assert!([to_idx(7, 2), to_idx(7, 7)].contains(&seq[0]));
}
#[test]
fn test_vct_mate_in_5_chain() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 7));
board.make_move(to_idx(0, 7));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(300)),
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
};
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_some(), "should find mate via open-three chain");
}
#[test]
fn test_vct_tt_consistency() {
let mut board = Board::new();
board.make_move(to_idx(7, 5));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(7, 6));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(7, 7));
board.make_move(to_idx(0, 7));
let cfg = VctConfig {
max_depth: 8,
time_budget: Some(Duration::from_millis(500)),
enable_jump_three: false,
enable_jump_three_attack_defense: false,
enable_jump_three_counter: false,
enable_jump_three_kind_scoped_defense: false,
jump_attack_max_or_levels: u32::MAX,
enable_gap_four: false,
};
let s1 = search_vct(&mut board, &cfg);
let s2 = search_vct(&mut board, &cfg);
assert_eq!(s1.is_some(), s2.is_some(), "VCT should be deterministic");
}
#[test]
fn test_vct_cannot_ignore_opponent_five_threat_for_forcing() {
let mut board = Board::new();
board.make_move(to_idx(7, 4));
board.make_move(to_idx(8, 0));
board.make_move(to_idx(7, 5));
board.make_move(to_idx(8, 1));
board.make_move(to_idx(0, 0));
board.make_move(to_idx(8, 2));
board.make_move(to_idx(0, 14));
board.make_move(to_idx(8, 3));
let cfg = VctConfig::default();
let seq = search_vct(&mut board, &cfg);
assert!(seq.is_none(), "no VCT when opponent has immediate Five");
}
}