two players can be in the same game
This commit is contained in:
parent
e1fe63cf89
commit
e285783f9f
8 changed files with 474 additions and 26 deletions
163
backend/index.js
163
backend/index.js
|
|
@ -8,6 +8,29 @@ const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(bodyParser.json());
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const LOGFILE = process.env.CHESSNUT_LOG || '/tmp/chessnut.log';
|
||||||
|
function appendLogLine(line){
|
||||||
|
try{ fs.appendFileSync(LOGFILE, line + '\n'); }catch(e){}
|
||||||
|
}
|
||||||
|
function log(){
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const msg = Array.prototype.slice.call(arguments).map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ');
|
||||||
|
const line = `${ts} ${msg}`;
|
||||||
|
console.log(line);
|
||||||
|
appendLogLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request logging middleware: logs method, path, small subset of headers and body
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
try{
|
||||||
|
const { method, path } = req;
|
||||||
|
const info = { method, path, headers: { origin: req.headers.origin, host: req.headers.host }, body: req.body };
|
||||||
|
log('HTTP', info);
|
||||||
|
}catch(e){}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = new Server(server, { cors: { origin: '*' } });
|
const io = new Server(server, { cors: { origin: '*' } });
|
||||||
|
|
||||||
|
|
@ -118,10 +141,18 @@ setInterval(() => { pruneOrphans(); }, 60 * 1000);
|
||||||
app.post('/api/join', (req, res) => {
|
app.post('/api/join', (req, res) => {
|
||||||
const { id, pass, nickname, colorChoice } = req.body || {};
|
const { id, pass, nickname, colorChoice } = req.body || {};
|
||||||
const g = games.find(x => x.id === id);
|
const g = games.find(x => x.id === id);
|
||||||
if(!g) return res.status(404).json({ error: 'not found' });
|
if(!g) {
|
||||||
|
// log current games snapshot to help debugging
|
||||||
|
try{
|
||||||
|
const snapshot = games.map(x => ({ id: x.id, name: x.name, players: (x.players||[]).length, started: !!x.started }));
|
||||||
|
log('start failed - game not found', { requestedId: id, gamesSnapshot: snapshot });
|
||||||
|
}catch(e){ log('start failed - game not found (and failed to snapshot)'); }
|
||||||
|
return res.status(404).json({ error: 'not found' });
|
||||||
|
}
|
||||||
if(g.pass && g.pass !== pass) return res.status(403).json({ error: 'bad password' });
|
if(g.pass && g.pass !== pass) return res.status(403).json({ error: 'bad password' });
|
||||||
if(g.players.length >= 2) return res.status(403).json({ error: 'full' });
|
if(g.players.length >= 2) return res.status(403).json({ error: 'full' });
|
||||||
const player = { id: 'p-' + Math.random().toString(36).slice(2,6), nickname: nickname || 'Anon', colorChoice: colorChoice || 'random', colorAssigned: null };
|
const player = { id: 'p-' + Math.random().toString(36).slice(2,6), nickname: nickname || 'Anon', colorChoice: colorChoice || 'random', colorAssigned: null };
|
||||||
|
player.connected = true;
|
||||||
g.players.push(player);
|
g.players.push(player);
|
||||||
io.to(g.id).emit('player-update', g);
|
io.to(g.id).emit('player-update', g);
|
||||||
io.emit('games-list', games);
|
io.emit('games-list', games);
|
||||||
|
|
@ -136,7 +167,9 @@ app.post('/api/leave', (req, res) => {
|
||||||
const g = games[gIndex];
|
const g = games[gIndex];
|
||||||
const pIndex = g.players.findIndex(p => p.id === playerId);
|
const pIndex = g.players.findIndex(p => p.id === playerId);
|
||||||
if(pIndex === -1) return res.status(404).json({ error: 'player not in game' });
|
if(pIndex === -1) return res.status(404).json({ error: 'player not in game' });
|
||||||
// remove player
|
// do not allow leaving once game started
|
||||||
|
if (g.started) return res.status(403).json({ error: 'game in progress' });
|
||||||
|
// remove player (allowed only if game not started)
|
||||||
const leaving = g.players.splice(pIndex, 1)[0];
|
const leaving = g.players.splice(pIndex, 1)[0];
|
||||||
// if the leaving player was the owner, remove the entire game
|
// if the leaving player was the owner, remove the entire game
|
||||||
if(g.ownerId && leaving.id === g.ownerId){
|
if(g.ownerId && leaving.id === g.ownerId){
|
||||||
|
|
@ -156,18 +189,25 @@ app.get('/api/game/:id', (req, res) => {
|
||||||
if(!g) return res.status(404).json({ error: 'not found' });
|
if(!g) return res.status(404).json({ error: 'not found' });
|
||||||
// if game exists but has no players, treat as not found and remove it
|
// if game exists but has no players, treat as not found and remove it
|
||||||
if(!g.players || g.players.length === 0){
|
if(!g.players || g.players.length === 0){
|
||||||
|
// only prune if game has not started
|
||||||
|
if (!g.started) {
|
||||||
const idx = games.findIndex(x => x.id === id);
|
const idx = games.findIndex(x => x.id === id);
|
||||||
if(idx !== -1) games.splice(idx, 1);
|
if(idx !== -1) games.splice(idx, 1);
|
||||||
return res.status(404).json({ error: 'not found' });
|
return res.status(404).json({ error: 'not found' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
res.json(g);
|
res.json(g);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/start', (req, res) => {
|
app.post('/api/start', (req, res) => {
|
||||||
const { id } = req.body || {};
|
const { id } = req.body || {};
|
||||||
|
log('POST /api/start', { id, body: req.body });
|
||||||
const g = games.find(x => x.id === id);
|
const g = games.find(x => x.id === id);
|
||||||
if(!g) return res.status(404).json({ error: 'not found' });
|
if(!g) return res.status(404).json({ error: 'not found' });
|
||||||
if(g.players.length < 2) return res.status(400).json({ error: 'need 2 players' });
|
if(g.players.length < 2) {
|
||||||
|
log('start rejected: need 2 players', { gameId: id, players: g.players.map(p=>p.id) });
|
||||||
|
return res.status(400).json({ error: 'need 2 players' });
|
||||||
|
}
|
||||||
// assign colors
|
// assign colors
|
||||||
const p0 = g.players[0];
|
const p0 = g.players[0];
|
||||||
const p1 = g.players[1];
|
const p1 = g.players[1];
|
||||||
|
|
@ -183,17 +223,59 @@ app.post('/api/start', (req, res) => {
|
||||||
}
|
}
|
||||||
assign();
|
assign();
|
||||||
g.started = true;
|
g.started = true;
|
||||||
|
// Initialize a minimal game.state to be consumed by frontend.
|
||||||
|
// We avoid importing engine/ here: state is a plain JS structure describing the board.
|
||||||
|
// Board is an 8x8 array of cells; each cell is either null or { color: 'white'|'black', type: 'PAWN'|'ROOK'|... }
|
||||||
|
function initialState() {
|
||||||
|
const w = 8, h = 8;
|
||||||
|
const board = Array.from({ length: h }, () => Array.from({ length: w }, () => null));
|
||||||
|
// pawns
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
board[1][x] = { color: 'white', type: 'PAWN' };
|
||||||
|
board[6][x] = { color: 'black', type: 'PAWN' };
|
||||||
|
}
|
||||||
|
// rooks
|
||||||
|
board[0][0] = { color: 'white', type: 'ROOK' };
|
||||||
|
board[0][7] = { color: 'white', type: 'ROOK' };
|
||||||
|
board[7][0] = { color: 'black', type: 'ROOK' };
|
||||||
|
board[7][7] = { color: 'black', type: 'ROOK' };
|
||||||
|
// knights
|
||||||
|
board[0][1] = { color: 'white', type: 'KNIGHT' };
|
||||||
|
board[0][6] = { color: 'white', type: 'KNIGHT' };
|
||||||
|
board[7][1] = { color: 'black', type: 'KNIGHT' };
|
||||||
|
board[7][6] = { color: 'black', type: 'KNIGHT' };
|
||||||
|
// bishops
|
||||||
|
board[0][2] = { color: 'white', type: 'BISHOP' };
|
||||||
|
board[0][5] = { color: 'white', type: 'BISHOP' };
|
||||||
|
board[7][2] = { color: 'black', type: 'BISHOP' };
|
||||||
|
board[7][5] = { color: 'black', type: 'BISHOP' };
|
||||||
|
// queen/king
|
||||||
|
board[0][3] = { color: 'white', type: 'QUEEN' };
|
||||||
|
board[0][4] = { color: 'white', type: 'KING' };
|
||||||
|
board[7][3] = { color: 'black', type: 'QUEEN' };
|
||||||
|
board[7][4] = { color: 'black', type: 'KING' };
|
||||||
|
|
||||||
|
return { board, width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
// only initialize if not already present
|
||||||
|
if (!g.state) {
|
||||||
|
g.state = initialState();
|
||||||
|
}
|
||||||
|
// set initial turn: white starts
|
||||||
|
g.turn = 'white';
|
||||||
|
log('start succeeded', { gameId: g.id, players: g.players.map(p => ({ id: p.id, nickname: p.nickname, colorAssigned: p.colorAssigned })) });
|
||||||
io.to(g.id).emit('game-start', g);
|
io.to(g.id).emit('game-start', g);
|
||||||
io.emit('games-list', games);
|
io.emit('games-list', games);
|
||||||
res.json({ ok: true, game: g });
|
res.json({ ok: true, game: g });
|
||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
console.log('socket connected', socket.id);
|
log('socket connected', { socketId: socket.id, remote: socket.handshake && socket.handshake.address });
|
||||||
socket.on('join-game', (gameId) => {
|
socket.on('join-game', (gameId) => {
|
||||||
const room = gameId;
|
const room = gameId;
|
||||||
socket.join(room);
|
socket.join(room);
|
||||||
console.log(`${socket.id} joined room ${room}`);
|
log('join-game', { socketId: socket.id, room });
|
||||||
});
|
});
|
||||||
// client can identify which player it is in a game so server can map socket -> player
|
// client can identify which player it is in a game so server can map socket -> player
|
||||||
socket.on('identify', (payload) => {
|
socket.on('identify', (payload) => {
|
||||||
|
|
@ -201,18 +283,69 @@ io.on('connection', (socket) => {
|
||||||
const { gameId, playerId } = payload || {};
|
const { gameId, playerId } = payload || {};
|
||||||
if(gameId && playerId){
|
if(gameId && playerId){
|
||||||
socketMap.set(socket.id, { gameId, playerId });
|
socketMap.set(socket.id, { gameId, playerId });
|
||||||
console.log(`socket ${socket.id} identified as player ${playerId} in ${gameId}`);
|
log('identify', { socketId: socket.id, gameId, playerId });
|
||||||
}
|
}
|
||||||
}catch(e){ }
|
}catch(e){ }
|
||||||
});
|
});
|
||||||
socket.on('move', (payload) => {
|
socket.on('move', (payload) => {
|
||||||
const { gameId } = payload;
|
try {
|
||||||
socket.to(gameId).emit('move', payload);
|
log('socket move', { socketId: socket.id, payload });
|
||||||
|
const { gameId, playerId, from, to } = payload || {};
|
||||||
|
const g = games.find(x => x.id === gameId);
|
||||||
|
if (!g) return;
|
||||||
|
|
||||||
|
// basic validation: must be started and it must be the player's turn
|
||||||
|
if (!g.started) return;
|
||||||
|
|
||||||
|
const player = g.players.find(p => p.id === playerId);
|
||||||
|
if (!player) return;
|
||||||
|
// determine player's color
|
||||||
|
const color = player.colorAssigned || (player.colorChoice === 'white' ? 'white' : (player.colorChoice === 'black' ? 'black' : null));
|
||||||
|
// if no assigned color (random choice), try to infer from players array
|
||||||
|
if (!color) {
|
||||||
|
if (g.players[0] && g.players[1]) {
|
||||||
|
// if not assigned, assume assignment as in /api/start logic
|
||||||
|
// fallback: first player white
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure it is this player's turn (simple toggle 'white'/'black')
|
||||||
|
if (g.turn && color && g.turn !== color) {
|
||||||
|
// not this player's turn
|
||||||
|
socket.emit('error', { error: 'not your turn' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounds check and cell existence
|
||||||
|
const h = g.state.height || g.state.board.length;
|
||||||
|
const w = g.state.width || (g.state.board[0] && g.state.board[0].length) || 8;
|
||||||
|
if (!from || !to) return;
|
||||||
|
if (from.x < 0 || from.x >= w || from.y < 0 || from.y >= h) return;
|
||||||
|
if (to.x < 0 || to.x >= w || to.y < 0 || to.y >= h) return;
|
||||||
|
|
||||||
|
const src = g.state.board[from.y][from.x];
|
||||||
|
if (!src) return; // no piece at source
|
||||||
|
if (src.color !== color) { socket.emit('error', { error: 'not your piece' }); return; }
|
||||||
|
|
||||||
|
// naive move apply: move piece from src to dest (no legality checks beyond ownership)
|
||||||
|
const dst = g.state.board[to.y][to.x];
|
||||||
|
// apply
|
||||||
|
g.state.board[to.y][to.x] = src;
|
||||||
|
g.state.board[from.y][from.x] = null;
|
||||||
|
|
||||||
|
// toggle turn
|
||||||
|
g.turn = g.turn === 'white' ? 'black' : 'white';
|
||||||
|
|
||||||
|
io.to(gameId).emit('move', { gameId, playerId, from, to });
|
||||||
|
io.to(gameId).emit('game-state', g.state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('move handler error', e && e.stack);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
// if we know which player this socket belonged to, remove them from the game
|
|
||||||
const meta = socketMap.get(socket.id);
|
const meta = socketMap.get(socket.id);
|
||||||
|
log('socket disconnect', { socketId: socket.id, reason, meta });
|
||||||
if(!meta) return;
|
if(!meta) return;
|
||||||
const { gameId, playerId } = meta;
|
const { gameId, playerId } = meta;
|
||||||
socketMap.delete(socket.id);
|
socketMap.delete(socket.id);
|
||||||
|
|
@ -221,9 +354,19 @@ io.on('connection', (socket) => {
|
||||||
const g = games[gIndex];
|
const g = games[gIndex];
|
||||||
const pIndex = g.players.findIndex(p => p.id === playerId);
|
const pIndex = g.players.findIndex(p => p.id === playerId);
|
||||||
if(pIndex === -1) return;
|
if(pIndex === -1) return;
|
||||||
|
const player = g.players[pIndex];
|
||||||
|
// mark as disconnected instead of removing when game started
|
||||||
|
if (g.started) {
|
||||||
|
player.connected = false;
|
||||||
|
console.log(`socket ${socket.id} disconnected (${reason}), marked player ${player.id} disconnected in ${gameId}`);
|
||||||
|
io.to(gameId).emit('player-update', g);
|
||||||
|
io.emit('games-list', games);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// if game not started, remove player as before
|
||||||
const leaving = g.players.splice(pIndex, 1)[0];
|
const leaving = g.players.splice(pIndex, 1)[0];
|
||||||
console.log(`socket ${socket.id} disconnected (${reason}), removed player ${leaving.id} from ${gameId}`);
|
console.log(`socket ${socket.id} disconnected (${reason}), removed player ${leaving.id} from ${gameId}`);
|
||||||
// if owner left, delete the game
|
// if owner left and game not started, delete the game
|
||||||
if(g.ownerId && leaving.id === g.ownerId){
|
if(g.ownerId && leaving.id === g.ownerId){
|
||||||
games.splice(gIndex, 1);
|
games.splice(gIndex, 1);
|
||||||
io.emit('games-list', games);
|
io.emit('games-list', games);
|
||||||
|
|
|
||||||
65
frontend/public/game.html
Normal file
65
frontend/public/game.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>ChessNut 1 Partie</title>
|
||||||
|
<link rel="stylesheet" href="src/styles.css">
|
||||||
|
<style>
|
||||||
|
/* Minimal board CSS: grid that adapts to container size */
|
||||||
|
.game-page{max-width:1000px;margin:12px auto;padding:8px}
|
||||||
|
#board-wrapper{width:100%;max-width:900px;margin:0 auto}
|
||||||
|
.board{display:grid;border:2px solid #333;box-sizing:border-box}
|
||||||
|
.row{display:contents}
|
||||||
|
.cell{position:relative;padding:0;overflow:hidden}
|
||||||
|
.cell .piece{width:100%;height:100%;object-fit:contain;display:block}
|
||||||
|
.cell.selected{outline:3px solid rgba(0,128,255,0.6);}
|
||||||
|
/* make board square and responsive: use a wrapper with aspect-ratio */
|
||||||
|
#board-wrapper .board{width:100%;aspect-ratio:1/1}
|
||||||
|
/* grid cells will be created dynamically via JS using grid-template */
|
||||||
|
#meta{margin-top:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="game-page">
|
||||||
|
<header><h1>Partie</h1></header>
|
||||||
|
<div id="meta">Chargement...</div>
|
||||||
|
<div id="board-wrapper"><div id="board-container"></div></div>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<button id="leave">Quitter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
|
<script src="src/game.js?v=3"></script>
|
||||||
|
<script>
|
||||||
|
// small bootstrap: fetch initial game state and wire socket updates
|
||||||
|
(function(){
|
||||||
|
const qs = new URLSearchParams(window.location.search);
|
||||||
|
const gid = qs.get('game');
|
||||||
|
const meta = document.getElementById('meta');
|
||||||
|
if(!gid){ meta.textContent = 'Game ID manquant'; return; }
|
||||||
|
meta.textContent = 'Chargement de la partie ' + gid + '...';
|
||||||
|
// fetch initial state
|
||||||
|
fetch('/api/game/' + gid).then(r => r.ok ? r.json() : null).then(g => {
|
||||||
|
if(g && g.state && typeof renderBoardFromState === 'function') renderBoardFromState(g.state);
|
||||||
|
meta.textContent = 'Partie ' + (g && g.id ? g.id : gid);
|
||||||
|
// set globals expected by src/game.js so socket handlers attach correctly
|
||||||
|
try {
|
||||||
|
if(typeof currentGameId !== 'undefined') currentGameId = gid;
|
||||||
|
if(typeof myPlayerId !== 'undefined' && g && g.players && g.players[0]) myPlayerId = g.players[0].id;
|
||||||
|
if(typeof socket === 'undefined' || !socket){
|
||||||
|
socket = io();
|
||||||
|
socket.on('connect', () => { socket.emit('join-game', gid); socket.emit('identify', { gameId: gid, playerId: myPlayerId }); });
|
||||||
|
socket.on('game-state', (s) => { if(typeof renderBoardFromState === 'function') renderBoardFromState(s); if(typeof setMeta === 'function') setMeta('game-state updated'); });
|
||||||
|
socket.on('move', (m) => { if(typeof setMeta === 'function') setMeta('remote move ' + JSON.stringify(m)); });
|
||||||
|
socket.on('player-update', (g) => { if(typeof setMeta === 'function') setMeta('players updated'); });
|
||||||
|
}
|
||||||
|
} catch(e){ console.warn('socket init failed', e); }
|
||||||
|
}).catch(()=>{ meta.textContent = 'Impossible de charger la partie'; });
|
||||||
|
|
||||||
|
document.getElementById('leave').addEventListener('click', ()=>{ window.location.href = 'waiting.html?game=' + encodeURIComponent(gid); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -29,6 +29,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="board-section" class="hidden">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Partie <span id="game-id"></span></h2>
|
||||||
|
<div>Rôle: <span id="player-role"></span></div>
|
||||||
|
<div id="board-container" style="margin-top:12px;"></div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button id="flip-btn">Retourner</button>
|
||||||
|
<button id="back-to-lobby">Retour au lobby</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<!-- waiting room is a separate page: waiting.html -->
|
<!-- waiting room is a separate page: waiting.html -->
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ function qsParam(name){
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameId = qsParam('game');
|
const gameId = qsParam('game');
|
||||||
|
console.log('[waiting.js] loaded - build: v2 -', { gameId, url: window.location.href });
|
||||||
if(!gameId){ document.body.innerHTML = '<p>Game ID missing in URL</p>'; }
|
if(!gameId){ document.body.innerHTML = '<p>Game ID missing in URL</p>'; }
|
||||||
else {
|
else {
|
||||||
// Query explicit backend first to avoid interpreting same-origin static 404 as game-deleted
|
// Query explicit backend first to avoid interpreting same-origin static 404 as game-deleted
|
||||||
|
|
@ -22,6 +23,8 @@ else {
|
||||||
}).then(g => {
|
}).then(g => {
|
||||||
q('#wait-game-id').textContent = g.name || '';
|
q('#wait-game-id').textContent = g.name || '';
|
||||||
renderWaitingPlayers(g.players||[]);
|
renderWaitingPlayers(g.players||[]);
|
||||||
|
// if game already started, redirect immediately to the game page
|
||||||
|
if (g.started) { window.location.href = `game.html?game=${g.id}`; return; }
|
||||||
// display current player nick and color if we have a playerId
|
// display current player nick and color if we have a playerId
|
||||||
const stored = sessionStorage.getItem(`playerId:${gameId}`);
|
const stored = sessionStorage.getItem(`playerId:${gameId}`);
|
||||||
if(stored){
|
if(stored){
|
||||||
|
|
@ -31,6 +34,9 @@ else {
|
||||||
// owner-only start button
|
// owner-only start button
|
||||||
const ownerId = g.players && g.players[0] && g.players[0].id;
|
const ownerId = g.players && g.players[0] && g.players[0].id;
|
||||||
if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); }
|
if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); }
|
||||||
|
// disable start button unless there are at least 2 players
|
||||||
|
const startBtn = q('#start-btn');
|
||||||
|
if(startBtn){ if((g.players||[]).length < 2){ startBtn.disabled = true; } else { startBtn.disabled = false; } }
|
||||||
if(!g.started) startPolling(gameId);
|
if(!g.started) startPolling(gameId);
|
||||||
// try to connect socket.io for instant updates and identification
|
// try to connect socket.io for instant updates and identification
|
||||||
tryConnectSocket(gameId);
|
tryConnectSocket(gameId);
|
||||||
|
|
@ -43,10 +49,15 @@ else {
|
||||||
}).then(g=>{
|
}).then(g=>{
|
||||||
q('#wait-game-id').textContent = g.name || '';
|
q('#wait-game-id').textContent = g.name || '';
|
||||||
renderWaitingPlayers(g.players||[]);
|
renderWaitingPlayers(g.players||[]);
|
||||||
|
// if game already started, redirect immediately to the game page
|
||||||
|
if (g.started) { window.location.href = `game.html?game=${g.id}`; return; }
|
||||||
const stored = sessionStorage.getItem(`playerId:${gameId}`);
|
const stored = sessionStorage.getItem(`playerId:${gameId}`);
|
||||||
if(stored){ const me = (g.players||[]).find(p=>p.id===stored); if(me){ q('#player-nick').textContent = 'Vous'; q('#player-color').textContent = me.colorChoice || '-'; } }
|
if(stored){ const me = (g.players||[]).find(p=>p.id===stored); if(me){ q('#player-nick').textContent = 'Vous'; q('#player-color').textContent = me.colorChoice || '-'; } }
|
||||||
const ownerId = g.players && g.players[0] && g.players[0].id;
|
const ownerId = g.players && g.players[0] && g.players[0].id;
|
||||||
if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); }
|
if(ownerId && stored && ownerId === stored){ q('#start-btn')?.classList.remove('hidden'); } else { q('#start-btn')?.classList.add('hidden'); }
|
||||||
|
// disable start button unless there are at least 2 players
|
||||||
|
const startBtn = q('#start-btn');
|
||||||
|
if(startBtn){ if((g.players||[]).length < 2){ startBtn.disabled = true; } else { startBtn.disabled = false; } }
|
||||||
if(!g.started) startPolling(gameId);
|
if(!g.started) startPolling(gameId);
|
||||||
tryConnectSocket(gameId);
|
tryConnectSocket(gameId);
|
||||||
}).catch(()=>{
|
}).catch(()=>{
|
||||||
|
|
@ -81,8 +92,8 @@ function tryConnectSocket(gameId){
|
||||||
});
|
});
|
||||||
sock.on('game-start', (g) => {
|
sock.on('game-start', (g) => {
|
||||||
if(!g) return;
|
if(!g) return;
|
||||||
// redirect to game view (index) when started
|
// redirect to game view when started
|
||||||
window.location.href = `index.html?game=${g.id}`;
|
window.location.href = `game.html?game=${g.id}`;
|
||||||
});
|
});
|
||||||
sock.on('connect_error', (err) => {
|
sock.on('connect_error', (err) => {
|
||||||
console.warn('socket connect error', err);
|
console.warn('socket connect error', err);
|
||||||
|
|
@ -100,8 +111,10 @@ let poll = null;
|
||||||
function startPolling(id){
|
function startPolling(id){
|
||||||
if(poll) clearInterval(poll);
|
if(poll) clearInterval(poll);
|
||||||
poll = setInterval(()=>{
|
poll = setInterval(()=>{
|
||||||
fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{
|
// Prefer explicit backend host to avoid same-origin 404 from static server
|
||||||
fetch(`http://localhost:4000/api/game/${id}`).then(r=>r.json()).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{});
|
fetch(`http://localhost:4000/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{
|
||||||
|
// fallback to same-origin if backend not reachable
|
||||||
|
fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{});
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
@ -109,8 +122,16 @@ function startPolling(id){
|
||||||
// Join handled from lobby; waiting room doesn't provide a join button
|
// Join handled from lobby; waiting room doesn't provide a join button
|
||||||
|
|
||||||
q('#start-btn')?.addEventListener('click', () => {
|
q('#start-btn')?.addEventListener('click', () => {
|
||||||
fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) })
|
// force explicit backend host (no fallback) so request always goes to :4000
|
||||||
.then(r=>{ if(!r.ok) throw new Error('start failed'); return r.json(); }).then(()=>{ alert('Partie démarrée'); }).catch(()=>{ alert('Impossible de démarrer'); });
|
console.log('[waiting] start click - POST to http://localhost:4000/api/start', { gameId });
|
||||||
|
fetch('http://localhost:4000/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) })
|
||||||
|
.then(async (r) => {
|
||||||
|
const body = await r.json().catch(()=>({}));
|
||||||
|
if(!r.ok) throw body;
|
||||||
|
return body;
|
||||||
|
})
|
||||||
|
.then((resp)=>{ try { window.location.href = `game.html?game=${resp.game.id}`; } catch(e) { alert('Partie démarrée'); } })
|
||||||
|
.catch((err)=>{ const msg = (err && err.error) ? err.error : (typeof err === 'string' ? err : JSON.stringify(err)); alert('Impossible de démarrer: ' + msg); console.error('start error', err); });
|
||||||
});
|
});
|
||||||
|
|
||||||
q('#leave-btn')?.addEventListener('click', () => {
|
q('#leave-btn')?.addEventListener('click', () => {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
<script src="src/waiting.js?v=2"></script>
|
<script src="src/waiting.js?v=3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
134
frontend/src/game.js
Normal file
134
frontend/src/game.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
const q = s => document.querySelector(s);
|
||||||
|
|
||||||
|
// socket variable will be initialized on first load/join
|
||||||
|
let socket = null;
|
||||||
|
let myPlayerId = null;
|
||||||
|
let currentGameId = null;
|
||||||
|
let selected = null; // {x,y}
|
||||||
|
|
||||||
|
function renderBoardFromState(state) {
|
||||||
|
const container = q('#board-container');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const boardEl = document.createElement('div');
|
||||||
|
boardEl.className = 'board';
|
||||||
|
|
||||||
|
const h = state.height || state.board.length;
|
||||||
|
const w = state.width || (state.board[0] && state.board[0].length) || 8;
|
||||||
|
|
||||||
|
for (let r = 0; r < h; r++) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
for (let c = 0; c < w; c++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cell';
|
||||||
|
cell.dataset.x = c;
|
||||||
|
cell.dataset.y = r;
|
||||||
|
const piece = document.createElement('img');
|
||||||
|
piece.className = 'piece';
|
||||||
|
|
||||||
|
const cellObj = state.board[r] && state.board[r][c];
|
||||||
|
if (cellObj) {
|
||||||
|
const t = cellObj.type || 'PAWN';
|
||||||
|
const color = (cellObj.color || 'white').toLowerCase();
|
||||||
|
const lookup = {
|
||||||
|
PAWN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wP' : 'bP'}.svg`,
|
||||||
|
ROOK: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wR' : 'bR'}.svg`,
|
||||||
|
KNIGHT: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wN' : 'bN'}.svg`,
|
||||||
|
BISHOP: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wB' : 'bB'}.svg`,
|
||||||
|
QUEEN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wQ' : 'bQ'}.svg`,
|
||||||
|
KING: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wK' : 'bK'}.svg`,
|
||||||
|
};
|
||||||
|
piece.src = lookup[t] || lookup.PAWN;
|
||||||
|
} else {
|
||||||
|
piece.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(piece);
|
||||||
|
row.appendChild(cell);
|
||||||
|
// click to select/move
|
||||||
|
cell.addEventListener('click', () => {
|
||||||
|
// if no game/socket, ignore
|
||||||
|
if (!currentGameId) return;
|
||||||
|
const x = parseInt(cell.dataset.x, 10);
|
||||||
|
const y = parseInt(cell.dataset.y, 10);
|
||||||
|
if (!selected) {
|
||||||
|
selected = { x, y };
|
||||||
|
cell.classList.add('selected');
|
||||||
|
setMeta('Selected ' + x + ',' + y);
|
||||||
|
} else {
|
||||||
|
// send move
|
||||||
|
const from = selected;
|
||||||
|
const to = { x, y };
|
||||||
|
selected = null;
|
||||||
|
// clear previously selected class
|
||||||
|
document.querySelectorAll('.cell.selected').forEach(el => el.classList.remove('selected'));
|
||||||
|
setMeta('Sending move ' + from.x + ',' + from.y + ' -> ' + to.x + ',' + to.y);
|
||||||
|
if (socket && myPlayerId && currentGameId) socket.emit('move', { gameId: currentGameId, playerId: myPlayerId, from, to });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
boardEl.appendChild(row);
|
||||||
|
}
|
||||||
|
container.appendChild(boardEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(text) { q('#meta').textContent = text; }
|
||||||
|
|
||||||
|
q('#create-only')?.addEventListener('click', async () => {
|
||||||
|
const name = q('#game-name').value || 'game-from-ui';
|
||||||
|
const res = await fetch('/api/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, ownerNickname: 'ui-host' }) });
|
||||||
|
const j = await res.json();
|
||||||
|
setMeta('Created: ' + j.game.id);
|
||||||
|
q('#game-id-input').value = j.game.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
q('#create-start')?.addEventListener('click', async () => {
|
||||||
|
const name = q('#game-name').value || 'game-from-ui';
|
||||||
|
// create
|
||||||
|
const createResp = await fetch('/api/create', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, ownerNickname: 'ui-host' }) });
|
||||||
|
const createJson = await createResp.json();
|
||||||
|
const id = createJson.game.id;
|
||||||
|
setMeta('Created: ' + id + ' — joining as second player...');
|
||||||
|
// join as second player
|
||||||
|
await fetch('/api/join', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id, nickname: 'ui-guest' }) });
|
||||||
|
setMeta('Joined — starting...');
|
||||||
|
const startResp = await fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id }) });
|
||||||
|
const startJson = await startResp.json();
|
||||||
|
setMeta('Started: ' + id);
|
||||||
|
if (startJson.game && startJson.game.state) renderBoardFromState(startJson.game.state);
|
||||||
|
// connect socket and join room as the guest player we created earlier
|
||||||
|
currentGameId = id;
|
||||||
|
// try to capture the second player id from server game.players
|
||||||
|
const players = startJson.game.players || [];
|
||||||
|
if (players[1]) myPlayerId = players[1].id; else if (players[0]) myPlayerId = players[0].id;
|
||||||
|
if (!socket) {
|
||||||
|
socket = io();
|
||||||
|
socket.on('connect', () => { socket.emit('join-game', currentGameId); socket.emit('identify', { gameId: currentGameId, playerId: myPlayerId }); });
|
||||||
|
socket.on('game-state', (s) => { renderBoardFromState(s); setMeta('game-state updated'); });
|
||||||
|
socket.on('move', (m) => { setMeta('remote move ' + JSON.stringify(m)); });
|
||||||
|
socket.on('player-update', (g) => { setMeta('players updated'); });
|
||||||
|
socket.on('error', (e) => { console.warn('socket error', e); setMeta('Socket error: ' + (e && e.error)); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
q('#load')?.addEventListener('click', async () => {
|
||||||
|
const id = q('#game-id-input').value;
|
||||||
|
if(!id) return setMeta('Enter a game id');
|
||||||
|
const res = await fetch('/api/game/' + id).catch(e => null);
|
||||||
|
if(!res || !res.ok) return setMeta('Game not found or backend unreachable');
|
||||||
|
const j = await res.json();
|
||||||
|
setMeta('Loaded: ' + id + ' (started=' + !!j.started + ')');
|
||||||
|
if (j.state && j.state.board) renderBoardFromState(j.state);
|
||||||
|
// wire socket to listen to updates in this game
|
||||||
|
currentGameId = id;
|
||||||
|
// choose a player id if any exists (by default pick first)
|
||||||
|
if (j.players && j.players.length > 0) myPlayerId = j.players[0].id;
|
||||||
|
if (!socket) {
|
||||||
|
socket = io();
|
||||||
|
socket.on('connect', () => { socket.emit('join-game', currentGameId); socket.emit('identify', { gameId: currentGameId, playerId: myPlayerId }); });
|
||||||
|
socket.on('game-state', (s) => { renderBoardFromState(s); setMeta('game-state updated'); });
|
||||||
|
socket.on('move', (m) => { setMeta('remote move ' + JSON.stringify(m)); });
|
||||||
|
socket.on('player-update', (g) => { setMeta('players updated'); });
|
||||||
|
socket.on('error', (e) => { console.warn('socket error', e); setMeta('Socket error: ' + (e && e.error)); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -11,8 +11,19 @@ function showBoard(gameId, role) {
|
||||||
q('#board-section')?.classList?.remove('hidden');
|
q('#board-section')?.classList?.remove('hidden');
|
||||||
q('#game-id').textContent = gameId;
|
q('#game-id').textContent = gameId;
|
||||||
q('#player-role').textContent = role;
|
q('#player-role').textContent = role;
|
||||||
|
|
||||||
|
// Try to fetch the server-provided game.state. If present, render from state.board.
|
||||||
|
fetch(`/api/game/${gameId}`).then(r => { if(!r.ok) throw new Error('no api'); return r.json(); }).then(g => {
|
||||||
|
if (g && g.state && g.state.board) {
|
||||||
|
renderBoardFromState(g.state, role === 'white');
|
||||||
|
} else {
|
||||||
renderBoard(role === 'white');
|
renderBoard(role === 'white');
|
||||||
}
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// fallback to static local render
|
||||||
|
renderBoard(role === 'white');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
q('#create-btn').addEventListener('click', () => {
|
q('#create-btn').addEventListener('click', () => {
|
||||||
console.log('[frontend] create-btn clicked');
|
console.log('[frontend] create-btn clicked');
|
||||||
|
|
@ -105,6 +116,7 @@ q('#leave-btn')?.addEventListener('click', () => showLobby());
|
||||||
q('#flip-btn')?.addEventListener('click', () => { const board = q('#board-container'); board.classList.toggle('flipped'); });
|
q('#flip-btn')?.addEventListener('click', () => { const board = q('#board-container'); board.classList.toggle('flipped'); });
|
||||||
|
|
||||||
function renderBoard(asWhite=true) {
|
function renderBoard(asWhite=true) {
|
||||||
|
// legacy static render (used as fallback)
|
||||||
const container = q('#board-container');
|
const container = q('#board-container');
|
||||||
if(!container) return;
|
if(!container) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
@ -132,8 +144,64 @@ function renderBoard(asWhite=true) {
|
||||||
container.appendChild(board);
|
container.appendChild(board);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBoardFromState(state, asWhite = true) {
|
||||||
|
const container = q('#board-container');
|
||||||
|
if(!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const boardEl = document.createElement('div');
|
||||||
|
boardEl.className = 'board';
|
||||||
|
if (!asWhite) boardEl.classList.add('flipped');
|
||||||
|
|
||||||
|
const h = state.height || state.board.length;
|
||||||
|
const w = state.width || (state.board[0] && state.board[0].length) || 8;
|
||||||
|
|
||||||
|
for (let r = 0; r < h; r++) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
for (let c = 0; c < w; c++) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'cell';
|
||||||
|
const piece = document.createElement('img');
|
||||||
|
piece.className = 'piece';
|
||||||
|
|
||||||
|
const cellObj = state.board[r] && state.board[r][c];
|
||||||
|
if (cellObj) {
|
||||||
|
// map type/color to an asset - minimal mapping for common pieces
|
||||||
|
const t = cellObj.type || 'PAWN';
|
||||||
|
const color = (cellObj.color || 'white').toLowerCase();
|
||||||
|
const lookup = {
|
||||||
|
PAWN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wP' : 'bP'}.svg`,
|
||||||
|
ROOK: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wR' : 'bR'}.svg`,
|
||||||
|
KNIGHT: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wN' : 'bN'}.svg`,
|
||||||
|
BISHOP: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wB' : 'bB'}.svg`,
|
||||||
|
QUEEN: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wQ' : 'bQ'}.svg`,
|
||||||
|
KING: `assets/chess-pieces/chess_maestro_bw/${color[0] === 'w' ? 'wK' : 'bK'}.svg`,
|
||||||
|
};
|
||||||
|
piece.src = lookup[t] || lookup.PAWN;
|
||||||
|
} else {
|
||||||
|
piece.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(piece);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
boardEl.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(boardEl);
|
||||||
|
}
|
||||||
|
|
||||||
// initial view
|
// initial view
|
||||||
|
// initial view
|
||||||
|
// If URL contains ?game=..., directly open the board view
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initialGame = params.get('game');
|
||||||
|
if (initialGame) {
|
||||||
|
// assign a default role (will be refreshed when fetching game state)
|
||||||
|
showBoard(initialGame, 'white');
|
||||||
|
} else {
|
||||||
showLobby();
|
showLobby();
|
||||||
|
}
|
||||||
|
|
||||||
// waiting room helpers
|
// waiting room helpers
|
||||||
let pollInterval = null;
|
let pollInterval = null;
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ let poll = null;
|
||||||
function startPolling(id){
|
function startPolling(id){
|
||||||
if(poll) clearInterval(poll);
|
if(poll) clearInterval(poll);
|
||||||
poll = setInterval(()=>{
|
poll = setInterval(()=>{
|
||||||
fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{
|
fetch(`/api/game/${id}`).then(r=>{ if(!r.ok) throw new Error('no api'); return r.json(); }).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{
|
||||||
fetch(`http://localhost:4000/api/game/${id}`).then(r=>r.json()).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `index.html?game=${g.id}`; } }).catch(()=>{});
|
fetch(`http://localhost:4000/api/game/${id}`).then(r=>r.json()).then(g=>{ renderWaitingPlayers(g.players||[]); if(g.started){ clearInterval(poll); window.location.href = `game.html?game=${g.id}`; } }).catch(()=>{});
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
@ -46,5 +46,11 @@ q('#join-btn')?.addEventListener('click', () => {
|
||||||
|
|
||||||
q('#start-btn')?.addEventListener('click', () => {
|
q('#start-btn')?.addEventListener('click', () => {
|
||||||
fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) })
|
fetch('/api/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ id: gameId }) })
|
||||||
.then(r=>{ if(!r.ok) throw new Error('start failed'); return r.json(); }).then(()=>{ alert('Partie démarrée'); }).catch(()=>{ alert('Impossible de démarrer'); });
|
.then(r=>{ if(!r.ok) throw new Error('start failed'); return r.json(); }).then((res)=>{
|
||||||
|
// If backend confirms, go to game page
|
||||||
|
window.location.href = `game.html?game=${gameId}`;
|
||||||
|
}).catch(()=>{
|
||||||
|
// If backend absent, still attempt to open the game page — polling may still load state from localStorage/backends
|
||||||
|
window.location.href = `game.html?game=${gameId}`;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue