code.pablomurad.com

emchess

Browser chess with emoji pieces against Stockfish, no server needed

src/game/engine.ts raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import type { Personality } from "./personalities";

const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href;
const WASM_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.wasm`, location.href).href;

// A tiny worker that fetches the wasm as a plain binary and hands it to Stockfish
// through Module.wasmBinary. That path ignores the response's MIME type, so a server
// that serves .wasm as text/plain (a very common misconfig) can't break the engine.
const BOOTSTRAP = `self.onmessage=function(e){var d=e.data||{};if(!d.__boot)return;` +
  `fetch(d.wasmUrl).then(function(r){return r.arrayBuffer();}).then(function(buf){` +
  `self.Module={wasmBinary:buf,locateFile:function(p){return p.slice(-5)==='.wasm'?d.wasmUrl:p;}};` +
  `try{importScripts(d.jsUrl);postMessage('__ready');}catch(err){postMessage('__err '+err);}` +
  `}).catch(function(err){postMessage('__err '+err);});};`;

type Line = string;

export class Engine {
  private worker: Worker;
  private ready: Promise<void>;
  private listeners: ((line: Line) => void)[] = [];

  constructor() {
    const bootUrl = URL.createObjectURL(new Blob([BOOTSTRAP], { type: "text/javascript" }));
    this.worker = new Worker(bootUrl);
    this.worker.onmessage = (e) => {
      const line = typeof e.data === "string" ? e.data : String(e.data?.data ?? "");
      for (const fn of this.listeners) fn(line);
    };
    this.ready = this.boot();
  }

  private send(cmd: string) {
    this.worker.postMessage(cmd);
  }

  private waitFor(token: string, timeout = 0): Promise<void> {
    return new Promise((resolve, reject) => {
      let timer: ReturnType<typeof setTimeout> | undefined;
      const fn = (line: Line) => {
        if (line.includes(token)) {
          this.listeners = this.listeners.filter((l) => l !== fn);
          if (timer) clearTimeout(timer);
          resolve();
        }
      };
      this.listeners.push(fn);
      if (timeout > 0) {
        timer = setTimeout(() => {
          this.listeners = this.listeners.filter((l) => l !== fn);
          reject(new Error("engine timeout waiting for " + token));
        }, timeout);
      }
    });
  }

  private async boot() {
    this.worker.postMessage({ __boot: true, jsUrl: ENGINE_URL, wasmUrl: WASM_URL });
    await this.waitFor("__ready", 20000);
    this.send("uci");
    await this.waitFor("uciok", 20000);
    this.send("isready");
    await this.waitFor("readyok", 20000);
  }

  async configure(p: Personality) {
    try {
      await this.ready;
    } catch {
      return;
    }
    this.send("setoption name Skill Level value " + p.skill);
    this.send("setoption name MultiPV value " + Math.max(1, p.multipv));
    this.send("isready");
    await this.waitFor("readyok", 8000).catch(() => {});
  }

  // Returns a move in UCI form, never before the personality's think floor. Falls back
  // to a legal move if the engine is unavailable, so the game can never hang.
  async chooseMove(fen: string, p: Personality, legal: string[]): Promise<string | null> {
    const started = performance.now();
    const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]);

    let move: string | null;
    let alive = true;
    try {
      await this.ready;
    } catch {
      alive = false;
    }

    if (!alive) {
      move = randomOf(legal);
    } else if (legal.length && Math.random() < p.blunderChance) {
      move = randomOf(legal);
    } else {
      move = (await this.search(fen, p)) ?? randomOf(legal);
    }

    const elapsed = performance.now() - started;
    if (elapsed < floor) await sleep(floor - elapsed);
    return move;
  }

  private search(fen: string, p: Personality): Promise<string | null> {
    return new Promise((resolve) => {
      const candidates = new Map<number, string>();
      let done = false;
      const finish = (m: string | null) => {
        if (done) return;
        done = true;
        clearTimeout(timer);
        this.listeners = this.listeners.filter((l) => l !== fn);
        resolve(m);
      };
      const fn = (line: Line) => {
        if (line.startsWith("info")) {
          const pv = line.match(/multipv (\d+).*? pv (\w+)/);
          if (pv) candidates.set(Number(pv[1]), pv[2]);
        } else if (line.startsWith("bestmove")) {
          const best = line.split(" ")[1];
          finish(best === "(none)" ? null : pick(candidates, best, p.multipv));
        }
      };
      const timer = setTimeout(() => finish(null), p.movetime + 4000);
      this.listeners.push(fn);

      this.send("setoption name Skill Level value " + p.skill);
      this.send("setoption name MultiPV value " + Math.max(1, p.multipv));
      this.send("position fen " + fen);
      this.send("go movetime " + p.movetime);
    });
  }

  // Full-strength evaluation for the coach, from the side-to-move's point of view.
  async evaluate(fen: string, movetime = 500): Promise<{ cp: number | null; mate: number | null }> {
    try {
      await this.ready;
    } catch {
      return { cp: null, mate: null };
    }
    return new Promise((resolve) => {
      let cp: number | null = null;
      let mate: number | null = null;
      let done = false;
      const finish = () => {
        if (done) return;
        done = true;
        clearTimeout(timer);
        this.listeners = this.listeners.filter((l) => l !== fn);
        resolve({ cp, mate });
      };
      const fn = (line: Line) => {
        if (line.startsWith("info")) {
          const s = line.match(/score (cp|mate) (-?\d+)/);
          if (s) {
            if (s[1] === "cp") { cp = Number(s[2]); mate = null; }
            else { mate = Number(s[2]); cp = null; }
          }
        } else if (line.startsWith("bestmove")) {
          finish();
        }
      };
      const timer = setTimeout(finish, movetime + 4000);
      this.listeners.push(fn);
      this.send("setoption name Skill Level value 20");
      this.send("setoption name MultiPV value 1");
      this.send("position fen " + fen);
      this.send("go movetime " + movetime);
    });
  }

  destroy() {
    this.send("quit");
    this.worker.terminate();
  }
}

function pick(candidates: Map<number, string>, best: string, multipv: number): string {
  if (multipv <= 1 || candidates.size <= 1) return best;
  const weights = [0.62, 0.26, 0.09, 0.03];
  const roll = Math.random();
  let acc = 0;
  for (let i = 1; i <= Math.min(multipv, candidates.size); i++) {
    acc += weights[i - 1] ?? 0.02;
    if (roll < acc && candidates.has(i)) return candidates.get(i)!;
  }
  return best;
}

function randomOf(moves: string[]): string | null {
  return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null;
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}