게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개

db.ts

/** Server-only SQLite (Node built-in). MariaGO 패턴. */

import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
import { randomBytes } from "node:crypto";
import type { GameAuth, GameListItem, GamePatch, GameRow, MoveRec } from "@/lib/gameTypes";
import { DEFAULT_SETTINGS } from "@/lib/types";
import { Game } from "@/lib/game";

export type { GameAuth, GameListItem, GamePatch, GameRow, MoveRec } from "@/lib/gameTypes";

const DATA_DIR = path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, "janggy_games.sqlite");

let db: DatabaseSync | null = null;

function newAccessKey(): string {
  return randomBytes(18).toString("base64url");
}

function getDb(): DatabaseSync {
  if (db) return db;
  fs.mkdirSync(DATA_DIR, { recursive: true });
  db = new DatabaseSync(DB_PATH);
  db.exec(`
    CREATE TABLE IF NOT EXISTS games (
      code TEXT PRIMARY KEY,
      guest_id TEXT NOT NULL,
      access_key TEXT,
      cho_formation TEXT NOT NULL DEFAULT 'inner',
      han_formation TEXT NOT NULL DEFAULT 'inner',
      mode TEXT NOT NULL DEFAULT 'human_cho',
      ai_depth INTEGER NOT NULL DEFAULT 3,
      bikjang INTEGER NOT NULL DEFAULT 1,
      snapshot TEXT NOT NULL,
      move_no INTEGER NOT NULL DEFAULT 0,
      epoch INTEGER NOT NULL,
      started INTEGER NOT NULL DEFAULT 0,
      status TEXT NOT NULL DEFAULT 'playing',
      end_text TEXT,
      moves TEXT NOT NULL DEFAULT '[]',
      created_at TEXT NOT NULL,
      updated_at TEXT NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_games_guest_updated
      ON games(guest_id, updated_at DESC);
  `);
  const cols = db.prepare("PRAGMA table_info(games)").all() as { name: string }[];
  if (!cols.some((c) => c.name === "ai_depth_han")) {
    db.exec("ALTER TABLE games ADD COLUMN ai_depth_han INTEGER NOT NULL DEFAULT 3");
  }
  return db;
}

const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

export function newGameCode(): string {
  const d = getDb();
  for (let attempt = 0; attempt < 32; attempt++) {
    let code = "";
    for (let i = 0; i < 6; i++) {
      code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]!;
    }
    const hit = d.prepare("SELECT 1 AS x FROM games WHERE code = ?").get(code);
    if (!hit) return code;
  }
  return `J${Date.now().toString(36).slice(-5).toUpperCase()}`;
}

function ensureAccessKey(code: string, existing: string | null | undefined): string {
  if (existing) return existing;
  const key = newAccessKey();
  getDb().prepare(`UPDATE games SET access_key = ? WHERE code = ?`).run(key, code);
  return key;
}

function emptySnapshot(): string {
  const g = new Game();
  g.newGame(DEFAULT_SETTINGS.choFormation, DEFAULT_SETTINGS.hanFormation);
  return JSON.stringify(g.snapshot());
}

function rowToGame(row: Record<string, unknown>): GameRow {
  const code = String(row.code);
  const accessKey = ensureAccessKey(
    code,
    row.access_key != null ? String(row.access_key) : null,
  );
  return {
    code,
    guest_id: String(row.guest_id),
    access_key: accessKey,
    cho_formation: String(row.cho_formation ?? "inner"),
    han_formation: String(row.han_formation ?? "inner"),
    mode: String(row.mode ?? "human_cho"),
    ai_depth: Number(row.ai_depth ?? 3),
    ai_depth_han: Number(row.ai_depth_han ?? 3),
    bikjang: Boolean(row.bikjang ?? 1),
    snapshot: String(row.snapshot ?? emptySnapshot()),
    move_no: Number(row.move_no),
    epoch: Number(row.epoch),
    started: Boolean(row.started),
    status: row.status === "ended" ? "ended" : "playing",
    end_text: row.end_text != null ? String(row.end_text) : null,
    moves: JSON.parse(String(row.moves || "[]")) as MoveRec[],
    created_at: String(row.created_at),
    updated_at: String(row.updated_at),
  };
}

export type CreateGameOpts = {
  cho_formation?: string;
  han_formation?: string;
  mode?: string;
  ai_depth?: number;
  ai_depth_han?: number;
  bikjang?: boolean;
  snapshot?: string;
};

