Drop7 Research
src/bench/d7p-server.tsTypeScript139 lines · 4.5 KB
/**
 * A stdin/stdout server speaking the Drop7 Policy Protocol (D7P), the
 * UCI-style text interface documented in docs/d7p-protocol.md. It wraps one
 * registry policy so any benchmark harness — in any language — can drive it:
 *
 *   node --experimental-strip-types src/bench/d7p-server.ts --policy greedy
 *
 * Protocol summary:
 *   -> d7p                                  <- id name / id family / d7pok
 *   -> isready                              <- readyok
 *   -> position board <49ch> next <1-7> rise <1-5>   (or "position startpos ...")
 *   -> go                                   <- bestmove <0-6>
 *   -> quit
 */
import { createInterface } from "node:readline";
import {
  BOARD_SIZE,
  MOVES_PER_LEVEL,
  createInitialBoard,
  legalColumns,
  type Board,
  type Cell,
  type DiscValue,
  type GameState,
} from "../core/typescript/engine.ts";
import { getPolicy } from "./policies.ts";

export interface D7pPosition {
  board: Board;
  nextDisc: DiscValue;
  movesRemaining: number;
}

export function parsePosition(tokens: readonly string[]): D7pPosition {
  let board: Board | null = null;
  let nextDisc: DiscValue | null = null;
  let movesRemaining: number | null = null;
  const rest = [...tokens];
  if (rest[0] === "startpos") {
    board = createInitialBoard();
    rest.shift();
  }
  while (rest.length > 0) {
    const token = rest.shift();
    if (token === "board") {
      const encoded = rest.shift() ?? "";
      if (encoded.length !== BOARD_SIZE * BOARD_SIZE) {
        throw new Error("board must be 49 characters");
      }
      const cells = [...encoded].map((char) => Number(char) as Cell);
      if (
        cells.some(
          (cell) =>
            !Number.isInteger(cell) || cell < 0 || cell > 9,
        )
      ) {
        throw new Error("board cells must be digits 0-9");
      }
      board = cells;
    } else if (token === "next") {
      const value = Number(rest.shift());
      if (!Number.isInteger(value) || value < 1 || value > BOARD_SIZE) {
        throw new Error("next must be a disc value 1-7");
      }
      nextDisc = value as DiscValue;
    } else if (token === "rise") {
      const value = Number(rest.shift());
      if (!Number.isInteger(value) || value < 1 || value > MOVES_PER_LEVEL) {
        throw new Error(`rise must be 1-${MOVES_PER_LEVEL}`);
      }
      movesRemaining = value;
    } else {
      throw new Error(`unknown position token: ${token}`);
    }
  }
  if (!board || nextDisc === null || movesRemaining === null) {
    throw new Error("position needs board (or startpos), next, and rise");
  }
  return { board, nextDisc, movesRemaining };
}

export function stateFromPosition(position: D7pPosition): GameState {
  return {
    board: position.board,
    nextDisc: position.nextDisc,
    score: 0,
    level: 1,
    movesRemaining: position.movesRemaining,
    movesPlayed: 0,
    gameOver: legalColumns(position.board).length === 0,
  };
}

function parsePolicyArg(argv: readonly string[]): string {
  const index = argv.indexOf("--policy");
  if (index === -1 || !argv[index + 1]) {
    throw new Error("usage: d7p-server.ts --policy <id>");
  }
  return argv[index + 1];
}

function main() {
  const policy = getPolicy(parsePolicyArg(process.argv.slice(2)));
  let position: D7pPosition | null = null;
  const send = (line: string) => process.stdout.write(`${line}\n`);

  const rl = createInterface({ input: process.stdin, terminal: false });
  rl.on("line", (line) => {
    const tokens = line.trim().split(/\s+/).filter(Boolean);
    const command = tokens[0];
    try {
      if (command === "d7p") {
        send(`id name ${policy.name}`);
        send(`id family ${policy.family}`);
        send(`id public-information ${policy.publicInformation ? "true" : "false"}`);
        send("d7pok");
      } else if (command === "isready") {
        send("readyok");
      } else if (command === "position") {
        position = parsePosition(tokens.slice(1));
      } else if (command === "go") {
        if (!position) throw new Error("no position");
        const column = policy.chooseColumn(stateFromPosition(position));
        send(`bestmove ${column ?? "none"}`);
      } else if (command === "quit") {
        rl.close();
      } else if (command) {
        send(`info unknown command ${command}`);
      }
    } catch (error) {
      send(`info error ${(error as Error).message}`);
    }
  });
}

// Run only when executed directly, not when imported by tests.
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"))) {
  main();
}