Загрузка данных


/*
 * ============================================================
 *                    ДУРАК — LAN EDITION
 * ============================================================
 *
 * Один файл.
 * Node.js >= 18
 *
 * Запуск:
 *     node durak.js
 *
 * Открыть на компьютере:
 *     http://localhost:3000
 *
 * Другие устройства в LAN:
 *     http://IP-ХОСТА:3000
 *
 * npm install НЕ НУЖЕН.
 *
 * ============================================================
 */

'use strict';

const http = require('http');
const os = require('os');
const crypto = require('crypto');
const fs = require('fs');

const PORT = Number(process.env.PORT || 3000);

const HOST = '0.0.0.0';

const sessions = new Map();
const rooms = new Map();

const COOKIE_NAME = 'DURAK_SESSION';

function uid(prefix = '') {
    return prefix + crypto.randomBytes(8).toString('hex');
}

function roomCode() {
    let code;
    do {
        code = String(Math.floor(100000 + Math.random() * 900000));
    } while (rooms.has(code));
    return code;
}

function json(res, data, status = 200) {
    const body = JSON.stringify(data);

    res.writeHead(status, {
        'Content-Type': 'application/json; charset=utf-8',
        'Cache-Control': 'no-store',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers': 'Content-Type',
        'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
    });

    res.end(body);
}

function html(res, body) {
    res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8',
        'Cache-Control': 'no-store',
    });

    res.end(body);
}

function readBody(req) {
    return new Promise((resolve, reject) => {
        let data = '';

        req.on('data', chunk => {
            data += chunk;

            if (data.length > 2_000_000) {
                req.destroy();
                reject(new Error('Request too large'));
            }
        });

        req.on('end', () => {
            if (!data) {
                resolve({});
                return;
            }

            try {
                resolve(JSON.parse(data));
            } catch {
                reject(new Error('Invalid JSON'));
            }
        });

        req.on('error', reject);
    });
}

function getCookies(req) {
    const result = {};

    const raw = req.headers.cookie || '';

    for (const part of raw.split(';')) {
        const index = part.indexOf('=');

        if (index === -1) continue;

        const key = part.slice(0, index).trim();
        const value = part.slice(index + 1).trim();

        result[key] = decodeURIComponent(value);
    }

    return result;
}

function getSession(req, res) {
    const cookies = getCookies(req);

    let sid = cookies[COOKIE_NAME];

    if (!sid || !sessions.has(sid)) {
        sid = uid('s_');

        sessions.set(sid, {
            id: sid,
            name: '',
            room: null,
            playerId: null,
            pollers: [],
            created: Date.now(),
            lastSeen: Date.now(),
        });

        res.setHeader(
            'Set-Cookie',
            `${COOKIE_NAME}=${encodeURIComponent(sid)}; Path=/; SameSite=Lax`
        );
    }

    const session = sessions.get(sid);

    session.lastSeen = Date.now();

    return session;
}

function sendEvent(session, event) {
    if (!session) return;

    const list = session.pollers.splice(0);

    for (const poller of list) {
        try {
            poller.end(JSON.stringify(event));
        } catch {}
    }
}

function broadcast(room, event) {
    for (const player of room.players) {
        if (player.sessionId) {
            sendEvent(sessions.get(player.sessionId), event);
        }
    }
}

function broadcastState(room) {
    room.version++;

    for (const player of room.players) {
        const session = sessions.get(player.sessionId);

        if (!session) continue;

        sendEvent(session, {
            type: 'state',
            state: publicState(room, player.id),
        });
    }
}

function createDefaultConfig() {
    return {
        mode: 'throwin',

        players: 4,

        deck: 36,

        throwMode: 'all',

        maxThrow: 'defender',

        finish: 'last',

        autoDraw: true,

        allowTrumpAce: true,

        animations: true,

        sounds: true,

        firstAttacker: 'lowestTrump',

        translateLimit: 1,

        allowTranslate: true,
    };
}

function createRoom(hostSession, name, config) {
    const code = roomCode();

    const room = {
        id: code,

        hostSessionId: hostSession.id,

        config: {
            ...createDefaultConfig(),
            ...(config || {}),
        },

        players: [],

        deck: [],

        trump: null,

        table: [],

        attackerId: null,

        defenderId: null,

        phase: 'lobby',

        winnerId: null,

        loserId: null,

        turnNumber: 0,

        version: 0,

        log: [],

        created: Date.now(),

        startedAt: null,

        finishedAt: null,

        lastActivity: Date.now(),
    };

    rooms.set(code, room);

    return room;
}

