Skip to content

Commit 9e14d41

Browse files
committed
inital commit
0 parents  commit 9e14d41

1,097 files changed

Lines changed: 30822 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
branches: [main, master]
8+
9+
jobs:
10+
lint:
11+
name: Lint
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Lua
17+
uses: leafo/gh-actions-lua@v9
18+
with:
19+
luaVersion: "5.4"
20+
buildCache: false
21+
22+
- name: Run tests
23+
run: ./bin/test.lua

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/.idea

README.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Delta Chess Engine
2+
3+
Single-threaded, async interface for Lua chess engines. Engines are **stateless**; position and options are passed per calculation. Supports ELO-based difficulty. Tests validate engines by self-play: each move is checked for legality.
4+
5+
## Interface
6+
7+
Engines register with **DeltaChess.Engines** and implement:
8+
9+
| Requirement | Description |
10+
|-------------|-------------|
11+
| `.id` | Unique identifier (string), e.g. `"beat_highest_piece"`. |
12+
| `.name` | Display name (string). |
13+
| `.description` | Optional short description. |
14+
| `GetEloRange()` | Returns `{min, max}` (numbers). |
15+
| `Calculate(state, yieldFn, onComplete)` | Async: call `yieldFn(next)` to yield (runner calls `next()` later); call `onComplete(result, err)` when done. |
16+
17+
**State** (per calculation):
18+
19+
- `state.fen` (required) – current position in FEN.
20+
- `state.moves` (optional) – list of moves that led to this position (UCI or SAN).
21+
- `state.elo` – requested strength.
22+
- `state.time_limit_ms` (optional) – max time in ms.
23+
- `state.cancelled` – set by runner; engine should abort when true.
24+
25+
**Return:** `(resultTable, err)`. On success, `resultTable.move` is UCI (e.g. `e2e4`, `e7e8q`).
26+
27+
**Optional:** `GetAverageCpuTime(elo)` for UI sorting.
28+
29+
## Registry API (DeltaChess.Engines)
30+
31+
- `Register(engine)` – register engine (must have `.id`, `.name`, `GetEloRange`, `Calculate`).
32+
- `Get(id)` – get engine by id (or effective default if id is nil).
33+
- `GetEngineList()` – list `{id, name, description, maxElo}` sorted by max ELO.
34+
- `GetEloRange(engineId)` – returns `{min, max}` or nil.
35+
- `GetGlobalEloRange()` – global min/max across all engines.
36+
- `GetEnginesForElo(elo)` – engines supporting that ELO, sorted by efficiency.
37+
- `SetDefaultId(id)` / `GetDefaultId()` / `GetEffectiveDefaultId()`.
38+
- `Unregister(id)`.
39+
40+
## Async runner (WoW-compatible, builder pattern)
41+
42+
- **Callback-based:** engines receive `yieldFn(next)` and `onComplete(result, err)`. Use `yieldFn(next)` to yield; the runner calls `next()` later (e.g. via `C_Timer.After(0, next)` in WoW).
43+
- **Builder:** `Runner.Create(engineId)` returns a builder. Chain state/options then `Run()`:
44+
- `:Fen(fen)` – position. Default: build from `:Moves()` (if set), else initial position.
45+
- `:Moves(moves)` – optional. Moves that led to this position (UCI). Used to build FEN if `:Fen()` not set.
46+
- `:Elo(elo)` – optional. Requested difficulty. Default: average of engine's ELO range.
47+
- `:TimeLimitMs(ms)` – optional. Max time in ms. Default: 20000 (20 seconds).
48+
- `:OnComplete(cb)``function(result, err)`. Required before `Run()`. If the engine returns an illegal move, the runner calls `onComplete(nil, err)` with `err` an object `{ message = "illegal move", move = "<uci>" }`.
49+
- `:Scheduler(fn)` – optional. `function(next)`. Default: call `next()` immediately.
50+
- `:Run()` – start calculation.
51+
52+
## Global registration (WoW addon compatible)
53+
54+
All functionality is registered on the global **DeltaChess** table:
55+
56+
- `DeltaChess.Engines` – registry and engine interface
57+
- `DeltaChess.MoveGen` – FEN, legal moves, move validation
58+
- `DeltaChess.EngineRunner``Create(engineId)` (builder)
59+
- `DeltaChess.Engines.BeatHighestPiece` – built-in engine (after load)
60+
61+
**Single init file (e.g. World of Warcraft):** Use `lua/Init.lua` as the only script in your TOC. Before loading, set `DeltaChess.AddonPath` to the addon directory (e.g. `"Interface\\AddOns\\DeltaChess\\"`). Init will load the other Lua files via `loadfile` and register engines. If `AddonPath` is not set, Init only registers engines (assume other files were loaded by the TOC in order).
62+
63+
**Load order** when using multiple TOC entries: `MoveGen.lua``EngineInterface.lua``Runner.lua``Engines/BeatHighestPiece.lua``Init.lua`.
64+
65+
## Usage
66+
67+
**With require (standalone):**
68+
```lua
69+
package.path = "lua/?.lua;lua/?/init.lua;" .. (package.path or "")
70+
require("engines.init") -- register all engines
71+
72+
local Runner = DeltaChess.EngineRunner
73+
Runner.Create("beat_highest_piece")
74+
:Fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
75+
:Elo(500)
76+
:OnComplete(function(result, err)
77+
if not err then -- use result.move
78+
end
79+
end)
80+
:Run()
81+
```
82+
83+
**With global (after init.lua or TOC load):**
84+
```lua
85+
local Runner = DeltaChess.EngineRunner
86+
Runner.Create("beat_highest_piece")
87+
:Fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
88+
:Elo(500)
89+
:OnComplete(function(result, err)
90+
if not err then -- use result.move
91+
end
92+
end)
93+
:Run()
94+
```
95+
96+
**World of Warcraft (async with C_Timer.After):**
97+
```lua
98+
DeltaChess.EngineRunner.Create("beat_highest_piece")
99+
:Fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
100+
:Elo(500)
101+
:OnComplete(function(result, err)
102+
if err then return end
103+
-- use result.move
104+
end)
105+
:Scheduler(function(next) C_Timer.After(0, next) end)
106+
:Run()
107+
```
108+
Engines receive `yieldFn(next)` and call it when they want to yield; the scheduler runs `next()` on the next frame so the game stays responsive.
109+
110+
**CLI game (bin/play.lua):** Run from repo root. Engine vs itself or engine1 vs engine2, with optional ELOs:
111+
```bash
112+
lua bin/play.lua <engine1> [elo1] [engine2] [elo2]
113+
# Examples:
114+
lua bin/play.lua beat_highest_piece
115+
lua bin/play.lua beat_highest_piece 500
116+
lua bin/play.lua beat_highest_piece 500 other_engine 600
117+
```
118+
119+
## Engines
120+
121+
- **beat_highest_piece** – prefers checkmate, then highest-value capture, else random; ELO 0–1500.
122+
123+
Add new engines in `src/Engines/` (with `.id`, `.name`, `GetEloRange`, `Calculate`) and load/register them in `init.lua`.
124+
125+
## Tests and CI
126+
127+
- **Interface tests:** `lua tests/test_interface.lua` (from repo root)
128+
- **Engine self-play:** `lua tests/test_engine_selfplay.lua` – each engine plays against itself; runner validates every move (illegal moves reported via `onComplete(nil, err)`).
129+
130+
GitHub Actions (`.github/workflows/ci.yml`) runs both on push/PR to `main`/`master`.
131+
132+
## Layout
133+
134+
```
135+
init.lua # Single entry: loadfile's all modules, registers engines (WoW addon)
136+
bin/
137+
play.lua # CLI: engine vs itself or engine1 vs engine2 (optional ELOs)
138+
src/
139+
EngineInterface.lua # DeltaChess.Engines registry and interface contract
140+
EngineRunner.lua # DeltaChess.Runner (callback-based, WoW-compatible)
141+
MoveGen.lua # DeltaChess.MoveGen (legal move generation)
142+
Engines/
143+
BeatHighestPiece.lua # DeltaChess.Engines.BeatHighestPiece
144+
tests/
145+
test_interface.lua
146+
test_engine_selfplay.lua
147+
.github/workflows/
148+
ci.yml
149+
```

