Skip to content

Commit bdcb22d

Browse files
committed
feat: enhance game statistics and add perft support in README and codebase
1 parent 4a5384e commit bdcb22d

File tree

9 files changed

+356
-22
lines changed

9 files changed

+356
-22
lines changed

README.md

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,30 @@ This project is **not** a top-tier competitive chess engine and does not current
3939
- full game replay to final position
4040
- **Per-game summaries and aggregate statistics**
4141
- player/result/plies summary fields
42-
- wins/draws/unresolved totals and average plies
42+
- opening move frequencies (White and Black first moves)
43+
- castling distribution (kingside, queenside, no castling)
44+
- total/average captures, checks, and promotions
45+
- average game length by result type
46+
- **Perft support**
47+
- reusable perft API for legal move generation validation
48+
- CLI command for quick node-count checks
4349
- **JSON export**
4450
- structured JSON for aggregate stats and per-game summaries
4551
- **CLI workflows**
4652
- validate PGN files
4753
- summarize reconstructed games
4854
- print aggregate stats (text or JSON)
4955
- inspect FEN positions and legal moves
56+
- run perft against startpos or custom FEN
57+
58+
## Correctness and validation
59+
60+
`ply` treats correctness as a core requirement and validates behavior at multiple layers:
61+
62+
- **Move legality validation**: legal move generation is covered by integration tests for baseline positions and special rules like en passant.
63+
- **Replay validation**: PGN reconstruction resolves SAN against generated legal moves and is exercised with fixture-based tests.
64+
- **Perft regression checks**: known perft node counts (including start position and tricky reference positions) are tested to catch move-generation regressions.
65+
- **CLI-level checks**: command integration tests verify that key workflows (`validate`, `stats --json`, `perft`) execute and emit expected output.
5066

5167
### Current implementation boundaries
5268

@@ -68,6 +84,9 @@ ply stats games.pgn --json
6884