function addPlayer(room, session, name) {
    const cleanName =
        String(name || '')
            .trim()
            .replace(/\s+/g, ' ')
            .slice(0, 24) || 'Игрок';

    const player = {
        id: uid('p_'),

        sessionId: session.id,

        name: cleanName,

        cards: [],

        connected: true,

        ready: false,

        joinedAt: Date.now(),
    };

    room.players.push(player);

    session.room = room.id;
    session.playerId = player.id;
    session.name = cleanName;

    return player;
}

function getPlayer(room, id) {
    return room.players.find(p => p.id === id);
}

function connectedPlayers(room) {
    return room.players.filter(p => p.connected);
}

function ranksForDeck(deckSize) {
    if (deckSize === 52) {
        return [
            '2', '3', '4', '5', '6', '7',
            '8', '9', '10', 'J', 'Q', 'K', 'A'
        ];
    }

    return [
        '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'
    ];
}

const suits = ['♠', '♥', '♦', '♣'];

function createDeck(size) {
    const deck = [];

    for (const suit of suits) {
        for (const rank of ranksForDeck(size)) {
            deck.push({
                id: uid('c_'),
                suit,
                rank,
            });
        }
    }

    return deck;
}

function shuffle(array) {
    for (let i = array.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));

        [array[i], array[j]] = [array[j], array[i]];
    }

    return array;
}

function rankValue(rank) {
    const values = {
        '6': 6,
        '7': 7,
        '8': 8,
        '9': 9,
        '10': 10,
        'J': 11,
        'Q': 12,
        'K': 13,
        'A': 14,
    };

    return values[rank];
}

function isTrump(room, card) {
    return !!card && card.suit === room.trump;
}

function canBeat(room, attack, defense) {
    if (!attack || !defense) return false;

    if (attack.suit === defense.suit) {
        return rankValue(defense.rank) > rankValue(attack.rank);
    }

    return isTrump(room, defense) && !isTrump(room, attack);
}

function cardKey(card) {
    return card.rank + card.suit;
}

function allTableRanks(room) {
    const set = new Set();

    for (const pair of room.table) {
        if (pair.attack) set.add(cardKey(pair.attack));
        if (pair.defense) set.add(cardKey(pair.defense));
    }

    return set;
}

function hasRankOnTable(room, rank) {
    for (const pair of room.table) {
        if (pair.attack && pair.attack.rank === rank) return true;
        if (pair.defense && pair.defense.rank === rank) return true;
    }

    return false;
}

function getCurrentPair(room) {
    return room.table.find(pair => pair.attack && !pair.defense);
}

function countUncovered(room) {
    return room.table.filter(p => p.attack && !p.defense).length;
}

function canThrow(room, player, card) {
    if (!player || !card) return false;

    if (room.phase !== 'attack') return false;

    if (room.attackerId !== player.id) {
        if (room.config.throwMode === 'attacker') return false;

        if (room.config.throwMode === 'neighbors') {
            if (!isNeighborOfAttacker(room, player.id)) return false;
        }

        if (room.config.throwMode === 'participants') {
            const participantIds = new Set();

            for (const p of room.table) {
                if (p.attack) participantIds.add(room.attackerId);
                if (p.defense) participantIds.add(room.defenderId);
            }

            if (!participantIds.has(player.id)) return false;
        }
    }

    if (!hasRankOnTable(room, card.rank)) return false;

    const maxCards = getMaxThrow(room);

    if (room.table.length >= maxCards) return false;

    return true;
}

function getMaxThrow(room) {
    if (room.config.maxThrow === 'defender') {
        const defender = getPlayer(room, room.defenderId);

        return Math.max(1, defender ? defender.cards.length : 6);
    }

    if (room.config.maxThrow === '6') return 6;

    if (room.config.maxThrow === '5') return 5;

    if (room.config.maxThrow === '4') return 4;

    if (room.config.maxThrow === '3') return 3;

    return 99;
}

function isNeighborOfAttacker(room, playerId) {
    const index = room.players.findIndex(p => p.id === room.attackerId);

    if (index === -1) return false;

    const n = room.players.length;

    const left = room.players[(index - 1 + n) % n];

    const right = room.players[(index + 1) % n];

    return playerId === left.id || playerId === right.id;
}

function isPlayerTurn(room, player) {
    if (!player) return false;

    if (room.phase === 'defense') {
        return player.id === room.defenderId;
    }

    if (room.phase === 'attack') {
        if (room.table.length === 0) {
            return player.id === room.attackerId;
        }

        return true;
    }

    return false;
}

