Something went wrong. Try again.
A bot for https://go.sky.boo that lets you play Go against TypeSafe's Jev.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179use anyhow::Result;use tracing::debug;use typesafe_ai_rs::{Choice, Client, Question, SystemOneRequest};
/// What Jev decided to do this turn.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Action { Place(usize, usize), Pass, Resign,}
/// Jev's move selection with confidence information. Callers should log/// this as one structured event at the point the action is actually/// posted, not scattered across each branch here — this function just/// decides.pub struct JevDecision { pub action: Action, /// Difference between top and 2nd-highest probability. /// 1.0 = absolute certainty, 0.0 = coin flip. pub margin: f64, /// Top probability values for logging. pub top_probs: Vec<(String, f64)>, /// Where this decision came from: "jev", "fallback" (Jev's choice /// wasn't in the valid set), or "last_resort" (Jev API call failed). pub source: &'static str,}
/// No hesitation for now — trying an instant bot to see how it feels.pub fn hesitation_delay(_margin: f64) -> std::time::Duration { std::time::Duration::ZERO}
/// The API caps Choice criteria at 255 options; reserve two slots for/// "pass" and "resign".const MAX_PLACEMENT_CHOICES: usize = 253;
/// General strategic principles for a beginner/lower-mid kyu player —/// not tactics the model has to search for, since `state` already hands/// it per-candidate captures/atari/liberties-after and a territory/// estimate. This is judgment for weighing those candidates against/// each other.const STRATEGY_GUIDE: &str = "\You are choosing an action for the current player in this Go game: either \a placement from the candidates given in `state`, or \"pass\", or \"resign\". \The state has three tiers of candidates by tactical relevance:\n\- `candidates`: full annotations (captures, atari threats, liberties-after)\n\- `candidates_brief`: coord + captures only\n\- `candidates_available`: coord only — legal but tactically unexplored\n\All three tiers are valid choices. Prefer fully-annotated candidates when \the tactical picture is clear; use brief/available candidates when they offer \a better positional fit. Apply these principles:- Corners are cheapest to secure, then sides, then center — early on, prefer \open corners and sides over central plays.- Avoid contact plays (touching an enemy stone) without a clear reason; \contact strengthens both sides and often helps the weaker side more.- Attend to your weakest, most threatened group before taking points \elsewhere — a group that dies erases everything built around it.- Favor securing two eyes for a group in danger over a few points elsewhere.- Keep your stones connected and avoid shapes that leave easy cuts.- When the position looks favorable, simplify and reduce risk; when behind, \look to complicate the position or pressure a weak enemy group rather than \take small guaranteed points.- Late in the game, when few open points remain, take the biggest points \first, favoring moves that force a response over ones that don't.- Pass when no move improves your position: every remaining placement fills \your own territory, gains nothing, or the position is settled.- Resign only when decisively lost with no realistic path back — don't \resign from a merely difficult position, only a hopeless one.";
pub async fn select_move( api_key: &str, state: &str, valid_placements: &[(usize, usize)],) -> Result<JevDecision> { // Cap at the API's 255-option Choice limit. The candidates in `state` // are already sorted by tactical importance (see build_state), so the // first N placements carry the most signal. let capped: &[ (usize, usize)] = if valid_placements.len() > MAX_PLACEMENT_CHOICES { &valid_placements[..MAX_PLACEMENT_CHOICES] } else { valid_placements };
let valid_set: std::collections::HashSet<String> = capped .iter() .map(|(x, y)| format!("{},{}", x, y)) .collect();
let mut criteria: Vec<(String, serde_json::Value)> = capped .iter() .map(|(x, y)| (format!("{},{}", x, y), serde_json::Value::Null)) .collect(); criteria.push(("pass".to_string(), serde_json::Value::Null)); criteria.push(("resign".to_string(), serde_json::Value::Null));
let request = SystemOneRequest::new( serde_json::json!({ "state": state }), [( "best_move", Question::from( Choice::new(criteria.iter().map(|(k, v)| (k.as_str(), v.clone()))) .instructions(STRATEGY_GUIDE) ), )], );
debug!("Jev request for {} valid placements (capped from {})", capped.len(), valid_placements.len());
let client = Client::builder().api_key(api_key).build()?;
let response = client .system_one(request) .await .map_err(|e| anyhow::anyhow!("Jev API error: {}", e))?;
let probs = &response.choices()["best_move"].probabilities; let choice = response.choices()["best_move"].choice.clone();
// Compute margin: top prob − 2nd prob, among placements plus pass/resign let mut sorted_probs: Vec<(String, f64)> = probs .iter() .filter(|(k, _)| valid_set.contains(k.as_str()) || k.as_str() == "pass" || k.as_str() == "resign") .map(|(k, v)| (k.clone(), *v)) .collect(); sorted_probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let margin = if sorted_probs.len() >= 2 { sorted_probs[0].1 - sorted_probs[1].1 } else if sorted_probs.len() == 1 { sorted_probs[0].1 } else { 0.0 };
let top_probs: Vec<(String, f64)> = sorted_probs.iter().take(3).cloned().collect();
let as_action = |choice: &str| -> Option<Action> { match choice { "pass" => Some(Action::Pass), "resign" => Some(Action::Resign), coord if valid_set.contains(coord) => { let parts: Vec<&str> = coord.split(',').collect(); let x: usize = parts.first()?.parse().ok()?; let y: usize = parts.get(1)?.parse().ok()?; Some(Action::Place(x, y)) } _ => None, } };
// Primary: use Jev's choice if let Some(action) = as_action(&choice) { return Ok(JevDecision { action, margin, top_probs, source: "jev" }); }
// Fallback: highest-probability valid choice — Jev's raw choice wasn't // itself valid. if let Some((coord, _prob)) = sorted_probs.first() { if let Some(action) = as_action(coord) { return Ok(JevDecision { action, margin, top_probs, source: "fallback" }); } }
// Last resort: pick the highest-importance placement (first in the // capped list, which is sorted by tactical importance) instead of // board-order valid[0] which walks the top edge. if let Some(&(x, y)) = capped.first() { return Ok(JevDecision { action: Action::Place(x, y), margin: 0.0, top_probs: vec![], source: "last_resort", }); }
anyhow::bail!("No valid placements available")}