6985
# Parse a FEN and list legal moves in coordinate notation
7086
ply fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" --legal-moves
87+
88+
# Run perft on start position (or pass --fen "<fen>")
89+
ply perft --depth 3
7190
```
7291

7392
You can also run the binary through Cargo during development:
@@ -90,13 +109,34 @@ JSON stats (`ply stats tests/fixtures/sample_games.pgn --json`):
90109
```json
91110
{
92111
"stats": {
93-
"games": 2,
94-
"white_wins": 1,
112+
"average_captures": 0.5,
113+
"average_checks": 0.5,
114+
"average_plies": 7.5,
115+
"average_plies_black_wins": null,
116+
"average_plies_draws": 8.0,
117+
"average_plies_unresolved": null,
118+
"average_plies_white_wins": 7.0,
119+
"average_promotions": 0.0,
120+
"black_first_moves": {
121+
"d5": 1,
122+
"e5": 1
123+
},
95124
"black_wins": 0,
96125
"draws": 1,
97-
"unresolved": 0,
126+
"games": 2,
127+
"games_with_kingside_castle": 0,
128+
"games_with_no_castling": 2,
129+
"games_with_queenside_castle": 0,
130+
"total_captures": 1,
131+
"total_checks": 1,
98132
"total_plies": 15,
99-
"average_plies": 7.5
133+
"total_promotions": 0,
134+
"unresolved": 0,
135+
"white_first_moves": {
136+
"d4": 1,
137+
"e4": 1
138+
},
139+
"white_wins": 1
100140
},
101141
"games": [
102142
{
@@ -119,6 +159,15 @@ JSON stats (`ply stats tests/fixtures/sample_games.pgn --json`):
119159
}
120160
```
121161

162+
Perft (`ply perft --depth 3`):
163+
164+
```text
165+
depth: 3
166+
nodes: 8902
167+
elapsed_ms: 24
168+
nps: 357520
169+
```
170+
122171
## Architecture
123172

124173
The repository is organized as a library-first core with a small command-line frontend.
@@ -161,6 +210,7 @@ cargo run -- validate tests/fixtures/sample_games.pgn
161210
cargo run -- summarize tests/fixtures/sample_games.pgn
162211
cargo run -- stats tests/fixtures/sample_games.pgn --json
163212
cargo run -- fen "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" --legal-moves
213+
cargo run -- perft --depth 3
164214
```
165215

166216
### Run tests
@@ -192,7 +242,6 @@ cargo bench
192242
## Roadmap
193243

194244
- Improve opening metadata extraction and ECO classification support.
195-
- Add perft tooling for move-generation validation and regression checks.
196245
- Introduce Zobrist hashing for efficient position keys and caching.
197246
- Add evaluation/search scaffolding (non-competitive baseline engine components).
198247
- Provide bindings/WASM targets for browser and polyglot tooling integration.

src/export.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::BTreeMap;
2+
13
use serde::Serialize;
24

35
use crate::board::Color;
@@ -22,6 +24,21 @@ pub struct JsonAggregateStats {
2224
pub unresolved: usize,
2325
pub total_plies: usize,
2426
pub average_plies: f64,
27+
pub average_plies_white_wins: Option<f64>,
28+
pub average_plies_black_wins: Option<f64>,
29+
pub average_plies_draws: Option<f64>,
30+
pub average_plies_unresolved: Option<f64>,
31+
pub white_first_moves: BTreeMap<String, usize>,
32+
pub black_first_moves: BTreeMap<String, usize>,
33+
pub games_with_kingside_castle: usize,
34+
pub games_with_queenside_castle: usize,
35+
pub games_with_no_castling: usize,
36+
pub total_captures: usize,
37+
pub average_captures: f64,
38+
pub total_checks: usize,
39+
pub average_checks: f64,
40+
pub total_promotions: usize,
41+
pub average_promotions: f64,
2542
}
2643

2744
pub fn to_json_summary(summary: &GameSummary) -> JsonGameSummary {
@@ -44,6 +61,21 @@ pub fn to_json_aggregate(stats: &AggregateStats) -> JsonAggregateStats {
4461
unresolved: stats.unresolved,
4562
total_plies: stats.total_plies,
4663
average_plies: stats.average_plies,
64+
average_plies_white_wins: stats.average_plies_white_wins,
65+
average_plies_black_wins: stats.average_plies_black_wins,
66+
average_plies_draws: stats.average_plies_draws,
67+
average_plies_unresolved: stats.average_plies_unresolved,
68+
white_first_moves: stats.white_first_moves.clone(),
69+
black_first_moves: stats.black_first_moves.clone(),
70+
games_with_kingside_castle: stats.games_with_kingside_castle,
71+
games_with_queenside_castle: stats.games_with_queenside_castle,
72+
games_with_no_castling: stats.games_with_no_castling,
73+
total_captures: stats.total_captures,
74+
average_captures: stats.average_captures,
75+
total_checks: stats.total_checks,
76+
average_checks: stats.average_checks,
77+
total_promotions: stats.total_promotions,
78+
average_promotions: stats.average_promotions,
4779
}
4880
}
4981

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ pub mod board;
22
pub mod export;
33
pub mod fen;
44
pub mod movegen;
5+
pub mod perft;
56
pub mod pgn;
67
pub mod stats;

src/main.rs

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
use std::fs;
22
use std::path::PathBuf;
3+
use std::time::Instant;
34

45
use clap::{Parser, Subcommand};
56
use ply::export::{to_json_aggregate, to_json_summary};
6-
use ply::fen::{parse_fen, to_fen};
7+
use ply::fen::{parse_fen, to_fen, STARTPOS_FEN};
78
use ply::movegen::generate_legal_moves;
9+
use ply::perft::perft;
810
use ply::pgn::{parse_pgn, reconstruct_game};
9-
use ply::stats::{aggregate_stats, summarize_game, summarize_games};
11+
use ply::stats::{aggregate_record_stats, summarize_game, summarize_games};
1012

1113
#[derive(Parser, Debug)]
1214
#[command(name = "ply")]
@@ -34,6 +36,12 @@ enum Commands {
3436
#[arg(long)]
3537
legal_moves: bool,
3638
},
39+
Perft {
40+
#[arg(long)]
41+
fen: Option<String>,
42+
#[arg(long)]
43+
depth: u8,
44+
},
3745
}
3846

3947
fn main() {
@@ -43,6 +51,7 @@ fn main() {
4351
Commands::Summarize { file } => cmd_summarize(&file),
4452
Commands::Stats { file, json } => cmd_stats(&file, json),
4553
Commands::Fen { fen, legal_moves } => cmd_fen(&fen, legal_moves),
54+
Commands::Perft { fen, depth } => cmd_perft(fen.as_deref(), depth),
4655
};
4756
if let Err(err) = result {
4857
eprintln!("error: {err}");
@@ -96,7 +105,7 @@ fn cmd_stats(file: &PathBuf, json: bool) -> Result<(), String> {
96105
let games = parse_pgn(&content).map_err(|e| format!("{e:?}"))?;
97106
let records = games.iter().filter_map(|g| reconstruct_game(g).ok()).collect::<Vec<_>>();
98107
let summaries = summarize_games(&records);
99-
let stats = aggregate_stats(&summaries);
108+
let stats = aggregate_record_stats(&records);
100109
if json {
101110
let payload = serde_json::json!({
102111
"stats": to_json_aggregate(&stats),
@@ -108,12 +117,41 @@ fn cmd_stats(file: &PathBuf, json: bool) -> Result<(), String> {
108117
);
109118
} else {
110119
println!("games: {}", stats.games);
111-
println!("white_wins: {}", stats.white_wins);
112-
println!("black_wins: {}", stats.black_wins);
113-
println!("draws: {}", stats.draws);
114-
println!("unresolved: {}", stats.unresolved);
115-
println!("total_plies: {}", stats.total_plies);
116-
println!("average_plies: {:.2}", stats.average_plies);
120+
println!("results:");
121+
println!(" white_wins: {}", stats.white_wins);
122+
println!(" black_wins: {}", stats.black_wins);
123+
println!(" draws: {}", stats.draws);
124+
println!(" unresolved: {}", stats.unresolved);
125+
126+
println!("length:");
127+
println!(" total_plies: {}", stats.total_plies);
128+
println!(" average_plies: {:.2}", stats.average_plies);
129+
println!(" average_plies_white_wins: {}", format_optional(stats.average_plies_white_wins));
130+
println!(" average_plies_black_wins: {}", format_optional(stats.average_plies_black_wins));
131+
println!(" average_plies_draws: {}", format_optional(stats.average_plies_draws));
132+
println!(" average_plies_unresolved: {}", format_optional(stats.average_plies_unresolved));
133+
134+
println!("first_moves_white:");
135+
for (mv, count) in &stats.white_first_moves {
136+
println!(" {mv}: {count}");
137+
}
138+
println!("first_moves_black:");
139+
for (mv, count) in &stats.black_first_moves {
140+
println!(" {mv}: {count}");
141+
}
142+
143+
println!("castling:");
144+
println!(" games_with_kingside_castle: {}", stats.games_with_kingside_castle);
145+
println!(" games_with_queenside_castle: {}", stats.games_with_queenside_castle);
146+
println!(" games_with_no_castling: {}", stats.games_with_no_castling);
147+
148+
println!("move_events:");
149+
println!(" total_captures: {}", stats.total_captures);
150+
println!(" average_captures: {:.2}", stats.average_captures);
151+
println!(" total_checks: {}", stats.total_checks);
152+
println!(" average_checks: {:.2}", stats.average_checks);
153+
println!(" total_promotions: {}", stats.total_promotions);
154+
println!(" average_promotions: {:.2}", stats.average_promotions);
117155
}
118156
Ok(())
119157
}
@@ -131,3 +169,24 @@ fn cmd_fen(fen: &str, legal_moves: bool) -> Result<(), String> {
131169
}
132170
Ok(())
133171
}
172+
173+
fn cmd_perft(fen: Option<&str>, depth: u8) -> Result<(), String> {
174+
let fen = fen.unwrap_or(STARTPOS_FEN);
175+
let position = parse_fen(fen).map_err(|e| format!("{e:?}"))?;
176+
177+
let started = Instant::now();
178+
let nodes = perft(&position, depth);
179+
let elapsed = started.elapsed();
180+
let secs = elapsed.as_secs_f64();
181+
let nps = if secs > 0.0 { (nodes as f64 / secs) as u64 } else { 0 };
182+
183+
println!("depth: {depth}");
184+
println!("nodes: {nodes}");
185+
println!("elapsed_ms: {}", elapsed.as_millis());
186+
println!("nps: {nps}");
187+
Ok(())
188+
}
189+
190+
fn format_optional(value: Option<f64>) -> String {
191+
value.map(|v| format!("{v:.2}")).unwrap_or_else(|| "-".to_string())
192+
}

src/perft.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
use crate::board::Position;
2+
use crate::movegen::{apply_move, generate_legal_moves};
3+
4+
pub fn perft(position: &Position, depth: u8) -> u64 {
5+
if depth == 0 {
6+
return 1;
7+
}
8+
9+
let legal_moves = generate_legal_moves(position);
10+
if depth == 1 {
11+
return legal_moves.len() as u64;
12+
}
13+
14+
let mut nodes = 0u64;
15+
for mv in legal_moves {
16+
let mut next = position.clone();
17+
apply_move(&mut next, mv);
18+
nodes += perft(&next, depth - 1);
19+
}
20+
nodes
21+
}

0 commit comments

Comments
 (0)