Files
tiennm99 d84bc3d1fb feat(noitu): make /noitu the group game and add /noitutop
/noitu now posts the group play card that /noitupvp used to, and the solo
game against the bot moves to /noitubot. In a private chat /noitu points
the user to a group, or to /noitubot.

/noitutop ranks a group's players across all of its cards by wins, best
game and games played.

A one-time startup migration moves /stats history to the new names in
order: noitu to noitubot first, then noitupvp to noitu.
2026-10-09 16:22:54 +07:00

761 lines
26 KiB
JavaScript

// Nối từ game page. Plain JavaScript, no build step. The server owns the game:
// this page only sends words and renders the state it gets back.
(function () {
'use strict';
/**
* @typedef {{pos: string, gloss: string}} Sense
* @typedef {{word: string, by: 'bot'|'player', points: number, meanings: Sense[]}} ChainEntry
* @typedef {{
* session: string, player: string, difficulty: string,
* status: 'playing'|'won'|'lost', end_reason: string, current: string,
* chain: ChainEntry[], score: number, turn_limit_ms: number,
* deadline_ms: number, server_now_ms: number, suggestions: string[],
* score_reported: string
* }} SessionView
* @typedef {{accepted: boolean, reason: string, message: string, player_word: string, bot_word: string}} MoveResult
* @typedef {{name: string, you: boolean, host: boolean, score: number, alive: boolean, out_reason: string}} RoomPlayer
* @typedef {{name: string, rank: number, score: number, bonus: number, words: number, out_reason: string, you: boolean}} RoomStanding
* @typedef {{winner: string, words: number, end_reason: string, standings: RoomStanding[], score_reported: string}} RoomResult
* @typedef {ChainEntry & {seat: number, name?: string}} RoomEntry
* @typedef {{
* member: string, version: number, status: 'lobby'|'playing', game: number,
* host: boolean, seat: number, turn: number, players: RoomPlayer[],
* watchers: number, min_players: number, max_players: number,
* current: string, chain: RoomEntry[], turn_limit_ms: number,
* deadline_ms: number, server_now_ms: number, result: RoomResult|null
* }} RoomView
*/
/** Grace the server allows after the deadline, plus a little slack. */
var GRACE_MS = 2300;
/** Shortest "bot đang nghĩ" pause, so the reply does not feel instant. */
var THINK_MS = 350;
var REPORT_POLL_MS = 1500;
var REPORT_POLLS = 10;
/** Room state polling while the page is visible, and while it is hidden. */
var POLL_MS = 1000;
var POLL_HIDDEN_MS = 4000;
/** @param {string} id @returns {HTMLElement} */
function $(id) {
var el = document.getElementById(id);
if (!el) throw new Error('missing #' + id);
return el;
}
var screens = {
missing: $('screen-missing'),
start: $('screen-start'),
lobby: $('screen-lobby'),
game: $('screen-game'),
end: $('screen-end')
};
var chainBox = $('chain-box');
var chainList = $('chain');
var wordInput = /** @type {HTMLInputElement} */ ($('word'));
var sendBtn = /** @type {HTMLButtonElement} */ ($('send-btn'));
var startBtn = /** @type {HTMLButtonElement} */ ($('start-btn'));
var giveUpBtn = /** @type {HTMLButtonElement} */ ($('give-up'));
var message = $('message');
var timerBar = $('timer-bar');
var timerText = $('timer-text');
var TOKEN_KEY = 'noitu-token';
var token = readToken();
/** A token from a /noitu card opens the card's room instead of a game vs the bot. */
var roomMode = isRoomToken(token);
/**
* Takes the signed token from the URL, then strips it from the address bar
* so copying, sharing or opening the page elsewhere does not leak it. The
* hash stays: Telegram passes its own parameters there. sessionStorage keeps
* the token for a reload; the page still works when storage is blocked.
* @returns {string}
*/
function readToken() {
var fromURL = new URLSearchParams(window.location.search).get('t') || '';
if (fromURL) {
try { window.sessionStorage.setItem(TOKEN_KEY, fromURL); } catch (e) { /* storage blocked */ }
try {
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
} catch (e) { /* history API unavailable */ }
return fromURL;
}
try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
}
/**
* Reads the PvP flag from the token's payload. The payload is signed, not
* secret; the server checks the flag again, so this only picks the screen.
* @param {string} t @returns {boolean}
*/
function isRoomToken(t) {
try {
var body = t.split('.')[0].replace(/-/g, '+').replace(/_/g, '/');
while (body.length % 4) body += '=';
return JSON.parse(window.atob(body)).p === true;
} catch (e) {
return false;
}
}
/** @type {SessionView|null} */
var view = null;
/** @type {RoomView|null} */
var room = null;
var pollHandle = 0;
var rejoined = false;
/** Game number whose chain is on screen, so a new game clears it. */
var shownGame = -1;
/** Game and deadline of the room turn on screen, so a new turn clears the message. */
var shownTurn = '';
/** Set while a room error shows a lost connection, so the next good poll clears it. */
var offlineShown = false;
/** Turn length of the countdown on screen. */
var turnLimitMs = 30000;
/** Local wall-clock time at which the current turn ends. */
var deadlineLocal = 0;
var timerHandle = 0;
var expiryHandle = 0;
var reportPolls = 0;
var busy = false;
var giveUpArmed = 0;
/** Words whose meanings are expanded, so a re-render keeps them open. */
var expanded = Object.create(null);
/** @param {keyof typeof screens} name */
function show(name) {
Object.keys(screens).forEach(function (key) {
screens[/** @type {keyof typeof screens} */ (key)].hidden = key !== name;
});
chainBox.hidden = !(name === 'game' || name === 'end' || (name === 'lobby' && chainList.children.length > 0));
}
/** @param {number} ms @returns {Promise<void>} */
function delay(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
/**
* POSTs a JSON body to the game API. Rejects with {code, message} on a
* non-2xx answer or a network failure.
* @param {string} path @param {object} body @returns {Promise<any>}
*/
function api(path, body) {
return fetch('api/' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'omit',
cache: 'no-store'
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) {
throw { code: data.error || 'http_' + res.status, message: data.message || 'Có lỗi xảy ra, thử lại sau.' };
}
return data;
});
}, function () {
throw { code: 'network', message: 'Mất kết nối tới máy chủ. Kiểm tra mạng rồi thử lại.' };
});
}
/** @param {string} text @param {string} [kind] */
function say(text, kind) {
message.textContent = text;
message.className = 'message' + (kind ? ' ' + kind : '');
}
/**
* Who played a chain word: the bot or "Bạn" in a game vs the bot; in a
* room, the opening, "Bạn", or the player's name.
* @param {ChainEntry|RoomEntry} entry @returns {{label: string, cls: string}}
*/
function author(entry) {
if (!roomMode) return entry.by === 'bot' ? { label: 'Bot', cls: 'bot' } : { label: 'Bạn', cls: 'player' };
var e = /** @type {RoomEntry} */ (entry);
if (e.by === 'bot') return { label: 'Mở đầu', cls: 'bot' };
if (room && room.seat >= 0 && e.seat === room.seat) return { label: 'Bạn', cls: 'player' };
return { label: e.name || '', cls: 'other' };
}
/** @param {ChainEntry|RoomEntry} entry @param {boolean} fresh @returns {HTMLLIElement} */
function chainItem(entry, fresh) {
var li = document.createElement('li');
var by = author(entry);
li.className = by.cls + (fresh ? ' fresh' : '');
var row = document.createElement('div');
row.className = 'row';
var who = document.createElement('span');
who.className = 'who';
who.textContent = by.label;
who.title = by.label;
var word = document.createElement('span');
word.className = 'word';
word.textContent = entry.word;
row.appendChild(who);
row.appendChild(word);
if (entry.by === 'player' && entry.points > 0) {
var pts = document.createElement('span');
pts.className = 'points';
pts.textContent = '+' + entry.points;
row.appendChild(pts);
}
li.appendChild(row);
if (!entry.meanings || entry.meanings.length === 0) {
var none = document.createElement('span');
none.className = 'no-meaning';
none.textContent = 'Chưa có nghĩa';
row.appendChild(none);
return li;
}
var senses = document.createElement('ol');
senses.className = 'senses';
entry.meanings.forEach(function (sense) {
var item = document.createElement('li');
item.textContent = (sense.pos ? '(' + sense.pos + ') ' : '') + sense.gloss;
senses.appendChild(item);
});
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle';
function sync() {
var open = !!expanded[entry.word];
senses.hidden = !open;
toggle.textContent = open ? 'Ẩn nghĩa' : 'Xem nghĩa';
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
toggle.setAttribute('aria-label', (open ? 'Ẩn nghĩa của ' : 'Xem nghĩa của ') + entry.word);
}
toggle.addEventListener('click', function () {
expanded[entry.word] = !expanded[entry.word];
sync();
});
sync();
row.appendChild(toggle);
li.appendChild(senses);
return li;
}
/** @param {Array<ChainEntry|RoomEntry>} chain */
function renderChain(chain) {
var before = chainList.children.length;
if (chain.length < before) {
chainList.textContent = '';
before = 0;
}
for (var i = before; i < chain.length; i++) {
chainList.appendChild(chainItem(chain[i], before > 0));
}
}
function stopTimers() {
clearInterval(timerHandle);
clearTimeout(expiryHandle);
timerHandle = 0;
expiryHandle = 0;
}
function tick() {
var left = Math.max(0, deadlineLocal - Date.now());
var ratio = turnLimitMs > 0 ? left / turnLimitMs : 0;
timerBar.style.transform = 'scaleX(' + Math.min(1, ratio).toFixed(3) + ')';
var low = left <= 5000;
timerBar.classList.toggle('low', low);
timerText.classList.toggle('low', low);
timerText.textContent = String(Math.ceil(left / 1000));
if (left <= 0 && timerHandle) {
clearInterval(timerHandle);
timerHandle = 0;
say('Hết giờ…', 'error');
}
}
/**
* Starts the countdown from the server's deadline, not the local clock.
* Once the grace period has passed, onExpire asks the server to settle it.
* @param {{deadline_ms: number, server_now_ms: number, turn_limit_ms: number}} v
* @param {() => void} onExpire
*/
function startCountdown(v, onExpire) {
stopTimers();
var left = v.deadline_ms - v.server_now_ms;
turnLimitMs = v.turn_limit_ms;
deadlineLocal = Date.now() + left;
tick();
timerHandle = window.setInterval(tick, 100);
expiryHandle = window.setTimeout(onExpire, Math.max(0, left) + GRACE_MS);
}
/** @param {SessionView} next */
function render(next) {
view = next;
$('player').textContent = next.player || '';
$('score').textContent = String(next.score);
$('current').textContent = next.current;
renderChain(next.chain);
if (next.status === 'playing') {
show('game');
startCountdown(next, refresh);
return;
}
stopTimers();
renderEnd(next);
}
var endText = {
bot_stuck: ['Bạn thắng!', 'Bot hết từ để nối.'],
max_moves: ['Bạn thắng!', 'Chuỗi đã đủ dài, bot chịu thua.'],
timeout: ['Bot thắng', 'Bạn đã hết giờ.'],
no_legal_move: ['Bot thắng', 'Không còn từ nào nối được — đây là ngõ cụt.'],
gave_up: ['Bot thắng', 'Bạn đã chịu thua.']
};
var reportText = {
pending: 'Đang lưu điểm lên bảng xếp hạng Telegram…',
ok: 'Đã lưu điểm vào bảng xếp hạng Telegram.',
failed: 'Không lưu được điểm lên Telegram.',
skipped: 'Không có điểm để lưu.'
};
/** @param {SessionView} v */
function renderEnd(v) {
show('end');
var text = endText[/** @type {keyof typeof endText} */ (v.end_reason)] ||
(v.status === 'won' ? ['Bạn thắng!', ''] : ['Bot thắng', '']);
$('result-title').textContent = text[0];
$('result-detail').textContent = text[1];
$('final-score').textContent = String(v.score);
var report = $('report-status');
report.textContent = reportText[/** @type {keyof typeof reportText} */ (v.score_reported)] || '';
report.className = v.score_reported === 'ok' ? 'report-ok' : v.score_reported === 'failed' ? 'report-failed' : 'muted';
var box = $('suggestions-box');
var list = $('suggestions');
list.textContent = '';
if (v.status === 'lost' && v.suggestions.length > 0) {
$('suggestions-label').textContent = 'Bạn có thể đã nối:';
v.suggestions.forEach(function (w) {
var li = document.createElement('li');
li.textContent = w;
list.appendChild(li);
});
box.hidden = false;
} else {
box.hidden = true;
}
$('share').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function');
if (v.score_reported === 'pending' && reportPolls < REPORT_POLLS) {
reportPolls++;
expiryHandle = window.setTimeout(refresh, REPORT_POLL_MS);
}
}
/** @param {{code: string, message: string}} err */
function handleError(err) {
if (err.code === 'no_session') {
stopTimers();
view = null;
chainList.textContent = '';
expanded = Object.create(null);
show('start');
$('start-error').textContent = err.message;
return;
}
if (err.code === 'game_over') {
refresh();
return;
}
say(err.message, 'error');
}
function refresh() {
if (!view) return;
api('state', { session: view.session }).then(render, handleError);
}
function startGame() {
if (busy) return;
busy = true;
startBtn.disabled = true;
$('start-error').textContent = '';
var picked = /** @type {HTMLInputElement|null} */ (document.querySelector('input[name="difficulty"]:checked'));
api('start', { token: token, difficulty: picked ? picked.value : 'medium' }).then(function (v) {
chainList.textContent = '';
expanded = Object.create(null);
reportPolls = 0;
say('');
wordInput.value = '';
render(v);
wordInput.focus();
}, function (err) {
$('start-error').textContent = err.message;
}).then(function () {
busy = false;
startBtn.disabled = false;
});
}
/** @param {Event} event */
function submitWord(event) {
event.preventDefault();
var word = wordInput.value.trim();
if (roomMode) {
submitRoomWord(word);
return;
}
if (!view || busy || word === '') return;
busy = true;
sendBtn.disabled = true;
say('Bot đang nghĩ', 'thinking');
Promise.all([api('move', { session: view.session, word: word }), delay(THINK_MS)]).then(function (out) {
/** @type {{result: MoveResult, state: SessionView}} */
var res = out[0];
if (res.result.accepted) {
wordInput.value = '';
say(res.result.bot_word ? 'Bot nối: ' + res.result.bot_word : '');
} else {
say(res.result.message || 'Từ không hợp lệ.', 'error');
wordInput.select();
}
render(res.state);
}, handleError).then(function () {
busy = false;
sendBtn.disabled = false;
if (view && view.status === 'playing') wordInput.focus();
});
}
function giveUp() {
if ((roomMode ? !room : !view) || busy) return;
if (!giveUpArmed) {
giveUpBtn.textContent = 'Bấm lần nữa để chịu thua';
giveUpArmed = window.setTimeout(function () {
giveUpArmed = 0;
giveUpBtn.textContent = 'Chịu thua';
}, 3000);
return;
}
clearTimeout(giveUpArmed);
giveUpArmed = 0;
giveUpBtn.textContent = 'Chịu thua';
if (roomMode) {
if (room) api('room/give-up', { member: room.member }).then(renderRoom, roomError);
return;
}
if (view) api('give-up', { session: view.session }).then(render, handleError);
}
// Room mode: the members of a chat play each other on one /noitu card.
// The page polls the room's state; the server owns the turns and the clock.
var roomStartBtn = /** @type {HTMLButtonElement} */ ($('room-start'));
var turnLine = $('turn-line');
var outText = {
timeout: 'hết giờ',
no_legal_move: 'hết từ để nối',
gave_up: 'chịu thua'
};
/** @param {number} [ms] */
function schedulePoll(ms) {
clearTimeout(pollHandle);
var wait = ms === undefined ? (document.hidden ? POLL_HIDDEN_MS : POLL_MS) : ms;
pollHandle = window.setTimeout(poll, wait);
}
function poll() {
if (!room) return;
api('room/state', { member: room.member, version: room.version }).then(function (res) {
if (offlineShown) {
offlineShown = false;
$('lobby-error').textContent = '';
say('');
}
if (!res.unchanged) renderRoom(res);
}, roomError).then(function () {
if (room) schedulePoll();
});
}
/** Joins the card's room with the page token; a reload rejoins the same seat. */
function joinRoom() {
stopTimers();
clearTimeout(pollHandle);
show('lobby');
roomStartBtn.hidden = true;
$('lobby-error').textContent = '';
$('lobby-wait').textContent = 'Đang vào phòng…';
return api('room/join', { token: token }).then(function (v) {
rejoined = false;
renderRoom(v);
schedulePoll();
}, function (err) {
room = null;
$('lobby-wait').textContent = '';
$('lobby-error').textContent = err.message;
roomStartBtn.textContent = 'Vào lại phòng';
roomStartBtn.disabled = false;
roomStartBtn.hidden = err.code === 'bad_token' || err.code === 'token_expired';
});
}
/** @param {{code: string, message: string}} err */
function roomError(err) {
if (err.code === 'no_room' && !rejoined) {
// The room expired or dropped this member; the token still names the card.
rejoined = true;
room = null;
return joinRoom();
}
if (err.code === 'no_room') {
room = null;
stopTimers();
show('lobby');
$('lobby-wait').textContent = '';
$('lobby-error').textContent = err.message;
return;
}
// A rate-limited move retries on the next tap; a rate-limited start says so.
if (err.code === 'too_fast' && room && room.status === 'playing') return;
offlineShown = err.code === 'network';
if (err.code === 'no_game' || err.code === 'game_running' || err.code === 'not_in_game') {
schedulePoll(0);
}
if (room && room.status === 'playing') {
say(err.message, 'error');
} else {
$('lobby-error').textContent = err.message;
}
}
/**
* @param {RoomPlayer} p @param {string} tag
* @returns {HTMLLIElement}
*/
function playerItem(p, tag) {
var li = document.createElement('li');
if (p.you) li.className = 'you';
var name = document.createElement('span');
name.className = 'name';
name.textContent = p.name + (p.you ? ' (bạn)' : '');
li.appendChild(name);
if (tag) {
var t = document.createElement('span');
t.className = 'tag';
t.textContent = tag;
li.appendChild(t);
}
return li;
}
/** @param {RoomView} v */
function renderRoom(v) {
room = v;
if (v.game !== shownGame) {
chainList.textContent = '';
expanded = Object.create(null);
$('lobby-error').textContent = '';
shownGame = v.game;
}
renderChain(v.chain);
/** @type {RoomPlayer|null} */
var me = null;
v.players.forEach(function (p) { if (p.you) me = p; });
$('player').textContent = me ? me.name : '';
if (v.status === 'playing') {
renderRoomGame(v, me);
} else {
renderLobby(v, me);
}
}
/** @param {RoomView} v @param {RoomPlayer|null} me */
function renderLobby(v, me) {
stopTimers();
show('lobby');
var list = $('lobby-players');
list.textContent = '';
v.players.forEach(function (p) {
list.appendChild(playerItem(p, p.host ? 'chủ phòng' : ''));
});
var watchers = $('lobby-watchers');
watchers.hidden = v.watchers === 0;
watchers.textContent = 'Thêm ' + v.watchers + ' người đang chờ: mỗi ván có tối đa ' + v.max_players + ' người chơi.';
var enough = v.players.length >= v.min_players;
roomStartBtn.textContent = 'Bắt đầu';
roomStartBtn.hidden = !v.host;
roomStartBtn.disabled = busy || !enough;
var wait = '';
if (v.host && !enough) {
wait = 'Cần ít nhất ' + v.min_players + ' người. Mời mọi người bấm Chơi trên thẻ trò chơi.';
} else if (!v.host) {
wait = me ? 'Chờ chủ phòng bắt đầu…' : 'Phòng đã đủ người chơi; bạn sẽ được xem ván tiếp theo.';
}
$('lobby-wait').textContent = wait;
renderResult(v.result);
}
/** @param {RoomResult|null} res */
function renderResult(res) {
var box = $('room-result');
if (!res) {
box.hidden = true;
return;
}
box.hidden = false;
$('room-result-title').textContent = res.winner + ' thắng!';
$('room-result-detail').textContent = 'Chuỗi có ' + res.words + ' từ.' +
(res.end_reason === 'max_moves' ? ' Chuỗi đã đủ dài, người nhiều điểm nhất thắng.' : '');
var list = $('room-standings');
list.textContent = '';
res.standings.forEach(function (st) {
var li = document.createElement('li');
if (st.you) li.className = 'you';
var why = outText[/** @type {keyof typeof outText} */ (st.out_reason)];
li.textContent = st.name + (st.you ? ' (bạn)' : '') + ': ' + st.score + ' điểm, ' + st.words + ' từ' +
(st.bonus > 0 ? ' (thưởng ' + st.bonus + ')' : '') + (why ? ' — ' + why : '');
list.appendChild(li);
});
var report = $('room-report');
report.textContent = reportText[/** @type {keyof typeof reportText} */ (res.score_reported)] || '';
report.className = res.score_reported === 'ok' ? 'report-ok' : res.score_reported === 'failed' ? 'report-failed' : 'muted';
}
/** @param {RoomView} v @param {RoomPlayer|null} me */
function renderRoomGame(v, me) {
var wasMine = !wordInput.disabled && !screens.game.hidden;
show('game');
var strip = $('room-players');
strip.hidden = false;
strip.textContent = '';
v.players.forEach(function (p, i) {
var li = playerItem(p, p.alive ? String(p.score) : (outText[/** @type {keyof typeof outText} */ (p.out_reason)] || 'bị loại'));
if (i === v.turn) li.classList.add('turn');
if (!p.alive) li.classList.add('out');
strip.appendChild(li);
});
var mine = v.seat >= 0 && v.seat === v.turn;
// A new turn clears the last one's message, such as "Hết giờ…"; a
// re-render within the turn keeps it, such as a rejected word's reason.
var turnKey = v.game + ':' + v.deadline_ms;
if (turnKey !== shownTurn) {
shownTurn = turnKey;
say('');
}
var turnName = v.players[v.turn] ? v.players[v.turn].name : '';
turnLine.hidden = false;
turnLine.className = 'turn-line' + (mine ? ' mine' : '');
if (mine) {
turnLine.textContent = 'Đến lượt bạn!';
} else if (!me) {
turnLine.textContent = 'Bạn đang xem. Lượt của ' + turnName + '.';
} else if (!me.alive) {
turnLine.textContent = 'Bạn đã bị loại. Lượt của ' + turnName + '.';
} else {
turnLine.textContent = 'Lượt của ' + turnName + '.';
}
$('score').textContent = String(me ? me.score : 0);
$('current').textContent = v.current;
wordInput.disabled = !mine;
sendBtn.disabled = busy || !mine;
giveUpBtn.hidden = !(me && me.alive);
if (mine && !wasMine) wordInput.focus();
startCountdown(v, function () { schedulePoll(0); });
}
/**
* Ends a room request: clears busy and re-enables the control the room now
* needs, which a render during the request left disabled.
*/
function finishRoomAction() {
busy = false;
if (!room) return;
if (room.status === 'playing') {
if (room.seat >= 0 && room.seat === room.turn) {
sendBtn.disabled = false;
wordInput.focus();
}
return;
}
roomStartBtn.disabled = room.players.length < room.min_players;
}
/** @param {string} word */
function submitRoomWord(word) {
if (!room || busy || word === '') return;
busy = true;
sendBtn.disabled = true;
say('');
api('room/move', { member: room.member, word: word }).then(function (res) {
// Render first: a late word moves the turn on, and the new turn
// clears the message.
renderRoom(res.state);
if (res.result.accepted) {
wordInput.value = '';
} else {
say(res.result.message || 'Từ không hợp lệ.', 'error');
wordInput.select();
}
}, roomError).then(finishRoomAction);
}
function startRoomGame() {
if (busy) return;
if (!room) {
joinRoom();
return;
}
busy = true;
roomStartBtn.disabled = true;
$('lobby-error').textContent = '';
api('room/start', { member: room.member }).then(function (v) {
say('');
wordInput.value = '';
renderRoom(v);
}, roomError).then(finishRoomAction);
}
function share() {
try {
if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore();
} catch (e) {
$('share').hidden = true;
}
}
startBtn.addEventListener('click', startGame);
$('move-form').addEventListener('submit', submitWord);
giveUpBtn.addEventListener('click', giveUp);
$('replay').addEventListener('click', function () {
stopTimers();
show('start');
});
$('share').addEventListener('click', share);
roomStartBtn.addEventListener('click', startRoomGame);
// A tab that slept (phone locked) has a stale countdown; resync on return.
document.addEventListener('visibilitychange', function () {
if (document.hidden) return;
if (roomMode && room) {
schedulePoll(0);
} else if (view && view.status === 'playing') {
refresh();
}
});
if (!token) {
show('missing');
} else if (roomMode) {
joinRoom();
} else {
show('start');
}
})();