Go game records
SGF game records read and written with their variations, escapes and compressed point lists; moves listed for any line of play, a board replayed with captures, ko and suicide rules and positional superko on request, and a position scored by area, with dead stones named by hand.
Version 1.0.0 · 7 functions · 1 crate
Functions
What use go_sgf.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use sgf_parse, sgf_moves from go_sgf (Quillo 0.6).
sgf_parse(text: str) → anyor an errorAn SGF file read: [{ info, result, moves, nodes, variations, tree }], one for each game in it. info has names like black, white, komi and date; result reads B+R and W+3.5; moves counts the main line and tree keeps every property, as a list of branches that sgf_write takes back.
sgf_moves(text: str, options: any) → anyor an errorThe moves of a line: [{ number, color, point, sgf, comment }], point like D4 or pass. options: { path: [..], game }: at each fork take that variation, 0 being the main line.
sgf_variations(text: str) → anyor an errorEach line of play from the root to a leaf: [{ path, moves, last }], with the path to give sgf_moves and go_replay.
sgf_write(tree: any) → stror an errorA tree back to SGF text, as sgf_parse gives it or built by hand: a list of branches [{ parent, nodes: [{ B: "pd" }] }], the first with parent -1, and each later one naming its parent's place in the list, which must come before it. Escapes ] and \\.
sgf_from_moves(moves: list, info: any) → stror an errorA game from moves, as SGF: moves a list of points like "D4" or "pass", black first unless info.handicap says, and info of { size, komi, black, white, date, result }.
go_replay(text: str, options: any) → anyor an errorA game played out on a board: { board, moves, captured: { black, white }, ko, to_play }. board is rows of ., X and O. Illegal moves stop it with an error value saying which move and why. options: { move (stop after that many), path, superko (positional), suicide (allowed, as under New Zealand rules) }.
go_score(game: str, options: any) → anyor an errorA position scored by area, Tromp-Taylor style: stones on the board plus empty regions only one colour touches, komi to white. Takes SGF text or rows of .XO. options: { komi (else the file's, or 7.5), dead: stones to take off first, as points like D4, move, path }. Dead stones must be named: nothing here guesses life and death.
Get it
$ quillo install go_sgf # go_sgf.qwer into this folder$ quillo install go_sgf --example # and its example, go_sgf.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/go_sgf.qwer$ curl -O https://quillolang.org/plugins/files/go_sgf.qweThen, in your program, all of it or only what you call:
use go_sgf.qweruse sgf_parse, sgf_moves from go_sgf // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// Go game records: an SGF file read, its variations listed, a game replayed with captures and ko,// an illegal move refused, a position scored, and a new record written.//// quillo run go_sgf.qwe// then open http://localhost:8080/read, /moves, /replay, /ko, /score, /write and /refuseduse "go_sgf.qwer"// A short game with a comment holding an escaped bracket, a pass, and two variations.let game = "(;GM[1]FF[4]CA[UTF-8]SZ[9]KM[6.5]PB[Ada]BR[3k]PW[Bob]WR[2k]DT[2026-11-01]RE[W+3.5]C[A friendly game \\[teaching\\]];B[ee]C[Tengen];W[cc](;B[gc];W[cg];B[tt])(;B[ge]C[A slower line];W[cg](;B[gg])(;B[gc];W[ee])))"route GET /read { let g = first(sgf_parse(game)?) { info: g.info, result: g.result, moves: g.moves, nodes: g.nodes, variations: g.variations, root: first(first(g.tree).nodes).C }}route GET /moves = { main: sgf_moves(game, {})?, second: sgf_moves(game, { path: [1, 0] })?, lines: sgf_variations(game)?,}// Black takes the white stone at E5 in the corner of a little fight.let capture = "(;SZ[9];B[dd];W[ee];B[ed];W[tt];B[fe];W[tt];B[ef];W[tt];B[de])"// A ko: black takes at D5; white may not take back at once, but may after a move elsewhere.let ko = "(;SZ[9]AB[cd][dc][de]AW[ec][ee][fd][dd];B[ed];W[dd])"let ko_waited = "(;SZ[9]AB[cd][dc][de]AW[ec][ee][fd][dd];B[ed];W[aa];B[ba];W[dd])"route GET /replay { let r = go_replay(capture, {})? { board: r.board, captured: r.captured, to_play: r.to_play, halfway: go_replay(capture, { move: 3 })?.board[3], last: last(r.log), }}route GET /ko = { position: go_replay(ko, { move: 1 })?.ko, refused: go_replay(ko, {}).error, after_a_threat: go_replay(ko_waited, {})?.captured,}let life = "XXXXOOOO.XXXXOOOO.XXXXOOOOOXXXXOOOOOXXXXOOOOOXXXXOOOOOXXXXOOOOOXX.XO.OOOXXXXOOOOO"let dead_stone = ".X.O.XX.OO.X.O.XX.OO.XOO."route GET /score = { file: go_score(game, { path: [0] })?, board: go_score(life, {})?, even: go_score(life, { komi: 0.0 })?.result, with_dead: go_score(dead_stone, { komi: 0.5, dead: ["C1"] })?, without: go_score(dead_stone, { komi: 0.5 })?.result,}route GET /write { let g = first(sgf_parse(game)?) let text = sgf_write(g.tree)? { same_moves: sgf_moves(text, { path: [1, 1] })? == sgf_moves(game, { path: [1, 1] })?, escaped: contains(text, "[teaching\\]"), built: sgf_from_moves(["D4", "F6", "pass", "J9"], { size: 9, komi: 6.5, black: "Ada", white: "Bob" })?, }}// Forks 80 deep, as a teaching file might have: too deep to be nested maps.fn deep(n) = reduce(range(n), "", (rest, i) => "(;B[aa];W[bb]" + rest + "(;B[cc]))")route GET /deep { let text = "(;SZ[9]" + deep(80) + ")" let g = first(sgf_parse(text)?) { variations: g.variations, branches: len(g.tree), same: sgf_write(g.tree)? == text }}route GET /refused = { taken: go_replay("(;SZ[9];B[ee];W[ee])", {}).error, suicide: go_replay("(;SZ[9]AB[ba][ab];W[aa])", {}).error, allowed: go_replay("(;SZ[9]AB[ba][ab];W[aa])", { suicide: true })?.captured, superko: go_replay("(;SZ[5];B[aa];W[tt])", { superko: true })?.moves, broken: sgf_parse("(;GM[1]SZ[9").error, no_tree: sgf_parse("hello").error, off: sgf_moves("(;SZ[9];B[zz])", {}).error, fork: sgf_moves(game, { path: [5] }).error, dead: go_score(life, { dead: ["C2"] }).error, column: sgf_from_moves(["I3"], {}).error, tree: sgf_write([{ parent: 3, nodes: [] }]).error,}$ quillo run go_sgf.qwe$ curl 'localhost:8080/read'$ curl 'localhost:8080/moves'$ curl 'localhost:8080/replay'$ curl 'localhost:8080/ko'$ curl 'localhost:8080/score'$ curl 'localhost:8080/write'$ curl 'localhost:8080/deep'$ curl 'localhost:8080/refused'Source
//! Go game records in SGF: read and written with their variations and escapes, a board replayed with captures, ko and suicide rules, and a position scored by area.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::HashSet;const MAX: usize = 32 * 1024 * 1024;#[derive(Clone, Default)]struct Tree { nodes: Vec<Vec<(String, Vec<String>)>>, kids: Vec<Tree>,}struct P<'a> { b: &'a [u8], i: usize, depth: usize,}impl<'a> P<'a> { fn ws(&mut self) { while self.i < self.b.len() && self.b[self.i].is_ascii_whitespace() { self.i += 1; } } fn at(&self) -> usize { self.i } // A value: `\` takes the next character literally; a `\` before a line break joins lines. fn value(&mut self) -> Result<String, String> { self.i += 1; let mut o: Vec<u8> = vec![]; while self.i < self.b.len() { match self.b[self.i] { b']' => { self.i += 1; return Ok(String::from_utf8_lossy(&o).into_owned()); } b'\\' => { self.i += 1; match self.b.get(self.i) { Some(b'\n') => { self.i += 1; if self.b.get(self.i) == Some(&b'\r') { self.i += 1; } } Some(b'\r') => { self.i += 1; if self.b.get(self.i) == Some(&b'\n') { self.i += 1; } } Some(c) => { o.push(*c); self.i += 1; } None => break, } } c => { o.push(c); self.i += 1; } } } Err(format!("a value at byte {} has no closing `]`", self.at())) } fn tree(&mut self) -> Result<Tree, String> { self.depth += 1; if self.depth > 2000 { return Err("variations nest too deeply".into()); } self.i += 1; let mut t = Tree::default(); self.ws(); while self.b.get(self.i) == Some(&b';') { self.i += 1; let mut node = vec![]; loop { self.ws(); let s = self.i; while self.i < self.b.len() && self.b[self.i].is_ascii_alphabetic() { self.i += 1; } if s == self.i { break; } // Old files write `GaMe`: the capitals are the property. let id: String = self.b[s..self.i].iter().filter(|c| c.is_ascii_uppercase()).map(|c| *c as char).collect(); self.ws(); if self.b.get(self.i) != Some(&b'[') { return Err(format!("property `{}` at byte {} has no value", id, s)); } let mut vals = vec![]; while self.b.get(self.i) == Some(&b'[') { vals.push(self.value()?); self.ws(); } node.push((id, vals)); } t.nodes.push(node); self.ws(); } if t.nodes.is_empty() { return Err(format!("a game tree at byte {} has no node", self.at())); } while self.b.get(self.i) == Some(&b'(') { t.kids.push(self.tree()?); self.ws(); } if self.b.get(self.i) != Some(&b')') { return Err(format!("expected `)` at byte {}", self.at())); } self.i += 1; self.depth -= 1; Ok(t) }}fn parse(text: &str) -> Result<Vec<Tree>, String> { if text.len() > MAX { return Err("that SGF is over 32 MB".into()); } let mut p = P { b: text.as_bytes(), i: 0, depth: 0 }; let mut out = vec![]; // Text before the first `(;` is not part of the file (FF[4] allows that). while let Some(r) = text[p.i..].find("(") { p.i += r; let save = p.i; p.i += 1; p.ws(); let good = p.b.get(p.i) == Some(&b';'); p.i = save; if !good { if out.is_empty() { p.i += 1; continue; } return Err(format!("expected `(;` at byte {}", p.i)); } out.push(p.tree()?); p.ws(); } if out.is_empty() { return Err("no game tree: an SGF file starts `(;`".into()); } Ok(out)}// A list of branches, each naming its parent, rather than nested maps: Quillo takes a plugin's value// only to 127 levels, and a game with 60 forks in a line would go past that.fn tree_rows(t: &Tree, parent: i64, out: &mut Vec<Value>) { let node = |n: &Vec<(String, Vec<String>)>| -> Value { Value::Object(n.iter().map(|(k, v)| (k.clone(), if v.len() == 1 { json!(v[0]) } else { json!(v) })).collect::<Map<_, _>>()) }; let me = out.len() as i64; out.push(json!({ "parent": parent, "nodes": t.nodes.iter().map(node).collect::<Vec<_>>() })); for k in &t.kids { tree_rows(k, me, out); }}fn tree_json(t: &Tree) -> Value { let mut out = vec![]; tree_rows(t, -1, &mut out); Value::Array(out)}fn prop<'a>(t: &'a Tree, id: &str) -> Option<&'a str> { t.nodes.first()?.iter().find(|p| p.0 == id).and_then(|p| p.1.first()).map(|s| s.as_str())}fn size_of(t: &Tree) -> Result<(usize, usize), String> { let s = prop(t, "SZ").unwrap_or("19"); let (a, b) = s.split_once(':').unwrap_or((s, s)); let w: usize = a.trim().parse().map_err(|_| format!("the board size `{}` is not a number", s))?; let h: usize = b.trim().parse().map_err(|_| format!("the board size `{}` is not a number", s))?; if !(1..=52).contains(&w) || !(1..=52).contains(&h) { return Err(format!("a board is 1 to 52 lines, not {}", s)); } Ok((w, h))}const INFO: &[(&str, &str)] = &[ ("GM", "game"), ("FF", "format"), ("CA", "charset"), ("AP", "application"), ("SZ", "size"), ("KM", "komi"), ("HA", "handicap"), ("RU", "rules"), ("RE", "result"), ("PB", "black"), ("PW", "white"), ("BR", "black_rank"), ("WR", "white_rank"), ("BT", "black_team"), ("WT", "white_team"), ("DT", "date"), ("EV", "event"), ("RO", "round"), ("PC", "place"), ("GN", "name"), ("GC", "comment"), ("TM", "main_time"), ("OT", "overtime"), ("SO", "source"), ("US", "user"), ("AN", "annotator"),];fn info(t: &Tree) -> Value { let mut m = Map::new(); for (k, name) in INFO { if let Some(v) = prop(t, k) { m.insert((*name).to_string(), json!(v)); } } Value::Object(m)}fn result_of(r: &str) -> Value { let u = r.trim().to_uppercase(); if u == "0" || u == "DRAW" { return json!({ "winner": null, "by": "draw", "points": null }); } if u.is_empty() || u == "?" || u == "VOID" { return json!({ "winner": null, "by": if u == "VOID" { "void" } else { "unknown" }, "points": null }); } let (who, how) = u.split_once('+').unwrap_or((&u, "")); let winner = match who { "B" => "black", "W" => "white", _ => return json!({ "winner": null, "by": r, "points": null }), }; match how { "R" | "RESIGN" => json!({ "winner": winner, "by": "resignation", "points": null }), "T" | "TIME" => json!({ "winner": winner, "by": "time", "points": null }), "F" | "FORFEIT" => json!({ "winner": winner, "by": "forfeit", "points": null }), "" => json!({ "winner": winner, "by": "unknown", "points": null }), p => json!({ "winner": winner, "by": "points", "points": p.parse::<f64>().ok() }), }}fn point(s: &str, h: usize, w: usize) -> Result<Option<(usize, usize)>, String> { if s.is_empty() || (s == "tt" && w <= 19 && h <= 19) { return Ok(None); // A pass. } let c = |b: u8| -> Option<usize> { match b { b'a'..=b'z' => Some((b - b'a') as usize), b'A'..=b'Z' => Some((b - b'A') as usize + 26), _ => None, } }; let b = s.as_bytes(); if b.len() != 2 { return Err(format!("`{}` is not a point", s)); } let (x, y) = (c(b[0]).ok_or(format!("`{}` is not a point", s))?, c(b[1]).ok_or(format!("`{}` is not a point", s))?); if x >= w || y >= h { return Err(format!("`{}` is off the board", s)); } Ok(Some((x, y)))}fn points_of(vals: &[String], w: usize, h: usize) -> Result<Vec<(usize, usize)>, String> { let mut out = vec![]; for v in vals { // `aa:cc` is a rectangle. if let Some((a, b)) = v.split_once(':') { let (p, q) = (point(a, h, w)?.ok_or("a rectangle needs real points")?, point(b, h, w)?.ok_or("a rectangle needs real points")?); for x in p.0..=q.0 { for y in p.1..=q.1 { out.push((x, y)); } } } else if let Some(p) = point(v, h, w)? { out.push(p); } } Ok(out)}// GTP style: columns skip I, rows count from the bottom.fn coord(x: usize, y: usize, h: usize) -> String { let c = if x < 8 { (b'A' + x as u8) as char } else { (b'A' + x as u8 + 1) as char }; format!("{}{}", c, h - y)}#[derive(Clone)]struct Board { w: usize, h: usize, cell: Vec<u8>, // 0 empty, 1 black, 2 white. ko: Option<usize>, caps: [usize; 3],}impl Board { fn new(w: usize, h: usize) -> Board { Board { w, h, cell: vec![0; w * h], ko: None, caps: [0; 3] } } fn near(&self, i: usize) -> Vec<usize> { let (x, y) = (i % self.w, i / self.w); let mut o = Vec::with_capacity(4); if x > 0 { o.push(i - 1); } if x + 1 < self.w { o.push(i + 1); } if y > 0 { o.push(i - self.w); } if y + 1 < self.h { o.push(i + self.w); } o } fn group(&self, i: usize) -> (Vec<usize>, usize) { let color = self.cell[i]; let mut seen = vec![false; self.cell.len()]; let mut libs = HashSet::new(); let mut stack = vec![i]; let mut stones = vec![]; seen[i] = true; while let Some(s) = stack.pop() { stones.push(s); for n in self.near(s) { if self.cell[n] == 0 { libs.insert(n); } else if self.cell[n] == color && !seen[n] { seen[n] = true; stack.push(n); } } } (stones, libs.len()) } // Plays a stone; gives what is wrong with the move, if anything. fn play(&mut self, color: u8, i: usize) -> Result<usize, String> { if self.cell[i] != 0 { return Err("that point is taken".into()); } if self.ko == Some(i) { return Err("that retakes a ko at once".into()); } self.cell[i] = color; let other = 3 - color; let mut taken = vec![]; for n in self.near(i) { if self.cell[n] == other { let (stones, libs) = self.group(n); if libs == 0 { for s in &stones { self.cell[*s] = 0; } taken.extend(stones); } } } let (own, libs) = self.group(i); if libs == 0 { self.cell[i] = 0; return Err("that is suicide".into()); } self.caps[color as usize] += taken.len(); // Ko: one stone taken by one stone that is left with one liberty, the taken point. self.ko = if taken.len() == 1 && own.len() == 1 && libs == 1 { Some(taken[0]) } else { None }; Ok(taken.len()) } fn lines(&self) -> Vec<String> { self.cell.chunks(self.w).map(|r| r.iter().map(|c| ['.', 'X', 'O'][*c as usize]).collect()).collect() }}fn rules_of(options: &Value) -> Result<(bool, bool), String> { let superko = options.get("superko").and_then(|v| v.as_bool()).unwrap_or(false); let suicide = options.get("suicide").and_then(|v| v.as_bool()).unwrap_or(false); Ok((superko, suicide))}struct Line { setup: Vec<(u8, Vec<(usize, usize)>)>, moves: Vec<(u8, Option<(usize, usize)>, Option<String>)>,}// Follows `path`, the variation to take at each fork; 0 throughout is the main line.fn line_of(t: &Tree, path: &[usize], w: usize, h: usize) -> Result<Line, String> { let mut l = Line { setup: vec![], moves: vec![] }; let mut cur = t; let mut fork = 0; loop { for n in &cur.nodes { let mut comment = None; for (k, v) in n { match k.as_str() { "AB" => l.setup.push((1, points_of(v, w, h)?)), "AW" => l.setup.push((2, points_of(v, w, h)?)), "AE" => l.setup.push((0, points_of(v, w, h)?)), "C" => comment = v.first().cloned(), _ => {} } } for (k, v) in n { if k == "B" || k == "W" { l.moves.push((if k == "B" { 1 } else { 2 }, point(v.first().map(|s| s.as_str()).unwrap_or(""), h, w)?, comment.clone())); } } } if cur.kids.is_empty() { return Ok(l); } let i = path.get(fork).copied().unwrap_or(0); cur = cur.kids.get(i).ok_or(format!("fork {} has {} variations, not {}", fork + 1, cur.kids.len(), i + 1))?; fork += 1; }}fn path_of(v: &Value) -> Vec<usize> { v.get("path").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|x| x.as_u64()).map(|x| x as usize).collect()).unwrap_or_default()}fn replay(t: &Tree, options: &Value) -> Result<(Board, Vec<Value>, usize), String> { let (w, h) = size_of(t)?; let (superko, suicide) = rules_of(options)?; let upto = options.get("move").and_then(|v| v.as_u64()).map(|x| x as usize).unwrap_or(usize::MAX); let line = line_of(t, &path_of(options), w, h)?; let mut b = Board::new(w, h); for (c, ps) in &line.setup { for (x, y) in ps { b.cell[y * w + x] = *c; } } let mut seen: HashSet<Vec<u8>> = HashSet::new(); seen.insert(b.cell.clone()); let mut log = vec![]; for (i, (c, p, comment)) in line.moves.iter().enumerate() { if i >= upto { break; } let who = if *c == 1 { "black" } else { "white" }; let (at, taken) = match p { None => { b.ko = None; ("pass".to_string(), 0) } Some((x, y)) => { let spot = y * w + x; let name = coord(*x, *y, h); let taken = match b.play(*c, spot) { Ok(n) => n, Err(e) if suicide && e == "that is suicide" => { let (stones, _) = { b.cell[spot] = *c; b.group(spot) }; for s in &stones { b.cell[*s] = 0; } b.caps[3 - *c as usize] += stones.len(); 0 } Err(e) => return Err(format!("move {}, {} at {}: {}", i + 1, who, name, e)), }; if superko && !seen.insert(b.cell.clone()) { return Err(format!("move {}, {} at {}: that repeats an earlier position", i + 1, who, name)); } (name, taken) } }; log.push(json!({ "number": i + 1, "color": who, "point": at, "captured": taken, "comment": comment })); } let n = log.len(); Ok((b, log, n))}/// An SGF file read: [{ info, result, moves, nodes, variations, tree }], one for each game in it. `info` has names like black, white, komi and date; `result` reads `B+R` and `W+3.5`; `moves` counts the main line and `tree` keeps every property, as a list of branches that sgf_write takes back.pub fn sgf_parse(text: &str) -> Result<Value, String> { let games = parse(text)?; let mut out = vec![]; for t in &games { let (w, h) = size_of(t)?; let line = line_of(t, &[], w, h)?; fn count(t: &Tree) -> (usize, usize) { let (n, v) = t.kids.iter().map(count).fold((t.nodes.len(), t.kids.len().saturating_sub(1)), |a, b| (a.0 + b.0, a.1 + b.1)); (n, v) } let (nodes, vars) = count(t); out.push(json!({ "info": info(t), "result": prop(t, "RE").map(result_of), "moves": line.moves.len(), "nodes": nodes, "variations": vars + 1, "tree": tree_json(t), })); } Ok(json!(out))}/// The moves of a line: [{ number, color, point, sgf, comment }], `point` like D4 or `pass`. `options`: { path: [..], game }: at each fork take that variation, 0 being the main line.pub fn sgf_moves(text: &str, options: serde_json::Value) -> Result<Value, String> { let g = parse(text)?; let t = g.get(options.get("game").and_then(|v| v.as_u64()).unwrap_or(0) as usize).ok_or("no such game in the file")?; let (w, h) = size_of(t)?; let l = line_of(t, &path_of(&options), w, h)?; Ok(json!(l.moves.iter().enumerate().map(|(i, (c, p, cm))| json!({ "number": i + 1, "color": if *c == 1 { "black" } else { "white" }, "point": p.map(|(x, y)| coord(x, y, h)).unwrap_or("pass".into()), "sgf": p.map(|(x, y)| format!("{}{}", (b'a' + x as u8) as char, (b'a' + y as u8) as char)).unwrap_or_default(), "comment": cm, })).collect::<Vec<_>>()))}/// Each line of play from the root to a leaf: [{ path, moves, last }], with the `path` to give sgf_moves and go_replay.pub fn sgf_variations(text: &str) -> Result<Value, String> { let g = parse(text)?; let t = g.first().ok_or("no game")?; let (w, h) = size_of(t)?; let mut out = vec![]; fn walk(t: &Tree, path: &mut Vec<usize>, leaves: &mut Vec<Vec<usize>>) { if t.kids.is_empty() { leaves.push(path.clone()); } for (i, k) in t.kids.iter().enumerate() { path.push(i); walk(k, path, leaves); path.pop(); } } let mut leaves = vec![]; walk(t, &mut vec![], &mut leaves); for p in leaves { let l = line_of(t, &p, w, h)?; out.push(json!({ "path": p, "moves": l.moves.len(), "last": l.moves.last().and_then(|m| m.1).map(|(x, y)| coord(x, y, h)) })); } Ok(json!(out))}fn esc(v: &str) -> String { v.replace('\\', "\\\\").replace(']', "\\]")}fn write_branch(rows: &[Value], at: usize, kids: &[Vec<usize>], o: &mut String) -> Result<(), String> { o.push('('); for n in rows[at].get("nodes").and_then(|x| x.as_array()).ok_or("a branch has `nodes`")? { o.push(';'); for (k, val) in n.as_object().ok_or("a node is a map")? { if k.is_empty() || !k.bytes().all(|b| b.is_ascii_uppercase()) { return Err(format!("`{}` is not a property name", k)); } o.push_str(k); match val { Value::Array(a) => { for x in a { o.push_str(&format!("[{}]", esc(x.as_str().unwrap_or(&x.to_string())))); } } x => o.push_str(&format!("[{}]", esc(x.as_str().unwrap_or(&x.to_string())))), } } } for k in &kids[at] { write_branch(rows, *k, kids, o)?; } o.push(')'); Ok(())}/// A `tree` back to SGF text, as sgf_parse gives it or built by hand: a list of branches [{ parent, nodes: [{ B: "pd" }] }], the first with parent -1, and each later one naming its parent's place in the list, which must come before it. Escapes `]` and `\\`.pub fn sgf_write(tree: serde_json::Value) -> Result<String, String> { let rows = tree.as_array().filter(|a| !a.is_empty()).ok_or("a tree is a list of branches")?; let mut kids = vec![vec![]; rows.len()]; for (i, r) in rows.iter().enumerate() { let p = r.get("parent").and_then(|v| v.as_i64()).ok_or(format!("branch {} has no parent", i))?; if i == 0 { if p != -1 { return Err("the first branch has parent -1".into()); } } else if p < 0 || p >= i as i64 { return Err(format!("branch {} has parent {}, which is not before it", i, p)); } else { kids[p as usize].push(i); } } let mut o = String::new(); write_branch(rows, 0, &kids, &mut o)?; Ok(o)}/// A game from moves, as SGF: `moves` a list of points like "D4" or "pass", black first unless `info.handicap` says, and `info` of { size, komi, black, white, date, result }.pub fn sgf_from_moves(moves: Vec<String>, info: serde_json::Value) -> Result<String, String> { let size = info.get("size").and_then(|v| v.as_i64()).unwrap_or(19); if !(1..=25).contains(&size) { return Err(format!("a board here is 1 to 25 lines, not {}", size)); } let h = size as usize; let mut o = format!("(;GM[1]FF[4]CA[UTF-8]SZ[{}]", size); for (k, id) in [("komi", "KM"), ("black", "PB"), ("white", "PW"), ("date", "DT"), ("result", "RE")] { if let Some(v) = info.get(k) { o.push_str(&format!("{}[{}]", id, esc(v.as_str().map(String::from).unwrap_or(v.to_string()).as_str()))); } } let mut black = true; for m in &moves { let u = m.trim().to_uppercase(); let spot = if u == "PASS" { String::new() } else { let c = u.chars().next().ok_or("an empty move")?; let x = (c as i64) - 65 - if c > 'I' { 1 } else { 0 }; if c == 'I' || !(0..size).contains(&x) { return Err(format!("`{}` is off a {}-line board: columns skip I", m, size)); } let row: usize = u[1..].parse().map_err(|_| format!("`{}` is not a point", m))?; if row < 1 || row > h { return Err(format!("`{}` is off a {}-line board", m, size)); } format!("{}{}", (b'a' + x as u8) as char, (b'a' + (h - row) as u8) as char) }; o.push_str(&format!(";{}[{}]", if black { "B" } else { "W" }, spot)); black = !black; } o.push(')'); Ok(o)}/// A game played out on a board: { board, moves, captured: { black, white }, ko, to_play }. `board` is rows of `.`, `X` and `O`. Illegal moves stop it with an error value saying which move and why. `options`: { move (stop after that many), path, superko (positional), suicide (allowed, as under New Zealand rules) }.pub fn go_replay(text: &str, options: serde_json::Value) -> Result<Value, String> { let g = parse(text)?; let t = g.first().ok_or("no game")?; let (b, log, n) = replay(t, &options)?; let last_black = log.last().map(|m| m["color"] == "black").unwrap_or(false); Ok(json!({ "board": b.lines(), "moves": n, "log": log, "captured": { "black": b.caps[1], "white": b.caps[2] }, "ko": b.ko.map(|k| coord(k % b.w, k / b.w, b.h)), "to_play": if last_black { "white" } else { "black" }, }))}fn score_cells(b: &Board, komi: f64, dead: &HashSet<usize>) -> Value { let mut cell = b.cell.clone(); for d in dead { cell[*d] = 0; } let n = Board { cell, ..b.clone() }; let mut seen = vec![false; n.cell.len()]; let (mut bs, mut ws) = (0usize, 0usize); let (mut bt, mut wt, mut neutral) = (0usize, 0usize, 0usize); for (i, c) in n.cell.iter().enumerate() { match c { 1 => bs += 1, 2 => ws += 1, _ if !seen[i] => { // An empty region is a colour's if only that colour touches it (Tromp-Taylor). let (mut touch, mut stack, mut size) = (0u8, vec![i], 0); seen[i] = true; while let Some(s) = stack.pop() { size += 1; for x in n.near(s) { if n.cell[x] == 0 { if !seen[x] { seen[x] = true; stack.push(x); } } else { touch |= n.cell[x]; } } } match touch { 1 => bt += size, 2 => wt += size, _ => neutral += size, } } _ => {} } } let (black, white) = ((bs + bt) as f64, (ws + wt) as f64 + komi); let margin = (black - white).abs(); let result = if black > white { format!("B+{}", margin) } else if white > black { format!("W+{}", margin) } else { "0".to_string() }; json!({ "black": { "stones": bs, "territory": bt, "total": black }, "white": { "stones": ws, "territory": wt, "komi": komi, "total": white }, "neutral": neutral, "result": result })}/// A position scored by area, Tromp-Taylor style: stones on the board plus empty regions only one colour touches, komi to white. Takes SGF text or rows of `.XO`. `options`: { komi (else the file's, or 7.5), dead: stones to take off first, as points like D4, move, path }. Dead stones must be named: nothing here guesses life and death.pub fn go_score(game: &str, options: serde_json::Value) -> Result<Value, String> { let (b, file_komi) = if game.trim_start().starts_with('(') { let g = parse(game)?; let t = g.first().ok_or("no game")?; let k = prop(t, "KM").and_then(|v| v.parse::<f64>().ok()); (replay(t, &options)?.0, k) } else { let rows: Vec<&str> = game.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect(); let (h, w) = (rows.len(), rows.first().map(|r| r.chars().count()).unwrap_or(0)); if h == 0 || rows.iter().any(|r| r.chars().count() != w) { return Err("rows of a board must be the same length".into()); } let mut b = Board::new(w, h); for (y, r) in rows.iter().enumerate() { for (x, c) in r.chars().enumerate() { b.cell[y * w + x] = match c.to_ascii_uppercase() { '.' | '+' | '-' => 0, 'X' | 'B' | '#' => 1, 'O' | 'W' => 2, o => return Err(format!("`{}` is not a stone: use `.`, `X` and `O`", o)), }; } } (b, None) }; let komi = options.get("komi").and_then(|v| v.as_f64()).or(file_komi).unwrap_or(7.5); let mut dead = HashSet::new(); for d in options.get("dead").and_then(|v| v.as_array()).into_iter().flatten() { let t = d.as_str().ok_or("dead stones are points like D4")?.to_uppercase(); let c = t.chars().next().ok_or("an empty point")?; let x = (c as i64) - 65 - if c > 'I' { 1 } else { 0 }; let row: usize = t[1..].parse().map_err(|_| format!("`{}` is not a point", t))?; if x < 0 || x as usize >= b.w || row < 1 || row > b.h { return Err(format!("`{}` is off the board", t)); } let i = (b.h - row) * b.w + x as usize; if b.cell[i] == 0 { return Err(format!("no stone at {}", t)); } dead.extend(b.group(i).0); } Ok(score_cells(&b, komi, &dead))}Related
dice.qwer · 1.0.0Dice notation parsed and rolled from a seed: `4d6kh3`, `d20adv`, exploding and compounding dice, rerolls, success counts, Fudge dice and arithmetic, each die shown, and the exact distribution of an expression worked out by convolution rather than sampled, with the chance of meeting a target.3 views · 9 downloadsMazes maze.qwer · 1.0.0Mazes generated from a seed by eight algorithms, from a recursive backtracker's long corridors to Wilson's unbiased walks; solved by breadth-first search, measured for dead ends, loops and the longest path, loosened with loops by a braid setting, and drawn as text and as a compact SVG.2 views · 8 downloadsPoker hands poker.qwer · 1.0.0Poker hands of 5 to 7 cards evaluated and named, showdowns with split pots and kickers, Omaha's rule of exactly two from the hand and three from the board, equity counted exactly on the flop and turn and simulated before it from a seed, and decks dealt from a seed.2 views · 8 downloadsSudoku sudoku.qwer · 1.0.0Sudoku read from 81 characters or a drawn grid, validated with its conflicts named, solved with a count of solutions, generated from a seed with exactly one solution, graded by the techniques a person needs, from hidden singles to x-wings, and a next step given as an explained hint.4 views · 7 downloadsThread 0 posts
On Go game records: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.