Compare commits
14
Commits
1be07440e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba45e06972 | ||
|
|
2913b8d2a2 | ||
|
|
335697e520 | ||
|
|
daa37390d0 | ||
|
|
a9cb0fa57f | ||
|
|
2bfc2aca1a | ||
|
|
846cfbc44a | ||
|
|
207346d571 | ||
|
|
dfa75719d0 | ||
|
|
444f54b6c6 | ||
|
|
98cf48b29d | ||
|
|
f6df9f7ba6 | ||
|
|
fc93486367 | ||
|
|
98066118a6 |
@@ -0,0 +1,27 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const buttons = document.querySelectorAll('[data-tab-target]');
|
||||||
|
if (!buttons.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = (id) => {
|
||||||
|
document.querySelectorAll('.admin-tab-panel').forEach(el => el.style.display = 'none');
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (target) {
|
||||||
|
target.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
const active = btn.dataset.tabTarget === id;
|
||||||
|
btn.style.background = active ? '#fff' : '#f8fafc';
|
||||||
|
btn.style.color = active ? '#1e40af' : '#64748b';
|
||||||
|
btn.style.fontWeight = active ? '600' : '400';
|
||||||
|
btn.style.borderColor = active ? '#3b82f6' : '#e2e8f0';
|
||||||
|
btn.style.borderBottom = active ? '1px solid #fff' : '1px solid #e2e8f0';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => activate(btn.dataset.tabTarget));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,3 +5,6 @@ import './styles/app.scss';
|
|||||||
import 'bootstrap/js/dist/collapse';
|
import 'bootstrap/js/dist/collapse';
|
||||||
import 'bootstrap/js/dist/alert';
|
import 'bootstrap/js/dist/alert';
|
||||||
import 'bootstrap/js/dist/dropdown';
|
import 'bootstrap/js/dist/dropdown';
|
||||||
|
import './game-waiting';
|
||||||
|
import './game-lobby';
|
||||||
|
import './admin-session-tabs';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const config = document.getElementById('game-lobby-config');
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = config.dataset.mercurePublicUrl;
|
||||||
|
const topic = config.dataset.topic;
|
||||||
|
const chatLog = document.getElementById('lobby-chat-log');
|
||||||
|
|
||||||
|
function appendLobbyMessage(username, content, createdAt) {
|
||||||
|
if (!chatLog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emptyNotice = document.getElementById('lobby-chat-empty');
|
||||||
|
if (emptyNotice) {
|
||||||
|
emptyNotice.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = createdAt
|
||||||
|
? new Date(createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'lobby-message';
|
||||||
|
|
||||||
|
const author = document.createElement('strong');
|
||||||
|
author.textContent = username;
|
||||||
|
|
||||||
|
const timestamp = document.createElement('span');
|
||||||
|
timestamp.className = 'text-muted small';
|
||||||
|
timestamp.textContent = ' ' + time;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.textContent = content;
|
||||||
|
|
||||||
|
wrapper.appendChild(author);
|
||||||
|
wrapper.appendChild(timestamp);
|
||||||
|
wrapper.appendChild(body);
|
||||||
|
chatLog.appendChild(wrapper);
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicUrl && topic) {
|
||||||
|
const url = new URL(publicUrl);
|
||||||
|
url.searchParams.append('topic', topic);
|
||||||
|
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
eventSource.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'lobby_message') {
|
||||||
|
appendLobbyMessage(data.username, data.content, data.createdAt);
|
||||||
|
} else if (data.type === 'player_joined' || data.type === 'session_started') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chatLog) {
|
||||||
|
chatLog.scrollTop = chatLog.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const config = document.getElementById('game-waiting-config');
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = config.dataset.mercurePublicUrl;
|
||||||
|
const topic = config.dataset.topic;
|
||||||
|
const readyAt = config.dataset.readyAt;
|
||||||
|
|
||||||
|
let reloading = false;
|
||||||
|
const reloadOnce = (eventSource) => {
|
||||||
|
if (reloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloading = true;
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (publicUrl && topic) {
|
||||||
|
const url = new URL(publicUrl);
|
||||||
|
url.searchParams.append('topic', topic);
|
||||||
|
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
eventSource.onmessage = event => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'all_ready' || data.type === 'player_ready') {
|
||||||
|
reloadOnce(eventSource);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own ready status expires 60s after we set it - proactively tell the
|
||||||
|
// server as close to that deadline as possible, so the other players find
|
||||||
|
// out live instead of only whenever someone else's request happens to
|
||||||
|
// trigger the lazy check.
|
||||||
|
if (readyAt) {
|
||||||
|
const timeoutMs = 61000; // slightly more than the server-side 60s
|
||||||
|
const readyAtMs = readyAt * 1000;
|
||||||
|
const countdownEl = document.getElementById('ready-countdown');
|
||||||
|
const expireForm = document.getElementById('expire-ready-form');
|
||||||
|
|
||||||
|
const updateCountdown = () => {
|
||||||
|
const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000));
|
||||||
|
if (countdownEl) {
|
||||||
|
const m = Math.floor(remaining / 60);
|
||||||
|
const s = remaining % 60;
|
||||||
|
countdownEl.textContent = m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
};
|
||||||
|
|
||||||
|
const remaining = updateCountdown();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
expireForm?.submit();
|
||||||
|
} else {
|
||||||
|
const countdownInterval = setInterval(() => {
|
||||||
|
if (updateCountdown() <= 0) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
expireForm?.submit();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -94,6 +94,7 @@ let currentLockedAt = null;
|
|||||||
function lockMessageClass(messageType) {
|
function lockMessageClass(messageType) {
|
||||||
if (messageType === 'virus') return 'message-virus';
|
if (messageType === 'virus') return 'message-virus';
|
||||||
if (messageType === 'mainframe') return 'message-mainframe';
|
if (messageType === 'mainframe') return 'message-mainframe';
|
||||||
|
if (messageType === 'hint') return 'message-hint';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ServerRoot "/etc/apache2"
|
||||||
|
Listen 80
|
||||||
|
User www-data
|
||||||
|
Group www-data
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||||
|
LogLevel warn
|
||||||
|
IncludeOptional mods-enabled/*.load
|
||||||
|
IncludeOptional sites-enabled/*.conf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
deb http://deb.debian.org/debian bookworm main contrib non-free-firmware
|
||||||
|
deb http://deb.debian.org/debian bookworm-updates main contrib non-free-firmware
|
||||||
|
deb http://security.debian.org/debian-security bookworm-security main contrib non-free-firmware
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# /etc/crontab: system-wide crontab
|
||||||
|
SHELL=/bin/sh
|
||||||
|
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
||||||
|
|
||||||
|
17 * * * * root cd / && run-parts --report /etc/cron.hourly
|
||||||
|
25 6 * * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily
|
||||||
|
47 6 * * 7 root test -x /usr/sbin/anacron || run-parts --report /etc/cron.weekly
|
||||||
|
52 6 1 * * root test -x /usr/sbin/anacron || run-parts --report /etc/cron.monthly
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# /etc/fstab: static file system information.
|
||||||
|
UUID=8f14e45f-ceea-4a63-9b3f-1a2b3c4d5e6f / ext4 errors=remount-ro 0 1
|
||||||
|
UUID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d /boot ext4 defaults 0 2
|
||||||
|
/swapfile none swap sw 0 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
archive-node-04
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
127.0.0.1 localhost
|
||||||
|
127.0.1.1 archive-node-04
|
||||||
|
::1 localhost ip6-localhost ip6-loopback
|
||||||
|
ff02::1 ip6-allnodes
|
||||||
|
ff02::2 ip6-allrouters
|
||||||
|
10.0.0.4 archive-node-04.internal
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Debian GNU/Linux 12 \n \l
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Welcome to archive-node-04.
|
||||||
|
All connections are logged and monitored for internal review purposes.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
source /etc/network/interfaces.d/*
|
||||||
|
|
||||||
|
auto lo
|
||||||
|
iface lo inet loopback
|
||||||
|
|
||||||
|
auto eth0
|
||||||
|
iface eth0 inet static
|
||||||
|
address 10.0.0.4
|
||||||
|
netmask 255.255.255.0
|
||||||
|
gateway 10.0.0.1
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
user www-data;
|
||||||
|
worker_processes auto;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
include /etc/nginx/sites-enabled/*;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
passwd: files
|
||||||
|
group: files
|
||||||
|
shadow: files
|
||||||
|
hosts: files dns
|
||||||
|
networks: files
|
||||||
|
protocols: db files
|
||||||
|
services: db files
|
||||||
|
ethers: db files
|
||||||
|
rpc: db files
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
|
||||||
|
NAME="Debian GNU/Linux"
|
||||||
|
VERSION_ID="12"
|
||||||
|
VERSION="12 (bookworm)"
|
||||||
|
VERSION_CODENAME=bookworm
|
||||||
|
ID=debian
|
||||||
|
HOME_URL="https://www.debian.org/"
|
||||||
|
SUPPORT_URL="https://www.debian.org/support"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
root:x:0:0:root:/root:/bin/bash
|
||||||
|
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
|
||||||
|
bin:x:2:2:bin:/bin:/usr/sbin/nologin
|
||||||
|
sys:x:3:3:sys:/dev:/usr/sbin/nologin
|
||||||
|
sync:x:4:65534:sync:/bin:/bin/sync
|
||||||
|
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
|
||||||
|
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
|
||||||
|
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
|
||||||
|
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin
|
||||||
|
admin:x:1000:1000:admin,,,:/home/admin:/bin/bash
|
||||||
|
guest:x:1001:1001:guest,,,:/home/guest:/bin/bash
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
nameserver 1.1.1.1
|
||||||
|
nameserver 9.9.9.9
|
||||||
|
options edns0
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
root:$6$rounds=656000$xJ2kLQmZ$aFq9zN3vQwErTyUiOpAsDfGhJkLzXcVbNm1234567890abcdefgh:19700:0:99999:7:::
|
||||||
|
daemon:*:19700:0:99999:7:::
|
||||||
|
bin:*:19700:0:99999:7:::
|
||||||
|
sys:*:19700:0:99999:7:::
|
||||||
|
sshd:*:19700:0:99999:7:::
|
||||||
|
admin:$6$rounds=656000$k3PqR8tW$bGr0oPqLmNbVcXzAsDfGhJkLqWeRtYuIoP0987654321zyxwvu:19700:0:99999:7:::
|
||||||
|
guest:*:19700:0:99999:7:::
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Host *
|
||||||
|
SendEnv LANG LC_*
|
||||||
|
HashKnownHosts yes
|
||||||
|
GSSAPIAuthentication yes
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Port 22
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication yes
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
X11Forwarding no
|
||||||
|
PrintMotd no
|
||||||
|
Subsystem sftp /usr/lib/openssh/sftp-server
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Europe/Amsterdam
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
app:
|
||||||
|
name: internal-archive-sync
|
||||||
|
version: 2.3.1
|
||||||
|
log_level: info
|
||||||
|
port: 8080
|
||||||
|
database:
|
||||||
|
driver: sqlite
|
||||||
|
path: /opt/app/data.db
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apt update
|
||||||
|
apt upgrade -y
|
||||||
|
systemctl restart nginx
|
||||||
|
df -h
|
||||||
|
journalctl -xe
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ~/.bashrc: executed by bash for non-login shells
|
||||||
|
|
||||||
|
case $- in
|
||||||
|
*i*) ;;
|
||||||
|
*) return;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export PS1='\u@\h:\w\$ '
|
||||||
|
alias ll='ls -alF'
|
||||||
|
alias la='ls -A'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZ8pQxT2mN0vRkLwYb6cJhU3sEoAeKdVmXpZq7tRnBs admin@archive-node-04
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
update-alternatives 2026-05-30 03:10:04: link group editor updated to point to /usr/bin/vim.basic
|
||||||
|
update-alternatives 2026-05-30 03:10:04: link group pager updated to point to /usr/bin/less
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
|
||||||
|
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin by (uid=0)
|
||||||
|
Jun 12 09:55:02 archive-node-04 sudo: admin : TTY=pts/0 ; PWD=/home/admin ; USER=root ; COMMAND=/usr/bin/apt update
|
||||||
|
Jun 12 10:12:40 archive-node-04 sshd[10233]: pam_unix(sshd:session): session closed for user admin
|
||||||
|
Jun 12 22:03:11 archive-node-04 sshd[15092]: Failed password for invalid user test from 203.0.113.44 port 39102 ssh2
|
||||||
|
Jun 12 22:03:14 archive-node-04 sshd[15092]: Connection closed by 203.0.113.44 port 39102 [preauth]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ OK ] Started Network Manager.
|
||||||
|
[ OK ] Started OpenSSH server daemon.
|
||||||
|
[ OK ] Started Nginx HTTP server.
|
||||||
|
[ OK ] Reached target Multi-User System.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
|
||||||
|
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Jun 12 03:00:05 archive-node-04 systemd-udevd[512]: Using default interface naming scheme 'v252'.
|
||||||
|
Jun 12 03:00:11 archive-node-04 dbus-daemon[601]: [system] Successfully activated service 'org.freedesktop.hostname1'
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[ 0.000000] Linux version 6.1.0-21-amd64 (debian-kernel@lists.debian.org)
|
||||||
|
[ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
|
||||||
|
[ 0.512033] ACPI: Core revision 20221020
|
||||||
|
[ 1.221004] usb 1-1: new high-speed USB device number 2
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2026-05-30 03:10:02 startup archives unpack
|
||||||
|
2026-05-30 03:10:04 install curl:amd64 <none> 8.4.0-2
|
||||||
|
2026-05-30 03:10:05 status installed curl:amd64 8.4.0-2
|
||||||
|
2026-06-02 09:44:11 upgrade openssh-server:amd64 1:9.2p1-2 1:9.2p1-2+deb12u2
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Jun 12 03:00:02 archive-node-04 kernel: [ 0.000000] Linux version 6.1.0-21-amd64
|
||||||
|
Jun 12 03:00:02 archive-node-04 kernel: [ 0.004211] Command line: BOOT_IMAGE=/boot/vmlinuz-6.1.0-21-amd64 root=UUID=8f14e45f
|
||||||
|
Jun 12 03:00:03 archive-node-04 kernel: [ 1.221004] usb 1-1: new high-speed USB device number 2
|
||||||
|
Jun 12 03:00:03 archive-node-04 kernel: [ 1.552210] eth0: link up, 1000Mbps, full-duplex
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Jun 12 05:11:02 archive-node-04 postfix/qmgr[812]: 3F2A1C0021: removed
|
||||||
|
Jun 12 05:11:02 archive-node-04 postfix/smtp[9944]: 3F2A1C0021: to=<root@localhost>, status=sent
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Jun 12 03:12:01 archive-node-04 systemd[1]: Starting Daily apt download activities...
|
||||||
|
Jun 12 03:12:04 archive-node-04 systemd[1]: apt-daily.service: Deactivated successfully.
|
||||||
|
Jun 12 04:00:11 archive-node-04 CRON[9021]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.daily)
|
||||||
|
Jun 12 06:25:00 archive-node-04 anacron[1122]: Job `cron.daily' terminated
|
||||||
|
Jun 12 09:41:55 archive-node-04 sshd[10233]: Accepted publickey for admin from 10.0.0.7 port 51422 ssh2
|
||||||
|
Jun 12 09:41:55 archive-node-04 sshd[10233]: pam_unix(sshd:session): session opened for user admin
|
||||||
|
Jun 12 12:03:19 archive-node-04 systemd[1]: Reloading nginx.service
|
||||||
|
Jun 12 18:30:02 archive-node-04 CRON[15544]: (root) CMD (test -x /usr/sbin/anacron || run-parts --report /etc/cron.hourly)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
From cron@archive-node-04 Wed Jun 10 06:25:01 2026
|
||||||
|
Subject: Cron <root@archive-node-04> run-parts --report /etc/cron.daily
|
||||||
|
|
||||||
|
Daily housekeeping completed without errors.
|
||||||
@@ -66,6 +66,11 @@ div.message-mainframe {
|
|||||||
color: #0F0;
|
color: #0F0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.message-hint {
|
||||||
|
color: #FF0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
div#lock-banner {
|
div#lock-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 68px;
|
top: 68px;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"symfony/process": "7.4.*",
|
"symfony/process": "7.4.*",
|
||||||
"symfony/property-access": "7.4.*",
|
"symfony/property-access": "7.4.*",
|
||||||
"symfony/property-info": "7.4.*",
|
"symfony/property-info": "7.4.*",
|
||||||
|
"symfony/rate-limiter": "7.4.*",
|
||||||
"symfony/runtime": "7.4.*",
|
"symfony/runtime": "7.4.*",
|
||||||
"symfony/security-bundle": "7.4.*",
|
"symfony/security-bundle": "7.4.*",
|
||||||
"symfony/serializer": "7.4.*",
|
"symfony/serializer": "7.4.*",
|
||||||
|
|||||||
Generated
+376
-317
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ framework:
|
|||||||
storage_factory_id: session.storage.factory.native
|
storage_factory_id: session.storage.factory.native
|
||||||
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
|
||||||
|
|
||||||
|
rate_limiter:
|
||||||
|
invite_code_join:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: 10
|
||||||
|
interval: '1 minute'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
framework:
|
framework:
|
||||||
session:
|
session:
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ when@prod:
|
|||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||||
nested:
|
nested:
|
||||||
|
type: group
|
||||||
|
members: [nested_file, nested_stderr]
|
||||||
|
nested_file:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/php/prod.log"
|
||||||
|
level: debug
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
nested_stderr:
|
||||||
type: stream
|
type: stream
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
level: debug
|
level: debug
|
||||||
@@ -56,7 +64,14 @@ when@prod:
|
|||||||
process_psr_3_messages: false
|
process_psr_3_messages: false
|
||||||
channels: ["!event", "!doctrine"]
|
channels: ["!event", "!doctrine"]
|
||||||
deprecation:
|
deprecation:
|
||||||
type: stream
|
type: group
|
||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
|
members: [deprecation_file, deprecation_stderr]
|
||||||
|
deprecation_file:
|
||||||
|
type: stream
|
||||||
|
path: "%kernel.logs_dir%/php/deprecation.log"
|
||||||
|
formatter: monolog.formatter.json
|
||||||
|
deprecation_stderr:
|
||||||
|
type: stream
|
||||||
path: php://stderr
|
path: php://stderr
|
||||||
formatter: monolog.formatter.json
|
formatter: monolog.formatter.json
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
framework:
|
framework:
|
||||||
notifier:
|
notifier:
|
||||||
chatter_transports:
|
chatter_transports:
|
||||||
texter_transports:
|
texter_transports:␍
|
||||||
sendgrid: '%env(MAILER_DSN)%'
|
channel_policy:
|
||||||
channel_policy:
|
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
urgent: ['email']
|
||||||
urgent: ['email']
|
high: ['email']
|
||||||
high: ['email']
|
medium: ['email']
|
||||||
medium: ['email']
|
low: ['email']
|
||||||
low: ['email']
|
admin_recipients:
|
||||||
admin_recipients:
|
- { email: admin@example.com }
|
||||||
- { email: admin@example.com }
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ security:
|
|||||||
enable_csrf: true
|
enable_csrf: true
|
||||||
username_parameter: username
|
username_parameter: username
|
||||||
password_parameter: password
|
password_parameter: password
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
interval: '15 minutes'
|
||||||
logout:
|
logout:
|
||||||
path: app_logout
|
path: app_logout
|
||||||
# where to redirect after logout
|
# where to redirect after logout
|
||||||
|
|||||||
@@ -66,6 +66,39 @@ services:
|
|||||||
# ipv4_address: 172.23.0.11
|
# ipv4_address: 172.23.0.11
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
php-cron:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/php/Dockerfile
|
||||||
|
args:
|
||||||
|
USER_ID: ${USER_ID}
|
||||||
|
GROUP_ID: ${GROUP_ID}
|
||||||
|
container_name: escapepage-php-cron
|
||||||
|
volumes:
|
||||||
|
- ../:/var/www/html:delegated
|
||||||
|
- /etc/hosts:/etc/hosts:ro
|
||||||
|
environment:
|
||||||
|
APP_ENV: ${APP_ENV}
|
||||||
|
SITE_BASE_URL: ${SITE_BASE_URL}
|
||||||
|
MAILER_DSN: ${MAILER_DSN}
|
||||||
|
MAILER_FROM: ${MAILER_FROM}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
MERCURE_URL: ${MERCURE_URL}
|
||||||
|
MERCURE_PUBLIC_URL: ${MERCURE_PUBLIC_URL}
|
||||||
|
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
|
||||||
|
MERCURE_CORS_ALLOWED_ORIGINS: ${MERCURE_CORS_ALLOWED_ORIGINS}
|
||||||
|
MERCURE_TOPIC_BASE: ${MERCURE_TOPIC_BASE}
|
||||||
|
RECAPTCHA3_KEY: ${RECAPTCHA3_KEY}
|
||||||
|
RECAPTCHA3_SECRET: ${RECAPTCHA3_SECRET}
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
- mercure
|
||||||
|
command: ["crond", "-f", "-l", "2"]
|
||||||
|
# networks:
|
||||||
|
# backend:
|
||||||
|
# ipv4_address: 172.23.0.16
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.29.4-alpine
|
image: nginx:1.29.4-alpine
|
||||||
container_name: escapepage-nginx
|
container_name: escapepage-nginx
|
||||||
|
|||||||
+12
-2
@@ -12,7 +12,8 @@ RUN apk add --no-cache \
|
|||||||
make \
|
make \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
shadow
|
shadow \
|
||||||
|
logrotate
|
||||||
|
|
||||||
# Install PHP extension installer
|
# Install PHP extension installer
|
||||||
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
@@ -41,6 +42,15 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|||||||
# Configure PHP
|
# Configure PHP
|
||||||
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
|
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/zz-custom.ini
|
||||||
|
|
||||||
|
# Cron daemon (BusyBox's built-in crond) for the php-cron container.
|
||||||
|
# Harmless for the php/php-worker containers too, since they never invoke crond.
|
||||||
|
COPY docker/php/crontab /etc/crontabs/root
|
||||||
|
RUN chmod 0600 /etc/crontabs/root
|
||||||
|
|
||||||
|
# Log rotation for the cron hint-check and PHP error logs: 25MB per file, kept for 3 months.
|
||||||
|
COPY docker/php/logrotate/cron-hints.conf /etc/logrotate.d/cron-hints
|
||||||
|
COPY docker/php/logrotate/php-logs.conf /etc/logrotate.d/php-logs
|
||||||
|
|
||||||
# Adjust www-data UID/GID to match host user (default 1000)
|
# Adjust www-data UID/GID to match host user (default 1000)
|
||||||
ARG USER_ID=1000
|
ARG USER_ID=1000
|
||||||
ARG GROUP_ID=1000
|
ARG GROUP_ID=1000
|
||||||
@@ -56,7 +66,7 @@ RUN if [ ${USER_ID:-0} -ne 0 ] && [ ${GROUP_ID:-0} -ne 0 ]; then \
|
|||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
# Set permissions for Symfony directories
|
# Set permissions for Symfony directories
|
||||||
RUN mkdir -p var/cache var/log var/sessions && \
|
RUN mkdir -p var/cache var/log/cron var/log/php var/sessions && \
|
||||||
chown -R www-data:www-data var
|
chown -R www-data:www-data var
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* * * * * php /var/www/html/bin/console app:hints:check >> /var/www/html/var/log/cron/cron.log 2>&1
|
||||||
|
5 3 * * * logrotate -s /var/www/html/var/log/.logrotate.state /etc/logrotate.d/cron-hints /etc/logrotate.d/php-logs
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/var/www/html/var/log/cron/cron.log {
|
||||||
|
size 25M
|
||||||
|
rotate 100
|
||||||
|
maxage 90
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
dateext
|
||||||
|
dateformat -%Y%m%d-%s
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/var/www/html/var/log/php/*.log {
|
||||||
|
size 25M
|
||||||
|
rotate 100
|
||||||
|
maxage 90
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
dateext
|
||||||
|
dateformat -%Y%m%d-%s
|
||||||
|
}
|
||||||
+1
-1
@@ -9,6 +9,6 @@ opcache.validate_timestamps=1
|
|||||||
opcache.revalidate_freq=0
|
opcache.revalidate_freq=0
|
||||||
|
|
||||||
log_errors=On
|
log_errors=On
|
||||||
error_log=/var/www/html/var/log/errorlog_php.log
|
error_log=/var/www/html/var/log/php/error.log
|
||||||
session.gc_maxlifetime=1440
|
session.gc_maxlifetime=1440
|
||||||
session.cookie_lifetime=0
|
session.cookie_lifetime=0
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ echo "Stopping and removing containers..."
|
|||||||
docker network rm escapepage_network || true
|
docker network rm escapepage_network || true
|
||||||
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
docker network rm $(docker network ls -q --filter name=escapepage) || true
|
||||||
docker network prune -f || true
|
docker network prune -f || true
|
||||||
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker || true
|
docker rm -f escapepage-db escapepage-php escapepage-nginx escapepage-mercure escapepage-mailer escapepage-php-worker escapepage-php-cron || true
|
||||||
docker system prune -f || true
|
docker system prune -f || true
|
||||||
|
|
||||||
echo "Clearing Docker build cache..."
|
echo "Clearing Docker build cache..."
|
||||||
@@ -21,7 +21,7 @@ docker builder prune -af
|
|||||||
echo "Setting permissions for var/volumes/db and var directories..."
|
echo "Setting permissions for var/volumes/db and var directories..."
|
||||||
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
|
||||||
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
|
sudo chmod -R 777 "$ROOT_DIR/var/volumes/db" || true
|
||||||
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
|
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/sessions"
|
||||||
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
|
sudo chown -R 1000:1000 "$ROOT_DIR/var" || true
|
||||||
sudo chmod -R 777 "$ROOT_DIR/var" || true
|
sudo chmod -R 777 "$ROOT_DIR/var" || true
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ Common commands:
|
|||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f nginx)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-worker)
|
||||||
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE logs -f php-cron) # crond scheduler activity
|
||||||
|
tail -f "$ROOT_DIR/var/log/cron/cron.log" # hint-check command output
|
||||||
|
tail -f "$ROOT_DIR/var/log/php/error.log" # raw PHP errors
|
||||||
|
tail -f "$ROOT_DIR/var/log/php/prod.log" # Symfony app errors (prod only)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php bash)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE exec php npm run watch)
|
||||||
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
|
(cd "$DOCKER_DIR" && $DOCKER_COMPOSE down)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260810120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add lobby_message table for pre-game lobby chat';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE lobby_message (id INT AUTO_INCREMENT NOT NULL, session_id INT NOT NULL, player_id INT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_LOBBY_MESSAGE_SESSION (session_id), INDEX IDX_LOBBY_MESSAGE_PLAYER (player_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_SESSION FOREIGN KEY (session_id) REFERENCES session (id)');
|
||||||
|
$this->addSql('ALTER TABLE lobby_message ADD CONSTRAINT FK_LOBBY_MESSAGE_PLAYER FOREIGN KEY (player_id) REFERENCES player (id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_SESSION');
|
||||||
|
$this->addSql('ALTER TABLE lobby_message DROP FOREIGN KEY FK_LOBBY_MESSAGE_PLAYER');
|
||||||
|
$this->addSql('DROP TABLE lobby_message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260810130000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add finished_at column to session table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE session ADD finished_at DATETIME DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE session DROP finished_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Game\Entity\Session;
|
||||||
|
use App\Game\Enum\GameSettingType;
|
||||||
|
use App\Game\Enum\SessionSettingType;
|
||||||
|
use App\Game\Enum\SessionStatus;
|
||||||
|
use App\Game\Repository\GameSettingRepository;
|
||||||
|
use App\Game\Repository\SessionRepository;
|
||||||
|
use App\Game\Repository\SessionSettingRepository;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
|
use Symfony\Component\Mercure\Update;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:hints:check',
|
||||||
|
description: 'Checks running game sessions and sends mainframe hints to players who are behind schedule.'
|
||||||
|
)]
|
||||||
|
final class SendMainframeHintsCommand extends Command
|
||||||
|
{
|
||||||
|
private const DEFAULT_TOTAL_TIME = 3600;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionRepository $sessionRepository,
|
||||||
|
private readonly SessionSettingRepository $sessionSettingRepository,
|
||||||
|
private readonly GameSettingRepository $gameSettingRepository,
|
||||||
|
private readonly HubInterface $hub,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]);
|
||||||
|
$hintsSent = 0;
|
||||||
|
|
||||||
|
foreach ($sessions as $session) {
|
||||||
|
if ($this->checkContactHint($session)) {
|
||||||
|
$hintsSent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s).</info>', count($sessions), $hintsSent));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Get in touch" hint: if 5 minutes into the game the players haven't messaged
|
||||||
|
* everyone (a private message to each other player, plus one broadcast), nudge them.
|
||||||
|
* Repeats every run of this command for as long as the condition still holds.
|
||||||
|
*/
|
||||||
|
private function checkContactHint(Session $session): bool
|
||||||
|
{
|
||||||
|
$elapsed = $this->getElapsedPlayingSeconds($session);
|
||||||
|
if ($elapsed === null || $elapsed < 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->allPlayersHaveContactedEveryone($session)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->publishHint($session, 'Get in contact with your fellow agents to work together on defeating this AI virus.');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getElapsedPlayingSeconds(Session $session): ?int
|
||||||
|
{
|
||||||
|
$timer = $session->getTimer();
|
||||||
|
if ($timer === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalTimeSetting = $this->gameSettingRepository->getSetting($session->getGame(), GameSettingType::TOTAL_TIME);
|
||||||
|
$totalTime = $totalTimeSetting ? (int)$totalTimeSetting->getValue() : self::DEFAULT_TOTAL_TIME;
|
||||||
|
|
||||||
|
$startedAt = $timer - $totalTime;
|
||||||
|
|
||||||
|
return time() - $startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function allPlayersHaveContactedEveryone(Session $session): bool
|
||||||
|
{
|
||||||
|
$players = $session->getPlayers();
|
||||||
|
$screens = [];
|
||||||
|
|
||||||
|
foreach ($players as $player) {
|
||||||
|
if ($player->getScreen() === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$screens[] = $player->getScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($players as $player) {
|
||||||
|
$screen = $player->getScreen();
|
||||||
|
$trackingSettingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $screen);
|
||||||
|
if (!$trackingSettingName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player);
|
||||||
|
$tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
|
||||||
|
|
||||||
|
if (!in_array(0, $tracking)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($screens as $otherScreen) {
|
||||||
|
if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishHint(Session $session, string $message): void
|
||||||
|
{
|
||||||
|
$topic = '/game/hub/' . $session->getId();
|
||||||
|
try {
|
||||||
|
$this->hub->publish(new Update($topic, json_encode([0, $message, 'hint'])));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Mercure might be down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Game\Controller;
|
|||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Tech\Repository\UserRepository;
|
use App\Tech\Repository\UserRepository;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -49,15 +50,14 @@ final class GameAdminController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
|
#[Route('/session/{session}', name: 'game_admin_view_session', methods: ['GET'])]
|
||||||
public function viewSession(Session $session): Response
|
public function viewSession(Session $session, LobbyMessageRepository $lobbyMessageRepository): Response
|
||||||
{
|
{
|
||||||
$playersLogs = [];
|
$playersLogs = [];
|
||||||
foreach ($session->getPlayers() as $player) {
|
foreach ($session->getPlayers() as $player) {
|
||||||
$username = $player->getUser()->getUsername();
|
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $player->getLogFileBasename() . '.txt';
|
||||||
$logFile = $this->projectDir . '/var/log/sessions/' . $session->getId() . '/' . $username . '.txt';
|
|
||||||
|
|
||||||
$playersLogs[] = [
|
$playersLogs[] = [
|
||||||
'username' => $username,
|
'username' => $player->getUser()->getUsername(),
|
||||||
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
|
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,7 @@ final class GameAdminController extends AbstractController
|
|||||||
return $this->render('game/admin/sessions/view.html.twig', [
|
return $this->render('game/admin/sessions/view.html.twig', [
|
||||||
'session' => $session,
|
'session' => $session,
|
||||||
'playersLogs' => $playersLogs,
|
'playersLogs' => $playersLogs,
|
||||||
|
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ final class GameAdminSessionController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
$this->addFlash('success', sprintf('Session #%d closed.', $session->getId()));
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class GameApiController extends AbstractController
|
|||||||
if ($session->getStatus() === SessionStatus::PLAYING) {
|
if ($session->getStatus() === SessionStatus::PLAYING) {
|
||||||
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
if ($session->getTimer() !== null && $now >= $session->getTimer()) {
|
||||||
$session->setStatus(SessionStatus::LOST);
|
$session->setStatus(SessionStatus::LOST);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
$isFinished = true;
|
$isFinished = true;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use Symfony\Component\Routing\Annotation\Route;
|
|||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Component\ExpressionLanguage\Expression;
|
use Symfony\Component\ExpressionLanguage\Expression;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
|
|
||||||
final class GameController extends AbstractController
|
final class GameController extends AbstractController
|
||||||
{
|
{
|
||||||
@@ -39,13 +41,20 @@ final class GameController extends AbstractController
|
|||||||
GameRepository $gameRepository,
|
GameRepository $gameRepository,
|
||||||
SessionRepository $sessionRepository,
|
SessionRepository $sessionRepository,
|
||||||
GameDashboardService $dashboardService,
|
GameDashboardService $dashboardService,
|
||||||
Security $security
|
Security $security,
|
||||||
|
#[Target('invite_code_join')]
|
||||||
|
RateLimiterFactoryInterface $inviteCodeJoinLimiter
|
||||||
): Response {
|
): Response {
|
||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST')) {
|
||||||
if ($request->request->has('create_session')) {
|
if ($request->request->has('create_session')) {
|
||||||
|
if (!$this->isCsrfTokenValid('create_session', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
$gameId = $request->request->get('game_id');
|
$gameId = $request->request->get('game_id');
|
||||||
$game = $gameRepository->find($gameId);
|
$game = $gameRepository->find($gameId);
|
||||||
|
|
||||||
@@ -55,6 +64,17 @@ final class GameController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} elseif ($request->request->has('join_session')) {
|
} elseif ($request->request->has('join_session')) {
|
||||||
|
if (!$this->isCsrfTokenValid('join_session', $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$limiter = $inviteCodeJoinLimiter->create($user->getUserIdentifier());
|
||||||
|
if (!$limiter->consume(1)->isAccepted()) {
|
||||||
|
$this->addFlash('error', 'Too many attempts. Please wait a moment and try again.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
$inviteCode = $request->request->get('invite_code');
|
$inviteCode = $request->request->get('invite_code');
|
||||||
if ($dashboardService->joinSession($inviteCode, $user)) {
|
if ($dashboardService->joinSession($inviteCode, $user)) {
|
||||||
$this->addFlash('success', 'Joined session successfully!');
|
$this->addFlash('success', 'Joined session successfully!');
|
||||||
@@ -70,6 +90,11 @@ final class GameController extends AbstractController
|
|||||||
return $this->redirectToRoute('game_dashboard');
|
return $this->redirectToRoute('game_dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->isCsrfTokenValid('create_invite_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
|
$inviteCode = $dashboardService->generateInviteCode($session, $user, $isAdmin);
|
||||||
if ($inviteCode) {
|
if ($inviteCode) {
|
||||||
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
|
$this->addFlash('success', 'Invite link created: ' . $inviteCode);
|
||||||
@@ -79,6 +104,11 @@ final class GameController extends AbstractController
|
|||||||
$session = $sessionRepository->find($sessionId);
|
$session = $sessionRepository->find($sessionId);
|
||||||
|
|
||||||
if ($session) {
|
if ($session) {
|
||||||
|
if (!$this->isCsrfTokenValid('leave_session_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
if ($dashboardService->leaveSession($session, $user)) {
|
if ($dashboardService->leaveSession($session, $user)) {
|
||||||
$this->addFlash('success', 'Left session successfully.');
|
$this->addFlash('success', 'Left session successfully.');
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +120,11 @@ final class GameController extends AbstractController
|
|||||||
$session = $sessionRepository->find($sessionId);
|
$session = $sessionRepository->find($sessionId);
|
||||||
|
|
||||||
if ($session) {
|
if ($session) {
|
||||||
|
if (!$this->isCsrfTokenValid('start_session_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
return $this->redirectToRoute('game_dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
if ($dashboardService->startSession($session)) {
|
if ($dashboardService->startSession($session)) {
|
||||||
$this->addFlash('success', 'Session started! Screens have been assigned.');
|
$this->addFlash('success', 'Session started! Screens have been assigned.');
|
||||||
} else {
|
} else {
|
||||||
@@ -127,7 +162,9 @@ final class GameController extends AbstractController
|
|||||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
|
if ($request->isMethod('POST') && $request->request->has('toggle_ready')) {
|
||||||
if (!$user->isVerified()) {
|
if (!$this->isCsrfTokenValid('toggle_ready_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$this->addFlash('error', 'Invalid CSRF token.');
|
||||||
|
} elseif (!$user->isVerified()) {
|
||||||
$this->addFlash('error', 'You must verify your email address before you can mark yourself as ready.');
|
$this->addFlash('error', 'You must verify your email address before you can mark yourself as ready.');
|
||||||
} else {
|
} else {
|
||||||
$dashboardService->toggleReady($session, $user);
|
$dashboardService->toggleReady($session, $user);
|
||||||
@@ -137,16 +174,29 @@ final class GameController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($request->isMethod('POST') && $request->request->has('expire_ready')) {
|
if ($request->isMethod('POST') && $request->request->has('expire_ready')) {
|
||||||
$dashboardService->expireOwnReadyIfDue($session, $user);
|
if ($this->isCsrfTokenValid('expire_ready_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$dashboardService->expireOwnReadyIfDue($session, $user);
|
||||||
|
}
|
||||||
|
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->isMethod('POST') && $request->request->has('send_message')) {
|
||||||
|
if ($this->isCsrfTokenValid('send_message_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
|
$dashboardService->postLobbyMessage($session, $user, (string) $request->request->get('content', ''));
|
||||||
|
}
|
||||||
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
return $this->redirectToRoute('game', ['session' => $session->getId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily pick up readiness changes from other players since our last request
|
// Lazily pick up readiness changes from other players since our last request
|
||||||
$dashboardService->checkAllPlayersReady($session);
|
$dashboardService->checkAllPlayersReady($session);
|
||||||
|
|
||||||
if ($session->getStatus() === SessionStatus::CREATED) {
|
if ($dashboardService->isLobbyChatOpen($session)) {
|
||||||
$this->addFlash('info', 'This session is still waiting for more players to join.');
|
return $this->render('game/lobby.html.twig', [
|
||||||
return $this->redirectToRoute('game_dashboard');
|
'session' => $session,
|
||||||
|
'messages' => $dashboardService->getLobbyMessages($session),
|
||||||
|
'player' => $player,
|
||||||
|
'mercure_public_url' => $this->mercurePublicUrl,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($session->getStatus() === SessionStatus::WON) {
|
if ($session->getStatus() === SessionStatus::WON) {
|
||||||
@@ -206,7 +256,7 @@ final class GameController extends AbstractController
|
|||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
$difficulty = $request->request->get('difficulty');
|
$difficulty = $request->request->get('difficulty');
|
||||||
$entertaining = $request->request->get('entertaining');
|
$entertaining = $request->request->get('entertaining');
|
||||||
$theme = $request->request->get('theme');
|
$theme = $request->request->get('theme');
|
||||||
@@ -238,7 +288,7 @@ final class GameController extends AbstractController
|
|||||||
$user = $security->getUser();
|
$user = $security->getUser();
|
||||||
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
$player = $playerRepository->findOneBy(['session' => $session, 'user' => $user]);
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
if ($request->isMethod('POST') && $this->isCsrfTokenValid('game_feedback_' . $session->getId(), $request->request->get('_token'))) {
|
||||||
$difficulty = $request->request->get('difficulty');
|
$difficulty = $request->request->get('difficulty');
|
||||||
$entertaining = $request->request->get('entertaining');
|
$entertaining = $request->request->get('entertaining');
|
||||||
$theme = $request->request->get('theme');
|
$theme = $request->request->get('theme');
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Game\Entity;
|
||||||
|
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: LobbyMessageRepository::class)]
|
||||||
|
#[ORM\Table(name: 'lobby_message')]
|
||||||
|
class LobbyMessage
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Session::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Session $session = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Player::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private ?Player $player = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 500)]
|
||||||
|
private ?string $content = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime')]
|
||||||
|
private ?\DateTimeInterface $createdAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSession(): ?Session
|
||||||
|
{
|
||||||
|
return $this->session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSession(?Session $session): static
|
||||||
|
{
|
||||||
|
$this->session = $session;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPlayer(): ?Player
|
||||||
|
{
|
||||||
|
return $this->player;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPlayer(?Player $player): static
|
||||||
|
{
|
||||||
|
$this->player = $player;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContent(): ?string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setContent(string $content): static
|
||||||
|
{
|
||||||
|
$this->content = $content;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeInterface $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,4 +66,19 @@ class Player
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filesystem-safe basename derived from the player's username, for use when
|
||||||
|
* building per-player log file paths. Usernames are validated to only contain
|
||||||
|
* safe characters at registration time, but this sanitizes defensively too, so
|
||||||
|
* a path segment can never traverse outside its intended directory regardless
|
||||||
|
* of what ends up stored on the user.
|
||||||
|
*/
|
||||||
|
public function getLogFileBasename(): string
|
||||||
|
{
|
||||||
|
$username = $this->user?->getUsername() ?? '';
|
||||||
|
$safe = preg_replace('/[^A-Za-z0-9_-]/', '_', $username);
|
||||||
|
|
||||||
|
return $safe !== null && $safe !== '' ? $safe : ('player-' . $this->id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class Session
|
|||||||
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
private ?\DateTimeInterface $created = null;
|
private ?\DateTimeInterface $created = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeInterface $finishedAt = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
#[ORM\OneToMany(mappedBy: 'session', targetEntity: Player::class)]
|
||||||
private Collection $players;
|
private Collection $players;
|
||||||
|
|
||||||
@@ -97,6 +100,18 @@ class Session
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getFinishedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFinishedAt(?\DateTimeInterface $finishedAt): static
|
||||||
|
{
|
||||||
|
$this->finishedAt = $finishedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Player>
|
* @return Collection<int, Player>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,4 +9,15 @@ enum SessionStatus: string
|
|||||||
case PLAYING = 'playing';
|
case PLAYING = 'playing';
|
||||||
case WON = 'won';
|
case WON = 'won';
|
||||||
case LOST = 'lost';
|
case LOST = 'lost';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CREATED => 'Waiting for players',
|
||||||
|
self::READY => 'Waiting for players to be ready',
|
||||||
|
self::PLAYING => 'In progress',
|
||||||
|
self::WON => 'Completed - Won',
|
||||||
|
self::LOST => 'Completed - Lost',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Game\Repository;
|
||||||
|
|
||||||
|
use App\Game\Entity\LobbyMessage;
|
||||||
|
use App\Game\Entity\Session;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<LobbyMessage>
|
||||||
|
*/
|
||||||
|
class LobbyMessageRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, LobbyMessage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LobbyMessage[]
|
||||||
|
*/
|
||||||
|
public function findForSession(Session $session, int $limit = 100): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('m')
|
||||||
|
->andWhere('m.session = :session')
|
||||||
|
->setParameter('session', $session)
|
||||||
|
->orderBy('m.createdAt', 'ASC')
|
||||||
|
->setMaxResults($limit)
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Game\Service;
|
namespace App\Game\Service;
|
||||||
|
|
||||||
use App\Game\Entity\Game;
|
use App\Game\Entity\Game;
|
||||||
|
use App\Game\Entity\LobbyMessage;
|
||||||
use App\Game\Entity\Player;
|
use App\Game\Entity\Player;
|
||||||
use App\Game\Entity\Session;
|
use App\Game\Entity\Session;
|
||||||
use App\Game\Entity\SessionSetting;
|
use App\Game\Entity\SessionSetting;
|
||||||
@@ -11,6 +12,7 @@ use App\Game\Enum\GameStatus;
|
|||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Repository\GameRepository;
|
use App\Game\Repository\GameRepository;
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Repository\SessionRepository;
|
use App\Game\Repository\SessionRepository;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -21,10 +23,13 @@ use Symfony\Component\Mercure\Update;
|
|||||||
final class GameDashboardService
|
final class GameDashboardService
|
||||||
{
|
{
|
||||||
private const READY_TIMEOUT_SECONDS = 60;
|
private const READY_TIMEOUT_SECONDS = 60;
|
||||||
|
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
|
||||||
|
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GameRepository $gameRepository,
|
private readonly GameRepository $gameRepository,
|
||||||
private readonly SessionRepository $sessionRepository,
|
private readonly SessionRepository $sessionRepository,
|
||||||
|
private readonly LobbyMessageRepository $lobbyMessageRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly HubInterface $hub,
|
private readonly HubInterface $hub,
|
||||||
) {
|
) {
|
||||||
@@ -122,6 +127,8 @@ final class GameDashboardService
|
|||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->publishLobbyEvent($session, 'player_joined');
|
||||||
|
|
||||||
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
if (count($session->getPlayers()) === $session->getGame()->getNumberOfPlayers()) {
|
||||||
$this->startSession($session);
|
$this->startSession($session);
|
||||||
}
|
}
|
||||||
@@ -283,9 +290,83 @@ final class GameDashboardService
|
|||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->publishLobbyEvent($session, 'session_started');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LobbyMessage[]
|
||||||
|
*/
|
||||||
|
public function getLobbyMessages(Session $session): array
|
||||||
|
{
|
||||||
|
return $this->lobbyMessageRepository->findForSession($session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lobby chat is open while a session is still gathering players, and stays
|
||||||
|
* open for a grace period after the game ends so players can wrap up the
|
||||||
|
* conversation before it disappears.
|
||||||
|
*/
|
||||||
|
public function isLobbyChatOpen(Session $session): bool
|
||||||
|
{
|
||||||
|
if ($session->getStatus() === SessionStatus::CREATED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($session->getStatus(), [SessionStatus::WON, SessionStatus::LOST], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finishedAt = $session->getFinishedAt();
|
||||||
|
if ($finishedAt === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new \DateTime())->getTimestamp() - $finishedAt->getTimestamp() < self::LOBBY_CHAT_GRACE_PERIOD_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postLobbyMessage(Session $session, User $user, string $content): ?LobbyMessage
|
||||||
|
{
|
||||||
|
if (!$this->isLobbyChatOpen($session)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$player = null;
|
||||||
|
foreach ($session->getPlayers() as $sessionPlayer) {
|
||||||
|
if ($sessionPlayer->getUser() === $user) {
|
||||||
|
$player = $sessionPlayer;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$player) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = trim($content);
|
||||||
|
if ($content === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$content = mb_substr($content, 0, self::LOBBY_MESSAGE_MAX_LENGTH);
|
||||||
|
|
||||||
|
$message = new LobbyMessage();
|
||||||
|
$message->setSession($session);
|
||||||
|
$message->setPlayer($player);
|
||||||
|
$message->setContent($content);
|
||||||
|
|
||||||
|
$this->entityManager->persist($message);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->publishLobbyEvent($session, 'lobby_message', [
|
||||||
|
'username' => $user->getUserIdentifier(),
|
||||||
|
'content' => $content,
|
||||||
|
'createdAt' => $message->getCreatedAt()->format(DATE_ATOM),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleReady(Session $session, User $user): bool
|
public function toggleReady(Session $session, User $user): bool
|
||||||
{
|
{
|
||||||
if ($session->getStatus() !== SessionStatus::READY) {
|
if ($session->getStatus() !== SessionStatus::READY) {
|
||||||
@@ -393,10 +474,15 @@ final class GameDashboardService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function publishPlayerReady(Session $session, int $screen, bool $ready): void
|
private function publishPlayerReady(Session $session, int $screen, bool $ready): void
|
||||||
|
{
|
||||||
|
$this->publishLobbyEvent($session, 'player_ready', ['player' => $screen, 'ready' => $ready]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$topic = '/game/hub/' . $session->getId();
|
$topic = '/game/hub/' . $session->getId();
|
||||||
$this->hub->publish(new Update($topic, json_encode(['type' => 'player_ready', 'player' => $screen, 'ready' => $ready])));
|
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Mercure might be down, but we don't want to crash the game
|
// Mercure might be down, but we don't want to crash the game
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,14 +107,13 @@ class GameResponseService
|
|||||||
private function logSessionActivity(Player $player, string $content): void
|
private function logSessionActivity(Player $player, string $content): void
|
||||||
{
|
{
|
||||||
$sessionId = $player->getSession()->getId();
|
$sessionId = $player->getSession()->getId();
|
||||||
$username = $player->getUser()->getUsername();
|
|
||||||
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
|
||||||
|
|
||||||
if (!is_dir($logDir)) {
|
if (!is_dir($logDir)) {
|
||||||
mkdir($logDir, 0777, true);
|
mkdir($logDir, 0777, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$logFile = $logDir . '/' . $username . '.txt';
|
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
|
||||||
$timestamp = date('Y-m-d H:i:s');
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
|
||||||
|
|
||||||
@@ -998,6 +997,61 @@ class GameResponseService
|
|||||||
$paths[] = '/etc/handle';
|
$paths[] = '/etc/handle';
|
||||||
$paths[] = '/etc/freak';
|
$paths[] = '/etc/freak';
|
||||||
$paths[] = '/etc/host';
|
$paths[] = '/etc/host';
|
||||||
|
$paths[] = '/etc/ssh';
|
||||||
|
$paths[] = '/etc/nginx';
|
||||||
|
$paths[] = '/etc/apache2';
|
||||||
|
$paths[] = '/etc/systemd';
|
||||||
|
$paths[] = '/etc/cron.d';
|
||||||
|
$paths[] = '/etc/network';
|
||||||
|
$paths[] = '/etc/apt';
|
||||||
|
$paths[] = '/etc/default';
|
||||||
|
$paths[] = '/etc/init.d';
|
||||||
|
$paths[] = '/etc/security';
|
||||||
|
$paths[] = '/etc/skel';
|
||||||
|
$paths[] = '/etc/logrotate.d';
|
||||||
|
|
||||||
|
$paths[] = '/bin';
|
||||||
|
$paths[] = '/boot';
|
||||||
|
$paths[] = '/dev';
|
||||||
|
$paths[] = '/home';
|
||||||
|
$paths[] = '/home/admin';
|
||||||
|
$paths[] = '/home/guest';
|
||||||
|
$paths[] = '/home/backup';
|
||||||
|
$paths[] = '/lib';
|
||||||
|
$paths[] = '/lib64';
|
||||||
|
$paths[] = '/media';
|
||||||
|
$paths[] = '/mnt';
|
||||||
|
$paths[] = '/opt';
|
||||||
|
$paths[] = '/opt/app';
|
||||||
|
$paths[] = '/proc';
|
||||||
|
$paths[] = '/root';
|
||||||
|
$paths[] = '/root/.ssh';
|
||||||
|
$paths[] = '/run';
|
||||||
|
$paths[] = '/sbin';
|
||||||
|
$paths[] = '/srv';
|
||||||
|
$paths[] = '/sys';
|
||||||
|
$paths[] = '/tmp';
|
||||||
|
$paths[] = '/usr';
|
||||||
|
$paths[] = '/usr/bin';
|
||||||
|
$paths[] = '/usr/sbin';
|
||||||
|
$paths[] = '/usr/lib';
|
||||||
|
$paths[] = '/usr/local';
|
||||||
|
$paths[] = '/usr/local/bin';
|
||||||
|
$paths[] = '/usr/local/sbin';
|
||||||
|
$paths[] = '/usr/share';
|
||||||
|
$paths[] = '/usr/share/doc';
|
||||||
|
$paths[] = '/usr/share/man';
|
||||||
|
$paths[] = '/usr/include';
|
||||||
|
$paths[] = '/usr/src';
|
||||||
|
|
||||||
|
$paths[] = '/var/log';
|
||||||
|
$paths[] = '/var/lib';
|
||||||
|
$paths[] = '/var/cache';
|
||||||
|
$paths[] = '/var/spool';
|
||||||
|
$paths[] = '/var/backups';
|
||||||
|
$paths[] = '/var/tmp';
|
||||||
|
$paths[] = '/var/mail';
|
||||||
|
$paths[] = '/var/run';
|
||||||
|
|
||||||
$paths[] = '/var/home';
|
$paths[] = '/var/home';
|
||||||
|
|
||||||
@@ -1063,6 +1117,7 @@ class GameResponseService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$session->setStatus(SessionStatus::WON);
|
$session->setStatus(SessionStatus::WON);
|
||||||
|
$session->setFinishedAt(new \DateTime());
|
||||||
$this->entityManager->persist($session);
|
$this->entityManager->persist($session);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
@@ -1213,6 +1268,127 @@ class GameResponseService
|
|||||||
$files[] = '/var/rapports/011_130-62.txt';
|
$files[] = '/var/rapports/011_130-62.txt';
|
||||||
$files[] = '/var/rapports/index.txt';
|
$files[] = '/var/rapports/index.txt';
|
||||||
|
|
||||||
|
$files[] = '/etc/passwd';
|
||||||
|
$files[] = '/etc/shadow';
|
||||||
|
$files[] = '/etc/hostname';
|
||||||
|
$files[] = '/etc/hosts';
|
||||||
|
$files[] = '/etc/os-release';
|
||||||
|
$files[] = '/etc/motd';
|
||||||
|
$files[] = '/etc/issue';
|
||||||
|
$files[] = '/etc/timezone';
|
||||||
|
$files[] = '/etc/resolv.conf';
|
||||||
|
$files[] = '/etc/crontab';
|
||||||
|
$files[] = '/etc/nsswitch.conf';
|
||||||
|
$files[] = '/etc/environment';
|
||||||
|
$files[] = '/etc/fstab';
|
||||||
|
$files[] = '/etc/ssh/sshd_config';
|
||||||
|
$files[] = '/etc/ssh/ssh_config';
|
||||||
|
$files[] = '/etc/nginx/nginx.conf';
|
||||||
|
$files[] = '/etc/apache2/apache2.conf';
|
||||||
|
$files[] = '/etc/network/interfaces';
|
||||||
|
$files[] = '/etc/apt/sources.list';
|
||||||
|
$files[] = '/etc/cron.d/backup-cron.sh';
|
||||||
|
$files[] = '/etc/init.d/nginx.sh';
|
||||||
|
$files[] = '/etc/init.d/ssh.sh';
|
||||||
|
$files[] = '/etc/init.d/cron.sh';
|
||||||
|
$files[] = '/etc/logrotate.d/rsyslog.sh';
|
||||||
|
$files[] = '/etc/logrotate.d/apt.sh';
|
||||||
|
$files[] = '/etc/skel/bashrc.sh';
|
||||||
|
$files[] = '/etc/skel/profile.sh';
|
||||||
|
|
||||||
|
$files[] = '/var/log/syslog';
|
||||||
|
$files[] = '/var/log/auth.log';
|
||||||
|
$files[] = '/var/log/kern.log';
|
||||||
|
$files[] = '/var/log/boot.log';
|
||||||
|
$files[] = '/var/log/dmesg';
|
||||||
|
$files[] = '/var/log/dpkg.log';
|
||||||
|
$files[] = '/var/log/cron.log';
|
||||||
|
$files[] = '/var/log/mail.log';
|
||||||
|
$files[] = '/var/log/daemon.log';
|
||||||
|
$files[] = '/var/log/alternatives.log';
|
||||||
|
$files[] = '/var/lib/dpkg-status.sh';
|
||||||
|
$files[] = '/var/lib/apt-extended-states.sh';
|
||||||
|
$files[] = '/var/cache/apt-archives.sh';
|
||||||
|
$files[] = '/var/spool/cron-crontabs.sh';
|
||||||
|
$files[] = '/var/spool/mail-root.sh';
|
||||||
|
$files[] = '/var/backups/passwd.bak.sh';
|
||||||
|
$files[] = '/var/backups/group.bak.sh';
|
||||||
|
$files[] = '/var/mail/root';
|
||||||
|
|
||||||
|
$files[] = '/root/.bashrc';
|
||||||
|
$files[] = '/root/.bash_history';
|
||||||
|
$files[] = '/root/.ssh/authorized_keys';
|
||||||
|
|
||||||
|
$files[] = '/home/admin/notes.sh';
|
||||||
|
$files[] = '/home/admin/todo.sh';
|
||||||
|
$files[] = '/home/guest/readme.sh';
|
||||||
|
$files[] = '/home/backup/backup.sh';
|
||||||
|
|
||||||
|
$files[] = '/opt/app/config.yml';
|
||||||
|
$files[] = '/opt/app/app.sh';
|
||||||
|
$files[] = '/opt/app/start.sh';
|
||||||
|
|
||||||
|
$files[] = '/usr/bin/ls.sh';
|
||||||
|
$files[] = '/usr/bin/cat.sh';
|
||||||
|
$files[] = '/usr/bin/grep.sh';
|
||||||
|
$files[] = '/usr/bin/awk.sh';
|
||||||
|
$files[] = '/usr/bin/sed.sh';
|
||||||
|
$files[] = '/usr/bin/bash.sh';
|
||||||
|
$files[] = '/usr/bin/python3.sh';
|
||||||
|
$files[] = '/usr/bin/perl.sh';
|
||||||
|
$files[] = '/usr/bin/curl.sh';
|
||||||
|
$files[] = '/usr/bin/wget.sh';
|
||||||
|
$files[] = '/usr/bin/ssh.sh';
|
||||||
|
$files[] = '/usr/bin/scp.sh';
|
||||||
|
$files[] = '/usr/bin/rsync.sh';
|
||||||
|
$files[] = '/usr/bin/tar.sh';
|
||||||
|
$files[] = '/usr/bin/gzip.sh';
|
||||||
|
$files[] = '/usr/bin/vim.sh';
|
||||||
|
$files[] = '/usr/bin/nano.sh';
|
||||||
|
$files[] = '/usr/bin/top.sh';
|
||||||
|
$files[] = '/usr/bin/ps.sh';
|
||||||
|
$files[] = '/usr/bin/kill.sh';
|
||||||
|
$files[] = '/usr/bin/chmod.sh';
|
||||||
|
$files[] = '/usr/bin/chown.sh';
|
||||||
|
$files[] = '/usr/bin/systemctl.sh';
|
||||||
|
$files[] = '/usr/bin/docker.sh';
|
||||||
|
$files[] = '/usr/bin/git.sh';
|
||||||
|
$files[] = '/usr/bin/find.sh';
|
||||||
|
$files[] = '/usr/bin/sort.sh';
|
||||||
|
$files[] = '/usr/bin/uniq.sh';
|
||||||
|
$files[] = '/usr/bin/head.sh';
|
||||||
|
$files[] = '/usr/bin/tail.sh';
|
||||||
|
|
||||||
|
$files[] = '/bin/sh.sh';
|
||||||
|
$files[] = '/bin/mount.sh';
|
||||||
|
$files[] = '/bin/umount.sh';
|
||||||
|
$files[] = '/bin/ping.sh';
|
||||||
|
$files[] = '/bin/netstat.sh';
|
||||||
|
$files[] = '/bin/ifconfig.sh';
|
||||||
|
$files[] = '/bin/hostname.sh';
|
||||||
|
$files[] = '/bin/date.sh';
|
||||||
|
$files[] = '/bin/ln.sh';
|
||||||
|
$files[] = '/bin/cp.sh';
|
||||||
|
$files[] = '/bin/mv.sh';
|
||||||
|
$files[] = '/bin/rm.sh';
|
||||||
|
$files[] = '/bin/mkdir.sh';
|
||||||
|
$files[] = '/bin/rmdir.sh';
|
||||||
|
$files[] = '/bin/touch.sh';
|
||||||
|
$files[] = '/bin/echo.sh';
|
||||||
|
|
||||||
|
$files[] = '/sbin/init.sh';
|
||||||
|
$files[] = '/sbin/reboot.sh';
|
||||||
|
$files[] = '/sbin/shutdown.sh';
|
||||||
|
$files[] = '/sbin/fsck.sh';
|
||||||
|
$files[] = '/sbin/ifup.sh';
|
||||||
|
$files[] = '/sbin/ifdown.sh';
|
||||||
|
$files[] = '/sbin/iptables.sh';
|
||||||
|
$files[] = '/sbin/sysctl.sh';
|
||||||
|
|
||||||
|
$files[] = '/usr/local/bin/composer.sh';
|
||||||
|
$files[] = '/usr/local/bin/node.sh';
|
||||||
|
$files[] = '/usr/local/bin/npm.sh';
|
||||||
|
|
||||||
if ($player === null) {
|
if ($player === null) {
|
||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
use Symfony\Component\Validator\Constraints\Email;
|
use Symfony\Component\Validator\Constraints\Email;
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\Regex;
|
||||||
|
|
||||||
class AdminUserType extends AbstractType
|
class AdminUserType extends AbstractType
|
||||||
{
|
{
|
||||||
@@ -26,7 +27,14 @@ class AdminUserType extends AbstractType
|
|||||||
'constraints' => [new NotBlank(), new Email()],
|
'constraints' => [new NotBlank(), new Email()],
|
||||||
])
|
])
|
||||||
->add('username', TextType::class, [
|
->add('username', TextType::class, [
|
||||||
'constraints' => [new NotBlank(), new Length(min: 2, max: 180)],
|
'constraints' => [
|
||||||
|
new NotBlank(),
|
||||||
|
new Length(min: 2, max: 32),
|
||||||
|
new Regex(
|
||||||
|
pattern: '/^[A-Za-z0-9_-]+$/',
|
||||||
|
message: 'Username may only contain letters, numbers, underscores, and hyphens.',
|
||||||
|
),
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->add('plainPassword', PasswordType::class, [
|
->add('plainPassword', PasswordType::class, [
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Component\Validator\Constraints\Regex;
|
||||||
|
|
||||||
class RegistrationFormType extends AbstractType
|
class RegistrationFormType extends AbstractType
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,11 @@ class RegistrationFormType extends AbstractType
|
|||||||
->add('username', TextType::class, [
|
->add('username', TextType::class, [
|
||||||
'constraints' => [
|
'constraints' => [
|
||||||
new NotBlank(message: 'Please enter a username'),
|
new NotBlank(message: 'Please enter a username'),
|
||||||
|
new Length(min: 3, max: 32, minMessage: 'Your username should be at least {{ limit }} characters', maxMessage: 'Your username cannot be longer than {{ limit }} characters'),
|
||||||
|
new Regex(
|
||||||
|
pattern: '/^[A-Za-z0-9_-]+$/',
|
||||||
|
message: 'Your username may only contain letters, numbers, underscores, and hyphens.',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->add('plainPassword', RepeatedType::class, [
|
->add('plainPassword', RepeatedType::class, [
|
||||||
|
|||||||
@@ -262,15 +262,6 @@
|
|||||||
"config/routes/security.yaml"
|
"config/routes/security.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"symfony/sendgrid-mailer": {
|
|
||||||
"version": "7.3",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "4.4",
|
|
||||||
"ref": "224aedffb66812dc2b0965dabc14d5f800941da6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"symfony/stimulus-bundle": {
|
"symfony/stimulus-bundle": {
|
||||||
"version": "2.30",
|
"version": "2.30",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -1,23 +1,99 @@
|
|||||||
{% extends 'layout/site.html.twig' %}
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
<style>
|
||||||
|
.admin-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
.admin-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.admin-sidebar-title {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.admin-nav-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.admin-nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.admin-sidebar-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
.admin-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.admin-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.admin-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-sidebar-title {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 0.75rem 1rem 0.25rem;
|
||||||
|
}
|
||||||
|
.admin-nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0.25rem 0.5rem 0.75rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.admin-nav-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.admin-sidebar-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.admin-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div style="display: flex; min-height: calc(100vh - 60px);">
|
<div class="admin-shell">
|
||||||
|
|
||||||
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
{# ── Sidebar ──────────────────────────────────────────────────────── #}
|
||||||
<nav style="
|
<nav class="admin-sidebar">
|
||||||
width: 220px;
|
<div class="admin-sidebar-title">Game Admin</div>
|
||||||
flex-shrink: 0;
|
|
||||||
background: #1e293b;
|
|
||||||
color: #cbd5e1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0;
|
|
||||||
">
|
|
||||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid #334155;">
|
|
||||||
<span style="font-weight: 700; font-size: 1rem; color: #f1f5f9;">Game Admin</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul style="list-style: none; margin: 0; padding: 0.5rem 0; flex: 1;">
|
<ul class="admin-nav-list">
|
||||||
{% set current = app.request.attributes.get('_route') %}
|
{% set current = app.request.attributes.get('_route') %}
|
||||||
|
|
||||||
{% set navItems = [
|
{% set navItems = [
|
||||||
@@ -31,15 +107,9 @@
|
|||||||
{% for item in navItems %}
|
{% for item in navItems %}
|
||||||
{% set isActive = current starts with item.route %}
|
{% set isActive = current starts with item.route %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ path(item.route) }}" style="
|
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.6rem 1.5rem;
|
|
||||||
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
|
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
|
||||||
background: {{ isActive ? '#334155' : 'transparent' }};
|
background: {{ isActive ? '#334155' : 'transparent' }};
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
|
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
|
||||||
">
|
">
|
||||||
<span>{{ item.icon }}</span>
|
<span>{{ item.icon }}</span>
|
||||||
@@ -49,7 +119,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div style="padding: 1rem 1.5rem; border-top: 1px solid #334155;">
|
<div class="admin-sidebar-footer">
|
||||||
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
|
||||||
← Back to game
|
← Back to game
|
||||||
</a>
|
</a>
|
||||||
@@ -57,7 +127,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# ── Content ──────────────────────────────────────────────────────── #}
|
{# ── Content ──────────────────────────────────────────────────────── #}
|
||||||
<main style="flex: 1; background: #f8fafc; padding: 2rem; overflow-x: auto;">
|
<main class="admin-main">
|
||||||
{% for label, messages in app.flashes %}
|
{% for label, messages in app.flashes %}
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<div style="
|
<div style="
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
|
<table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
|
<table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
{% extends 'base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}View Session Logs - {{ session.id }}{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<h1>Session: {{ session.game.name }} (#{{ session.id }})</h1>
|
|
||||||
<p><a href="{{ path('game_admin_dashboard') }}">Back to Dashboard</a></p>
|
|
||||||
|
|
||||||
<div class="tabs">
|
|
||||||
<ul style="display: flex; list-style: none; padding: 0; border-bottom: 1px solid #ccc;">
|
|
||||||
{% for playerLog in playersLogs %}
|
|
||||||
<li style="margin-right: 10px;">
|
|
||||||
<button
|
|
||||||
onclick="openTab(event, 'player-{{ loop.index }}')"
|
|
||||||
class="tablinks {{ loop.first ? 'active' : '' }}"
|
|
||||||
style="padding: 10px; cursor: pointer; border: 1px solid #ccc; border-bottom: none; background: {{ loop.first ? '#eee' : '#fff' }};"
|
|
||||||
>
|
|
||||||
{{ playerLog.username }}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for playerLog in playersLogs %}
|
|
||||||
<div id="player-{{ loop.index }}" class="tabcontent" style="display: {{ loop.first ? 'block' : 'none' }}; border: 1px solid #ccc; border-top: none; padding: 20px;">
|
|
||||||
<h3>Logs for {{ playerLog.username }}</h3>
|
|
||||||
<pre style="background: #f4f4f4; padding: 15px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function openTab(evt, playerName) {
|
|
||||||
var i, tabcontent, tablinks;
|
|
||||||
tabcontent = document.getElementsByClassName("tabcontent");
|
|
||||||
for (i = 0; i < tabcontent.length; i++) {
|
|
||||||
tabcontent[i].style.display = "none";
|
|
||||||
}
|
|
||||||
tablinks = document.getElementsByClassName("tablinks");
|
|
||||||
for (i = 0; i < tablinks.length; i++) {
|
|
||||||
tablinks[i].className = tablinks[i].className.replace(" active", "");
|
|
||||||
tablinks[i].style.background = "#fff";
|
|
||||||
}
|
|
||||||
document.getElementById(playerName).style.display = "block";
|
|
||||||
evt.currentTarget.className += " active";
|
|
||||||
evt.currentTarget.style.background = "#eee";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
|
<table style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -10,74 +10,95 @@
|
|||||||
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
|
<h1 style="margin: 0 0 0.25rem; font-size: 1.5rem; color: #0f172a;">{{ session.game.name }} — Session #{{ session.id }}</h1>
|
||||||
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
|
<p style="margin: 0 0 1.5rem; color: #64748b; font-size: 0.9rem;">{{ session.status.value }} · {{ session.players|length }} player(s) · Created {{ session.created|date('Y-m-d H:i') }}</p>
|
||||||
|
|
||||||
{% if playersLogs is empty %}
|
{# Tab buttons #}
|
||||||
<div style="background: #fff; border-radius: 8px; padding: 2rem; text-align: center; color: #94a3b8; box-shadow: 0 1px 3px rgba(0,0,0,.07);">
|
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
No players in this session.
|
<button
|
||||||
</div>
|
data-tab-target="lobby-chat-tab"
|
||||||
{% else %}
|
id="tab-lobby-chat"
|
||||||
{# Tab buttons #}
|
style="
|
||||||
<div style="display: flex; gap: 0; margin-bottom: 0; border-bottom: 1px solid #e2e8f0;">
|
flex-shrink: 0;
|
||||||
{% for playerLog in playersLogs %}
|
white-space: nowrap;
|
||||||
<button
|
padding: 0.6rem 1.25rem;
|
||||||
onclick="openTab('player-{{ loop.index }}')"
|
border: 1px solid #3b82f6;
|
||||||
id="tab-{{ loop.index }}"
|
border-bottom: 1px solid #fff;
|
||||||
style="
|
|
||||||
padding: 0.6rem 1.25rem;
|
|
||||||
border: 1px solid {{ loop.first ? '#3b82f6' : '#e2e8f0' }};
|
|
||||||
border-bottom: {{ loop.first ? '1px solid #fff' : '1px solid #e2e8f0' }};
|
|
||||||
background: {{ loop.first ? '#fff' : '#f8fafc' }};
|
|
||||||
color: {{ loop.first ? '#1e40af' : '#64748b' }};
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: {{ loop.first ? '600' : '400' }};
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
"
|
|
||||||
>{{ playerLog.username }}</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Tab content #}
|
|
||||||
{% for playerLog in playersLogs %}
|
|
||||||
<div id="player-{{ loop.index }}" style="
|
|
||||||
display: {{ loop.first ? 'block' : 'none' }};
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e2e8f0;
|
color: #1e40af;
|
||||||
border-top: none;
|
font-size: 0.9rem;
|
||||||
border-radius: 0 0 8px 8px;
|
font-weight: 600;
|
||||||
padding: 1.25rem;
|
cursor: pointer;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
border-radius: 6px 6px 0 0;
|
||||||
">
|
margin-bottom: -1px;
|
||||||
<pre style="
|
"
|
||||||
background: #1e293b;
|
>Lobby Chat</button>
|
||||||
color: #e2e8f0;
|
{% for playerLog in playersLogs %}
|
||||||
padding: 1rem;
|
<button
|
||||||
border-radius: 6px;
|
data-tab-target="player-{{ loop.index }}"
|
||||||
overflow-x: auto;
|
id="tab-{{ loop.index }}"
|
||||||
white-space: pre-wrap;
|
style="
|
||||||
word-break: break-word;
|
flex-shrink: 0;
|
||||||
font-size: 0.85rem;
|
white-space: nowrap;
|
||||||
line-height: 1.5;
|
padding: 0.6rem 1.25rem;
|
||||||
margin: 0;
|
border: 1px solid #e2e8f0;
|
||||||
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
border-bottom: 1px solid #e2e8f0;
|
||||||
</div>
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
"
|
||||||
|
>{{ playerLog.username }}</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
</div>
|
||||||
|
|
||||||
<script>
|
{# Tab content #}
|
||||||
function openTab(id) {
|
<div id="lobby-chat-tab" class="admin-tab-panel" style="
|
||||||
document.querySelectorAll('[id^="player-"]').forEach(el => el.style.display = 'none');
|
display: block;
|
||||||
document.getElementById(id).style.display = 'block';
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
">
|
||||||
|
{% if lobbyMessages is empty %}
|
||||||
|
<p style="margin: 0; color: #94a3b8;">No lobby chat messages for this session.</p>
|
||||||
|
{% else %}
|
||||||
|
<div style="max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem;">
|
||||||
|
{% for message in lobbyMessages %}
|
||||||
|
<div style="font-size: 0.9rem;">
|
||||||
|
<strong style="color: #0f172a;">{{ message.player.user.username }}</strong>
|
||||||
|
<span style="color: #94a3b8; font-size: 0.8rem;">{{ message.createdAt|date('Y-m-d H:i') }}</span>
|
||||||
|
<div style="color: #334155;">{{ message.content }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
const idx = id.split('-')[1];
|
{% for playerLog in playersLogs %}
|
||||||
document.querySelectorAll('[id^="tab-"]').forEach((btn, i) => {
|
<div id="player-{{ loop.index }}" class="admin-tab-panel" style="
|
||||||
const active = String(i + 1) === idx;
|
display: none;
|
||||||
btn.style.background = active ? '#fff' : '#f8fafc';
|
background: #fff;
|
||||||
btn.style.color = active ? '#1e40af' : '#64748b';
|
border: 1px solid #e2e8f0;
|
||||||
btn.style.fontWeight = active ? '600' : '400';
|
border-top: none;
|
||||||
btn.style.borderColor = active ? '#3b82f6' : '#e2e8f0';
|
border-radius: 0 0 8px 8px;
|
||||||
btn.style.borderBottom = active ? '1px solid #fff' : '1px solid #e2e8f0';
|
padding: 1.25rem;
|
||||||
});
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
}
|
">
|
||||||
</script>
|
<pre style="
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.9rem;">
|
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
|
||||||
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">ID</th>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if availableGames is not empty %}
|
{% if availableGames is not empty %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
|
||||||
<select name="game_id" class="form-select mb-3">
|
<select name="game_id" class="form-select mb-3">
|
||||||
{% for game in availableGames %}
|
{% for game in availableGames %}
|
||||||
<option value="{{ game.id }}">
|
<option value="{{ game.id }}">
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<div class="card-header bg-secondary text-white">Join Session</div>
|
<div class="card-header bg-secondary text-white">Join Session</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" class="d-flex gap-2">
|
<form method="post" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('join_session') }}">
|
||||||
<input type="text" name="invite_code" class="form-control" placeholder="Enter Invite Code" required>
|
<input type="text" name="invite_code" class="form-control" placeholder="Enter Invite Code" required>
|
||||||
<button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button>
|
<button type="submit" name="join_session" class="btn btn-primary text-nowrap">Join Session</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ session.id }}</td>
|
<td>{{ session.id }}</td>
|
||||||
<td>{{ session.game.name }}</td>
|
<td>{{ session.game.name }}</td>
|
||||||
<td><span class="badge bg-info text-dark">{{ session.status.value }}</span></td>
|
<td><span class="badge bg-info text-dark">{{ session.status.label }}</span></td>
|
||||||
<td>{{ session.created|date('Y-m-d H:i') }}</td>
|
<td>{{ session.created|date('Y-m-d H:i') }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% set inviteCode = '' %}
|
{% set inviteCode = '' %}
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
<code>{{ inviteCode }}</code>
|
<code>{{ inviteCode }}</code>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" class="d-inline">
|
<form method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('create_invite_' ~ session.id) }}">
|
||||||
<input type="hidden" name="session_id" value="{{ session.id }}">
|
<input type="hidden" name="session_id" value="{{ session.id }}">
|
||||||
<button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button>
|
<button type="submit" name="create_invite" class="btn btn-sm btn-outline-secondary">Generate Invite</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -91,12 +94,14 @@
|
|||||||
{% if session.status.value == 'created' %}
|
{% if session.status.value == 'created' %}
|
||||||
{% if session.players|length >= session.game.numberOfPlayers %}
|
{% if session.players|length >= session.game.numberOfPlayers %}
|
||||||
<form method="post" class="d-inline">
|
<form method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('start_session_' ~ session.id) }}">
|
||||||
<input type="hidden" name="session_id" value="{{ session.id }}">
|
<input type="hidden" name="session_id" value="{{ session.id }}">
|
||||||
<button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button>
|
<button type="submit" name="start_session" class="btn btn-sm btn-success">Start Session</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if session.timer == 0 %}
|
{% if session.timer == 0 %}
|
||||||
<form method="post" class="d-inline">
|
<form method="post" class="d-inline">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('leave_session_' ~ session.id) }}">
|
||||||
<input type="hidden" name="session_id" value="{{ session.id }}">
|
<input type="hidden" name="session_id" value="{{ session.id }}">
|
||||||
<button type="submit" name="leave_session" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
|
<button type="submit" name="leave_session" class="btn btn-sm btn-outline-danger" onclick="return confirm('Are you sure you want to leave this session?')">Leave Session</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'layout/site.html.twig' %}
|
||||||
|
|
||||||
|
{% set isCreated = session.status.value == 'created' %}
|
||||||
|
{% set isFinished = session.status.value in ['won', 'lost'] %}
|
||||||
|
|
||||||
|
{% block title %}{{ isCreated ? 'Waiting for players' : 'Post-game chat' }} - {{ session.game.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if isCreated %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h3 class="card-title mb-0">Waiting for more players to join</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<p>Share the invite code with your friends. Feel free to chat below while you wait — no need to reload the page.</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<strong>Players joined:</strong> {{ session.players|length }} / {{ session.game.numberOfPlayers }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
{% for sessionPlayer in session.players %}
|
||||||
|
<li class="list-group-item">{{ sessionPlayer.user.username }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if session.players|length >= session.game.numberOfPlayers %}
|
||||||
|
<form method="post" action="{{ path('game_dashboard') }}" class="mb-3">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('start_session_' ~ session.id) }}">
|
||||||
|
<input type="hidden" name="session_id" value="{{ session.id }}">
|
||||||
|
<button type="submit" name="start_session" class="btn btn-success">Start Session</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elseif isFinished %}
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header {{ session.status.value == 'won' ? 'bg-success' : 'bg-secondary' }} text-white">
|
||||||
|
<h3 class="card-title mb-0">{{ session.status.value == 'won' ? 'You won!' : 'Game over' }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{{ session.game.name }}</h4>
|
||||||
|
<p>The game has ended, but the chat is still open for a little while — feel free to keep talking.</p>
|
||||||
|
|
||||||
|
<a href="{{ path(session.status.value == 'won' ? 'game_won' : 'game_lost', {session: session.id}) }}" class="btn btn-primary btn-sm">View results</a>
|
||||||
|
<a href="{{ path('game_dashboard') }}" class="btn btn-outline-secondary btn-sm">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Lobby chat</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="lobby-chat-log" class="mb-3 d-flex flex-column gap-2" style="max-height: 320px; overflow-y: auto;">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="lobby-message">
|
||||||
|
<strong>{{ message.player.user.username }}</strong>
|
||||||
|
<span class="text-muted small">{{ message.createdAt|date('H:i') }}</span>
|
||||||
|
<div>{{ message.content }}</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0" id="lobby-chat-empty">No messages yet. Say hi!</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if player %}
|
||||||
|
<form method="post" class="d-flex gap-2">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('send_message_' ~ session.id) }}">
|
||||||
|
<input type="text" name="content" class="form-control" placeholder="Type a message…" maxlength="500" required autocomplete="off">
|
||||||
|
<button type="submit" name="send_message" class="btn btn-primary">Send</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Only players in this session can chat.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="game-lobby-config"
|
||||||
|
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
||||||
|
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
||||||
|
style="display:none">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
<div class="feedback-form mt-4">
|
<div class="feedback-form mt-4">
|
||||||
<h5>Feedback</h5>
|
<h5>Feedback</h5>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
|
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
|
||||||
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
|
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" class="mt-4">
|
<form method="post" class="mt-4">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('toggle_ready_' ~ session.id) }}">
|
||||||
<input type="hidden" name="toggle_ready" value="0">
|
<input type="hidden" name="toggle_ready" value="0">
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" value="1" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}>
|
<input class="form-check-input" type="checkbox" id="toggle_ready" name="toggle_ready" value="1" onchange="this.form.submit()" {{ isReady ? 'checked' : '' }} {{ not app.user.verified ? 'disabled' : '' }}>
|
||||||
@@ -87,77 +88,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="expire-ready-form" method="post" style="display:none">
|
<form id="expire-ready-form" method="post" style="display:none">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('expire_ready_' ~ session.id) }}">
|
||||||
<input type="hidden" name="expire_ready" value="1">
|
<input type="hidden" name="expire_ready" value="1">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="mercure-config"
|
<div id="game-waiting-config"
|
||||||
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
data-mercure-public-url="{{ mercure_public_url|e('html_attr') }}"
|
||||||
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
data-topic="/game/hub/{{ session.id|e('html_attr') }}"
|
||||||
data-ready-at="{{ readyAt|e('html_attr') }}"
|
data-ready-at="{{ readyAt|e('html_attr') }}"
|
||||||
style="display:none">
|
style="display:none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
const config = document.getElementById('mercure-config');
|
|
||||||
const publicUrl = config.dataset.mercurePublicUrl;
|
|
||||||
const topic = config.dataset.topic;
|
|
||||||
const readyAt = config.dataset.readyAt;
|
|
||||||
|
|
||||||
let reloading = false;
|
|
||||||
const reloadOnce = (eventSource) => {
|
|
||||||
if (reloading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reloading = true;
|
|
||||||
if (eventSource) {
|
|
||||||
eventSource.close();
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (publicUrl && topic) {
|
|
||||||
const url = new URL(publicUrl);
|
|
||||||
url.searchParams.append('topic', topic);
|
|
||||||
|
|
||||||
const eventSource = new EventSource(url);
|
|
||||||
eventSource.onmessage = event => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.type === 'all_ready' || data.type === 'player_ready') {
|
|
||||||
reloadOnce(eventSource);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own ready status expires 60s after we set it - proactively tell the
|
|
||||||
// server as close to that deadline as possible, so the other players find
|
|
||||||
// out live instead of only whenever someone else's request happens to
|
|
||||||
// trigger the lazy check.
|
|
||||||
if (readyAt) {
|
|
||||||
const timeoutMs = 61000; // slightly more than the server-side 60s
|
|
||||||
const readyAtMs = readyAt * 1000;
|
|
||||||
const countdownEl = document.getElementById('ready-countdown');
|
|
||||||
|
|
||||||
const updateCountdown = () => {
|
|
||||||
const remaining = Math.max(0, Math.ceil((readyAtMs + timeoutMs - Date.now()) / 1000));
|
|
||||||
if (countdownEl) {
|
|
||||||
const m = Math.floor(remaining / 60);
|
|
||||||
const s = remaining % 60;
|
|
||||||
countdownEl.textContent = m + ':' + s.toString().padStart(2, '0');
|
|
||||||
}
|
|
||||||
return remaining;
|
|
||||||
};
|
|
||||||
|
|
||||||
const remaining = updateCountdown();
|
|
||||||
if (remaining <= 0) {
|
|
||||||
document.getElementById('expire-ready-form').submit();
|
|
||||||
} else {
|
|
||||||
const countdownInterval = setInterval(() => {
|
|
||||||
if (updateCountdown() <= 0) {
|
|
||||||
clearInterval(countdownInterval);
|
|
||||||
document.getElementById('expire-ready-form').submit();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
<div class="feedback-form mt-4">
|
<div class="feedback-form mt-4">
|
||||||
<h5>Feedback</h5>
|
<h5>Feedback</h5>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="_token" value="{{ csrf_token('game_feedback_' ~ session.id) }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
|
<label for="difficulty" class="form-label">How would you rate the difficulty? (<span id="difficulty-val">5</span>/10)</label>
|
||||||
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
|
<input type="range" class="form-range" min="1" max="10" step="1" id="difficulty" name="difficulty" value="5" oninput="document.getElementById('difficulty-val').innerText = this.value">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<form method="post">
|
<form method="post">
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
{{ error.messageKey|trans(error.messageData, 'security')|raw }}
|
{{ error.messageKey|trans(error.messageData, 'security') }}
|
||||||
{% if error.messageData['%resend_link%'] is defined %}
|
{% if error.messageData['%resend_link%'] is defined %}
|
||||||
<a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a>
|
<a href="{{ error.messageData['%resend_link%'] }}">Resend activation link</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Game\Entity\SessionSetting;
|
|||||||
use App\Game\Enum\GameStatus;
|
use App\Game\Enum\GameStatus;
|
||||||
use App\Game\Enum\SessionStatus;
|
use App\Game\Enum\SessionStatus;
|
||||||
use App\Game\Enum\SessionSettingType;
|
use App\Game\Enum\SessionSettingType;
|
||||||
|
use App\Game\Repository\LobbyMessageRepository;
|
||||||
use App\Game\Service\GameDashboardService;
|
use App\Game\Service\GameDashboardService;
|
||||||
use App\Tech\Entity\User;
|
use App\Tech\Entity\User;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -23,6 +24,7 @@ class GameDashboardServiceTest extends TestCase
|
|||||||
private $entityManager;
|
private $entityManager;
|
||||||
private $gameRepository;
|
private $gameRepository;
|
||||||
private $sessionRepository;
|
private $sessionRepository;
|
||||||
|
private $lobbyMessageRepository;
|
||||||
private $hub;
|
private $hub;
|
||||||
private $service;
|
private $service;
|
||||||
|
|
||||||
@@ -31,14 +33,15 @@ class GameDashboardServiceTest extends TestCase
|
|||||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
$this->gameRepository = $this->createMock(GameRepository::class);
|
$this->gameRepository = $this->createMock(GameRepository::class);
|
||||||
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
||||||
|
$this->lobbyMessageRepository = $this->createMock(LobbyMessageRepository::class);
|
||||||
$this->hub = $this->createMock(HubInterface::class);
|
$this->hub = $this->createMock(HubInterface::class);
|
||||||
|
|
||||||
$this->service = new GameDashboardService(
|
$this->service = new GameDashboardService(
|
||||||
$this->gameRepository,
|
$this->gameRepository,
|
||||||
$this->sessionRepository,
|
$this->sessionRepository,
|
||||||
|
$this->lobbyMessageRepository,
|
||||||
$this->entityManager,
|
$this->entityManager,
|
||||||
$this->hub,
|
$this->hub,
|
||||||
'http://localhost/topic'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class SessionLoggingTest extends TestCase
|
|||||||
$player->method('getUser')->willReturn($user);
|
$player->method('getUser')->willReturn($user);
|
||||||
$player->method('getSession')->willReturn($session);
|
$player->method('getSession')->willReturn($session);
|
||||||
$player->method('getScreen')->willReturn(1);
|
$player->method('getScreen')->willReturn(1);
|
||||||
|
$player->method('getLogFileBasename')->willReturn('player1');
|
||||||
|
|
||||||
$this->security->method('getUser')->willReturn($user);
|
$this->security->method('getUser')->willReturn($user);
|
||||||
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
|
$this->playerService->method('GetCurrentlyActiveAsPlayer')->willReturn($player);
|
||||||
|
|||||||
Reference in New Issue
Block a user