export function createGame(guestId: string, opts: CreateGameOpts = {}): GameRow {
  const d = getDb();
  const code = newGameCode();
  const accessKey = newAccessKey();
  const now = new Date().toISOString();
  const epoch = Date.now() & 0xffffffff;
  const snapshot = opts.snapshot ?? emptySnapshot();
  d.prepare(
    `INSERT INTO games (
      code, guest_id, access_key, cho_formation, han_formation, mode, ai_depth, ai_depth_han,
      bikjang, snapshot, move_no, epoch, started, status, moves, created_at, updated_at
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, 'playing', '[]', ?, ?)`,
  ).run(
    code,
    guestId,
    accessKey,
    opts.cho_formation ?? "inner",
    opts.han_formation ?? "inner",
    opts.mode ?? "human_cho",
    opts.ai_depth ?? 3,
    opts.ai_depth_han ?? 3,
    opts.bikjang === false ? 0 : 1,
    snapshot,
    epoch,
    now,
    now,
  );
  return getGameRaw(code)!;
}

function getGameRaw(code: string): GameRow | null {
  const d = getDb();
  const row = d.prepare("SELECT * FROM games WHERE code = ?").get(code.toUpperCase());
  if (!row) return null;
  return rowToGame(row as Record<string, unknown>);
}

export function authorizeGame(game: GameRow, auth: GameAuth): boolean {
  const guest = auth.guestId?.trim();
  const key = auth.accessKey?.trim();
  if (guest && guest === game.guest_id) return true;
  if (key && key === game.access_key) return true;
  return false;
}

export function getGameAuthorized(
  code: string,
  auth: GameAuth,
): { game: GameRow } | { error: "not_found" | "forbidden" } {
  const game = getGameRaw(code);
  if (!game) return { error: "not_found" };
  if (!authorizeGame(game, auth)) return { error: "forbidden" };
  return { game };
}

export function getLatestPlayingForGuest(guestId: string): GameRow | null {
  const d = getDb();
  const row = d
    .prepare(
      `SELECT * FROM games
       WHERE guest_id = ? AND status = 'playing' AND (started = 1 OR move_no > 0)
       ORDER BY updated_at DESC LIMIT 1`,
    )
    .get(guestId);
  if (!row) return null;
  return rowToGame(row as Record<string, unknown>);
}

export function listGamesForGuest(guestId: string, limit = 80): GameListItem[] {
  const d = getDb();
  const rows = d
    .prepare(
      `SELECT code, access_key, move_no, started, status, end_text, mode, created_at, updated_at
       FROM games
       WHERE guest_id = ? AND (started = 1 OR move_no > 0)
       ORDER BY updated_at DESC
       LIMIT ?`,
    )
    .all(guestId, limit) as Record<string, unknown>[];

  return rows.map((row) => {
    const code = String(row.code);
    const accessKey = ensureAccessKey(code, row.access_key != null ? String(row.access_key) : null);
    const endText = row.end_text != null ? String(row.end_text) : null;
    return {
      code,
      access_key: accessKey,
      move_no: Number(row.move_no),
      started: Boolean(row.started),
      status: row.status === "ended" ? ("ended" as const) : ("playing" as const),
      end_text: endText,
      summary: endText ? endText.split("\n")[0] : null,
      mode: String(row.mode ?? "human_cho"),
      created_at: String(row.created_at),
      updated_at: String(row.updated_at),
    };
  });
}

export function getActiveDbFileName(): string {
  return path.basename(DB_PATH);
}

export function updateGameAuthorized(
  code: string,
  auth: GameAuth,
  patch: GamePatch,
): { game: GameRow } | { error: "not_found" | "forbidden" } {
  const authed = getGameAuthorized(code, auth);
  if ("error" in authed) return authed;
  const d = getDb();
  const now = new Date().toISOString();
  const r = d
    .prepare(
      `UPDATE games SET
        cho_formation = ?, han_formation = ?, mode = ?, ai_depth = ?,
        ai_depth_han = COALESCE(?, ai_depth_han), bikjang = ?,
        snapshot = ?, move_no = ?, epoch = ?, started = ?, status = ?, end_text = ?,
        moves = ?, updated_at = ?
       WHERE code = ?`,
    )
    .run(
      patch.cho_formation,
      patch.han_formation,
      patch.mode,
      patch.ai_depth,
      patch.ai_depth_han ?? null,
      patch.bikjang ? 1 : 0,
      patch.snapshot,
      patch.move_no,
      patch.epoch,
      patch.started ? 1 : 0,
      patch.status,
      patch.end_text ?? null,
      JSON.stringify(patch.moves),
      now,
      code.toUpperCase(),
    );
  if (r.changes === 0) return { error: "not_found" };
  const game = getGameRaw(code);
  if (!game) return { error: "not_found" };
  return { game };
}