function removeCard(player, cardId) {
    const index = player.cards.findIndex(c => c.id === cardId);

    if (index === -1) return null;

    return player.cards.splice(index, 1)[0];
}

function getCard(player, cardId) {
    return player.cards.find(c => c.id === cardId);
}

function dealInitial(room) {
    room.deck = shuffle(createDeck(room.config.deck));

    room.trump = null;

    const trumpCard = room.deck[room.deck.length - 1];

    room.trump = trumpCard.suit;

    for (const player of room.players) {
        player.cards = [];
    }

    for (let i = 0; i < 6; i++) {
        for (const player of room.players) {
            if (room.deck.length > 0) {
                player.cards.push(room.deck.shift());
            }
        }
    }

    chooseFirstAttacker(room);

    room.table = [];

    room.phase = 'attack';

    room.turnNumber = 1;

    room.log = [];

    addLog(room, 'Игра началась.');

    const attacker = getPlayer(room, room.attackerId);

    if (attacker) {
        addLog(room, `Первым ходит ${attacker.name}.`);
    }
}

function chooseFirstAttacker(room) {
    if (room.config.firstAttacker === 'random') {
        const p =
            room.players[Math.floor(Math.random() * room.players.length)];

        room.attackerId = p.id;

        room.defenderId = nextPlayer(room, p.id).id;

        return;
    }

    if (room.config.firstAttacker === 'host') {
        const hostPlayer =
            room.players.find(p => p.sessionId === room.hostSessionId);

        if (hostPlayer) {
            room.attackerId = hostPlayer.id;
            room.defenderId = nextPlayer(room, hostPlayer.id).id;
            return;
        }
    }

    let best = null;

    for (const player of room.players) {
        for (const card of player.cards) {
            if (card.suit !== room.trump) continue;

            if (!best || rankValue(card.rank) < rankValue(best.card.rank)) {
                best = {
                    player,
                    card,
                };
            }
        }
    }

    if (best) {
        room.attackerId = best.player.id;
        room.defenderId = nextPlayer(room, best.player.id).id;
    } else {
        const p = room.players[0];

        room.attackerId = p.id;

        room.defenderId = nextPlayer(room, p.id).id;
    }
}

function nextPlayer(room, playerId) {
    const active = room.players.filter(p => p.connected);

    const index = active.findIndex(p => p.id === playerId);

    if (index === -1) return active[0];

    return active[(index + 1) % active.length];
}

function playerAfter(room, playerId) {
    return nextPlayer(room, playerId);
}

function drawCards(room) {
    if (!room.config.autoDraw) return;

    const order = [];

    const attacker = getPlayer(room, room.attackerId);

    if (attacker) order.push(attacker);

    const defender = getPlayer(room, room.defenderId);

    if (defender && !order.includes(defender)) {
        order.push(defender);
    }

    for (const p of room.players) {
        if (!order.includes(p)) order.push(p);
    }

    for (const player of order) {
        while (player.cards.length < 6 && room.deck.length > 0) {
            player.cards.push(room.deck.shift());
        }
    }
}

function clearTable(room) {
    room.table = [];
}

function finishRoundSuccess(room) {
    const defender = getPlayer(room, room.defenderId);

    if (!defender) return;

    addLog(room, `${defender.name} отбился.`);

    clearTable(room);

    drawCards(room);

    if (checkGameEnd(room)) return;

    const oldDefender = defender;

    room.attackerId = oldDefender.id;

    room.defenderId = nextPlayer(room, oldDefender.id).id;

    room.phase = 'attack';

    room.turnNumber++;

    addLog(
        room,
        `Теперь атакует ${oldDefender.name}.`
    );
}

function finishRoundTake(room) {
    const defender = getPlayer(room, room.defenderId);

    if (!defender) return;

    const cards = [];

    for (const pair of room.table) {
        if (pair.attack) cards.push(pair.attack);

        if (pair.defense) cards.push(pair.defense);
    }

    defender.cards.push(...cards);

    addLog(
        room,
        `${defender.name} взял ${cards.length} карт.`
    );

    clearTable(room);

    drawCards(room);

    if (checkGameEnd(room)) return;

    const oldAttacker = getPlayer(room, room.attackerId);

    room.attackerId = oldAttacker.id;

    room.defenderId = nextPlayer(room, oldAttacker.id).id;

    room.phase = 'attack';

    room.turnNumber++;

    addLog(
        room,
        `${oldAttacker.name} продолжает атаковать.`
    );
}

