Removing all 3 locked files while the timer is still running now marks
the session WON immediately (checked right after every successful rm)
and broadcasts a "game_finished" signal over Mercure so every
connected player gets redirected together, not just the one who
removed the last file. A new /won/{session} route + won.html.twig
mirrors the existing lost flow (victory narrative + the same feedback
form).
The existing timer-expiry path already set LOST but always redirected
to lostUrl regardless of actual status; it now picks won/lost based on
the status the server reports.
Also fixes a pre-existing bug on the lost page (and would-be bug on
the new won page): PlayerService::GetCurrentlyActiveAsPlayer() only
matches players in READY/PLAYING sessions, so by the time a session
has ended it always returned null there, silently breaking the
feedback form. Both pages now look the player up directly via
PlayerRepository instead.
Added a navigatingAway flag so the page's "confirm before leaving"
prompt doesn't block our own win/lose redirects.
490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
/* Game1 entry point built with Webpack Encore */
|
|
import './styles/game1.css';
|
|
|
|
let sequenceFinished = false;
|
|
let stillPlayingSound = true;
|
|
let navigatingAway = false;
|
|
|
|
function goTo(url) {
|
|
navigatingAway = true;
|
|
window.location.href = url;
|
|
}
|
|
|
|
function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl) {
|
|
try {
|
|
const url = mercurePublicUrl + '?topic=' + encodeURIComponent(topic);
|
|
const es = new EventSource(url);
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log('[Mercure][game1] Update:', data);
|
|
|
|
if (data && !Array.isArray(data) && data.type === 'game_finished') {
|
|
const destination = data.status === 'won' ? wonUrl : lostUrl;
|
|
if (destination) {
|
|
goTo(destination);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// data is [sendTo, message, messageType?] - messageType defaults to 'mainframe' (green)
|
|
if (Array.isArray(data) && data.length >= 2) {
|
|
const sendTo = parseInt(data[0]);
|
|
// Filter: 0 means everyone, otherwise must match myScreen
|
|
if (sendTo !== 0 && sendTo !== parseInt(myScreen)) {
|
|
console.log('[Mercure][game1] Message not for this player, skipping.');
|
|
return;
|
|
}
|
|
|
|
const messageContainer = document.getElementById('message-container');
|
|
if (messageContainer) {
|
|
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
if(stillPlayingSound)
|
|
playSound();
|
|
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
|
|
if (sequenceFinished) {
|
|
flashRed();
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log('[Mercure][game1] Raw event:', event.data);
|
|
}
|
|
};
|
|
|
|
es.onerror = (err) => {
|
|
console.warn('[Mercure][game1] EventSource error:', err);
|
|
};
|
|
|
|
console.log('[Mercure][game1] Subscribed to', url);
|
|
} catch (e) {
|
|
console.error('[Mercure][game1] Failed to subscribe:', e);
|
|
}
|
|
}
|
|
|
|
function playSound() {
|
|
const sound = document.getElementById('message-sound');
|
|
if (sound) {
|
|
sound.currentTime = 0;
|
|
sound.play().catch(e => console.warn('[Audio] Playback failed:', e));
|
|
}
|
|
}
|
|
|
|
function flashRed() {
|
|
console.log('[Game1] Triggering flashRed');
|
|
const body = document.body;
|
|
body.classList.remove('flash-red');
|
|
void body.offsetWidth; // Trigger reflow to restart animation
|
|
body.classList.add('flash-red');
|
|
|
|
// Also remove it after animation finishes so it's clean for inspection
|
|
setTimeout(() => {
|
|
body.classList.remove('flash-red');
|
|
console.log('[Game1] Removed flash-red class');
|
|
}, 150);
|
|
}
|
|
|
|
let lockRevealTimer = null;
|
|
let lockExpireTimer = null;
|
|
let lockCountdownTimer = null;
|
|
let currentLockedAt = null;
|
|
|
|
function lockMessageClass(messageType) {
|
|
if (messageType === 'virus') return 'message-virus';
|
|
if (messageType === 'mainframe') return 'message-mainframe';
|
|
return '';
|
|
}
|
|
|
|
function appendResultMessage(container, text, messageType) {
|
|
const msgEl = document.createElement('div');
|
|
msgEl.className = ('message ' + lockMessageClass(messageType)).trim();
|
|
msgEl.textContent = text;
|
|
container.appendChild(msgEl);
|
|
}
|
|
|
|
function setInputDisabled(disabled) {
|
|
const inputField = document.getElementById('input-message');
|
|
if (inputField) {
|
|
inputField.disabled = disabled;
|
|
}
|
|
}
|
|
|
|
function clearLockTimers() {
|
|
if (lockRevealTimer) { clearTimeout(lockRevealTimer); lockRevealTimer = null; }
|
|
if (lockExpireTimer) { clearTimeout(lockExpireTimer); lockExpireTimer = null; }
|
|
if (lockCountdownTimer) { clearInterval(lockCountdownTimer); lockCountdownTimer = null; }
|
|
}
|
|
|
|
function clearLock() {
|
|
clearLockTimers();
|
|
currentLockedAt = null;
|
|
document.body.classList.remove('locked');
|
|
const banner = document.getElementById('lock-banner');
|
|
if (banner) banner.style.display = 'none';
|
|
setInputDisabled(false);
|
|
}
|
|
|
|
function updateLockCountdown(unlockAtMs) {
|
|
const countdownEl = document.getElementById('lock-countdown');
|
|
if (!countdownEl) return;
|
|
const remaining = Math.max(0, Math.ceil((unlockAtMs - Date.now()) / 1000));
|
|
countdownEl.textContent = remaining + 's';
|
|
}
|
|
|
|
async function fetchLockReveal(apiEchoUrl, messageContainer) {
|
|
if (!apiEchoUrl) return;
|
|
try {
|
|
const response = await fetchJson(apiEchoUrl, {
|
|
method: 'POST',
|
|
body: { message: '', ts: new Date().toISOString() },
|
|
});
|
|
const result = response && response.result;
|
|
if (result && Array.isArray(result.result)) {
|
|
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
}
|
|
if (result && result.locked === false) {
|
|
clearLock();
|
|
return;
|
|
}
|
|
// Code has been revealed (or already was), let the player try /unlock
|
|
setInputDisabled(false);
|
|
} catch (e) {
|
|
console.error('[Game1] Failed to fetch lock reveal:', e);
|
|
}
|
|
}
|
|
|
|
function applyLock(lockData, apiEchoUrl, messageContainer) {
|
|
if (currentLockedAt === lockData.lockedAt) {
|
|
return; // already tracking this lock, avoid re-fetching/duplicating messages
|
|
}
|
|
currentLockedAt = lockData.lockedAt;
|
|
|
|
clearLockTimers();
|
|
|
|
const banner = document.getElementById('lock-banner');
|
|
if (banner) banner.style.display = 'flex';
|
|
document.body.classList.add('locked');
|
|
|
|
const revealAtMs = lockData.revealAt * 1000;
|
|
const unlockAtMs = lockData.unlockAt * 1000;
|
|
const now = Date.now();
|
|
|
|
if (now < revealAtMs) {
|
|
setInputDisabled(true);
|
|
lockRevealTimer = setTimeout(() => fetchLockReveal(apiEchoUrl, messageContainer), revealAtMs - now);
|
|
} else {
|
|
fetchLockReveal(apiEchoUrl, messageContainer);
|
|
}
|
|
|
|
lockExpireTimer = setTimeout(() => clearLock(), Math.max(0, unlockAtMs - now));
|
|
|
|
updateLockCountdown(unlockAtMs);
|
|
lockCountdownTimer = setInterval(() => {
|
|
updateLockCountdown(unlockAtMs);
|
|
if (Date.now() >= unlockAtMs) {
|
|
clearInterval(lockCountdownTimer);
|
|
lockCountdownTimer = null;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
let filesRemovalTimer = null;
|
|
let scheduledFilesRemovalDeadline = null;
|
|
|
|
async function pingFilesRemovalDeadline(apiEchoUrl) {
|
|
if (!apiEchoUrl) return;
|
|
try {
|
|
// A no-op message is enough to make the server evaluate the deadline server-side;
|
|
// the actual restore notice (if any) arrives for everyone via the Mercure broadcast.
|
|
await fetchJson(apiEchoUrl, {
|
|
method: 'POST',
|
|
body: { message: '', ts: new Date().toISOString() },
|
|
});
|
|
} catch (e) {
|
|
console.error('[Game1] Failed to ping files-removal deadline:', e);
|
|
}
|
|
}
|
|
|
|
function scheduleFilesRemovalCheck(deadline, apiEchoUrl) {
|
|
if (!deadline || scheduledFilesRemovalDeadline === deadline) {
|
|
return; // nothing to (re)schedule
|
|
}
|
|
scheduledFilesRemovalDeadline = deadline;
|
|
|
|
if (filesRemovalTimer) {
|
|
clearTimeout(filesRemovalTimer);
|
|
filesRemovalTimer = null;
|
|
}
|
|
|
|
const delay = Math.max(0, deadline * 1000 - Date.now());
|
|
filesRemovalTimer = setTimeout(() => {
|
|
filesRemovalTimer = null;
|
|
scheduledFilesRemovalDeadline = null;
|
|
pingFilesRemovalDeadline(apiEchoUrl);
|
|
}, delay);
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const opts = { ...options };
|
|
const headers = new Headers(opts.headers || {});
|
|
headers.set('Accept', 'application/json');
|
|
if (opts.body !== undefined && typeof opts.body !== 'string') {
|
|
headers.set('Content-Type', 'application/json');
|
|
opts.body = JSON.stringify(opts.body);
|
|
}
|
|
// Useful convention for server-side checks
|
|
if (!headers.has('X-Requested-With')) {
|
|
headers.set('X-Requested-With', 'XMLHttpRequest');
|
|
}
|
|
opts.headers = headers;
|
|
const res = await fetch(url, opts);
|
|
const text = await res.text();
|
|
let data;
|
|
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
if (!res.ok) {
|
|
const err = new Error('HTTP ' + res.status + ' ' + res.statusText);
|
|
console.error('[API][game1]', err, data);
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Simple boot log so you can verify it in the browser console
|
|
// and confirm this specific bundle is loaded on the Game Hub page.
|
|
console.log('Game1 bundle loaded');
|
|
|
|
// Example: add a CSS class to <body> so page-specific styles can apply
|
|
document.body.classList.add('game1-page');
|
|
|
|
// Look for config injected by Twig in the page
|
|
const cfgEl = document.getElementById('mercure-config');
|
|
|
|
// Prevent/warn on page reload, except for our own win/lose redirects
|
|
window.addEventListener('beforeunload', (event) => {
|
|
if (navigatingAway) {
|
|
return;
|
|
}
|
|
// Standard way to trigger the browser's confirmation dialog
|
|
event.preventDefault();
|
|
// Included for compatibility with older browsers
|
|
event.returnValue = '';
|
|
});
|
|
|
|
if (!cfgEl) {
|
|
console.warn('[Mercure][game1] #mercure-config element not found on page');
|
|
return;
|
|
}
|
|
|
|
const mercurePublicUrl = cfgEl.dataset.mercurePublicUrl;
|
|
const topic = cfgEl.dataset.topic;
|
|
const screen = cfgEl.dataset.screen;
|
|
const apiPingUrl = cfgEl.dataset.apiPingUrl;
|
|
const apiEchoUrl = cfgEl.dataset.apiEchoUrl;
|
|
const apiCheckFinishedUrl = cfgEl.dataset.apiCheckFinishedUrl;
|
|
const lostUrl = cfgEl.dataset.lostUrl;
|
|
const wonUrl = cfgEl.dataset.wonUrl;
|
|
const lockLockedAt = cfgEl.dataset.lockLockedAt;
|
|
const lockRevealAt = cfgEl.dataset.lockRevealAt;
|
|
const lockUnlockAt = cfgEl.dataset.lockUnlockAt;
|
|
const filesRemovalDeadline = cfgEl.dataset.filesRemovalDeadline;
|
|
|
|
// Resume the auto-restore timer after a page refresh, if a window is already running
|
|
if (filesRemovalDeadline) {
|
|
scheduleFilesRemovalCheck(parseInt(filesRemovalDeadline, 10), apiEchoUrl);
|
|
}
|
|
|
|
if (mercurePublicUrl && topic) {
|
|
subscribeToMercure(mercurePublicUrl, topic, screen, wonUrl, lostUrl);
|
|
} else {
|
|
console.warn('[Mercure][game1] Missing data attributes on #mercure-config');
|
|
}
|
|
|
|
// Timer logic
|
|
const timerEl = document.getElementById('game-timer');
|
|
if (timerEl && timerEl.dataset.endTime) {
|
|
const endTime = parseInt(timerEl.dataset.endTime) * 1000;
|
|
|
|
const updateTimer = async () => {
|
|
const now = Date.now();
|
|
const diff = endTime - now;
|
|
|
|
if (diff <= 0) {
|
|
timerEl.textContent = '00:00:00';
|
|
|
|
// Timer reached zero, check with server
|
|
if (apiCheckFinishedUrl && lostUrl) {
|
|
try {
|
|
const response = await fetchJson(apiCheckFinishedUrl, { method: 'POST' });
|
|
if (response && response.finished) {
|
|
goTo(response.status === 'won' && wonUrl ? wonUrl : lostUrl);
|
|
return; // Stop the timer loop
|
|
}
|
|
} catch (e) {
|
|
console.error('[API][game1] Failed to check finished status:', e);
|
|
}
|
|
}
|
|
|
|
// Even if check failed or not finished, stop the loop if diff <= 0
|
|
// (though technically if the server says not finished, we might want to keep checking,
|
|
// but 00:00:00 usually means it's over).
|
|
return;
|
|
}
|
|
|
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
|
|
const hDisplay = hours.toString().padStart(2, '0');
|
|
const mDisplay = minutes.toString().padStart(2, '0');
|
|
const sDisplay = seconds.toString().padStart(2, '0');
|
|
|
|
timerEl.textContent = `${hDisplay}:${mDisplay}:${sDisplay}`;
|
|
setTimeout(updateTimer, 1000);
|
|
};
|
|
|
|
updateTimer();
|
|
}
|
|
|
|
// Demo API calls
|
|
try {
|
|
if (apiPingUrl) {
|
|
const ping = await fetchJson(apiPingUrl);
|
|
console.log('[API][game1] ping →', ping);
|
|
} else {
|
|
console.warn('[API][game1] data-api-ping-url missing');
|
|
}
|
|
|
|
if (apiEchoUrl) {
|
|
const echo = await fetchJson(apiEchoUrl, {
|
|
method: 'POST',
|
|
body: { message: 'from game1.js', ts: new Date().toISOString() },
|
|
});
|
|
console.log('[API][game1] echo →', echo);
|
|
} else {
|
|
console.warn('[API][game1] data-api-echo-url missing');
|
|
}
|
|
} catch (e) {
|
|
console.error('[API][game1] Request failed:', e);
|
|
}
|
|
|
|
// Add messages to message-container
|
|
const messageContainer = document.getElementById('message-container');
|
|
if (messageContainer) {
|
|
let messages = [
|
|
['System initializing...', 500],
|
|
['Connection established.', 200],
|
|
['Welcome agent to the mainframe.', 1000],
|
|
['Scanning...', 3000],
|
|
['Virus detected.', 500],
|
|
['Starting Mainframe help modus...', 2000],
|
|
['Help modus activated.', 500],
|
|
['Blocking virus activated', 0]
|
|
];
|
|
|
|
let currentMessageIndex = 0;
|
|
|
|
const printNextMessage = () => {
|
|
if (currentMessageIndex < messages.length) {
|
|
|
|
const msg = messages[currentMessageIndex];
|
|
const msgEl = document.createElement('div');
|
|
|
|
let extraClass = '';
|
|
if(msg[2])
|
|
extraClass = msg[2];
|
|
|
|
msgEl.className = 'message ' + extraClass;
|
|
msgEl.textContent = msg[0];
|
|
messageContainer.appendChild(msgEl);
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
|
|
playSound();
|
|
|
|
currentMessageIndex++;
|
|
setTimeout(printNextMessage, msg[1]);
|
|
if (sequenceFinished) {
|
|
flashRed();
|
|
}
|
|
} else {
|
|
// After it has printed a set of messages, it has to start a timer of 2 seconds
|
|
console.log('[Game1] All messages printed. Starting 2s timer to expand message-container height...');
|
|
setTimeout(() => {
|
|
messageContainer.style.height = '400vh';
|
|
const inputField = document.getElementById('input-message');
|
|
inputField.disabled = false;
|
|
|
|
// Add event listener for Enter key
|
|
inputField.addEventListener('keypress', async (e) => {
|
|
if (e.key === 'Enter') {
|
|
stillPlayingSound = false;
|
|
sequenceFinished = false;
|
|
const message = inputField.value.trim();
|
|
|
|
const msgEl = document.createElement('div');
|
|
msgEl.className = 'message';
|
|
msgEl.textContent = message;
|
|
messageContainer.appendChild(msgEl);
|
|
|
|
if (message && apiEchoUrl) {
|
|
inputField.value = '';
|
|
try {
|
|
const response = await fetchJson(apiEchoUrl, {
|
|
method: 'POST',
|
|
body: { message, ts: new Date().toISOString() },
|
|
});
|
|
console.log('[API][game1] message sent →', response);
|
|
if (response && response.result && Array.isArray(response.result.result)) {
|
|
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
|
|
window.scrollTo(0, document.body.scrollHeight);
|
|
}
|
|
if (response && response.result) {
|
|
if (response.result.gameWon === true && wonUrl) {
|
|
goTo(wonUrl);
|
|
return;
|
|
}
|
|
|
|
if (response.result.locked === true) {
|
|
applyLock({
|
|
lockedAt: response.result.lockedAt,
|
|
revealAt: response.result.revealAt,
|
|
unlockAt: response.result.unlockAt,
|
|
}, apiEchoUrl, messageContainer);
|
|
} else if (response.result.locked === false) {
|
|
clearLock();
|
|
}
|
|
|
|
if (response.result.filesRemovalDeadline) {
|
|
scheduleFilesRemovalCheck(response.result.filesRemovalDeadline, apiEchoUrl);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('[API][game1] Failed to send message:', err);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('[Game1] message-container height changed to 400vh and input enabled');
|
|
sequenceFinished = true;
|
|
console.log('[Game1] sequenceFinished is now TRUE');
|
|
|
|
// Restore an in-progress lock after a page refresh
|
|
if (lockUnlockAt && parseInt(lockUnlockAt, 10) * 1000 > Date.now()) {
|
|
applyLock({
|
|
lockedAt: parseInt(lockLockedAt, 10),
|
|
revealAt: parseInt(lockRevealAt, 10),
|
|
unlockAt: parseInt(lockUnlockAt, 10),
|
|
}, apiEchoUrl, messageContainer);
|
|
}
|
|
}, 2000);
|
|
}
|
|
};
|
|
|
|
printNextMessage();
|
|
}
|
|
});
|