Files

2857 lines
118 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const http = require('http');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { Server } = require('socket.io');
const PORT = process.env.PORT || 3001;
const BASE_PATH = '/Game';
const AI_SETTINGS_PATH = path.join(__dirname, 'data', 'ai-settings.json');
const ADMIN_PASSWORD = process.env.GAME_AI_ADMIN_PASSWORD || 'game-ai-admin';
const adminTokens = new Set();
/** Lobby หลังคดี — ไม่ลบออกจาก spaces ตอน prune รายการห้องว่าง */
const POST_CASE_LOBBY_SPACE_ID = 'mn8nx46h';
/** หลัง host เลือกผู้ต้องสงสัยแล้วกด «เริ่มสืบสวน» — ย้ายทุกคนไปฉากควิซนี้และเริ่มเกม */
const SUSPECT_INVESTIGATION_QUIZ_MAP_ID = 'mng8a80o';
/** ห้องสาธารณะที่สร้างแล้วไม่มีคนเข้าเกินนี้ → ลบออกจาก memory (Join Room ไม่โชว์ห้องผี) */
const SPACE_EMPTY_TTL_MS = 45000;
// Chat completion models from https://developers.openai.com/api/docs/models (Feb 2025)
const AI_MODELS = [
'gpt-5.2', 'gpt-5.2-pro', 'gpt-5.1', 'gpt-5', 'gpt-5-pro', 'gpt-5-mini', 'gpt-5-nano',
'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano',
'o3', 'o3-mini', 'o3-pro', 'o4-mini', 'o3-deep-research', 'o4-mini-deep-research',
'o1', 'o1-mini', 'o1-pro',
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4',
'gpt-3.5-turbo',
];
function loadAiSettings() {
try {
if (fs.existsSync(AI_SETTINGS_PATH)) {
const raw = fs.readFileSync(AI_SETTINGS_PATH, 'utf8');
return JSON.parse(raw);
}
} catch (e) { console.error('loadAiSettings', e.message); }
return { openai_api_key: '', model: 'gpt-4o-mini', intent: '', rag_enabled: false, rag_context: '' };
}
function saveAiSettings(settings) {
try {
const dir = path.dirname(AI_SETTINGS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(AI_SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
} catch (e) { console.error('saveAiSettings', e.message); }
}
function getAdminToken(cookieHeader) {
if (!cookieHeader) return null;
const m = cookieHeader.match(/game_ai_admin=([^;\s]+)/);
return m && adminTokens.has(m[1]) ? m[1] : null;
}
const MAPS_DIR = path.join(__dirname, 'data', 'maps');
const CHARACTERS_DIR = path.join(__dirname, 'data', 'characters');
const GAUNTLET_ASSETS_DIR = path.join(__dirname, 'data', 'gauntlet-assets');
const GAUNTLET_ASSETS_META_PATH = path.join(__dirname, 'data', 'gauntlet-assets-meta.json');
const QUIZ_SETTINGS_PATH = path.join(__dirname, 'data', 'quiz-settings.json');
const GAME_TIMING_PATH = path.join(__dirname, 'data', 'game-timing.json');
/** ช่องตัวเลือกบนกริด quiz_carry เก็บเลข 1..N (ไม่ใช่แค่ 0/1 เหมือน hub) */
const QUIZ_CARRY_MAX_OPTION_SLOTS = 16;
const maps = new Map();
const defaultMap = () => ({
width: 20, height: 15, tileSize: 32, gameType: 'zep',
characterCells: 1, characterCellsW: 1, characterCellsH: 1,
ground: [], objects: [], blockPlayer: [], interactive: [], startGameArea: [], spawnArea: [], spawn: { x: 1, y: 1 },
quizTrueArea: [], quizFalseArea: [], quizQuestionArea: [], quizQuestions: [],
quizCarryHubArea: [], quizCarryOptionArea: [],
/** Quiz Battle (MCQ โดม) — ช่องที่ 1 = จุดกด E เปิดคำถามจาก battleQuizMcq */
quizBattleDomeArea: [],
/** Quiz Battle — ถ้าวาดอย่างน้อย 1 ช่อง = จำกัดเดินเฉพาะเส้นทาง (เหมือนแผนที่อ้างอิง) · ไม่วาด = เดินอิสระเหมือนเดิม */
quizBattlePathArea: [],
stackReleaseArea: [], stackLandArea: [],
/** กระโดดให้รอด — แพลตฟอร์มในระบบ tile (ร่างสำหรับ side-view ภายหลัง) */
jumpSurvivePlatforms: [],
jumpSurvivePlatformArea: [],
jumpSurviveRisePxPerSec: 42,
jumpSurviveGravity: 3200,
/** 0 = ให้ไคลเอนต์ใช้ค่าเริ่มต้นกระโดดสูง; ตั้งเป็นพิกเซล/วิ (เช่น 980) เพื่อกำหนดเอง */
jumpSurviveJumpImpulse: 0,
jumpSurviveMovePxPerSec: 200,
});
function defaultQuizSettings() {
return {
readMs: 10000,
answerMs: 5000,
betweenMs: 3500,
questions: [],
carryQuestions: [],
battleQuizMcq: [],
};
}
/** หมวด Quiz Battle (อ้างอิงธีมหน้า Quiz-Battle) — id ต้องตรงกับแอดมิน */
const QUIZ_BATTLE_CATEGORY_IDS = new Set([
'cybercrime',
'cyber_law',
'privacy',
'digital_security',
'social_media',
'ethics',
'ai_data',
]);
/** คำถาม 3 ตัวเลือก A/B/C แยกหมวด — สำหรับโหมด Quiz Battle / โดม */
function sanitizeBattleQuizMcq(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
let categoryId = String(q.categoryId || '').trim();
if (!categoryId || !QUIZ_BATTLE_CATEGORY_IDS.has(categoryId)) {
categoryId = 'cybercrime';
}
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
const rawChoices = Array.isArray(q.choices) ? q.choices : [];
const choices = rawChoices.map((c) => String(c || '').trim().slice(0, 160));
if (choices.length !== 3 || !choices.every(Boolean)) continue;
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(2, Math.floor(ci)));
out.push({ categoryId, text, choices, correctIndex: ci });
if (out.length >= 200) break;
}
return out;
}
/** คำถามแบบหลายตัวเลือก (หยิบมาวาง / quiz_carry) — เก็บใน quiz-settings.json */
function sanitizeCarryQuestions(arr) {
if (!Array.isArray(arr)) return [];
const out = [];
for (const q of arr) {
if (!q || typeof q !== 'object') continue;
const text = String(q.text || '').trim().slice(0, 500);
if (!text) continue;
let choices = Array.isArray(q.choices)
? q.choices.map((c) => String(c || '').trim().slice(0, 160)).filter(Boolean)
: [];
if (choices.length < 2) continue;
if (choices.length > 16) choices = choices.slice(0, 16);
let ci = Number(q.correctIndex);
if (!Number.isFinite(ci)) ci = 0;
ci = Math.max(0, Math.min(choices.length - 1, Math.floor(ci)));
out.push({ text, choices, correctIndex: ci });
if (out.length >= 80) break;
}
return out;
}
function clampQuizMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(1000, Math.min(300000, Math.floor(v)));
}
function clampQuizBetweenMs(n, def) {
const v = Number(n);
if (Number.isNaN(v)) return def;
return Math.max(0, Math.min(300000, Math.floor(v)));
}
function loadQuizSettings() {
try {
if (fs.existsSync(QUIZ_SETTINGS_PATH)) {
const j = JSON.parse(fs.readFileSync(QUIZ_SETTINGS_PATH, 'utf8'));
const d = defaultQuizSettings();
const questions = Array.isArray(j.questions) ? j.questions : [];
const carryQuestions = sanitizeCarryQuestions(j.carryQuestions);
const battleQuizMcq = sanitizeBattleQuizMcq(j.battleQuizMcq);
return {
readMs: clampQuizMs(j.readMs, d.readMs),
answerMs: clampQuizMs(j.answerMs, d.answerMs),
betweenMs: clampQuizBetweenMs(j.betweenMs, d.betweenMs),
questions,
carryQuestions,
battleQuizMcq,
};
}
} catch (e) { console.error('loadQuizSettings', e.message); }
return defaultQuizSettings();
}
const GAUNTLET_DEFAULT_TICK_MS = 220;
const GAUNTLET_DEFAULT_JUMP_TICKS = 16;
const GAUNTLET_DEFAULT_TIME_LIMIT_SEC = 0;
/** รอบสวิงซ้าย-ขวา ต่อวินาที (Stack) — ค่าน้อย = ช้า · cycles per second of horizontal swing */
const STACK_SWING_HZ_DEFAULT = 0.55;
function defaultGameTiming() {
return {
gauntletTickMs: GAUNTLET_DEFAULT_TICK_MS,
gauntletJumpTicks: GAUNTLET_DEFAULT_JUMP_TICKS,
gauntletTimeLimitSec: GAUNTLET_DEFAULT_TIME_LIMIT_SEC,
...defaultGauntletVisuals(),
stackSwingHz: STACK_SWING_HZ_DEFAULT,
stackBlockWidthTiles: null,
/** กระโดดให้รอด: ความสูงกระโดด = ทวีคูณ × ความสูงตัวละคร (tile) — 0.5–4 ค่าเริ่ม 1.5 */
jumpSurviveJumpHeightMult: 1.5,
};
}
function clampStackSwingHz(n) {
const v = Number(n);
if (Number.isNaN(v)) return STACK_SWING_HZ_DEFAULT;
return Math.max(0.08, Math.min(2.8, v));
}
/** ความกว้างบล็อก Stack เป็น tile — null = ให้ client คำนวณจากพื้นที่ลงบนแผนที่ */
function clampStackBlockWidthTiles(n) {
if (n === null || n === undefined || n === '') return null;
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return null;
return Math.round(Math.max(0.85, Math.min(3.2, v)) * 100) / 100;
}
function clampJumpSurviveJumpHeightMult(n) {
const v = Number(n);
if (Number.isNaN(v)) return 1.5;
return Math.round(Math.max(0.5, Math.min(4, v)) * 100) / 100;
}
function clampGauntletTickMs(n) {
const v = Number(n);
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_TICK_MS;
return Math.max(80, Math.min(800, Math.floor(v)));
}
function clampGauntletJumpTicks(n) {
const v = Number(n);
if (Number.isNaN(v)) return GAUNTLET_DEFAULT_JUMP_TICKS;
return Math.max(4, Math.min(40, Math.floor(v)));
}
/** 0 = ไม่จำกัดเวลา · ค่าอื่น = วินาที (สูงสุด 2 ชม.) */
function clampGauntletTimeLimitSec(n) {
const v = Number(n);
if (Number.isNaN(v) || v <= 0) return 0;
return Math.max(10, Math.min(7200, Math.floor(v)));
}
function defaultGauntletVisuals() {
return {
gauntletLaneImageUrls: [],
gauntletLaserTopUrl: '',
gauntletLaserBottomUrl: '',
gauntletLaserLineUrl: '',
gauntletLaserFillColor: 'rgba(140,230,255,0.42)',
gauntletLaserStrokeColor: 'rgba(255,220,255,0.9)',
gauntletLaserLineWidthPx: 2,
};
}
function sanitizeGauntletAssetUrl(s) {
const t = String(s || '').trim();
if (!t || t.length > 500) return '';
if (/[\s<>"'`]/.test(t)) return '';
if (t.startsWith('/')) return t;
if (/^https?:\/\//i.test(t)) return t;
return '';
}
function clampGauntletLaserColor(s, def) {
const t = String(s || '').trim().slice(0, 100);
if (!t) return def;
if (/[<>"'`]/.test(t)) return def;
return t;
}
function clampGauntletLaserLineWidthPx(n) {
const v = Number(n);
if (Number.isNaN(v)) return 2;
return Math.max(0, Math.min(24, Math.round(v)));
}
function normalizeGauntletVisualsFromRequest(d) {
const def = defaultGauntletVisuals();
if (!d || typeof d !== 'object') return { ...def };
let laneArr = d.gauntletLaneImageUrls;
if (typeof laneArr === 'string') laneArr = laneArr.split(/[\n\r]+/);
if (!Array.isArray(laneArr)) laneArr = [];
const gauntletLaneImageUrls = [];
for (let i = 0; i < laneArr.length && gauntletLaneImageUrls.length < 24; i++) {
const u = sanitizeGauntletAssetUrl(laneArr[i]);
if (u) gauntletLaneImageUrls.push(u);
}
return {
gauntletLaneImageUrls,
gauntletLaserTopUrl: sanitizeGauntletAssetUrl(d.gauntletLaserTopUrl),
gauntletLaserBottomUrl: sanitizeGauntletAssetUrl(d.gauntletLaserBottomUrl),
gauntletLaserLineUrl: sanitizeGauntletAssetUrl(d.gauntletLaserLineUrl),
gauntletLaserFillColor: clampGauntletLaserColor(d.gauntletLaserFillColor, def.gauntletLaserFillColor),
gauntletLaserStrokeColor: clampGauntletLaserColor(d.gauntletLaserStrokeColor, def.gauntletLaserStrokeColor),
gauntletLaserLineWidthPx: clampGauntletLaserLineWidthPx(d.gauntletLaserLineWidthPx),
};
}
function getGauntletVisualsForClient() {
const r = runtimeGameTiming;
const d = defaultGauntletVisuals();
return {
gauntletLaneImageUrls: Array.isArray(r.gauntletLaneImageUrls) ? r.gauntletLaneImageUrls : d.gauntletLaneImageUrls,
gauntletLaserTopUrl: r.gauntletLaserTopUrl || '',
gauntletLaserBottomUrl: r.gauntletLaserBottomUrl || '',
gauntletLaserLineUrl: r.gauntletLaserLineUrl || '',
gauntletLaserFillColor: r.gauntletLaserFillColor != null ? r.gauntletLaserFillColor : d.gauntletLaserFillColor,
gauntletLaserStrokeColor: r.gauntletLaserStrokeColor != null ? r.gauntletLaserStrokeColor : d.gauntletLaserStrokeColor,
gauntletLaserLineWidthPx: r.gauntletLaserLineWidthPx != null ? r.gauntletLaserLineWidthPx : d.gauntletLaserLineWidthPx,
};
}
function loadGameTiming() {
try {
if (fs.existsSync(GAME_TIMING_PATH)) {
const j = JSON.parse(fs.readFileSync(GAME_TIMING_PATH, 'utf8'));
const vis = normalizeGauntletVisualsFromRequest(j);
return {
gauntletTickMs: clampGauntletTickMs(j.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(j.gauntletJumpTicks),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(j.gauntletTimeLimitSec),
...vis,
stackSwingHz: Object.prototype.hasOwnProperty.call(j, 'stackSwingHz')
? clampStackSwingHz(j.stackSwingHz)
: STACK_SWING_HZ_DEFAULT,
stackBlockWidthTiles: Object.prototype.hasOwnProperty.call(j, 'stackBlockWidthTiles')
? clampStackBlockWidthTiles(j.stackBlockWidthTiles)
: null,
jumpSurviveJumpHeightMult: Object.prototype.hasOwnProperty.call(j, 'jumpSurviveJumpHeightMult')
? clampJumpSurviveJumpHeightMult(j.jumpSurviveJumpHeightMult)
: 1.5,
};
}
} catch (e) { console.error('loadGameTiming', e.message); }
return defaultGameTiming();
}
let runtimeGameTiming = loadGameTiming();
function getGauntletTickMs() {
return runtimeGameTiming.gauntletTickMs;
}
function getGauntletJumpTicks() {
return runtimeGameTiming.gauntletJumpTicks;
}
function getGauntletTimeLimitSec() {
return runtimeGameTiming.gauntletTimeLimitSec || 0;
}
function saveGameTimingToFile(d) {
try {
const dir = path.dirname(GAME_TIMING_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const prev = { ...runtimeGameTiming };
const vis = normalizeGauntletVisualsFromRequest(d);
const stackHz = Object.prototype.hasOwnProperty.call(d, 'stackSwingHz')
? clampStackSwingHz(d.stackSwingHz)
: clampStackSwingHz(prev.stackSwingHz != null ? prev.stackSwingHz : STACK_SWING_HZ_DEFAULT);
const stackBlockW = Object.prototype.hasOwnProperty.call(d, 'stackBlockWidthTiles')
? clampStackBlockWidthTiles(d.stackBlockWidthTiles)
: (Object.prototype.hasOwnProperty.call(prev, 'stackBlockWidthTiles')
? prev.stackBlockWidthTiles
: null);
const prevMult = Object.prototype.hasOwnProperty.call(prev, 'jumpSurviveJumpHeightMult')
? prev.jumpSurviveJumpHeightMult
: 1.5;
const jumpMult = Object.prototype.hasOwnProperty.call(d, 'jumpSurviveJumpHeightMult')
? clampJumpSurviveJumpHeightMult(d.jumpSurviveJumpHeightMult)
: clampJumpSurviveJumpHeightMult(prevMult);
const out = {
gauntletTickMs: clampGauntletTickMs(d.gauntletTickMs),
gauntletJumpTicks: clampGauntletJumpTicks(d.gauntletJumpTicks),
gauntletTimeLimitSec: clampGauntletTimeLimitSec(d.gauntletTimeLimitSec),
...vis,
stackSwingHz: stackHz,
stackBlockWidthTiles: stackBlockW,
jumpSurviveJumpHeightMult: jumpMult,
};
fs.writeFileSync(GAME_TIMING_PATH, JSON.stringify(out, null, 2), 'utf8');
runtimeGameTiming = out;
rescheduleAllGauntletTickers();
return { ok: true, ...out };
} catch (e) {
console.error('saveGameTimingToFile', e.message);
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') {
err = 'ไม่มีสิทธิ์เขียนไฟล์ game-timing.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
function ensureGauntletAssetsDir() {
if (!fs.existsSync(GAUNTLET_ASSETS_DIR)) fs.mkdirSync(GAUNTLET_ASSETS_DIR, { recursive: true });
}
function loadGauntletAssetsMeta() {
try {
if (fs.existsSync(GAUNTLET_ASSETS_META_PATH)) {
const j = JSON.parse(fs.readFileSync(GAUNTLET_ASSETS_META_PATH, 'utf8'));
return j && typeof j === 'object' ? j : {};
}
} catch (e) { console.error('loadGauntletAssetsMeta', e.message); }
return {};
}
function saveGauntletAssetsMeta(meta) {
const dir = path.dirname(GAUNTLET_ASSETS_META_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(GAUNTLET_ASSETS_META_PATH, JSON.stringify(meta, null, 2), 'utf8');
}
function safeGauntletStoredFilename(name) {
const base = path.basename(String(name || ''));
if (!/^gauntlet-[a-f0-9]{16}\.(png|jpg|jpeg|gif|webp)$/i.test(base)) return null;
return base;
}
function gauntletAssetMimeByExt(ext) {
const e = String(ext || '').toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
if (e === '.gif') return 'image/gif';
if (e === '.webp') return 'image/webp';
return 'application/octet-stream';
}
function listGauntletAssetsApi() {
ensureGauntletAssetsDir();
const meta = loadGauntletAssetsMeta();
let files = [];
try {
files = fs.readdirSync(GAUNTLET_ASSETS_DIR);
} catch (e) {
return [];
}
return files
.filter((f) => safeGauntletStoredFilename(f))
.map((f) => {
const fp = path.join(GAUNTLET_ASSETS_DIR, f);
let st;
try {
st = fs.statSync(fp);
} catch (e) {
return null;
}
const entry = meta[f];
const label = entry && typeof entry.label === 'string' ? entry.label.slice(0, 120) : '';
return {
filename: f,
url: BASE_PATH + '/img/gauntlet-assets/' + f,
label: label || f,
bytes: st.size,
mtime: st.mtimeMs,
};
})
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime);
}
function saveQuizSettings(d) {
try {
const prev = loadQuizSettings();
const dir = path.dirname(QUIZ_SETTINGS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const readMs = d.readMs != null ? clampQuizMs(d.readMs, prev.readMs) : prev.readMs;
const answerMs = d.answerMs != null ? clampQuizMs(d.answerMs, prev.answerMs) : prev.answerMs;
const betweenMs = d.betweenMs != null ? clampQuizBetweenMs(d.betweenMs, prev.betweenMs) : prev.betweenMs;
const questions = Array.isArray(d.questions)
? (d.questions || [])
.filter((q) => q && String(q.text || '').trim())
.map((q) => ({ text: String(q.text).trim(), answerTrue: !!q.answerTrue }))
: prev.questions;
const carryQuestions = Array.isArray(d.carryQuestions)
? sanitizeCarryQuestions(d.carryQuestions)
: prev.carryQuestions;
const battleQuizMcq = Array.isArray(d.battleQuizMcq)
? sanitizeBattleQuizMcq(d.battleQuizMcq)
: (prev.battleQuizMcq || []);
const out = {
readMs,
answerMs,
betweenMs,
questions,
carryQuestions,
battleQuizMcq,
};
fs.writeFileSync(QUIZ_SETTINGS_PATH, JSON.stringify(out, null, 2), 'utf8');
return {
ok: true,
battleQuizMcqSaved: (out.battleQuizMcq || []).length,
carryQuestionsSaved: (out.carryQuestions || []).length,
};
} catch (e) {
console.error('saveQuizSettings', e.message);
let err = 'บันทึกไม่ได้';
if (e && e.code === 'EACCES') {
err = 'ไม่มีสิทธิ์เขียนไฟล์ quiz-settings.json (ให้ chown เป็น user ที่รันเกม เช่น www-data)';
} else if (e && e.message) err = 'บันทึกไม่ได้: ' + e.message;
return { ok: false, error: err };
}
}
function getQuizQuestionPool(md) {
const g = loadQuizSettings();
const globalQ = (g.questions || []).filter((q) => q && String(q.text || '').trim());
if (globalQ.length) return globalQ;
return (md.quizQuestions || []).filter((q) => q && String(q.text || '').trim());
}
function quizCellOn(grid, tx, ty) {
return !!(grid && grid[ty] && grid[ty][tx] === 1);
}
function getCharacterFootprintWHForMove(md) {
let cw = Math.floor(Number(md.characterCellsW));
let ch = Math.floor(Number(md.characterCellsH));
const hasW = Number.isFinite(cw) && cw >= 1;
const hasH = Number.isFinite(ch) && ch >= 1;
if (hasW && hasH) {
return { cw: Math.max(1, Math.min(4, cw)), ch: Math.max(1, Math.min(4, ch)) };
}
const leg = Math.max(1, Math.min(4, Math.floor(Number(md.characterCells)) || 1));
return { cw: leg, ch: leg };
}
function quizCarryFootprintTileKeys(md, px, py) {
const w = md.width || 20;
const h = md.height || 15;
const { cw, ch } = getCharacterFootprintWHForMove(md);
const minTx = Math.floor(Number(px));
const minTy = Math.floor(Number(py));
const maxTx = Math.min(w - 1, minTx + cw - 1);
const maxTy = Math.min(h - 1, minTy + ch - 1);
const out = [];
for (let ty = minTy; ty <= maxTy; ty++) {
for (let tx = minTx; tx <= maxTx; tx++) {
if (tx >= 0 && ty >= 0) out.push(`${tx},${ty}`);
}
}
return out;
}
function quizCarryFootprintOverlapsHubServer(md, px, py) {
if (!md || !md.quizCarryHubArea || md.gameType !== 'quiz_carry') return false;
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (quizCellOn(md.quizCarryHubArea, tx, ty)) return true;
}
return false;
}
/** Nearest walkable tile center not in true/false answer zones (BFS). */
function findNearestOutsideQuizAnswerZones(md, sx, sy) {
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
const inAnswer = (x, y) => quizCellOn(tGrid, x, y) || quizCellOn(fGrid, x, y);
const walkable = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
return row && row[x] !== 1;
};
const fx = Math.floor(Number(sx));
const fy = Math.floor(Number(sy));
if (!walkable(fx, fy)) {
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
if (!inAnswer(fx, fy)) return { x: sx, y: sy };
const q = [[fx, fy]];
const seen = new Set([`${fx},${fy}`]);
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
while (q.length) {
const [cx, cy] = q.shift();
for (let di = 0; di < dirs.length; di++) {
const nx = cx + dirs[di][0];
const ny = cy + dirs[di][1];
const k = `${nx},${ny}`;
if (seen.has(k)) continue;
if (!walkable(nx, ny)) continue;
seen.add(k);
if (!inAnswer(nx, ny)) {
return { x: nx + 0.5, y: ny + 0.5 };
}
q.push([nx, ny]);
}
}
const sp = md.spawn || { x: 1, y: 1 };
return { x: (typeof sp.x === 'number' ? sp.x : 1) + 0.5, y: (typeof sp.y === 'number' ? sp.y : 1) + 0.5 };
}
function validateQuizMap(md) {
const pool = getQuizQuestionPool(md);
if (pool.length < 1) {
return 'เพิ่มคำถามใน Admin แท็บ «คำถามเกม» (หรือบันทึกคำถามในฉากแบบเก่าเป็นสำรอง)';
}
const w = md.width || 20;
const h = md.height || 15;
const tGrid = md.quizTrueArea || [];
const fGrid = md.quizFalseArea || [];
let anyT = false;
let anyF = false;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (quizCellOn(tGrid, x, y)) anyT = true;
if (quizCellOn(fGrid, x, y)) anyF = true;
}
}
if (!anyT || !anyF) return 'วาดโซนคำตอบ ถูก และ ผิด อย่างน้อยช่องละ 1';
return null;
}
function clearSpaceQuizTimers(space) {
if (!space.quizTimers || !Array.isArray(space.quizTimers)) {
space.quizTimers = [];
return;
}
space.quizTimers.forEach((t) => clearTimeout(t));
space.quizTimers = [];
}
function shuffleQuizQuestions(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function endQuizGame(sid, space, message) {
clearSpaceQuizTimers(space);
if (space.quizSession) space.quizSession.active = false;
io.to(sid).emit('quiz-ended', { message: message || 'จบเกม' });
space.quizSession = null;
}
function emitQuizPlayerStates(sid, space) {
const sess = space.quizSession;
if (!sess || !sess.players) return;
space.peers.forEach((_, peerId) => {
const st = sess.players[peerId];
if (st) io.to(peerId).emit('quiz-player-state', { ...st });
});
}
function scheduleQuizReadPhase(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
const idx = sess.qIndex;
const qList = sess.questions;
if (idx >= qList.length) {
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
return;
}
const q = qList[idx];
const readMs = (Number.isFinite(sess.readMs) && sess.readMs >= 1000) ? sess.readMs : 10000;
sess.phase = 'read';
sess.phaseEndsAt = Date.now() + readMs;
io.to(sid).emit('quiz-phase', {
phase: 'read',
questionIndex: idx + 1,
questionTotal: qList.length,
text: String(q.text || '').trim(),
endsAt: sess.phaseEndsAt,
});
const t = setTimeout(() => scheduleQuizAnswerPhase(sid, space, md), readMs);
space.quizTimers.push(t);
}
function scheduleQuizAnswerPhase(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
const answerMs = (Number.isFinite(sess.answerMs) && sess.answerMs >= 1000) ? sess.answerMs : 5000;
sess.phase = 'answer';
sess.phaseEndsAt = Date.now() + answerMs;
io.to(sid).emit('quiz-phase', {
phase: 'answer',
questionIndex: sess.qIndex + 1,
questionTotal: sess.questions.length,
endsAt: sess.phaseEndsAt,
});
emitQuizPlayerStates(sid, space);
const t = setTimeout(() => resolveQuizRound(sid, space, md), answerMs);
space.quizTimers.push(t);
}
function resolveQuizRound(sid, space, md) {
const sess = space.quizSession;
if (!sess || !sess.active) return;
/* ใช้แผนที่ตอนเริ่มเกมเป็นหลัก — กัน getLobbyLayoutMapForSpace คืน lobby/zep แล้ว return ทิ้งทำให้ไม่มีคะแนน/ไม่เตะ */
const mdLive = sess.quizMapMd || getLobbyLayoutMapForSpace(space) || md;
if (!mdLive) return;
const idx = sess.qIndex;
const q = sess.questions[idx];
const correctTrue = !!q.answerTrue;
const tGrid = mdLive.quizTrueArea || [];
const fGrid = mdLive.quizFalseArea || [];
let eligibleCount = 0;
let anyRight = false;
const results = [];
const ejectMoves = [];
space.peers.forEach((p, peerId) => {
const st = sess.players[peerId];
if (!st) return;
eligibleCount++;
const tx = Math.floor(Number(p.x));
const ty = Math.floor(Number(p.y));
const inT = quizCellOn(tGrid, tx, ty);
const inF = quizCellOn(fGrid, tx, ty);
let choice = null;
if (inT && !inF) choice = true;
else if (inF && !inT) choice = false;
else if (inT && inF) choice = true;
let right = false;
if (choice === null) right = false;
else right = (choice === correctTrue);
if (right) {
st.score = (typeof st.score === 'number' ? st.score : 0) + 1;
anyRight = true;
}
/* ผิดทุกกรณี = ล็อกโซนจริงเท็จ + กลับจุดเกิด (สุ่มใน spawnArea ถ้ามี) */
if (!right) {
st.cannotTrue = true;
st.cannotFalse = true;
const pos = quizWrongAnswerRespawnPosition(mdLive);
p.x = pos.x;
p.y = pos.y;
ejectMoves.push({
id: peerId,
x: p.x,
y: p.y,
direction: p.direction || 'down',
characterId: p.characterId ?? null,
});
}
results.push({
id: peerId,
nickname: p.nickname || '',
right,
choice,
eliminated: !!st.eliminated,
score: typeof st.score === 'number' ? st.score : 0,
});
});
ejectMoves.forEach((ev) => {
io.to(sid).emit('user-move', ev);
try {
const sock = io.of('/').sockets && io.of('/').sockets.get(ev.id);
if (sock) sock.emit('user-move', ev);
} catch (e) { /* ignore */ }
});
const allWrong = eligibleCount > 0 && !anyRight;
const scores = {};
space.peers.forEach((_, peerId) => {
const st = sess.players[peerId];
scores[peerId] = st && typeof st.score === 'number' ? st.score : 0;
});
io.to(sid).emit('quiz-result', {
questionIndex: idx + 1,
correctTrue,
results,
allWrong,
scores,
});
emitQuizPlayerStates(sid, space);
if (allWrong) {
endQuizGame(sid, space, 'จบเกม — ทุกคนตอบผิดข้อนี้');
return;
}
sess.qIndex++;
if (sess.qIndex >= sess.questions.length) {
endQuizGame(sid, space, 'จบเกม — ครบทุกข้อแล้ว');
return;
}
const betweenMs = (Number.isFinite(sess.betweenMs) && sess.betweenMs >= 0) ? sess.betweenMs : 3500;
const mapRef = sess.quizMapMd || md;
const t = setTimeout(() => scheduleQuizReadPhase(sid, space, mapRef), betweenMs);
space.quizTimers.push(t);
}
function startQuizGame(sid, space, md) {
clearSpaceQuizTimers(space);
const settings = loadQuizSettings();
const pool = getQuizQuestionPool(md);
const shuffled = shuffleQuizQuestions(pool);
const picked = shuffled.slice(0, Math.min(10, shuffled.length));
const players = {};
space.peers.forEach((_, peerId) => {
players[peerId] = { cannotTrue: false, cannotFalse: false, eliminated: false, score: 0 };
});
space.quizSession = {
active: true,
questions: picked,
qIndex: 0,
phase: 'idle',
phaseEndsAt: 0,
readMs: settings.readMs,
answerMs: settings.answerMs,
betweenMs: settings.betweenMs,
players,
quizMapMd: md,
};
scheduleQuizReadPhase(sid, space, md);
}
function safeMapId(id) {
return (id || '').replace(/[^a-z0-9_-]/gi, '') || null;
}
/** กริด hub = 0/1 · option = 0 หรือเลขช่อง 1..QUIZ_CARRY_MAX_OPTION_SLOTS (ห้ามใช้กฎ === 1 กับ option — เลข 216หาย) */
function normalizeQuizCarryLayersOnMap(m) {
if (!m || m.gameType !== 'quiz_carry') return;
const w = m.width || 20, h = m.height || 15;
const normHub = () => {
const src = m.quizCarryHubArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizCarryHubArea = rows;
};
const normOptions = () => {
const src = m.quizCarryOptionArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) {
const v = r && r[x];
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
row.push(Number.isFinite(n) && n >= 1 && n <= QUIZ_CARRY_MAX_OPTION_SLOTS ? Math.floor(n) : 0);
}
rows.push(row);
}
m.quizCarryOptionArea = rows;
};
normHub();
normOptions();
}
/** กริดแพลตฟอร์มกระโดดให้รอด — 0/1 ต่อช่อง */
function normalizeQuizBattleDomeAreaOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
const w = m.width || 20, h = m.height || 15;
const src = m.quizBattleDomeArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizBattleDomeArea = rows;
}
function normalizeQuizBattlePathAreaOnMap(m) {
if (!m || m.gameType !== 'quiz_battle') return;
const w = m.width || 20, h = m.height || 15;
const src = m.quizBattlePathArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.quizBattlePathArea = rows;
}
function quizBattlePathModeActiveServer(m) {
if (!m || m.gameType !== 'quiz_battle' || !m.quizBattlePathArea) return false;
const g = m.quizBattlePathArea;
for (let y = 0; y < g.length; y++) {
const row = g[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
function quizBattleFootprintFullyOnPathServer(m, px, py) {
if (!quizBattlePathModeActiveServer(m)) return true;
const g = m.quizBattlePathArea;
for (const k of quizCarryFootprintTileKeys(m, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (!g[ty] || g[ty][tx] !== 1) return false;
}
return true;
}
function serverFootprintClearOfWalls(md, px, py) {
const w = md.width || 20, h = md.height || 15;
for (const k of quizCarryFootprintTileKeys(md, px, py)) {
const [tx, ty] = k.split(',').map(Number);
if (tx < 0 || tx >= w || ty < 0 || ty >= h) return false;
const row = md.objects && md.objects[ty];
if (!row || row[tx] === 1) return false;
}
return true;
}
/** จุดใกล้สุดบนเส้นทาง ( footprint อยู่ใน path + ไม่ทับกำแพง ) — ใช้ตอน join / เริ่มเกม */
function snapPositionOntoQuizBattlePathServer(md, px, py) {
if (!md || md.gameType !== 'quiz_battle' || !quizBattlePathModeActiveServer(md)) return { x: px, y: py };
if (quizBattleFootprintFullyOnPathServer(md, px, py) && serverFootprintClearOfWalls(md, px, py)) return { x: px, y: py };
const w = md.width || 20, h = md.height || 15;
const g = md.quizBattlePathArea;
let bestX = null, bestY = null, bestD = Infinity;
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const nx = tx + 0.01;
const ny = ty + 0.01;
if (!quizBattleFootprintFullyOnPathServer(md, nx, ny)) continue;
if (!serverFootprintClearOfWalls(md, nx, ny)) continue;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
if (!g[ty] || g[ty][tx] !== 1) continue;
const row = md.objects && md.objects[ty];
if (!row || row[tx] === 1) continue;
const nx = tx + 0.5;
const ny = ty + 0.5;
const d = Math.abs(nx - px) + Math.abs(ny - py);
if (d < bestD) { bestD = d; bestX = nx; bestY = ny; }
}
}
if (bestX != null) return { x: bestX, y: bestY };
return { x: px, y: py };
}
function normalizeJumpSurvivePlatformAreaOnMap(m) {
if (!m || m.gameType !== 'jump_survive') return;
const w = m.width || 20, h = m.height || 15;
const src = m.jumpSurvivePlatformArea || [];
const rows = [];
for (let y = 0; y < h; y++) {
const r = src[y];
const row = [];
for (let x = 0; x < w; x++) row.push(r && r[x] === 1 ? 1 : 0);
rows.push(row);
}
m.jumpSurvivePlatformArea = rows;
}
function loadMaps() {
try {
if (!fs.existsSync(path.join(__dirname, 'data'))) fs.mkdirSync(path.join(__dirname, 'data'), { recursive: true });
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(MAPS_DIR);
for (const f of files) {
if (!f.endsWith('.json')) continue;
const id = f.slice(0, -5);
if (!safeMapId(id)) continue;
try {
const data = JSON.parse(fs.readFileSync(path.join(MAPS_DIR, f), 'utf8'));
normalizeQuizCarryLayersOnMap(data);
normalizeJumpSurvivePlatformAreaOnMap(data);
normalizeQuizBattleDomeAreaOnMap(data);
normalizeQuizBattlePathAreaOnMap(data);
maps.set(id, data);
} catch (e) { console.error('load map', f, e.message); }
}
} catch (e) { console.error('loadMaps', e.message); }
}
/** โหลด LobbyB จากไฟล์อีกครั้งถ้ายังไม่อยู่ใน memory (หลังรีสตาร์ท / ไฟล์เพิ่งวาง) */
function ensurePostCaseLobbyMapLoaded() {
if (maps.has(POST_CASE_LOBBY_SPACE_ID)) return true;
try {
const fp = path.join(MAPS_DIR, POST_CASE_LOBBY_SPACE_ID + '.json');
if (!fs.existsSync(fp)) return false;
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
normalizeQuizCarryLayersOnMap(data);
maps.set(POST_CASE_LOBBY_SPACE_ID, data);
return true;
} catch (e) {
console.error('ensurePostCaseLobbyMapLoaded', e.message);
return false;
}
}
function saveMap(id, m) {
const safe = safeMapId(id);
if (!safe) return;
try {
if (!fs.existsSync(MAPS_DIR)) fs.mkdirSync(MAPS_DIR, { recursive: true });
fs.writeFileSync(path.join(MAPS_DIR, safe + '.json'), JSON.stringify(m, null, 0), 'utf8');
} catch (e) { console.error('saveMap', id, e.message); }
}
function isMapNameTaken(name, excludeId) {
const n = (name || '').trim().toLowerCase();
for (const [id, m] of maps) if (id !== excludeId && (m.name || '').trim().toLowerCase() === n) return true;
return false;
}
const server = http.createServer((req, res) => {
let url = req.url;
if (url === BASE_PATH || url === BASE_PATH + '/') url = BASE_PATH + '/index.html';
if (url.startsWith(BASE_PATH + '/api/maps')) {
const id = url.replace(BASE_PATH + '/api/maps/', '').replace(BASE_PATH + '/api/maps', '').replace(/^\//, '').split('/')[0];
if (req.method === 'GET' && id) {
const m = maps.get(id);
if (!m) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบฉาก' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(m));
}
if (req.method === 'GET' && !id) {
const list = [...maps].map(([id, m]) => ({ id, name: m.name }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
}
if (req.method === 'POST' && url === BASE_PATH + '/api/maps' || url === BASE_PATH + '/api/maps/') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || 'ฉากใหม่';
if (isMapNameTaken(name)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const id = Date.now().toString(36);
const m = { ...defaultMap(), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
if (req.method === 'PUT' && id && maps.has(id)) {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body);
const name = (d.name || '').trim() || maps.get(id).name;
if (isMapNameTaken(name, id)) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ชื่อฉากซ้ำ กรุณาตั้งชื่ออื่น' }));
const m = { ...maps.get(id), ...d, name };
if (!m.blockPlayer) m.blockPlayer = [];
if (!m.interactive) m.interactive = [];
if (!m.startGameArea) m.startGameArea = [];
if (!m.spawnArea) m.spawnArea = [];
if (!m.quizTrueArea) m.quizTrueArea = [];
if (!m.quizFalseArea) m.quizFalseArea = [];
if (!m.quizQuestionArea) m.quizQuestionArea = [];
if (!m.quizQuestions) m.quizQuestions = [];
if (!m.quizCarryHubArea) m.quizCarryHubArea = [];
if (!m.quizCarryOptionArea) m.quizCarryOptionArea = [];
if (!m.quizBattleDomeArea) m.quizBattleDomeArea = [];
if (!m.quizBattlePathArea) m.quizBattlePathArea = [];
if (!m.stackReleaseArea) m.stackReleaseArea = [];
if (!m.stackLandArea) m.stackLandArea = [];
if (!Array.isArray(m.jumpSurvivePlatforms)) m.jumpSurvivePlatforms = [];
if (!m.jumpSurvivePlatformArea) m.jumpSurvivePlatformArea = [];
normalizeQuizCarryLayersOnMap(m);
normalizeJumpSurvivePlatformAreaOnMap(m);
normalizeQuizBattleDomeAreaOnMap(m);
normalizeQuizBattlePathAreaOnMap(m);
maps.set(id, m);
saveMap(id, m);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, mapId: id }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
}
const quizSettingsUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (quizSettingsUrlPath === BASE_PATH + '/api/quiz-settings') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
});
return res.end(JSON.stringify(loadQuizSettings()));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const saved = saveQuizSettings(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
battleQuizMcqSaved: saved.battleQuizMcqSaved,
carryQuestionsSaved: saved.carryQuestionsSaved,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gameTimingUrlPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gameTimingUrlPath === BASE_PATH + '/api/game-timing') {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
});
return res.end(JSON.stringify({ ...runtimeGameTiming }));
}
if (req.method === 'PUT') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
try {
const d = JSON.parse(body || '{}');
const saved = saveGameTimingToFile(d);
if (!saved.ok) {
res.writeHead(500);
return res.end(JSON.stringify({ ok: false, error: saved.error || 'บันทึกไม่ได้' }));
}
res.writeHead(200);
const { ok: _ok, error: _err, ...timingRest } = saved;
return res.end(JSON.stringify({ ok: true, ...timingRest }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
res.writeHead(405);
return res.end(JSON.stringify({ ok: false, error: 'Method not allowed' }));
}
const gaAssetsApiPath = url.split('?')[0].replace(/\/+$/, '') || '/';
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true, items: listGauntletAssetsApi() }));
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets/upload' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const dataUrl = d.imageDataUrl;
if (!dataUrl || typeof dataUrl !== 'string') {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ต้องส่ง imageDataUrl (data URL รูป)' }));
}
const m = dataUrl.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,([\s\S]+)$/i);
if (!m) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'รองรับเฉพาะ png / jpg / gif / webp' }));
}
const extRaw = m[1].toLowerCase();
const ext = extRaw === 'jpeg' ? 'jpg' : extRaw;
const b64 = m[2].replace(/\s/g, '');
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'base64 ไม่ถูกต้อง' }));
}
if (buf.length < 16 || buf.length > 4 * 1024 * 1024) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ขนาดไฟล์ต้องอยู่ระหว่าง 16 ไบต์ ถึง 4 MB' }));
}
ensureGauntletAssetsDir();
const labelIn = d.label != null ? String(d.label).trim().slice(0, 120) : '';
const replaceFilename = d.replaceFilename != null ? String(d.replaceFilename) : '';
let fname;
if (replaceFilename) {
const s = safeGauntletStoredFilename(replaceFilename);
if (!s) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'ชื่อไฟล์แทนที่ไม่ถูกต้อง' }));
}
const cur = path.join(GAUNTLET_ASSETS_DIR, s);
if (!fs.existsSync(cur)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์เดิม' }));
}
const norm = (e) => {
let x = String(e || '').toLowerCase();
if (x.startsWith('.')) x = x.slice(1);
return x === 'jpeg' ? 'jpg' : x;
};
if (norm(path.extname(s)) !== norm(ext)) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'นามสกุลรูปใหม่ต้องตรงกับไฟล์เดิม' }));
}
fname = s;
} else {
fname = `gauntlet-${crypto.randomBytes(8).toString('hex')}.${ext}`;
}
fs.writeFileSync(path.join(GAUNTLET_ASSETS_DIR, fname), buf);
const meta = loadGauntletAssetsMeta();
const prev = meta[fname] || {};
meta[fname] = {
...prev,
label: labelIn || prev.label || fname,
updatedAt: Date.now(),
};
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({
ok: true,
filename: fname,
url: BASE_PATH + '/img/gauntlet-assets/' + fname,
}));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'PATCH') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const fname = safeGauntletStoredFilename(d.filename);
if (!fname) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: 'filename ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (!fs.existsSync(fp)) {
res.writeHead(404);
return res.end(JSON.stringify({ ok: false, error: 'ไม่พบไฟล์' }));
}
const meta = loadGauntletAssetsMeta();
const label = d.label != null ? String(d.label).trim().slice(0, 120) : '';
meta[fname] = { ...(meta[fname] || {}), label: label || fname, updatedAt: Date.now() };
saveGauntletAssetsMeta(meta);
res.writeHead(200);
return res.end(JSON.stringify({ ok: true, filename: fname, label: meta[fname].label }));
} catch (e) {
res.writeHead(400);
return res.end(JSON.stringify({ ok: false, error: e.message || 'บันทึกไม่ได้' }));
}
});
return;
}
if (gaAssetsApiPath === BASE_PATH + '/api/gauntlet-assets' && req.method === 'DELETE') {
try {
const q = url.includes('?') ? url.split('?')[1] : '';
const sp = new URLSearchParams(q);
const rawFn = sp.get('file');
const fname = safeGauntletStoredFilename(rawFn);
if (!fname) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ระบุ file ไม่ถูกต้อง' }));
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (fs.existsSync(fp)) fs.unlinkSync(fp);
const meta = loadGauntletAssetsMeta();
delete meta[fname];
saveGauntletAssetsMeta(meta);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ' }));
}
}
if (url === BASE_PATH + '/api/characters' && req.method === 'GET') {
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const ids = new Set();
const ext = /\.(png|jpg|jpeg|gif|webp)$/i;
files.forEach(f => {
let m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
m = f.match(/^(.+)_(up|down|left|right)(?:_\d+)?_layer_[a-zA-Z]+\.(png|jpg|jpeg|gif|webp)$/i);
if (m) ids.add(m[1]);
});
const idArr = [...ids];
const oldestMsById = {};
idArr.forEach((id) => {
let oldest = Infinity;
const prefix = id + '_';
files.forEach((f) => {
if (!f.startsWith(prefix) || !ext.test(f)) return;
try {
const st = fs.statSync(path.join(CHARACTERS_DIR, f));
const t = st.mtimeMs != null ? st.mtimeMs : st.mtime.getTime();
if (t < oldest) oldest = t;
} catch (e) { /* ignore */ }
});
oldestMsById[id] = oldest === Infinity ? 0 : oldest;
});
idArr.sort((a, b) => {
const ta = oldestMsById[a] || 0;
const tb = oldestMsById[b] || 0;
if (ta !== tb) return ta - tb;
return String(a).localeCompare(String(b));
});
const list = idArr.map((id) => {
const prefix = id + '_';
const hasLayerFiles = files.some((f) => f.startsWith(prefix) && f.includes('_layer_') && ext.test(f));
return { id, name: id, hasLayerFiles };
});
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(list));
} catch (e) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify([]));
}
}
const urlPath = url.split('?')[0];
if (urlPath.startsWith(BASE_PATH + '/api/characters/') && req.method === 'DELETE') {
const idRaw = decodeURIComponent(urlPath.slice((BASE_PATH + '/api/characters/').length));
const id = (idRaw || '').replace(/[^a-z0-9_-]/gi, '');
if (!id) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'id ไม่ถูกต้อง' }));
}
try {
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const files = fs.readdirSync(CHARACTERS_DIR);
const esc = id.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const re = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?\\.(png|jpg|jpeg|gif|webp)$', 'i');
const layerRe = new RegExp('^' + esc + '_(up|down|left|right)(?:_\\d+)?_layer_[a-zA-Z]+\\.(png|jpg|jpeg|gif|webp)$', 'i');
let removed = 0;
files.forEach(f => {
if (re.test(f) || layerRe.test(f)) {
try {
fs.unlinkSync(path.join(CHARACTERS_DIR, f));
removed++;
} catch (e) {
// ignore single file errors
}
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, removed }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'ลบไม่สำเร็จ: ' + (e.message || '') }));
}
return;
}
if (urlPath === BASE_PATH + '/api/characters/upload' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
if (!body || body.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีข้อมูล (เลือกรูปแล้วกดอัปโหลด)' }));
}
const d = JSON.parse(body);
const dirs = ['up', 'down', 'left', 'right'];
const id = (d.name || '').trim().replace(/[^a-z0-9_-]/gi, '') || 'char-' + Date.now();
if (!fs.existsSync(CHARACTERS_DIR)) fs.mkdirSync(CHARACTERS_DIR, { recursive: true });
const ALLOWED_LAYER = { shadow: 1, bodyColor: 1, bodyStroke: 1, headColor: 1, headStroke: 1, hairColor: 1, hairStroke: 1, face: 1 };
let written = 0;
for (const dir of dirs) {
let data = d[dir];
if (!data) continue;
const list = Array.isArray(data) ? data : [data];
let frameIndex = 0;
for (const dataUrl of list) {
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
// เขียนไฟล์เฟรม (สำหรับ animation)
const fnameFrame = list.length > 1 ? (id + '_' + dir + '_' + frameIndex + '.png') : (id + '_' + dir + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameFrame), buf);
// ถ้ามีหลายเฟรม ให้เฟรมแรกซ้ำไปเป็นไฟล์หลัก <id>_<dir>.png เพื่อใช้เป็น preview / fallback
if (list.length > 1 && frameIndex === 0) {
const baseName = id + '_' + dir + '.png';
fs.writeFileSync(path.join(CHARACTERS_DIR, baseName), buf);
}
written++;
frameIndex++;
}
}
// ไฟล์เลเยอร์แยก (ให้หน้า play ย้อม bodyColor / hairColor / headColor ตามส่วน — ตรงกับ character.html)
const lf = d.layerFrames;
if (lf && typeof lf === 'object') {
for (const dir of dirs) {
const arr = lf[dir];
if (!Array.isArray(arr)) continue;
const multi = arr.length > 1;
arr.forEach((frameObj, frameIndex) => {
if (!frameObj || typeof frameObj !== 'object') return;
for (const layerName of Object.keys(frameObj)) {
if (!ALLOWED_LAYER[layerName]) continue;
const dataUrl = frameObj[layerName];
if (!dataUrl || typeof dataUrl !== 'string') continue;
const m = dataUrl.match(/^data:image\/[\w+]+;base64,([\s\S]+)$/);
if (!m) continue;
const b64 = m[1].replace(/\s/g, '');
if (!b64.length) continue;
let buf;
try {
buf = Buffer.from(b64, 'base64');
} catch (err) {
continue;
}
if (buf.length === 0) continue;
const fnameLayer = multi
? (id + '_' + dir + '_' + frameIndex + '_layer_' + layerName + '.png')
: (id + '_' + dir + '_layer_' + layerName + '.png');
fs.writeFileSync(path.join(CHARACTERS_DIR, fnameLayer), buf);
}
});
}
}
if (written === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'ไม่มีรูปที่อัปโหลดได้ (เลือกรูป png/jpg)' }));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, characterId: id }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'อัปโหลดไม่สำเร็จ: ' + (e.message || '') }));
}
});
return;
}
if (url.startsWith(BASE_PATH + '/api/spaces')) {
const parts = url.slice((BASE_PATH + '/api/spaces').length).split('/').filter(Boolean);
const spaceId = parts[0];
if (req.method === 'GET' && !spaceId) {
const now = Date.now();
for (const [id, s] of [...spaces.entries()]) {
if (s.isPrivate || id === POST_CASE_LOBBY_SPACE_ID) continue;
const n = s.peers ? s.peers.size : 0;
if (n !== 0) continue;
const created = s.createdAt;
const stale = created == null || now - created > SPACE_EMPTY_TTL_MS;
if (stale) spaces.delete(id);
}
const list = [...spaces]
.filter(([, s]) => !s.isPrivate)
.filter(([, s]) => (s.peers && s.peers.size > 0))
.map(([id, s]) => ({
spaceId: id,
spaceName: s.spaceName || id,
mapName: (s.mapData && s.mapData.name) || '—',
peerCount: s.peers ? s.peers.size : 0,
maxPlayers: s.maxPlayers || 10
}));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify(list));
}
if (req.method === 'GET' && spaceId) {
const s = spaces.get(spaceId);
if (!s) return res.writeHead(404), res.end(JSON.stringify({ error: 'ไม่พบห้อง' }));
return res.writeHead(200, { 'Content-Type': 'application/json' }), res.end(JSON.stringify({ ok: true, mapName: s.mapData?.name }));
}
if (req.method === 'POST' && !spaceId) {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const d = JSON.parse(body || '{}');
const mapData = d.mapId ? maps.get(d.mapId) : null;
if (!mapData) return res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ไม่พบฉาก' }));
const isPrivate = !!d.isPrivate;
let sid;
if (isPrivate) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code;
for (let i = 0; i < 20; i++) {
code = '';
for (let j = 0; j < 6; j++) code += chars[Math.floor(Math.random() * chars.length)];
if (!spaces.has(code)) { sid = code; break; }
}
if (!sid) sid = 'priv-' + Date.now();
} else {
const base = (d.name || '').trim() || 'space';
sid = base + '-' + Date.now();
}
const spaceName = (d.name || '').trim() || (isPrivate ? 'ห้องส่วนตัว' : sid);
let maxPlayers = parseInt(d.maxPlayers, 10);
if (isNaN(maxPlayers) || maxPlayers < 1) maxPlayers = 10;
maxPlayers = Math.min(10, Math.max(1, maxPlayers));
if (mapData.gameType === 'gauntlet' || mapData.gameType === 'space_shooter' || mapData.gameType === 'balloon_boss') maxPlayers = Math.min(maxPlayers, 6);
const mapId = d.mapId ? String(d.mapId).trim() : null;
const previewEditorTest = isPrivate && String(spaceName).toLowerCase() === 'preview';
spaces.set(sid, {
mapData, mapId, peers: new Map(), spaceName, hostId: null, isPrivate, maxPlayers, createdAt: Date.now(),
previewEditorTest,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, spaceId: sid, roomCode: isPrivate ? sid : null }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
}
if (url === BASE_PATH + '/api/ice-servers' && req.method === 'GET') {
function sendIceServers(iceServers) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ iceServers: iceServers || [] }));
}
function fallback() {
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
const turnUser = process.env.TURN_USER;
const turnCred = process.env.TURN_CRED;
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
sendIceServers(iceServers);
}
const meteredKey = process.env.METERED_TURN_API_KEY || process.env.METERED_TURN_SECRET_KEY;
const meteredAppId = process.env.METERED_TURN_APP_ID;
let meteredBase = process.env.METERED_TURN_URL;
if (!meteredBase) {
let meteredHost = process.env.METERED_TURN_SUBDOMAIN || 'openrelayprojectsecret';
if (meteredHost.indexOf('metered.live') === -1) meteredHost = meteredHost + '.metered.live';
meteredBase = 'https://' + meteredHost;
}
meteredBase = meteredBase.replace(/\/$/, '');
if (meteredKey) {
function tryNext(credUrl) {
return fetch(credUrl).then(function (r) { return r.json(); }).then(function (data) {
if (data && (data.error || data.message)) throw new Error(data.error || data.message);
const list = Array.isArray(data) ? data : (data && data.iceServers);
if (list && list.length) return sendIceServers(list);
if (data && data.data && Array.isArray(data.data) && data.data.length) {
const turnHost = process.env.METERED_TURN_HOST || 'global.relay.metered.ca';
const built = data.data.map(function (c) {
return { urls: 'turn:' + turnHost + ':443', username: c.username, credential: c.password };
});
return sendIceServers(built);
}
throw new Error('No iceServers');
});
}
const urlsToTry = [
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredKey),
meteredBase + '/api/v1/turn/credentials?apiKey=' + encodeURIComponent(meteredAppId || meteredKey),
meteredBase + '/api/v2/turn/credentials?secretKey=' + encodeURIComponent(meteredKey)
];
let p = tryNext(urlsToTry[0]);
for (let i = 1; i < urlsToTry.length; i++) {
p = p.catch(function () { return tryNext(urlsToTry[i]); });
}
p.catch(fallback);
return;
}
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
const turnUrl = process.env.TURN_SERVER || process.env.TURN_URL;
const turnUser = process.env.TURN_USER;
const turnCred = process.env.TURN_CRED;
if (turnUrl && turnUser && turnCred) iceServers.push({ urls: turnUrl, username: turnUser, credential: turnCred });
sendIceServers(iceServers);
return;
}
if (url === BASE_PATH + '/api/ai-admin-login' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
try {
const d = JSON.parse(body || '{}');
const password = (d.password || '').trim();
if (password !== ADMIN_PASSWORD) {
return res.writeHead(401), res.end(JSON.stringify({ ok: false, error: 'รหัสผ่านไม่ถูกต้อง' }));
}
const token = require('crypto').randomBytes(24).toString('hex');
adminTokens.add(token);
res.setHeader('Set-Cookie', 'game_ai_admin=' + token + '; Path=' + BASE_PATH + '; HttpOnly; Max-Age=86400; SameSite=Lax');
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: 'ข้อมูลไม่ถูกต้อง' }));
}
});
return;
}
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'GET') {
const token = getAdminToken(req.headers.cookie);
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
const s = loadAiSettings();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
model: s.model || 'gpt-4o-mini',
models: AI_MODELS,
hasKey: !!(s.openai_api_key && s.openai_api_key.length > 0),
intent: s.intent != null ? s.intent : '',
rag_enabled: !!s.rag_enabled,
rag_context: s.rag_context != null ? s.rag_context : '',
}));
return;
}
if (url === BASE_PATH + '/api/ai-settings' && req.method === 'PUT') {
const token = getAdminToken(req.headers.cookie);
if (!token) return res.writeHead(401), res.end(JSON.stringify({ error: 'ต้องล็อกอินก่อน' }));
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json');
try {
const d = JSON.parse(body || '{}');
const s = loadAiSettings();
if (d.openai_api_key !== undefined) s.openai_api_key = String(d.openai_api_key).trim();
if (d.model !== undefined) s.model = String(d.model).trim() || 'gpt-4o-mini';
if (d.intent !== undefined) s.intent = String(d.intent);
if (d.rag_enabled !== undefined) s.rag_enabled = !!d.rag_enabled;
if (d.rag_context !== undefined) s.rag_context = String(d.rag_context);
saveAiSettings(s);
res.writeHead(200);
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return;
}
if (url === BASE_PATH + '/api/ai-chat' && req.method === 'POST') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
const d = JSON.parse(body || '{}');
const message = (d.message || d.text || '').trim();
if (!message) return res.writeHead(400), res.end(JSON.stringify({ error: 'ไม่มีข้อความ' }));
const s = loadAiSettings();
if (!s.openai_api_key) return res.writeHead(503), res.end(JSON.stringify({ error: 'ยังไม่ได้ตั้งค่า OpenAI API Key ในหน้า Admin' }));
const model = s.model || 'gpt-4o-mini';
const messages = [];
const systemParts = [];
if (s.intent && String(s.intent).trim()) systemParts.push(String(s.intent).trim());
if (s.rag_enabled && s.rag_context && String(s.rag_context).trim()) {
systemParts.push('ความรู้/บริบทเพิ่มเติม (ใช้ประกอบการตอบ):\n' + String(s.rag_context).trim());
}
if (systemParts.length) messages.push({ role: 'system', content: systemParts.join('\n\n') });
messages.push({ role: 'user', content: message });
const payload = JSON.stringify({
model: model,
messages: messages,
max_tokens: 500,
});
const urlObj = new URL('https://api.openai.com/v1/chat/completions');
const opts = {
hostname: urlObj.hostname,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + s.openai_api_key,
'Content-Length': Buffer.byteLength(payload, 'utf8'),
},
};
const reqOut = require('https').request(opts, (resOut) => {
let data = '';
resOut.on('data', chunk => data += chunk);
resOut.on('end', () => {
try {
const parsed = JSON.parse(data);
const text = parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content;
if (text != null) {
res.writeHead(200);
res.end(JSON.stringify({ response: String(text).trim() }));
} else {
res.writeHead(502);
res.end(JSON.stringify({ error: parsed.error && parsed.error.message ? parsed.error.message : 'OpenAI error' }));
}
} catch (e) {
res.writeHead(502), res.end(JSON.stringify({ error: 'Invalid response from OpenAI' }));
}
});
});
reqOut.on('error', (e) => { res.writeHead(502), res.end(JSON.stringify({ error: e.message })); });
reqOut.write(payload);
reqOut.end();
} catch (e) {
res.writeHead(400), res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
if (url.startsWith(BASE_PATH + '/img/characters/') && req.method === 'GET') {
const sub = url.slice((BASE_PATH + '/img/characters/').length).split('?')[0];
const fname = path.basename(sub).replace(/[^a-z0-9_.-]/gi, '');
if (fname && fname.length > 0) {
const fp = path.join(CHARACTERS_DIR, fname);
if (fs.existsSync(fp)) {
return fs.readFile(fp, (err, data) => {
if (err) return res.writeHead(500), res.end();
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(data);
});
}
}
}
if (url.startsWith(BASE_PATH + '/img/gauntlet-assets/') && req.method === 'GET') {
const sub = decodeURIComponent(url.slice((BASE_PATH + '/img/gauntlet-assets/').length).split('?')[0]);
const fname = safeGauntletStoredFilename(sub);
if (!fname) {
res.writeHead(400);
return res.end();
}
const fp = path.join(GAUNTLET_ASSETS_DIR, fname);
if (!fs.existsSync(fp)) {
res.writeHead(404);
return res.end();
}
const ext = path.extname(fname);
return fs.readFile(fp, (err, data) => {
if (err) return res.writeHead(500), res.end();
res.writeHead(200, { 'Content-Type': gauntletAssetMimeByExt(ext) });
res.end(data);
});
}
if (url.indexOf(BASE_PATH) === 0) url = url.slice(BASE_PATH.length) || '/index.html';
const filePath = path.join(__dirname, 'public', url);
const ext = path.extname(filePath);
const types = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css' };
fs.readFile(filePath, (err, data) => {
if (err) return res.writeHead(404), res.end('Not Found');
res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' });
res.end(data);
});
});
const io = new Server(server, { path: BASE_PATH + '/socket.io', cors: { origin: '*' } });
const spaces = new Map();
const GAUNTLET_MAX_PLAYERS = 6;
/** ระยะอยู่กลางอากาศ (tick) — ปรับได้ที่ Admin / data/game-timing.json */
/** สุ่มเป็นคอลัมน์เลเซอร์ (ทุกแถว) ต่อหนึ่งรอบเกิด */
const GAUNTLET_LASER_SPAWN_CHANCE = 0.14;
/** เฉพาะแถวที่มีผู้เล่น — แถวละโอกาสเกิด lane obstacle */
const GAUNTLET_LANE_ROW_SPAWN_CHANCE = 0.34;
function gauntletSpawnYs(md, playerCount) {
const h = md.height || 15;
const lo = 1;
const hi = Math.max(lo, h - 2);
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
if (hi <= lo) return Array.from({ length: n }, () => lo);
const ys = [];
for (let i = 0; i < n; i++) {
ys.push(Math.round(lo + (i * (hi - lo)) / Math.max(1, n - 1)));
}
return ys;
}
/** ช่อง spawnArea=1 ที่เดินได้ เรียงจากบนลงล่าง — ใช้เมื่อไม่มี gauntletPlayerSpawns */
function collectGauntletSpawnSlotsFromSpawnArea(md) {
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return [];
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) slots.push({ x, y });
}
}
slots.sort((a, b) => a.y - b.y || a.x - b.x);
return slots;
}
/** ลำดับผู้เล่น 1..6: ใช้ gauntletPlayerSpawns ตามลำดับใน JSON ก่อน ถ้าไม่มี/ว่างค่อยใช้ spawnArea */
function collectGauntletSpawnSlotsFromMap(md) {
const explicit = md.gauntletPlayerSpawns;
if (Array.isArray(explicit) && explicit.length > 0) {
const w = md.width || 20;
const h = md.height || 15;
const slots = [];
for (const raw of explicit) {
if (slots.length >= GAUNTLET_MAX_PLAYERS) break;
const x = Math.floor(Number(raw && raw.x));
const y = Math.floor(Number(raw && raw.y));
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x >= w || y < 0 || y >= h) continue;
if (!isMapTileWalkableForSpawn(md, x, y)) continue;
slots.push({ x, y });
}
if (slots.length > 0) return slots;
}
return collectGauntletSpawnSlotsFromSpawnArea(md);
}
function gauntletResolveSpawnXForRow(md, spawnColX, y, slotXFallback) {
const w = md.width || 20;
if (isMapTileWalkableForSpawn(md, spawnColX, y)) return spawnColX;
if (slotXFallback != null && isMapTileWalkableForSpawn(md, slotXFallback, y)) return slotXFallback;
for (let d = 0; d < w; d++) {
if (spawnColX + d < w && isMapTileWalkableForSpawn(md, spawnColX + d, y)) return spawnColX + d;
if (spawnColX - d >= 0 && isMapTileWalkableForSpawn(md, spawnColX - d, y)) return spawnColX - d;
}
return Math.max(0, Math.min(w - 1, spawnColX));
}
/** แนว Y ตามลำดับจุดเกิด แต่ทุกคนใช้คอลัมน์ x เดียวกัน (ซ้ายสุดจากชุดจุดที่กำหนด) — กันยืนเฉียง */
function gauntletSpawnPositions(md, playerCount) {
const n = Math.min(GAUNTLET_MAX_PLAYERS, Math.max(1, playerCount));
const slots = collectGauntletSpawnSlotsFromMap(md);
const fallbackYs = gauntletSpawnYs(md, n);
const spawnColX = slots.length ? Math.min(...slots.map((s) => s.x)) : 1;
const out = [];
for (let i = 0; i < n; i++) {
const y = i < slots.length
? slots[i].y
: (fallbackYs[i] != null ? fallbackYs[i] : fallbackYs[fallbackYs.length - 1]);
const slotX = i < slots.length ? slots[i].x : null;
const x = gauntletResolveSpawnXForRow(md, spawnColX, y, slotX);
out.push({ x, y });
}
return out;
}
function stopGauntletTicker(space) {
if (space.gauntletRun && space.gauntletRun.timerId) {
clearInterval(space.gauntletRun.timerId);
space.gauntletRun.timerId = null;
}
}
/** ถ้าตั้งจำกัดเวลาใน Admin แต่ยังไม่มี endsAt (เช่น join ห้อง Gauntlet โดยตรง) — เริ่มนับตอนนี้ */
function ensureGauntletEndsAtIfNeeded(space) {
const gr = space.gauntletRun;
if (!gr) return;
const lim = getGauntletTimeLimitSec();
if (lim > 0 && gr.endsAt == null) {
gr.endsAt = Date.now() + lim * 1000;
}
}
function startGauntletTicker(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if (space.gauntletRun && space.gauntletRun.timerId) return;
if (!space.gauntletRun) {
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
}
ensureGauntletEndsAtIfNeeded(space);
space.gauntletRun.timerId = setInterval(() => {
runGauntletTick(sid, space);
}, getGauntletTickMs());
}
function initGauntletPeersAll(space, md) {
const list = [...space.peers.values()];
const pos = gauntletSpawnPositions(md, list.length);
list.forEach((p, i) => {
const pt = pos[i] || pos[pos.length - 1];
p.x = pt.x;
p.y = pt.y;
p.gauntletJumpTicks = 0;
p.gauntletScore = 0;
p.gauntletJumpPending = false;
});
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
space.gauntletPreviewRowsBySocket = new Map();
}
function findFallbackLobbyMapForGauntletEnd() {
for (const [id, m] of maps) {
if (m && m.gameType === 'lobby') return { id, m };
}
return null;
}
/** หมดเวลา Gauntlet — หยุด tick, คืนแผนที่ lobby, แจ้งไคลเอนต์ */
function endGauntletGame(sid, space, reason) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
stopGauntletTicker(space);
const rankings = [...space.peers.values()].map((p) => ({
id: p.id,
nickname: (p.nickname || '').trim() || 'ผู้เล่น',
score: Math.max(0, p.gauntletScore | 0),
})).sort((a, b) => b.score - a.score);
let retId = space.gauntletReturnMapId;
let retMap = retId && maps.has(retId) ? maps.get(retId) : null;
if (!retMap || retMap.gameType === 'gauntlet') {
const fb = findFallbackLobbyMapForGauntletEnd();
if (fb) {
retId = fb.id;
retMap = fb.m;
}
}
if (retMap && retMap.gameType !== 'gauntlet') {
space.mapId = retId;
space.mapData = retMap;
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(retMap);
p.x = sp.x;
p.y = sp.y;
p.gauntletJumpTicks = 0;
p.gauntletScore = 0;
p.gauntletJumpPending = false;
});
} else {
console.warn('endGauntletGame: no lobby map to return to', sid);
}
space.gauntletReturnMapId = null;
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.clear();
space.gauntletRun = null;
const msg = reason === 'time' ? 'หมดเวลา — เกมพรมแดงจบแล้ว' : 'เกมพรมแดงจบแล้ว';
io.to(sid).emit('gauntlet-ended', {
reason: reason || 'time',
message: msg,
rankings,
});
}
function runGauntletTick(sid, space) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') {
stopGauntletTicker(space);
return;
}
const w = md.width || 20;
const h = md.height || 15;
const gr = space.gauntletRun;
if (!gr) return;
ensureGauntletEndsAtIfNeeded(space);
if (gr.endsAt != null && Date.now() >= gr.endsAt) {
endGauntletGame(sid, space, 'time');
return;
}
/* ใช้ pending ให้ jump กับ collision อยู่รอบ tick เดียวกัน (กัน race ระหว่าง setInterval กับ socket) */
space.peers.forEach((p) => {
if (p.gauntletJumpPending) {
if ((p.gauntletJumpTicks || 0) === 0) {
p.gauntletJumpTicks = getGauntletJumpTicks();
}
p.gauntletJumpPending = false;
}
});
for (let i = 0; i < gr.obstacles.length; i++) gr.obstacles[i].x -= 1;
gr.obstacles = gr.obstacles.filter((o) => o.x >= 0);
const occupiedY = new Set();
space.peers.forEach((peer) => {
const fy = Math.floor(Number(peer.y));
if (Number.isFinite(fy) && fy >= 0 && fy < h) occupiedY.add(fy);
});
/* แถวที่ client แจ้ง (บอท preview อยู่บน client ไม่ใช่ peer) — ให้ spawn lane บนแถวบอทด้วย */
const previewRows = space.gauntletPreviewRowsBySocket;
if (previewRows && previewRows.size) {
previewRows.forEach((set) => {
set.forEach((y) => {
const fy = Math.floor(Number(y));
if (Number.isFinite(fy) && fy >= 0 && fy < h) occupiedY.add(fy);
});
});
}
/* lane obstacle เฉพาะแถวที่มีคน — ลบชิ้นที่ลอยในแถวว่าง */
gr.obstacles = gr.obstacles.filter((o) => o.kind !== 'lane' || occupiedY.has(o.y));
gr.spawnAcc = (gr.spawnAcc || 0) + 1;
if (gr.spawnAcc >= (gr.nextSpawnIn || 3)) {
gr.spawnAcc = 0;
gr.nextSpawnIn = 2 + Math.floor(Math.random() * 6);
/* ประเภท 2: เลเซอร์ — ทุกแถว */
if (Math.random() < GAUNTLET_LASER_SPAWN_CHANCE) {
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'laser', x: w - 1 });
}
/* ประเภท 1: แยกเลน — เฉพาะแถวที่มีผู้เล่นยืนอยู่ (สุ่มแยกแถว) */
occupiedY.forEach((ly) => {
if (Math.random() < GAUNTLET_LANE_ROW_SPAWN_CHANCE) {
gr.obstacles.push({ id: ++gr.nextObsId, kind: 'lane', x: w - 1, y: ly });
}
});
}
/* กระโดดข้ามสำเร็จ → เลื่อนผู้เล่นไปขวา แต่ไม่ลบ obstacle (ยังไหลต่อให้คนอื่น/รอบถัดไป) */
space.peers.forEach((p) => {
let px = Math.floor(Number(p.x)) || 0;
let py = Math.floor(Number(p.y)) || 0;
px = Math.max(0, Math.min(w - 1, px));
py = Math.max(0, Math.min(h - 1, py));
const air = (p.gauntletJumpTicks || 0) > 0;
let advanceX = false;
let hitBack = false;
for (const o of gr.obstacles) {
if (o.kind === 'lane' && o.x === px && o.y === py) {
if (air) advanceX = true;
else hitBack = true;
}
if (o.kind === 'laser' && o.x === px) {
if (air) advanceX = true;
else hitBack = true;
}
}
if (advanceX) {
px = Math.min(w - 2, px + 1);
p.gauntletJumpTicks = 0;
p.gauntletScore = (p.gauntletScore || 0) + 1;
} else if (hitBack) {
px = Math.max(0, px - 1);
}
if ((p.gauntletJumpTicks || 0) > 0) p.gauntletJumpTicks--;
p.x = px;
p.y = py;
});
const obsOut = gr.obstacles.map((o) => ({ id: o.id, kind: o.kind, x: o.x, y: o.kind === 'laser' ? null : o.y }));
const playersOut = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
characterId: p.characterId ?? null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
}));
io.to(sid).emit('gauntlet-sync', {
obstacles: obsOut,
players: playersOut,
gauntletTickMs: getGauntletTickMs(),
gauntletJumpTicks: getGauntletJumpTicks(),
gauntletTimeLimitSec: getGauntletTimeLimitSec(),
gauntletEndsAt: gr.endsAt != null ? gr.endsAt : null,
...getGauntletVisualsForClient(),
});
}
function rescheduleAllGauntletTickers() {
for (const [sid, space] of spaces) {
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') continue;
if (space.gauntletRun && space.gauntletRun.timerId) {
clearInterval(space.gauntletRun.timerId);
space.gauntletRun.timerId = null;
}
if (space.peers.size === 0) continue;
if (!space.gauntletRun) {
space.gauntletRun = { obstacles: [], nextObsId: 1, spawnAcc: 0, nextSpawnIn: 3 };
}
ensureGauntletEndsAtIfNeeded(space);
space.gauntletRun.timerId = setInterval(() => {
runGauntletTick(sid, space);
}, getGauntletTickMs());
}
}
function getLobbyLayoutMapForSpace(space) {
return (space.mapId && maps.get(space.mapId)) || space.mapData;
}
/** ห้องกำลังใช้เลย์เอาต์ LobbyB (mn8nx46h หรือแมป lobby ชื่อ LobbyB) — กัน mapId บน space ค้างค่าอื่นแต่ mapData เป็น LobbyB */
function serverMapIsPostCaseLobbyB(space) {
if (!space) return false;
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
const md = getLobbyLayoutMapForSpace(space);
if (!md || md.gameType !== 'lobby') return false;
const nm = String(md.name || '').trim().toLowerCase();
return nm === 'lobbyb';
}
/** ให้ชื่อใน whitelist ตรงกับตอน join หลังเริ่มคดี (กันช่องว่าง/ยูนิโค้ดต่างรูปแบบ) */
function normalizeLobbyNickname(nickname) {
const raw = String(nickname || '').trim();
if (!raw) return 'ผู้เล่น';
try {
return raw.normalize('NFKC').toLowerCase();
} catch (e) {
return raw.toLowerCase();
}
}
/** ห้องนี้เป็น LobbyB (โถงหลังคดี) แล้ว — ไม่ต้องย้ายซ้ำ */
function lobbySpaceAlreadyLobbyB(space, md) {
if (!md || md.gameType !== 'lobby') return false;
if (space.mapId === POST_CASE_LOBBY_SPACE_ID) return true;
if (String(md.name || '').trim() === 'LobbyB') return true;
return false;
}
function lobbyMapHasStartGameArea(md) {
if (!md) return false;
const namedLobbyA = String(md.name || '').trim().toLowerCase() === 'lobbya';
if (md.gameType !== 'lobby' && !namedLobbyA) return false;
const area = md.startGameArea;
if (!area || !Array.isArray(area)) return false;
for (let y = 0; y < area.length; y++) {
const row = area[y];
if (!row) continue;
for (let x = 0; x < row.length; x++) if (row[x] === 1) return true;
}
return false;
}
/** ถ้าไม่ได้วาดพื้นที่เริ่มเกมในเอดิเตอร์ = ไม่บังคับ */
function lobbyHostStandingInStartArea(space, hostId) {
const md = getLobbyLayoutMapForSpace(space);
if (!lobbyMapHasStartGameArea(md)) return true;
const p = space.peers.get(hostId);
if (!p || p.x == null || p.y == null) return false;
const tx = Math.floor(p.x);
const ty = Math.floor(p.y);
const row = md.startGameArea[ty];
return !!(row && row[tx] === 1);
}
function isMapTileWalkableForSpawn(md, x, y) {
const w = md.width || 20;
const h = md.height || 15;
if (x < 0 || x >= w || y < 0 || y >= h) return false;
const row = md.objects && md.objects[y];
if (row && row[x] === 1) return false;
return true;
}
/** สุ่มจุดเกิดในช่อง spawnArea=1 ที่เดินได้ — ไม่มีช่องว่าง = ใช้ spawn ค่าเดิมจากเอดิเตอร์ */
function pickRandomSpawnFromMap(md) {
const fallback = md.spawn || { x: 1, y: 1 };
const fx = Number.isFinite(Number(fallback.x)) ? Number(fallback.x) : 1;
const fy = Number.isFinite(Number(fallback.y)) ? Number(fallback.y) : 1;
const grid = md.spawnArea;
if (!grid || !Array.isArray(grid)) return { x: fx, y: fy };
const w = md.width || 20;
const h = md.height || 15;
const pool = [];
for (let y = 0; y < h; y++) {
const row = grid[y];
if (!row) continue;
for (let x = 0; x < w; x++) {
if (Number(row[x]) === 1 && isMapTileWalkableForSpawn(md, x, y)) pool.push({ x, y });
}
}
if (!pool.length) return { x: fx, y: fy };
const idx = typeof crypto.randomInt === 'function'
? crypto.randomInt(0, pool.length)
: Math.floor(Math.random() * pool.length);
const pick = pool[idx];
return { x: pick.x, y: pick.y };
}
/** ตอบผิดในเกมคำถาม — กลับจุดเกิด (spawnArea / spawn) แล้วดีดออกนอกโซนถ้าจุดเกิดทับโซนตอบ */
function quizWrongAnswerRespawnPosition(md) {
const sp = pickRandomSpawnFromMap(md);
return findNearestOutsideQuizAnswerZones(md, Number(sp.x) + 0.5, Number(sp.y) + 0.5);
}
function initTroublesomeState(space) {
if (!space.troublesomeEligible) space.troublesomeEligible = new Set();
if (space.troublesomeOfferSent == null) space.troublesomeOfferSent = false;
if (space.suspectPickIndex == null) space.suspectPickIndex = 0;
if (space.suspectPhaseActive == null) space.suspectPhaseActive = false;
}
function attemptTroublesomeRoll(sid, force) {
const space = spaces.get(sid);
if (!space || space.troublesomeOfferSent) return;
const peerIds = [...space.peers.keys()];
if (peerIds.length === 0) return;
if (!force && space.troublesomeEligible.size < peerIds.length) return;
space.troublesomeOfferSent = true;
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
let pool = peerIds.filter((id) => space.troublesomeEligible.has(id));
if (pool.length === 0) pool = peerIds;
const chosen = pool[Math.floor(Math.random() * pool.length)];
space.troublesomeTargetId = chosen;
space.troublesomeResponseReceived = false;
io.to(chosen).emit('troublesome-offer', { seconds: 15 });
}
function scheduleTroublesomeRoll(sid) {
const space = spaces.get(sid);
if (!space || space.troublesomeOfferSent) return;
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
space.troublesomeDebTimer = setTimeout(() => attemptTroublesomeRoll(sid, false), 450);
if (!space.troublesomeMaxTimer) {
space.troublesomeMaxTimer = setTimeout(() => attemptTroublesomeRoll(sid, true), 8000);
}
}
io.on('connection', (socket) => {
socket.on('join-space', ({ spaceId, nickname, characterId }, cb) => {
const space = spaces.get(spaceId);
if (!space || !space.mapData) return cb && cb({ ok: false, error: 'ไม่พบห้อง' });
const maxPlayers = space.maxPlayers || 10;
const mdJoinPre = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (mdJoinPre && mdJoinPre.gameType === 'gauntlet' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'เกมพรมแดงรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (mdJoinPre && mdJoinPre.gameType === 'space_shooter' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'ยิงยานอวกาศรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (mdJoinPre && mdJoinPre.gameType === 'balloon_boss' && space.peers.size >= GAUNTLET_MAX_PLAYERS) {
return cb && cb({ ok: false, error: 'ลูกโป้งยิงบอสรับได้สูงสุด ' + GAUNTLET_MAX_PLAYERS + ' คน' });
}
if (space.peers.size >= maxPlayers) return cb && cb({ ok: false, error: 'ห้องเต็มแล้ว (' + space.peers.size + '/' + maxPlayers + ')' });
const joinNickNorm = normalizeLobbyNickname(nickname);
if (space.joinLocked) {
const wl = space.caseParticipantNicknames;
if (wl && wl.size > 0) {
if (!wl.has(joinNickNorm)) {
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
}
} else {
return cb && cb({ ok: false, error: 'ห้องนี้เริ่มคดีแล้ว ไม่รับผู้เล่นเพิ่ม' });
}
}
socket.join(spaceId);
socket.data.spaceId = spaceId;
if (!space.hostId) space.hostId = socket.id;
if (serverMapIsPostCaseLobbyB(space)) initTroublesomeState(space);
const mdJoin = (space.mapId && maps.get(space.mapId)) || space.mapData;
const spawnPt = pickRandomSpawnFromMap(mdJoin);
const bbStartBalloons = Math.max(1, Math.min(12, Math.floor(Number(mdJoin.balloonBossBalloonsPerPlayer)) || 5));
const peer = {
id: socket.id, x: +spawnPt.x, y: +spawnPt.y, direction: 'down', nickname: nickname || 'ผู้เล่น', ready: false, characterId: characterId || null, voiceMicOn: true,
gauntletJumpTicks: 0, gauntletScore: 0, gauntletJumpPending: false, spaceShooterScore: 0,
balloonBossScore: 0, balloonBossBalloons: mdJoin.gameType === 'balloon_boss' ? bbStartBalloons : 5, balloonBossEliminated: false,
};
if (mdJoin.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(mdJoin)) {
const sn = snapPositionOntoQuizBattlePathServer(mdJoin, Number(peer.x) + 0.5, Number(peer.y) + 0.5);
peer.x = sn.x;
peer.y = sn.y;
}
space.peers.set(socket.id, peer);
if (mdJoin.gameType === 'gauntlet') {
const ord = [...space.peers.keys()].indexOf(socket.id);
const pos = gauntletSpawnPositions(mdJoin, space.peers.size);
const pt = pos[ord] != null ? pos[ord] : pos[pos.length - 1];
peer.x = pt.x;
peer.y = pt.y;
peer.gauntletJumpTicks = 0;
startGauntletTicker(spaceId, space);
}
const peersList = [...space.peers.values()];
const mapData = mdJoin;
const joinCb = {
ok: true,
mapData,
mapId: space.mapId || null,
peers: peersList,
hostId: space.hostId,
spaceName: space.spaceName || spaceId,
isPrivate: !!space.isPrivate,
roomCode: space.isPrivate ? spaceId : null,
maxPlayers: space.maxPlayers || 10,
suspectPhaseActive: !!space.suspectPhaseActive && serverMapIsPostCaseLobbyB(space),
suspectPickIndex: typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0,
};
if (mdJoin.gameType === 'gauntlet' && space.gauntletRun) {
joinCb.gauntletEndsAt = space.gauntletRun.endsAt != null ? space.gauntletRun.endsAt : null;
}
if (typeof cb === 'function') cb(joinCb);
socket.to(spaceId).emit('user-joined', peer);
if (space.voiceInits) {
for (const [peerId, initData] of Object.entries(space.voiceInits)) {
if (space.peers.get(peerId)?.voiceMicOn) socket.emit('voice-init', { from: peerId, data: initData });
}
}
});
/** ห้องทดสอบจากเอดิเตอร์ (ชื่อ preview) — สลับโฮสต์ให้เครื่องนี้กดเริ่มเกมได้ */
socket.on('preview-claim-host', (cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return reply({ ok: false, error: 'ไม่อยู่ในห้อง' });
const isPreviewEditorRoom = !!(space.previewEditorTest
|| (space.isPrivate && String(space.spaceName || '').toLowerCase() === 'preview'));
if (!isPreviewEditorRoom) return reply({ ok: false, error: 'ใช้ได้เฉพาะห้องทดสอบจากเอดิเตอร์' });
space.hostId = socket.id;
io.to(sid).emit('host-changed', { hostId: socket.id });
return reply({ ok: true });
});
socket.on('troublesome-eligible', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space) || space.troublesomeOfferSent) return;
initTroublesomeState(space);
space.troublesomeEligible.add(socket.id);
scheduleTroublesomeRoll(sid);
});
socket.on('troublesome-response', (data) => {
const accept = !!(data && data.accept);
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space)) return;
if (space.troublesomeTargetId && socket.id !== space.troublesomeTargetId) return;
if (space.troublesomeResponseReceived) return;
space.troublesomeResponseReceived = true;
space.troublesomeAccept = accept;
let idx = Math.floor(Number(space.suspectPickIndex));
if (Number.isNaN(idx) || idx < 0 || idx > 2) idx = 0;
space.suspectPickIndex = idx;
space.suspectPhaseActive = true;
io.to(sid).emit('suspect-phase-open', {
hostId: space.hostId,
selectedIndex: space.suspectPickIndex,
disruptorAccepted: accept
});
socket.emit('troublesome-ack', { ok: true, accept });
});
socket.on('suspect-pick-select', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space) || !space.suspectPhaseActive) return;
if (space.hostId !== socket.id) return;
let idx = Math.floor(Number(data && data.index));
if (Number.isNaN(idx) || idx < 0 || idx > 2) return;
space.suspectPickIndex = idx;
io.to(sid).emit('suspect-pick-update', { selectedIndex: idx });
});
socket.on('suspect-pick-start', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !serverMapIsPostCaseLobbyB(space)) {
return reply({ ok: false, error: 'ไม่พร้อม — ต้องอยู่โถง LobbyB' });
}
if (!space.suspectPhaseActive) {
return reply({ ok: false, error: 'ไม่อยู่ในขั้นเลือกผู้ต้องสงสัย' });
}
if (space.hostId !== socket.id) {
return reply({ ok: false, error: 'เฉพาะ host กดเริ่มสืบสวนได้' });
}
const md = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID);
if (!md) {
return reply({ ok: false, error: 'ไม่พบฉากเกม (mng8a80o) บนเซิร์ฟเวอร์' });
}
const qErr = validateQuizMap(md);
if (qErr) {
return reply({ ok: false, error: qErr });
}
space.suspectPhaseActive = false;
clearSpaceQuizTimers(space);
space.mapId = SUSPECT_INVESTIGATION_QUIZ_MAP_ID;
space.mapData = md;
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(md);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
});
const peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null
}));
const selectedIndex = typeof space.suspectPickIndex === 'number' ? space.suspectPickIndex : 0;
io.to(sid).emit('suspect-investigation-start', { selectedIndex });
io.to(sid).emit('game-start', {
quizMode: true,
stayInRoomLobby: true,
mapId: SUSPECT_INVESTIGATION_QUIZ_MAP_ID,
peersSnap
});
setTimeout(() => {
const spNow = spaces.get(sid);
if (!spNow || !spNow.peers.has(socket.id)) return;
const mdLive = maps.get(SUSPECT_INVESTIGATION_QUIZ_MAP_ID);
if (!mdLive) return;
startQuizGame(sid, spNow, mdLive);
}, 900);
reply({ ok: true });
});
/* พร้อมแล้ว — ไม่บังคับจำนวนคนในห้อง */
socket.on('set-ready', ({ ready }) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
if (p) p.ready = !!ready;
io.to(sid).emit('peer-ready', { id: socket.id, ready: !!ready });
break;
}
}
});
socket.on('start-game', (data, cb) => {
const reply = typeof cb === 'function' ? cb : () => {};
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id) && space.hostId === socket.id) {
let lobbyLevel = (data && data.lobbyLevel != null) ? String(data.lobbyLevel).trim() : '';
let caseId = (data && data.caseId != null) ? String(data.caseId).trim() : '';
if (lobbyLevel && !/^[1-5]$/.test(lobbyLevel)) lobbyLevel = '';
if (caseId && !/^[1-3]$/.test(caseId)) caseId = '';
ensurePostCaseLobbyMapLoaded();
const mdBefore = getLobbyLayoutMapForSpace(space);
if (mdBefore && mdBefore.gameType === 'quiz') {
const qErr = validateQuizMap(mdBefore);
if (qErr) return reply({ ok: false, error: qErr });
clearSpaceQuizTimers(space);
startQuizGame(sid, space, mdBefore);
io.to(sid).emit('game-start', { quizMode: true, stayInRoomLobby: true });
return reply({ ok: true });
}
const detectiveB = !!(data && data.detectiveLobbyBStart);
if (detectiveB && (!lobbyLevel || !caseId)) {
return reply({ ok: false, error: 'กรุณาเลือกระดับและคดีให้ครบก่อนเริ่ม' });
}
const mapNameNorm = mdBefore ? String(mdBefore.name || '').trim().toLowerCase() : '';
const isNamedLobbyA = mapNameNorm === 'lobbya';
const notZepOrFrogger = !!(mdBefore && mdBefore.gameType !== 'zep' && mdBefore.gameType !== 'frogger' && mdBefore.gameType !== 'gauntlet');
/* LobbyA: gameType lobby, หรือชื่อฉาก LobbyA (กันเซฟผิดเป็น zep), หรือ wizard + ไม่ใช่มินิเกม zep/frogger/gauntlet (รวม quiz/stack ฯลฯ) */
const isLobbyADetectiveStart = !!(mdBefore && !lobbySpaceAlreadyLobbyB(space, mdBefore)
&& lobbyLevel && caseId && maps.has(POST_CASE_LOBBY_SPACE_ID)
&& (
mdBefore.gameType === 'lobby'
|| isNamedLobbyA
|| (detectiveB && notZepOrFrogger)
));
if (detectiveB && lobbyLevel && caseId && !isLobbyADetectiveStart) {
return reply({ ok: false, error: 'ย้ายไป LobbyB ไม่ได้ — ต้องอยู่ฉาก LobbyA (หรือล็อบบี้ชื่อ LobbyA) และเซิร์ฟต้องมีไฟล์แผนที่ LobbyB' });
}
if (!lobbyHostStandingInStartArea(space, socket.id)) {
return reply({ ok: false, error: 'ยืนในพื้นที่เริ่มเกม (สีส้มในเอดิเตอร์) ก่อนกดเริ่ม' });
}
if (isLobbyADetectiveStart) {
space.mapId = POST_CASE_LOBBY_SPACE_ID;
space.mapData = maps.get(POST_CASE_LOBBY_SPACE_ID);
const caseNickWl = new Set();
space.peers.forEach((p) => {
caseNickWl.add(normalizeLobbyNickname(p.nickname));
});
space.caseParticipantNicknames = caseNickWl;
space.joinLocked = true;
space.troublesomeOfferSent = false;
space.troublesomeTargetId = null;
space.troublesomeResponseReceived = false;
space.suspectPhaseActive = false;
space.suspectPickIndex = 0;
if (space.troublesomeEligible) space.troublesomeEligible.clear();
else space.troublesomeEligible = new Set();
if (space.troublesomeDebTimer) clearTimeout(space.troublesomeDebTimer);
if (space.troublesomeMaxTimer) clearTimeout(space.troublesomeMaxTimer);
space.troublesomeDebTimer = null;
space.troublesomeMaxTimer = null;
initTroublesomeState(space);
space.peers.forEach((p) => {
const sp = pickRandomSpawnFromMap(space.mapData);
p.x = sp.x;
p.y = sp.y;
p.direction = 'down';
});
const peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null
}));
io.to(sid).emit('game-start', {
stayInRoomLobby: true,
mapId: POST_CASE_LOBBY_SPACE_ID,
lobbyLevel,
caseId,
peersSnap
});
return reply({ ok: true });
}
const mapId = (data && data.mapId) ? String(data.mapId).trim() : null;
let peersSnap = null;
let gauntletEndsAtEmit = undefined;
if (mapId && maps.has(mapId)) {
const prevMapIdForReturn = space.mapId;
space.mapId = mapId;
space.mapData = maps.get(mapId);
const md = space.mapData;
if (md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
space.peers.forEach((p) => {
const sn = snapPositionOntoQuizBattlePathServer(md, Number(p.x), Number(p.y));
p.x = sn.x;
p.y = sn.y;
});
}
if (!md || md.gameType !== 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = null;
space.gauntletRun = null;
}
if (md && md.gameType === 'gauntlet') {
stopGauntletTicker(space);
space.gauntletReturnMapId = prevMapIdForReturn;
initGauntletPeersAll(space, md);
const lim = getGauntletTimeLimitSec();
if (lim > 0 && space.gauntletRun) {
space.gauntletRun.endsAt = Date.now() + lim * 1000;
} else if (space.gauntletRun) {
space.gauntletRun.endsAt = null;
}
startGauntletTicker(sid, space);
gauntletEndsAtEmit = space.gauntletRun && space.gauntletRun.endsAt != null
? space.gauntletRun.endsAt
: null;
peersSnap = [...space.peers.values()].map((p) => ({
id: p.id,
x: p.x,
y: p.y,
direction: p.direction || 'down',
nickname: p.nickname,
ready: !!p.ready,
characterId: p.characterId ?? null,
gauntletJumpTicks: p.gauntletJumpTicks || 0,
gauntletScore: p.gauntletScore || 0,
}));
}
}
const gameStartPayload = {
mapId: mapId || undefined,
lobbyLevel: lobbyLevel || undefined,
caseId: caseId || undefined,
peersSnap: peersSnap || undefined,
};
if (peersSnap) gameStartPayload.gauntletEndsAt = gauntletEndsAtEmit != null ? gauntletEndsAtEmit : null;
io.to(sid).emit('game-start', gameStartPayload);
return reply({ ok: true });
}
}
reply({ ok: false, error: 'ไม่ใช่ host' });
});
socket.on('disconnect', () => {
let sid = socket.data.spaceId;
let space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) {
for (const [s, sp] of spaces) {
if (sp.peers.has(socket.id)) { sid = s; space = sp; break; }
}
}
if (!space || !space.peers.has(socket.id)) return;
const wasHost = space.hostId === socket.id;
if (space.voiceInits) delete space.voiceInits[socket.id];
if (space.troublesomeEligible) space.troublesomeEligible.delete(socket.id);
if (space.quizSession && space.quizSession.players) delete space.quizSession.players[socket.id];
if (space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket.delete(socket.id);
space.peers.delete(socket.id);
if (space.peers.size === 0) {
stopGauntletTicker(space);
clearSpaceQuizTimers(space);
space.quizSession = null;
spaces.delete(sid);
} else {
if (wasHost) {
const firstPeer = space.peers.values().next().value;
if (firstPeer) {
space.hostId = firstPeer.id;
io.to(sid).emit('host-changed', { hostId: firstPeer.id });
}
}
io.to(sid).emit('user-left', { id: socket.id });
}
});
socket.on('gauntlet-jump', () => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
if ((p.gauntletJumpTicks || 0) > 0) return;
p.gauntletJumpPending = true;
});
/** Client ส่งแถว y ของบอททดสอบ (ไม่อยู่ใน peers) เพื่อให้ spawn lane obstacles บนแถวนั้น */
socket.on('gauntlet-preview-rows', (data) => {
const sid = socket.data.spaceId;
const space = sid ? spaces.get(sid) : null;
if (!space || !space.peers.has(socket.id)) return;
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md || md.gameType !== 'gauntlet') return;
const hh = md.height || 15;
if (!space.gauntletPreviewRowsBySocket) space.gauntletPreviewRowsBySocket = new Map();
const raw = data && Array.isArray(data.ys) ? data.ys : [];
const set = new Set();
const cap = Math.min(raw.length, 48);
for (let i = 0; i < cap; i++) {
const fy = Math.floor(Number(raw[i]));
if (Number.isFinite(fy) && fy >= 0 && fy < hh) set.add(fy);
}
space.gauntletPreviewRowsBySocket.set(socket.id, set);
});
socket.on('move', (data) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (md && md.gameType === 'gauntlet') {
const out = { id: socket.id, x: p.x, y: p.y, direction: p.direction || 'down', characterId: p.characterId };
socket.emit('user-move', out);
break;
}
let nx = data.x;
let ny = data.y;
const sess = space.quizSession;
const mdQuiz = (sess && sess.quizMapMd) || md;
if (p && sess && sess.active && mdQuiz && mdQuiz.gameType === 'quiz') {
const st = sess.players && sess.players[socket.id];
if (st && !st.eliminated) {
const tx = Math.floor(Number(nx));
const ty = Math.floor(Number(ny));
const blockTrue = st.cannotTrue && quizCellOn(mdQuiz.quizTrueArea, tx, ty);
const blockFalse = st.cannotFalse && quizCellOn(mdQuiz.quizFalseArea, tx, ty);
if (blockTrue || blockFalse) {
nx = p.x;
ny = p.y;
}
}
}
if (p && md && md.gameType === 'quiz_carry' && md.quizCarryHubArea) {
const txN = Number(nx);
const tyN = Number(ny);
if (Number.isFinite(txN) && Number.isFinite(tyN) && quizCarryFootprintOverlapsHubServer(md, txN, tyN)) {
nx = p.x;
ny = p.y;
}
}
if (p && md && md.gameType === 'quiz_battle' && quizBattlePathModeActiveServer(md)) {
const txN = Number(nx);
const tyN = Number(ny);
if (!Number.isFinite(txN) || !Number.isFinite(tyN)) {
nx = p.x;
ny = p.y;
} else if (!quizBattleFootprintFullyOnPathServer(md, txN, tyN)) {
nx = p.x;
ny = p.y;
}
const sn = snapPositionOntoQuizBattlePathServer(md, Number(nx), Number(ny));
nx = sn.x;
ny = sn.y;
}
if (p && md && md.gameType === 'space_shooter' && data && data.spaceShooterScore != null) {
const ns = Math.floor(Number(data.spaceShooterScore));
if (Number.isFinite(ns) && ns >= 0) {
const prev = Math.max(0, p.spaceShooterScore | 0);
if (ns <= prev + 35) p.spaceShooterScore = Math.max(prev, ns);
}
}
if (p && md && md.gameType === 'balloon_boss' && data) {
const bbDefaultBalloons = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
if (data.balloonBossScore != null) {
const ns = Math.floor(Number(data.balloonBossScore));
if (Number.isFinite(ns) && ns >= 0) {
const prev = Math.max(0, p.balloonBossScore | 0);
if (ns <= prev + 25) p.balloonBossScore = Math.max(prev, ns);
}
}
if (data.balloonBossBalloons != null) {
const nb = Math.floor(Number(data.balloonBossBalloons));
if (Number.isFinite(nb) && nb >= 0) {
const prevB = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
? Math.floor(p.balloonBossBalloons)
: bbDefaultBalloons;
if (nb <= prevB && nb >= Math.max(0, prevB - 2)) p.balloonBossBalloons = nb;
}
}
if (data.balloonBossEliminated) p.balloonBossEliminated = true;
}
if (p) {
p.x = nx;
p.y = ny;
p.direction = data.direction || p.direction;
const out = { id: socket.id, x: nx, y: ny, direction: p.direction, characterId: p.characterId };
if (md && md.gameType === 'space_shooter') out.spaceShooterScore = Math.max(0, p.spaceShooterScore | 0);
if (md && md.gameType === 'balloon_boss') {
const bbDef = Math.max(1, Math.min(12, Math.floor(Number(md.balloonBossBalloonsPerPlayer)) || 5));
out.balloonBossScore = Math.max(0, p.balloonBossScore | 0);
out.balloonBossBalloons = typeof p.balloonBossBalloons === 'number' && Number.isFinite(p.balloonBossBalloons)
? Math.max(0, Math.floor(p.balloonBossBalloons))
: bbDef;
out.balloonBossEliminated = !!p.balloonBossEliminated;
}
if (nx !== data.x || ny !== data.y) socket.emit('user-move', out);
socket.to(sid).emit('user-move', out);
}
break;
}
}
});
socket.on('lobby-interact', (data, ack) => {
const reply = typeof ack === 'function' ? ack : () => {};
const tx = Math.floor(Number(data && data.x));
const ty = Math.floor(Number(data && data.y));
for (const [sid, space] of spaces) {
if (!space.peers.has(socket.id)) continue;
const p = space.peers.get(socket.id);
const md = (space.mapId && maps.get(space.mapId)) || space.mapData;
if (!md) return reply({ ok: false, error: 'ไม่มีแผนที่' });
const w = md.width || 20;
const h = md.height || 15;
if (Number.isNaN(tx) || Number.isNaN(ty) || tx < 0 || tx >= w || ty < 0 || ty >= h) {
return reply({ ok: false, error: 'จุดไม่ถูกต้อง' });
}
if (!md.interactive || !md.interactive[ty] || md.interactive[ty][tx] !== 1) {
return reply({ ok: false, error: 'ไม่มีจุดโต้ตอบที่นี่' });
}
const px = Math.floor(Number(p.x));
const py = Math.floor(Number(p.y));
const neighbors = [[0, 0], [0, -1], [0, 1], [-1, 0], [1, 0]];
let near = false;
for (const [dx, dy] of neighbors) {
if (px + dx === tx && py + dy === ty) { near = true; break; }
}
if (!near) return reply({ ok: false, error: 'เข้าใกล้จุดสีเขียว (ช่องโต้ตอบ) ก่อน' });
const nick = (p.nickname || '').trim() || 'ผู้เล่น';
io.to(sid).emit('lobby-interact', { id: socket.id, nickname: nick, x: tx, y: ty });
return reply({ ok: true });
}
reply({ ok: false, error: 'ไม่ได้อยู่ในห้อง' });
});
socket.on('chat', (text) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
const nick = space.peers.get(socket.id)?.nickname || '';
io.to(sid).emit('chat', { id: socket.id, text, time: new Date(), nickname: nick });
break;
}
}
});
socket.on('voice-state', ({ micOn }) => {
for (const [sid, space] of spaces) {
if (space.peers.has(socket.id)) {
const p = space.peers.get(socket.id);
if (p) p.voiceMicOn = micOn !== false;
if (micOn === false && space.voiceInits) delete space.voiceInits[socket.id];
io.to(sid).emit('peer-voice-state', { id: socket.id, micOn: micOn !== false });
break;
}
}
});
socket.on('voice-activity', ({ level }) => {
const sid = socket.data.spaceId;
if (!sid || !spaces.get(sid)) return;
const until = Date.now() + 400;
io.to(sid).emit('peer-speaking', { id: socket.id, level: typeof level === 'number' ? level : 0.5, until });
});
socket.on('voice-init', (data) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
if (!space.voiceInits) space.voiceInits = {};
space.voiceInits[socket.id] = data;
io.to(sid).emit('voice-init', { from: socket.id, data });
break;
}
}
});
socket.on('voice-chunk', (data) => {
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid)) {
socket.to(sid).emit('voice-chunk', { from: socket.id, data });
break;
}
}
});
socket.on('webrtc-signal', (data) => {
const { to, type, sdp, candidate } = data;
if (!to) return;
for (const [sid, space] of spaces) {
if (socket.rooms.has(sid) && space.peers.has(to)) {
io.to(to).emit('webrtc-signal', { from: socket.id, type, sdp, candidate });
break;
}
}
});
});
loadMaps();
server.listen(PORT, () => console.log('Game', PORT));