Files
Poker/server/review/store.js
T
agomizi1cmandClaude Opus 5.5 3f2515a9fa Add admin-only CPU tables with per-hand reviews
A new table type, "review" (レビュー付きCPU戦), can only be created by
admins. Only the creator and CPUs sit there: friends cannot join, and the
table is hidden from non-admin lobbies. After every hand a review opens,
and the next hand waits until the creator presses "次のハンドへ".

- Preflop: shows which color of the registered hand range the hole cards
  are and whether the action followed it. With no entrant before you,
  open with the color for the number of players still to act; facing an
  entrant, estimate their range from their position, call one color
  above it and raise two above. Purple ranks just below white (SB only).
- Flop onward: equity vs ranges estimated from those colors (narrowed by
  later bets/calls) gives a rough EV per option. The score is the EV lost
  against the best option, relative to the pot. It is a guide, not GTO.
- Also: pot-odds call check, all-in equity and luck-adjusted P&L,
  VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance,
  habits to watch, worst-5 hands, a P&L chart and a step-by-step replay
  on the admin-only /review page.

Hand now logs every action for the review. The range data moves to
admin/range-data.js, shared by the range page and the server. Reviews are
saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are
no longer cached for a day, so updates show up immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:11:16 +09:00

129 lines
3.8 KiB
JavaScript

'use strict';
// レビューの保存先(data/reviews/<id>.json)。テーブルを閉じた後も見返せるようにファイルに残す。
// 対局中のものはメモリにも置き、ハンドが終わるたびに書き出す
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const DIR = process.env.REVIEW_DIR || path.join(__dirname, '..', '..', 'data', 'reviews');
const MAX_SESSIONS = 50; // これより古い対局は消す
const MAX_HANDS = 1000; // 1回の対局で保存するハンド数の上限
const active = new Map(); // id → 対局(メモリ上)
const writing = new Map(); // id → 書き出し中の Promise(順番に書く)
const validId = (id) => typeof id === 'string' && /^[0-9a-f]{16}$/.test(id);
const fileOf = (id) => path.join(DIR, `${id}.json`);
function create({ tableName, heroName, startingStack }) {
const session = {
id: crypto.randomBytes(8).toString('hex'),
tableName,
heroName,
startingStack,
createdAt: Date.now(),
updatedAt: Date.now(),
hands: [],
};
active.set(session.id, session);
return session;
}
function addHand(session, record) {
if (session.hands.length >= MAX_HANDS) return;
session.hands.push(record);
session.updatedAt = Date.now();
save(session);
}
function save(session) {
const prev = writing.get(session.id) || Promise.resolve();
const next = prev
.then(async () => {
await fs.promises.mkdir(DIR, { recursive: true, mode: 0o700 });
const tmp = `${fileOf(session.id)}.tmp`;
await fs.promises.writeFile(tmp, JSON.stringify(session), { mode: 0o600 });
await fs.promises.rename(tmp, fileOf(session.id));
await prune();
})
.catch((err) => console.error('[review] 保存に失敗しました', err.message));
writing.set(session.id, next);
next.then(() => {
if (writing.get(session.id) === next) writing.delete(session.id);
});
return next;
}
// テーブルが閉じられたらメモリから外す(ファイルには残る)
function close(session) {
active.delete(session.id);
}
async function readAll() {
let files;
try {
files = await fs.promises.readdir(DIR);
} catch {
return [];
}
const out = [];
for (const f of files) {
const id = f.replace(/\.json$/, '');
if (!f.endsWith('.json') || !validId(id)) continue;
const s = await get(id);
if (s) out.push(s);
}
return out;
}
// 古い対局のファイルを消す(更新日時で判断する。中身は読まない)
async function prune() {
let files;
try {
files = (await fs.promises.readdir(DIR)).filter((f) => f.endsWith('.json'));
} catch {
return;
}
if (files.length <= MAX_SESSIONS) return;
const stats = [];
for (const f of files) {
const st = await fs.promises.stat(path.join(DIR, f)).catch(() => null);
if (st) stats.push({ f, mtime: st.mtimeMs });
}
stats.sort((a, b) => b.mtime - a.mtime);
for (const { f } of stats.slice(MAX_SESSIONS)) {
if (active.has(f.replace(/\.json$/, ''))) continue;
await fs.promises.unlink(path.join(DIR, f)).catch(() => {});
}
}
async function get(id) {
if (!validId(id)) return null;
if (active.has(id)) return active.get(id);
try {
return JSON.parse(await fs.promises.readFile(fileOf(id), 'utf8'));
} catch {
return null;
}
}
// 一覧(新しい順)。中身のハンドは含めない
async function list() {
const all = await readAll();
for (const s of active.values()) if (!all.some((x) => x.id === s.id)) all.push(s);
return all
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((s) => ({
id: s.id,
tableName: s.tableName,
heroName: s.heroName,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
hands: s.hands.length,
active: active.has(s.id),
}));
}
module.exports = { create, addHand, close, get, list, validId, DIR };