'use strict'; // レビューの保存先(data/reviews/.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 };