function checkGameEnd(room) {
    if (room.phase === 'lobby') return false;

    const active = connectedPlayers(room);

    if (active.length <= 1) {
        if (active.length === 1) {
            room.loserId = active[0].id;
            room.winnerId = null;
        }

        room.phase = 'finished';

        room.finishedAt = Date.now();

        return true;
    }

    const noCards = active.filter(p => p.cards.length === 0);

    if (noCards.length === 0) return false;

    if (room.config.finish === 'last') {
        const stillPlaying = active.filter(p => p.cards.length > 0);

        if (stillPlaying.length === 1) {
            room.loserId = stillPlaying[0].id;

            room.winnerId = null;

            room.phase = 'finished';

            room.finishedAt = Date.now();

            addLog(
                room,
                `${stillPlaying[0].name} остался последним с картами.`
            );

            return true;
        }
    }

    if (room.config.finish === 'first') {
        const winner = noCards[0];

        room.winnerId = winner.id;

        room.loserId = null;

        room.phase = 'finished';

        room.finishedAt = Date.now();

        addLog(
            room,
            `${winner.name} первым остался без карт.`
        );

        return true;
    }

    if (room.config.finish === 'single') {
        if (noCards.length === 1) {
            room.winnerId = noCards[0].id;

            room.phase = 'finished';

            room.finishedAt = Date.now();

            return true;
        }
    }

    if (room.config.finish === 'draw') {
        if (noCards.length >= 2) {
            room.phase = 'finished';

            room.winnerId = null;

            room.loserId = null;

            addLog(room, 'Игра закончилась ничьей.');

            return true;
        }
    }

    return false;
}

function addLog(room, text) {
    room.log.push({
        id: uid('l_'),
        time: Date.now(),
        text,
    });

    if (room.log.length > 100) {
        room.log.shift();
    }
}

function publicCard(card) {
    return {
        id: card.id,
        suit: card.suit,
        rank: card.rank,
    };
}

function publicState(room, viewerId) {
    const viewer = getPlayer(room, viewerId);

    const players = room.players.map(p => ({
        id: p.id,
        name: p.name,
        cards: p.id === viewerId
            ? p.cards.map(publicCard)
            : p.cards.map(() => ({ hidden: true })),
        cardCount: p.cards.length,
        connected: p.connected,
        ready: p.ready,
    }));

    return {
        room: room.id,

        viewerId,

        host: room.players.find(
            p => p.sessionId === room.hostSessionId
        )?.id || null,

        config: room.config,

        players,

        deckCount: room.deck.length,

        trump: room.trump,

        table: room.table.map(pair => ({
            attack: pair.attack ? publicCard(pair.attack) : null,
            defense: pair.defense ? publicCard(pair.defense) : null,
        })),

        attackerId: room.attackerId,

        defenderId: room.defenderId,

        phase: room.phase,

        winnerId: room.winnerId,

        loserId: room.loserId,

        turnNumber: room.turnNumber,

        log: room.log.slice(-40),

        version: room.version,

        maxThrow: getMaxThrow(room),
    };
}

function startGame(room) {
    if (room.phase !== 'lobby') return false;

    const required = Number(room.config.players);

    if (room.players.length < required) {
        return false;
    }

    room.players = room.players.slice(0, required);

    for (const player of room.players) {
        player.ready = true;
    }

    room.startedAt = Date.now();

    dealInitial(room);

    return true;
}

function findSessionPlayer(req, res) {
    const session = getSession(req, res);

    if (!session.room || !session.playerId) {
        return {
            session,
            room: null,
            player: null,
        };
    }

    const room = rooms.get(session.room);

    if (!room) {
        session.room = null;
        session.playerId = null;

        return {
            session,
            room: null,
            player: null,
        };
    }

    const player = getPlayer(room, session.playerId);

    return {
        session,
        room,
        player,
    };
}

function action(room, player, type, data) {
    room.lastActivity = Date.now();

    if (room.phase === 'finished') {
        return {
            ok: false,
            error: 'Игра уже закончена.',
        };
    }

    if (type === 'playAttack') {
        return playAttack(room, player, data.cardId);
    }

    if (type === 'playDefense') {
        return playDefense(room, player, data.cardId);
    }

    if (type === 'throwCard') {
        return throwCard(room, player, data.cardId);
    }

    if (type === 'translate') {
        return translate(room, player, data.cardId);
    }

    if (type === 'take') {
        if (room.defenderId !== player.id) {
            return {
                ok: false,
                error: 'Сейчас брать карты может только защищающийся.',
            };
        }

        finishRoundTake(room);

        return {
            ok: true,
        };
    }

    if (type === 'done') {
        if (room.defenderId !== player.id) {
            return {
                ok: false,
                error: 'Завершить защиту может только защищающийся.',
            };