The lobby chat was a standalone card sitting above the per-player log
tabs. Folds it into the same tab bar as the first (default-active)
tab instead, so the session log view has one consistent tab strip.
The tab-switching script now toggles by an .admin-tab-panel class
instead of assuming every panel's id starts with "player-", since
that's no longer true.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The chat used to disappear the moment a session left CREATED status.
Sessions now record a finishedAt timestamp when they're won or lost,
and the lobby (with chat) stays reachable via /game/{session} for an
hour afterward instead of immediately redirecting to the win/lose
feedback page. The lobby template shows a distinct "game finished"
header with a link to that feedback page during this window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unreferenced by any controller - superseded by
templates/game/admin/sessions/view.html.twig, which is what
GameAdminController::viewSession() actually renders. Confirmed via
grep across src/ and templates/, plus a clean lint:twig and phpunit
run after removal.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Several templates had their own <script> blocks instead of going
through webpack like game1.js already does. Extracted the waiting
page, lobby page, and admin session-log tab scripts into their own
files under assets/, imported from the main app.js entry. Since
app.js is already loaded on every page, each module just reads its
own data-* attributes and no-ops if its target element isn't present
on the current page - same pattern game1.js already uses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins can now see the full lobby chat transcript (who said what,
when) at the top of a session's log view, alongside the existing
per-player terminal logs. Also swaps the log tabs' inline onclick
handler for a data attribute, in prep for moving the tab-switching
script out of the template.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Players used to get bounced back to the dashboard when a session
wasn't full yet. Now they land on a lobby page showing who has
joined, and can chat with each other while waiting - messages are
broadcast live over the existing Mercure hub, and the session
auto-starts (and the lobby notifies everyone) once it fills up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sidebar was a fixed 220px flex column that left barely any room
for content on a phone, and tables had no min-width so columns just
squeezed into unreadable slivers instead of scrolling.
Moves the sidebar's layout rules out of inline styles (which media
queries can't override) into real CSS classes, and collapses it into
a horizontally-scrollable pill bar below 768px so the content area
gets the full screen width. Tables now get a sensible min-width so
they scroll horizontally on narrow screens instead of squishing, and
the per-player tab bar on the session log view scrolls too.
Players saw the raw enum value (e.g. "created") in the sessions
table, which doesn't mean anything to them. Adds SessionStatus::label()
mapping each status to a player-facing description like "Waiting for
players".
A player's own browser now starts a 60s timer the moment they check
"ready" (with a visible countdown) and proactively tells the server
when it's up via a new expire_ready action - idempotent, so it only
actually clears the status if the deadline has genuinely passed.
Everyone else finds out live through a Mercure player_ready broadcast
that now carries which player and their new ready/not-ready state
(previously the broadcast payload's `ready` flag was always false due
to a stale-variable bug, though nothing consumed it yet).
checkAllPlayersReady() still does the same expiry check server-side
and broadcasts on anyone else's request in the meantime, so a stalled
frontend timer doesn't leave a stale "ready" badge showing forever.
This only affects the pre-PLAYING ready phase: checkAllPlayersReady()
still flips the session to PLAYING and stops touching ready state the
instant everyone is simultaneously ready, so the earlier fix (a reload
should never send an already-started game back to the waiting room)
is unaffected.
Also fixed the ready checkbox itself: unchecking it submitted a POST
without `toggle_ready` in the body (unchecked checkboxes aren't sent),
so un-readying silently did nothing server-side. Added the standard
hidden-fallback-input pattern to fix it.
GameDashboardService::toggleReady() now rejects the toggle if the
user's email isn't verified (mirrors the same gate UserChecker already
applies at login). The waiting-room checkbox is disabled client-side
with an explanatory alert and a link to resend the verification email,
and the controller adds a flash error as a server-side fallback if it
somehow gets submitted anyway.
New /profile route (nav link between Admin and Logout) with two
independent forms, both requiring the current password before
applying a change:
- Change password: standard current/new/repeat flow, same password
strength rules as registration.
- Change email: re-checks the new address isn't already taken (avoids
a 500 from the unique constraint), then marks the account
unverified and re-sends the confirmation email via the existing
EmailVerifier/verify-email flow, same as registration. The current
session stays logged in, but the user needs to verify the new
address before their next login (UserChecker already blocks
unverified accounts).
Fixes the 500 on admin user deletion: deleting a user with an
email_log row (i.e. basically anyone who received any email) hit an
unhandled ForeignKeyConstraintViolationException, since email_log,
reset_password_request and player all have non-nullable FKs to user
with no cascade at the DB level.
Instead of cascading the delete (which would be fine for email_log
and reset_password_request but risky for player - removing a player
row could corrupt other real players' session state), admin delete
now sets a deletedAt timestamp instead of removing the row:
- UserChecker blocks login for deleted users (checkPreAuth).
- EmailLoggerListener rejects the message before send for deleted
recipients (checked at actual send time, not at queue time).
- Added a last_login_at column + a LoginSuccessEvent listener to
populate it, and surfaced both last login and status in the admin
users list.
Added `app:users:purge-deleted`, a command intended to run on a
schedule that permanently removes users who were soft-deleted more
than 3 months ago and never played a game (join to Player via a
NOT EXISTS subquery). For that eventual hard-delete to actually
succeed, email_log and reset_password_request now cascade-delete at
the DB level (migration drops+recreates both FK constraints with ON
DELETE CASCADE) - player intentionally still isn't cascaded, so a
user with game history can never be purged this way even by mistake.
Ready status previously expired 60 seconds after a player checked the
box, evaluated per-player against their own timestamp. Unless every
player happened to click ready within the same 60-second window, the
earliest player's readiness would silently expire before the last one
joined, so the session could get stuck on "Waiting for all players to
be ready" indefinitely, especially after a reload re-triggered the
timeout check.
Ready state is now durable: once checked, it stays until the player
unchecks it or the whole group is simultaneously ready, at which point
the session always transitions to PLAYING regardless of how long that
took. session.timer is still only ever set once during that one-way
READY -> PLAYING transition, so a reload never restarts or desyncs the
countdown between players.
Removing all 3 locked files while the timer is still running now marks
the session WON immediately (checked right after every successful rm)
and broadcasts a "game_finished" signal over Mercure so every
connected player gets redirected together, not just the one who
removed the last file. A new /won/{session} route + won.html.twig
mirrors the existing lost flow (victory narrative + the same feedback
form).
The existing timer-expiry path already set LOST but always redirected
to lostUrl regardless of actual status; it now picks won/lost based on
the status the server reports.
Also fixes a pre-existing bug on the lost page (and would-be bug on
the new won page): PlayerService::GetCurrentlyActiveAsPlayer() only
matches players in READY/PLAYING sessions, so by the time a session
has ended it always returned null there, silently breaking the
feedback form. Both pages now look the player up directly via
PlayerRepository instead.
Added a navigatingAway flag so the page's "confirm before leaving"
prompt doesn't block our own win/lose redirects.
Previously the restore check only ran lazily on a player's next
message, so files could stay wrongly-removed for an arbitrary amount
of time after the window expired. The frontend now schedules a
setTimeout (using the server-provided deadline, mirroring the terminal
lock's reveal timer) that pings the backend right at the deadline so
the check runs promptly regardless of player activity. Restored via
data-files-removal-deadline on page load too, so a refresh mid-window
doesn't lose the timer.
Connects the previously-disconnected /decode command to per-player
messages: player 1 unlocks sudo for everyone, player 2 unlocks a scan
command that reveals which files the AI virus has locked, player 3
learns those files should be removed.
Also adds a retaliation mechanic: successfully removing a file locks
the player's terminal. The AI virus locks it out in red immediately;
the mainframe reveals a 12-character recovery code in green after 30
seconds, which can be submitted via /unlock to restore access early,
otherwise the terminal auto-recovers after 45 seconds.
Overlay a rotated CSS badge on the homepage hero and briefing page
image instead of baking it into the PNG, so it's easy to remove once
the game ships.
Give the public homepage a themed breach-alert hero and add a /briefing
page with the full mission narrative, both linking into the existing
game dashboard flow.
Adds a Marketing column to the admin users table and a total opt-in
count in the header, so admins can see who signed up for updates on
future projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Registration now requires agreeing to the Terms and Conditions and lets
users opt in to hear about future projects. The opt-in is persisted on
the user via a new marketing_opt_in column (migration included).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins now see an Admin link in the main navigation, and the admin
section inherits the branded header/footer from layout/site.html.twig
instead of the bare base layout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When the last player clicked ready, toggleReady() published a
redundant 'player_ready' event on top of checkAllPlayersReady()'s
'all_ready', and the client reloaded on every message with no guard
and without closing the EventSource. Chrome kept the old page's
script (and its EventSource) alive across the overlapping reload
calls, causing repeated reconnects to the Mercure hub; Firefox
apparently tore the page down fast enough to mask it. Skip the
redundant publish server-side, and make the client reload idempotent
by tracking whether it already fired and closing the EventSource
before reloading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
confirmation_email.html.twig and reset_password/email.html.twig were
plain unstyled HTML with no shared structure. Extract a table-based
layout (templates/emails/layout.html.twig) with header/logo, content
block, and footer, so future transactional emails can extend it
instead of starting from scratch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>