bin/benchmark.lua

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env lua
2+
--[[
3+
Benchmark: run every engine at all supported ELO settings (100 ELO steps)
4+
vs random moves. Play maximum of 4 engine moves per test; measure average
5+
thinking time per move. Every 10 tests print a ranking table (engine, elo,
6+
avg ms, time/elo) sorted by time/elo.
7+
Usage: lua bin/benchmark.lua
8+
]]
9+
10+
local scriptPath = debug.getinfo(1, "S").source:match("@?(.*/)")
11+
if not scriptPath then scriptPath = "./" end
12+
local projectRoot = scriptPath .. "../"
13+
package.path = projectRoot .. "src/?.lua;src/?/init.lua;lib/?.lua;?.lua;" .. (package.path or "")
14+
15+
DeltaChess = DeltaChess or {}
16+
require("init")
17+
18+
local EngineRunner = DeltaChess.EngineRunner
19+
local Engines = DeltaChess.Engines
20+
local Constants = DeltaChess.Constants
21+
local Board = DeltaChess.Board
22+
local MoveGen = DeltaChess.MoveGen
23+
24+
local ELO_STEP = 100
25+
local MAX_ENGINE_MOVES = 4
26+
27+
-- Build list of { engineId, elo } for every engine at 100 ELO steps (min to max)
28+
local function buildTests()
29+
local tests = {}
30+
for id, engine in pairs(Engines.Registry or {}) do
31+
if type(engine.GetEloRange) == "function" then
32+
local ok, range = pcall(function() return engine:GetEloRange() end)
33+
if ok and range and range[1] and range[2] then
34+
local minElo, maxElo = range[1], range[2]
35+
for elo = minElo, maxElo, ELO_STEP do
36+
table.insert(tests, { engineId = id, elo = elo })
37+
end
38+
end
39+
end
40+
end
41+
return tests
42+
end
43+
44+
-- Pick a random legal move from current board position. Returns UCI string or nil.
45+
local function randomLegalMove(board)
46+
local legal = board:LegalMoves()
47+
if not legal or #legal == 0 then return nil end
48+
local idx = math.random(1, #legal)
49+
return MoveGen.MoveToUci(legal[idx])
50+
end
51+
52+
-- Run one test: engine vs random, up to MAX_ENGINE_MOVES engine moves; return average thinking time in ms or nil on error.
53+
-- Runs synchronously via Scheduler(next) => next().
54+
local function runOneTest(engineId, elo)
55+
local board = Board.New()
56+
local times = {} -- list of thinking times in seconds (from os.clock())
57+
local engineMoveCount = 0
58+
local done = false
59+
local errMsg = nil
60+
61+
local function playRandomMove()
62+
local uci = randomLegalMove(board)
63+
if not uci then return true end -- game over (no legal moves)
64+
local ok = board:MakeMoveUci(uci)
65+
if not ok then return true end
66+
return false
67+
end
68+
69+
local function doEngineMove(cont)
70+
if done then if cont then cont() end return end
71+
if board:IsEnded() then
72+
done = true
73+
if cont then cont() end
74+
return
75+
end
76+
if engineMoveCount >= MAX_ENGINE_MOVES then
77+
done = true
78+
if cont then cont() end
79+
return
80+
end
81+
82+
local fen = board:GetFen()
83+
local t0 = os.clock()
84+
85+
EngineRunner.Create(engineId)
86+
:Fen(fen)
87+
:Elo(elo)
88+
:Scheduler(function(next) next() end)
89+
:OnComplete(function(res, err)
90+
local t1 = os.clock()
91+
if err or not res or not res.move then
92+
errMsg = err and (err.message or tostring(err)) or "no move"
93+
done = true
94+
if cont then cont() end
95+
return
96+
end
97+
local ok = board:MakeMoveUci(res.move)
98+
if not ok then
99+
errMsg = "illegal move"
100+
done = true
101+
if cont then cont() end
102+
return
103+
end
104+
engineMoveCount = engineMoveCount + 1
105+
table.insert(times, t1 - t0)
106+
107+
if board:IsEnded() or engineMoveCount >= MAX_ENGINE_MOVES then
108+
done = true
109+
if cont then cont() end
110+
return
111+
end
112+
-- Random reply
113+
if playRandomMove() then
114+
done = true
115+
if cont then cont() end
116+
return
117+
end
118+
doEngineMove(cont)
119+
end)
120+
:Run()
121+
end
122+
123+
-- Engine plays white (first move); runs synchronously via Scheduler(next) next()
124+
doEngineMove(function() end)
125+
126+
if errMsg then return nil, errMsg end
127+
if #times == 0 then return nil, "no moves" end
128+
local sum = 0
129+
for _, t in ipairs(times) do sum = sum + t end
130+
return (sum / #times) * 1000 -- average ms
131+
end
132+
133+
-- Results: { engineId, elo, avgTimeMs, timePerElo }
134+
local results = {}
135+
136+
local function printRanking()
137+
local list = {}
138+
for _, r in ipairs(results) do
139+
list[#list + 1] = {
140+
engineId = r.engineId,
141+
elo = r.elo,
142+
avgTimeMs = r.avgTimeMs,
143+
timePerElo = r.timePerElo,
144+
}
145+
end
146+
table.sort(list, function(a, b) return a.timePerElo < b.timePerElo end)
147+
io.write("\n--- Ranking (time/elo, lower is better) ---\n")
148+
io.write(string.format(" %-24s %6s %12s %12s\n", "Engine", "ELO", "Avg(ms)", "Time/ELO"))
149+
io.write(" " .. string.rep("-", 56) .. "\n")
150+
for i, row in ipairs(list) do
151+
io.write(string.format(" %2d. %-20s %6d %12.2f %12.4f\n",
152+
i, row.engineId, row.elo, row.avgTimeMs, row.timePerElo))
153+
end
154+
io.write("\n")
155+
end
156+
157+
local tests = buildTests()
158+
if #tests == 0 then
159+
io.stderr:write("No engines with GetEloRange found.\n")
160+
os.exit(1)
161+
end
162+
163+
math.randomseed(os.time())
164+
io.write("Benchmark: " .. #tests .. " tests (each engine at 100 ELO steps, 4 moves vs random).\n\n")
165+
166+
for i, t in ipairs(tests) do
167+
io.write("[" .. i .. "/" .. #tests .. "] " .. t.engineId .. " @" .. t.elo .. " ... ")
168+
io.flush()
169+
local avgMs, err = runOneTest(t.engineId, t.elo)
170+
if not avgMs then
171+
io.write("FAIL (" .. tostring(err) .. ")\n")
172+
else
173+
local timePerElo = avgMs / t.elo
174+
table.insert(results, {
175+
engineId = t.engineId,
176+
elo = t.elo,
177+
avgTimeMs = avgMs,
178+
timePerElo = timePerElo,
179+
})
180+
io.write(string.format("%.2f ms avg\n", avgMs))
181+
end
182+
if i % 10 == 0 and #results > 0 then
183+
printRanking()
184+
end
185+
end
186+
187+
if #results > 0 then
188+
io.write("Done. Final ")
189+
printRanking()
190+
end

0 commit comments

Comments
 (0)