Commit Graph
100 Commits
Author SHA1 Message Date
FrankandClaude Sonnet 5 ab0976751f Rename the nav link "Escape rooms" to "Rooms"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
FrankandClaude Sonnet 5 28f7e9809e Clamp OSM import field values to their column widths
addr:housenumber / addr:postcode etc. are free-text in OSM and occasionally
exceed the entity's VARCHAR limits (e.g. housenumber "12-14, 16, 18"), which
aborted the whole --tiled run with a 1406 "Data too long" error. firstTag()
now takes a max length and each field is clamped to its real column size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
FrankandClaude Sonnet 5 008887ab60 Add physical escape room map, reviews, and website admin
Public:
- /escape-rooms: Leaflet + OpenStreetMap map with marker clustering and a
  searchable list, both built from a cached slim JSON feed
- /escape-rooms/{id}-{slug}: room detail with a mini-map and user reviews
  (rating + title + body, login required)
- /escape-rooms/suggest: visitor submission form with click-to-drop-pin map
  and reCAPTCHA v3; new rooms land as pending

Data:
- EscapeRoom / EscapeRoomReview entities (soft-delete), migration
- app:escaperooms:import-osm console command pulls leisure=escape_game from
  the Overpass API, upserts by (osmType, osmId), skips locked rows;
  --tiled / --area / --bbox options

Admin (new "Website Admin" area, separate from Game Admin):
- src/Website/Controller/AdminEscapeRoomController.php at /admin/escape-rooms:
  status-filtered list, new/edit, publish/hide/reject, delete, review removal
- game admin sidebar extracted into a block and cross-linked

Assets: leaflet + leaflet.markercluster; escaperoom-map and escaperoom-detail
Encore entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:24:03 +02:00
Frank 0edaa9d8cc Merge branch 'docker-test-environment': multi-instance Docker stack + test.escapepage.com scripts 2026-08-31 00:23:05 +02:00
FrankandClaude Sonnet 5 97d35f8fcb Add dedicated setup.test.sh / restart.test.sh for the staging stack
Both refuse to run unless docker/.env names a non-prod COMPOSE_PROJECT_NAME and
scope every compose call to that project.

setup.test.sh: like setup.sh but runs the DB/cache/console steps as www-data and
repairs var/cache|log|sessions ownership at the end, so php-fpm can read its own
compiled cache (bare `docker exec` runs as root -> silent 500s). Never touches
var/volumes/db. Test-appropriate final message (NPM upstream, local curl check).

restart.test.sh: keeps the database by default (--fresh-db to wipe + re-init),
never runs a host-wide docker prune, and only chowns var/cache|log|sessions
(chowning var/volumes/db is what corrupted the MySQL data dir).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:19:13 +02:00
FrankandClaude Sonnet 5 b565825cd9 docker: make the stack multi-instance so a test.escapepage.com copy can run alongside prod
compose.yaml / compose.override.yaml:
- container_name is now ${STACK_NAME:-escapepage}-* (STACK_NAME is a plain var,
  not COMPOSE_PROJECT_NAME, so prod keeps its escapepage-* names with no config change)
- every published host port is ${*_PORT:-<current default>}, so prod is unchanged
  and a second stack can bind its own (localhost-only) ports
- nginx joins the external nginx_default network so Nginx Proxy Manager can
  forward to <stack>-nginx by name

restart.sh:
- scoped to STACK_NAME / COMPOSE_PROJECT_NAME read from docker/.env, so running it
  from the test checkout can't touch the prod stack
- host-wide `docker system prune` / `docker builder prune` moved behind --prune-all

Adds docker/.env.test.example and doc/test-environment.md (separate checkout,
env layers, NPM proxy host + Access List IP allowlist, Mercure on test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:52:53 +02:00
Frank 07e8e68742 Ignore auto-generated config/reference.php
Symfony regenerates this config-builder reference file locally (e.g.
on composer install); it doesn't need to be tracked.
2026-08-29 23:02:27 +02:00
Frank e470a9848d Add read-tracking and soft-delete to contact messages
Admin can now open a message via a per-row "View" page (which stamps
read_at the first time it's opened), see a sortable Read column on the
list instead of the raw message text, and soft-delete messages via a
Delete button on both the list and detail view. Deleted messages are
excluded from the default list so it doesn't pile up over time.
2026-08-29 11:19:54 +02:00
Frank 6fb3ed597b Add contact form with reCAPTCHA, saved to DB, viewable in admin
Adds a public /contact page (name/email/message, protected by the same
reCAPTCHA v3 setup used on registration) that persists submissions to
a new contact_message table, plus a Contact section in the admin panel
to read them. Linked from the site footer.
2026-08-29 11:11:06 +02:00
Frank fd8d1730e8 Add Terms and Conditions page and link it from registration/footer
Adds a /terms page and links it from the site footer and from the
"I agree to the Terms and Conditions" checkbox on registration, which
previously didn't point anywhere.
2026-08-29 10:57:48 +02:00
FrankandClaude Sonnet 5 cafa60b0f2 Update Composer and npm dependencies to latest
Composer: ~40 Symfony 7.4.x packages patched to 7.4.17, plus four
majors - doctrine/dbal 3->4, phpdocumentor/reflection-docblock 5->6,
symfony/mercure-bundle 0.3->0.5, symfony/monolog-bundle 3->4. Removed
two doctrine.yaml keys (use_savepoints, report_fields_where_declared)
that DBAL 4 deprecated in favor of fixed defaults.

npm: @symfony/webpack-encore 4->6, which required bumping its peers
webpack-cli 5->6 and sass-loader 14->16 together, plus babel-loader
9->10. Fixes the one high-severity audit finding (RCE in
serialize-javascript, via the old css-minimizer-webpack-plugin). One
moderate finding remains in webpack-notifier's dev-only notification
chain - audit's suggested fix would downgrade it, so left alone; it's
build tooling only, never shipped to users.

Verified: full test suite green, lint:container clean, both `encore
dev` and `npm run build` compile without errors, and the production
CSS output was inspected byte-for-byte to confirm the new SVG-minifier
warnings (on Bootstrap's pre-encoded icon data-URIs) don't corrupt
anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 21:02:08 +02:00
FrankandClaude Sonnet 5 5dc01a358a Add pagination/search/sort to the admin tables that can grow large
Uses simple-datatables (vanilla JS, no jQuery needed) on the Users,
Sessions, Email Log, and Feedback tables, since those grow with real
usage. Left Games (small, admin-curated) and Hints (row order is
meaningful - "checked in order" - a sortable column would mislead)
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:39:14 +02:00
FrankandClaude Sonnet 5 4daaa8d102 Hide the "In Development" banner once a game is open
The badge on the homepage and briefing page was static markup, wired
to nothing - flipping a game's status to open in the admin panel had
no effect on it. It's now driven by GameRepository::hasOpenGame() and
only shows while every game is still in development/locked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 20:19:00 +02:00
Frank 996087ec4e Allow console commands to also be typed with a leading slash
pwd/ls/scan/sudo/rm/cd/cat now work as /pwd, /ls, /scan, /sudo, /rm,
/cd, /cat too, delegating to checkConsoleCommando the same way the
bare chat/verify/decode aliasing already delegates the other
direction.
2026-08-23 21:39:57 +02:00
Frank c63af7becc Add a per-game hints CRUD in the admin panel
Hints used to be 11 hardcoded PHP strings/thresholds in
SendMainframeHintsCommand, walked through a fixed if/else chain. They
are now Hint rows (game, condition, thresholdSeconds, message,
sortOrder) manageable at /admin/games/{game}/hints - freely
addable, removable, and reorderable per game, which sets up exactly
what's needed for difficulty-dependent hint timing later (Easy/Medium/
Hard are separate games, so each gets its own hint set).

The condition a hint fires behind (e.g. "hasn't used help yet",
"isn't verified yet") is still a fixed, code-defined check
(HintCondition enum) tied to real game state - a true free-form
condition editor would need a full rules engine, which is out of
scope. What's fully free-form is which hints exist, in what order,
gated behind which of those conditions, with what wording and timing.

SendMainframeHintsCommand now reads hints from the DB: walking them in
sortOrder, it finds the first distinct condition still unresolved for
a player, fires the most-escalated hint sharing that condition whose
threshold has passed, and stops - matching the original "stop at the
first unresolved, dependent step" behavior.

Migration seeds every existing game with the previous hardcoded
hints so behavior doesn't regress on deploy. Two minor fidelity
simplifications from the original hardcoded logic, called out here
since they're easy to miss: the "go to rapports directory" hint no
longer additionally checks whether the player is already in that
folder, and the two chat hints are now independent conditions
(broadcast done / contacted everyone privately) instead of one
combined check - both are edge-case-only behavior changes, not
regressions in the common path.
2026-08-23 21:35:05 +02:00
Frank 552c71b718 Bold the win condition on the waiting-room page
Makes it clearer up front how the game is actually solved. Also fixed
an unclosed <strong> tag (was <strong>...<strong> instead of </strong>).
2026-08-23 21:21:42 +02:00
Frank c6227ea8c1 Add admin "create game" form; show total time on session-creation dropdown
New game_admin_game_new route/form (reuses AdminGameType, same pattern
as editing a game's total time) so games - including the 3 upcoming
Easy/Medium/Hard difficulty options - can be created through the admin
panel instead of needing a direct DB insert.

Also show each game's total time (in minutes) next to the player
count in the "Create New Session" dropdown, since that's the actual
difficulty signal players are choosing between - number of players
alone didn't convey it.

Difficulty itself needs no new session-level concept: each difficulty
is just a separate Game row with its own TOTAL_TIME setting, which
checkAllPlayersReady() already reads when starting the session's
timer. Hint timing depending on difficulty is separate, upcoming work.
2026-08-23 21:18:03 +02:00
Frank 1c0ab4780b Consolidate rights-granting through a single function
grantRights() is now the only place a player's RightsForPlayer{N}
setting ever gets written. All three previous call sites (verify+cat
after chat tracking, cd+decode after /verify, and the all-players
grant from decoding) now delegate to it instead of duplicating the
same read-modify-persist logic.

The main point: since every grant now flows through one place, it can
notify the specific player over Mercure the moment they receive new
rights, instead of them only finding out by trying a command that
used to be "Unknown command".

Also includes an already-present (uncommitted) tweak allowing bare
chat/verify/decode console input without the leading slash - unrelated
to this change but sitting in the same file, so it's coming along.
2026-08-23 21:07:36 +02:00
FrankandClaude Sonnet 5 7f389fbbc2 Reword two confusing mainframe hints
- DecodeMessage::PLAYER_3 ("locked up bash files should be removed to
  lock it up") was awkward and unclear about what it meant.
- The private-communication hint said "contact each other privately",
  which read as any one private message rather than the actual
  requirement of messaging every other agent privately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:15:13 +02:00
FrankandClaude Sonnet 5 ffa2b12786 Fix rm not accepting absolute paths
rm always glued its argument onto the player's current directory, so
typing an absolute path (e.g. sudo rm /var/arrest/handle.sh) built a
garbage path that matched no file and was rejected as "not allowed" -
even with full rm/sudo rights. cd already special-cased a leading '/'
as absolute; rm now does the same.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:15:05 +02:00
FrankandClaude Sonnet 5 51c542c7bb Explain the win condition on the ready-up screen
Players had no in-game indication of what actually ends the game
before reaching the terminal itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:14:55 +02:00
FrankandClaude Sonnet 5 a290b75238 Stop auto-scrolling the game terminal when a new message arrives
Every message append (mainframe broadcasts, lock reveals, the boot
sequence, and responses to your own commands) forced the page to jump
to the bottom. Removed all four window.scrollTo calls so the view
stays put.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:16:48 +02:00
FrankandClaude Sonnet 5 2b3c90d3fc Show feedback in the admin panel and as aggregate stats on the briefing page
Feedback was only ever written to session_setting rows with no way to
view it short of querying the database directly. Adds a FeedbackService
that pulls per-player feedback entries and aggregate averages, backing
two new views:

- /admin/feedback: a full table of every submission (game, session,
  player, ratings, comment) plus summary tiles, linked from the admin
  sidebar.
- /briefing: a "Field Reports" block with the average difficulty/
  entertainment/theme ratings, shown to prospective players. Free-text
  comments are deliberately left off this public page since they're
  unmoderated player input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 18:41:47 +02:00
FrankandClaude Sonnet 5 5d9c113eea Recall the last command with ArrowUp in the game terminal
Pressing ArrowUp while the input field is focused now repopulates it
with the last submitted command, cursor placed at the end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:32:00 +02:00
FrankandClaude Sonnet 5 779ddcbccb Fix 3 pre-existing failing tests
- testToggleReady: user was never marked verified, so toggleReady()
  returned false before doing anything (an isVerified() gate was
  added to the service after this test was written).
- testCheckAllPlayersReadyTransitionsStatus: entityManager mock
  returned the same SessionSettingRepository for every getRepository()
  call, but the service now also fetches a GameSettingRepository for
  the session's total-time setting, causing a TypeError. Route the
  mock by requested class instead.
- testChatRegeneratesVerifyCodesIfShared: two competing
  method('getSetting') stubs were registered without with()
  constraints; PHPUnit keeps the first one it sees active for every
  call, so the (correct, more complete) willReturnCallback stub was
  silently dead code and the regeneration path never actually ran.
  Dropped the redundant first stub, and updated the flush() count now
  that the real flow (chat tracking + code regeneration) executes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:18:36 +02:00
FrankandClaude Sonnet 5 6b1436dfe7 Randomize decoy usernames and which rapports carry coded messages
Decoy names in /var/home and verifyCodes.txt were a hardcoded 4-name
list (Luke, Charles, William, Peter), so a real player registering
under one of those names would collide with it. Now each session
picks 6 decoys from a pool of 10, excluding any name already taken by
a real player, and stores the pick as a session setting.

Likewise the 3 "special report" rapports that get coded messages were
always Doyle, Vega and Lennox. Each session now randomly assigns 3 of
the 20 rapports to that role, and the win screen / mainframe-help
message reference whichever agents were actually picked instead of
the hardcoded names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:09:52 +02:00
FrankandClaude Sonnet 5 02780d8f04 Add a favicon
Crops the key/globe mark out of the existing logo and wires it into
base.html.twig as favicon.ico plus PNG/apple-touch-icon variants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:09:44 +02:00
Frank 9a52e6b32c Turn the tutorial link into a real button with clearer copy
Replaces the inline "New here?" link with a proper button and a heading
that more directly asks whether the player is unfamiliar with running
commands on a command line.
2026-08-18 22:42:45 +02:00
Frank 7c14ab3331 Add a back button to the tutorial that docks into the input row on completion
Lets players leave the tutorial at any time via a top-left link back to the
game session. Once the tutorial finishes and the input field is disabled,
the button relocates into the input row so it's the obvious next action.
2026-08-18 22:35:52 +02:00
Frank 6a04a0274a Add command-line tutorial (PreGameController) and link it from the lobby
Adds a black-terminal tutorial page at /game/pregame/{session} that walks
players through help, pwd, cd, ls, cat, rm and sudo before the real game
starts. Links to it are shown on both the lobby (waiting for players) and
ready-up screens so players can practice, repeatedly, before starting.
2026-08-18 22:23:26 +02:00
Frank 367ded6441 Only regenerate verify codes on broadcast, not any targeted /chat
Any /chat {agent-id} {code} was previously exempted only if aimed at
the code's rightful owner; sending to the wrong player still burned
it. Now only an untargeted, open-chat broadcast counts as a leak -
mistargeting via /chat is harmless.
2026-08-18 21:49:45 +02:00
Frank 1f35784c52 Don't regenerate a verify code when sent privately to its rightful owner
checkAndRegenerateVerifyCodes() regenerated a code on any message
containing it, regardless of who the message was sent to - so sharing
a code exactly as intended, via /chat {agent-id} {code} to the
correct recipient, still burned it immediately, making the puzzle
unsolvable. Now only broadcasts and messages sent to the wrong player
count as a leak.
2026-08-18 21:47:21 +02:00
Frank b0b357f99b Strip spaces from generated decode report codes
generateSpecialCode() reused generateRandomString(), whose charset
includes a space. Across a 75-100 character code that made a space
almost certain, and /decode splits its argument on spaces, silently
truncating the code the player typed back in - breaking decoding.

Fixed at generation time rather than at display time, since the
stored value itself was the problem, not just how it's shown.
generateRandomString() is left untouched for its other use (the
"garbage" filler text shown to players who fail to decode), where
spaces are harmless.
2026-08-18 21:32:06 +02:00
FrankandClaude Sonnet 5 a00899e444 Route every Mercure publish through an event, log pushes per-player
Every $hub->publish() call site (chat, hints, security alerts, virus
alerts, game_finished, and the pre-game lobby events) now dispatches
a PushMercureMessageEvent instead of publishing directly. Two
listeners handle it: PublishMercureMessageListener does the actual
Mercure publish exactly as before (same wire format, no client
changes needed), and LogMercureMessageListener appends it to the
activity log of whichever player(s) it was actually delivered to.

A private /chat to one agent only gets logged for that agent, never
broadcast into everyone's transcript - the event carries an explicit
targetScreen (null = everyone) rather than leaving listeners to guess
from the payload shape.

The per-player log format moved from flat text to JSON Lines (one
timestamped, structured entry per line) via a new shared
SessionActivityLogger service, since a flat string can't carry an
event's type or exact payload - both needed for a future feature to
replay a player's full session history, not just their own commands,
on page reload. The admin session log viewer now parses and
formats these entries back into readable lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 19:47:53 +02:00
FrankandClaude Sonnet 5 aba79251e0 Broadcast game_finished when a player's own timeout check catches it
Previously only the cron's timeout sweep broadcast to everyone;
check-finished (called by a player's own client the moment their
local countdown hits zero) just updated the database silently. Now
whichever path notices the timeout first broadcasts - both only act
while the session is still PLAYING, so there's no double broadcast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:24 +02:00
FrankandClaude Sonnet 5 2951a39204 Add mainframe hint system and cron-driven timeout handling
The cron (app:hints:check, every minute) now walks each playing
player through a strict dependency chain - help, communication,
verification, navigation, rapports/decode, endgame urgency - and
sends at most one hint per player per run: the first step that's
both unresolved and old enough to nudge about. Later steps genuinely
depend on earlier ones (e.g. /verify isn't usable until a player has
finished contacting everyone), so a player never gets a hint for
something they can't act on yet.

Two bits of new tracking were needed:
- Help command usage wasn't recorded anywhere, so it's now saved to
  a new HelpUsedForPlayer{N} session setting.
- "All messages decoded" needed a reliable session-wide signal, so
  the rm right (previously granted alongside sudo when player 1
  decodes) now comes from player 3's decode instead, making
  sudo+scan+rm together mean "the whole team has decoded".

The cron also now finishes sessions whose countdown has run out:
sets the session to LOST and broadcasts the same game_finished
Mercure event the win path already uses, so every connected player
gets pushed to the lost page within a minute of the game actually
ending - not just whichever player's own client-side timer happens
to notice first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:16 +02:00
FrankandClaude Sonnet 5 2bda70b32b Fix missing player number in mainframe welcome message
Welcome agent ' +  + ' to the mainframe.' had a stray += with nothing
between them, evaluating to NaN. Plugs in the screen variable that
was already sitting in scope right above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 17:56:04 +02:00
Frank ebd2f68df6 Sync PhpStorm project index with updated vendor packages
Reflects the sendgrid-mailer removal and mailgun-mailer/rate-limiter/
polyfill-php85 additions from the recent composer update.
2026-08-16 16:04:46 +02:00
FrankandClaude Sonnet 5 2c8e568255 Fix sessions never persisting as PLAYING, and dangerous-mode logrotate configs
checkAllPlayersReady() set the session to PLAYING and cleared/updated its
timer in memory but never flushed, relying on callers to do so. The
toggleReady() caller flushed right after, but GameController::index()'s
lazy catch-up call did not - so if the "everyone ready" transition was
only detected on a page load (e.g. players didn't click ready within the
same 60s window), the terminal would render for that one request from the
in-memory state, letting the game be played entirely through the
unguarded message API, while the database silently kept the session on
'ready' with timer 0 forever. This made sessions invisible to the mainframe
hint cron and the admin "running sessions" count. Moved the flush inside
checkAllPlayersReady() itself so both callers persist reliably.

Also chmod the logrotate configs after COPY in the Dockerfile, since
their on-disk mode ended up group-writable (0664) depending on the
build host's umask, which made logrotate refuse to use them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 00:14:00 +02:00
FrankandClaude Sonnet 5 ba45e06972 Remove |raw from login error message rendering
Not exploitable today - the only custom auth message data is a
hardcoded resend link - but |raw on a translated exception message is
a latent XSS pattern if a future change ever threads user input
through the auth exception's message data. Twig's default
autoescaping is sufficient here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:53 +02:00
FrankandClaude Sonnet 5 2913b8d2a2 Add CSRF protection, login throttling, and invite-code rate limiting
The admin panel already checked CSRF tokens on destructive actions,
but the player-facing raw HTML forms (create/join/leave/start
session, toggle ready, lobby chat, feedback) had none - cookie
SameSite=Lax blunts classic cross-site auto-submit attacks but isn't
a substitute for real tokens. Adds matching csrf_token()/
isCsrfTokenValid() checks to all of them.

Also adds login_throttling (5 attempts/15 min) to stop unlimited
password guessing, and a per-user rate limiter (10/min) on the
invite-code join endpoint, since invite codes are only 32-bit and
had no protection against brute-forcing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:47 +02:00
FrankandClaude Sonnet 5 335697e520 Update dependencies to patch known CVEs
composer audit reported 37 advisories across 15 packages, including
high-severity ones in symfony/security-http. Ran composer update
within the existing 7.4.* constraints - composer audit now reports
zero advisories. Also adds symfony/rate-limiter, needed for login
throttling and invite-code rate limiting in the next commit.

Flex removed a stale, non-functional sendgrid notifier config left
over from before the app switched to Mailgun as part of the sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:36 +02:00
FrankandClaude Sonnet 5 daa37390d0 Fix path traversal via username in session log file paths
Registration only validated username with NotBlank, so a username
like "../../../../public/x" got concatenated directly into a
filesystem path for both writing (game activity logs) and reading
(admin log viewer) - reachable from the public webroot since public/
is a few directories up from where those logs are stored.

Adds a character-set validator (letters, numbers, underscore, hyphen)
to registration and admin user editing going forward, and sanitizes
at the point of use (Player::getLogFileBasename()) so any
already-stored unsafe username can't escape the log directory either.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 21:41:25 +02:00
FrankandClaude Sonnet 5 a9cb0fa57f Turn admin lobby chat panel into a tab
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>
2026-08-10 16:48:06 +02:00
FrankandClaude Sonnet 5 2bfc2aca1a Keep pregame lobby chat open for an hour after the game ends
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>
2026-08-10 16:47:57 +02:00
FrankandClaude Sonnet 5 846cfbc44a Remove dead admin/session.html.twig template
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>
2026-08-10 15:32:53 +02:00
FrankandClaude Sonnet 5 207346d571 Move inline template scripts into webpack-built assets
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>
2026-08-10 15:32:44 +02:00
FrankandClaude Sonnet 5 dfa75719d0 Show pregame lobby chat in admin session logs
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>
2026-08-10 15:32:31 +02:00
FrankandClaude Sonnet 5 444f54b6c6 Add pregame lobby with live chat
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>
2026-08-10 15:32:23 +02:00
Frank 98cf48b29d Make admin panel usable on mobile
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.
2026-08-10 14:58:12 +02:00
Frank f6df9f7ba6 Show friendly session status labels on the player dashboard
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".
2026-08-10 14:58:01 +02:00
FrankandClaude Sonnet 5 fc93486367 Restore executable bit on docker/restart.sh and docker/setup.sh
Lost accidentally in the previous commit; both scripts are invoked
directly (./docker/setup.sh) rather than via bash, so they need +x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:35:50 +02:00
FrankandClaude Sonnet 5 98066118a6 Expand terminal filesystem, add mainframe hint cron, redirect PHP logs
- Game1 terminal: flesh out the virtual filesystem with a realistic
  spread of Linux directories/files (~70 dirs, ~140 files) so it no
  longer reads as an obviously small puzzle set, without touching any
  win-condition or rapport files.
- Add app:hints:check command + a php-cron container (BusyBox crond)
  that nudges players who haven't contacted every teammate 5 minutes
  into a session, via a new 'hint' Mercure message type.
- Log the cron command's output to var/log/cron/cron.log and rotate
  it (25MB / 90 days) via logrotate, run daily from the same crontab.
- Redirect PHP's error_log and Symfony's prod app/deprecation logs
  from stderr-only into var/log/php/*.log (kept alongside stderr),
  with the same rotation policy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:34:36 +02:00
Frank 1be07440e3 Bring back 1-minute ready-status expiry with live per-player broadcasts
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.
2026-07-11 23:45:41 +02:00
Frank c28abef5b7 Block unverified users from marking themselves ready
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.
2026-07-11 23:25:50 +02:00
Frank 886208c5c0 Add profile page for changing password and email
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).
2026-07-11 23:23:38 +02:00
Frank 2941cfca49 Fix game route dumping players into the terminal for non-PLAYING sessions
GameController::index() only special-cased READY; every other status
(including a freshly-created session still waiting for players, and
even an already WON/LOST one) fell straight through to rendering the
live game terminal - which is broken there since screens/rights/pwd
are never initialized before startSession() flips CREATED -> READY.

The dashboard's "Enter Game" button links to this route regardless of
status, so any player clicking it on a CREATED session hit this
directly. Now CREATED redirects back to the dashboard with an
explanatory flash, and WON/LOST redirect to their respective pages
instead of re-rendering a dead terminal.
2026-07-11 23:13:30 +02:00
Frank 3c58e153dc Soft-delete users instead of hard-deleting, add last login tracking
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.
2026-07-11 23:09:52 +02:00
Frank aa667df889 Add /donation/success route and flash messages for both outcomes
Cancelled donations now show "Donation failed, but thank you for
trying anyway." and successful ones show "Thank you for the
donation!" on the dashboard after redirect.
2026-07-11 22:25:05 +02:00
Frank c06c45cb52 Add /donation/cancel route to fix 404 on cancelled PayPal donations
Redirects to the game dashboard instead of 404ing.
2026-07-11 22:19:51 +02:00
Frank 51679d9a6a Revert webpack-encore major bump from npm audit fix
The audit fix (presumably run with --force) jumped @symfony/webpack-encore
from ^4.6.1 to ^7.1.0, which requires @babel/core ^8.0.0 as a peer -
but @babel/core was left at ^7.25.0, so a clean `npm install` failed
outright with ERESOLVE. Since docker/setup.sh and docker/restart.sh
both run npm install unconditionally, this would have broken every
future deploy/rebuild.

Reverted webpack-encore (and the incidentally-downgraded
webpack-notifier) back to the versions that were actually working,
regenerated package-lock.json, and verified both `npm install` and
`npm run build` succeed cleanly.
2026-07-11 22:10:46 +02:00
Frank cb2e945419 Remove 60-second ready-status expiry so the group can always start
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.
2026-07-11 21:48:32 +02:00
Frank 3984a33282 Add win/lose game-ending flow
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.
2026-07-11 21:41:16 +02:00
Frank eee6c3a369 Auto-trigger locked-files restoration exactly at the 60s deadline
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.
2026-07-11 17:45:08 +02:00
Frank e6ba469ef9 Restore locked files if not all removed within 60 seconds
Removing one of the 3 AI-virus-protected files now starts a 60-second
window (tracked session-wide via LockedFilesRemovalDeadline). If the
other locked files aren't also removed before it expires, the virus
restores whichever ones were deleted and broadcasts a red warning to
the whole session, forcing players to coordinate the removal instead
of picking them off one at a time.

Also extends the Mercure broadcast payload with an optional 3rd
"messageType" element so pushed messages can render red (virus) or
green (mainframe) instead of always defaulting to green.
2026-07-11 17:26:59 +02:00
Frank f6a0d62017 Make terminal output more authentic: smaller font, tighter line spacing
Message font dropped from 20px to 14px and the 10px inline margin-bottom
(set from JS on every message) replaced with a 2px CSS margin so the
terminal reads like dense console output instead of spaced-out chat
bubbles. Colors are unchanged.
2026-07-11 17:21:41 +02:00
Frank 6fd0b5d993 Grant rm right alongside sudo when player 1 decodes their message
rm was checked everywhere but never actually added to any player's
rights, so the command was unreachable until now.
2026-07-11 17:08:44 +02:00
Frank 6db9d42852 Wire up decode puzzle chain and lock terminal on file removal
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.
2026-07-11 16:48:15 +02:00
Frank 1e644eb13b Add "Looking for Help" page for the open visual artist request
Adds a /looking-for-help page listing the open volunteer request and
links it from the site footer on every page.
2026-07-04 23:23:04 +02:00
Frank 8554f04735 Add "In Development" watermark badge over AI virus artwork
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.
2026-07-04 23:02:46 +02:00
Frank 839113c356 Add AI Virus Deflection story to homepage and new agent briefing page
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.
2026-07-04 23:00:31 +02:00
FrankandClaude Sonnet 5 7dbb738da8 Show marketing opt-in status in admin users overview
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>
2026-07-04 20:48:48 +02:00
FrankandClaude Sonnet 5 ec34a1ddb9 Add Terms and Conditions and marketing opt-in checkboxes to registration
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>
2026-07-04 20:28:52 +02:00
FrankandClaude Sonnet 5 703d2af3d8 Add admin nav link and reuse site header/footer on admin pages
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>
2026-07-04 19:15:24 +02:00
Frank 5b03bd1d1c Complete layout overhaul 2026-07-04 19:08:31 +02:00
FrankandClaude Sonnet 5 05f4f2c32f Fix EveryoneVerified setting being scoped to a specific player
checkIfAllPlayersVerified() read and wrote the EveryoneVerified
setting scoped to whichever player happened to trigger the check
(getSetting(..., $player) / setPlayer($player)), but it's meant to be
a single session-wide flag. getFileContent() correctly reads it as
session-global (no player, filters player IS NULL), so the two never
matched: the "everyone verified" Mercure message still fired (that
code path only checks its own player-scoped copy), but the special
code injection into the Doyle/Vega/Lennox report files never ran
since getFileContent() never found the setting. Store and read it
consistently as session-global.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:19:14 +02:00
FrankandClaude Sonnet 5 ddd2726687 Fix cat command using relative path for physical file lookup
getFileContent() built the game1 filesystem path as a relative
string with no leading slash. That only resolves correctly when
PHP's cwd happens to be the project root (e.g. CLI), but under
PHP-FPM/nginx the cwd is nginx's document root (public/), so
file_exists() always failed for real requests even though the file
existed and the in-memory virtual file list (used by ls) said it
should. Use the already-injected $projectDir (%kernel.project_dir%),
matching how the class already builds the session log path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 18:12:55 +02:00
FrankandClaude Sonnet 5 3721abcc5d Fix missing leading slash in player's initial working directory
Every possible path/file in GameResponseService uses a leading slash
(e.g. /var/home/{username}), but a player's initial pwd was seeded as
'var/home/{username}' without one. getAllCurrentFilesInDirectory()
matches entries by comparing getPrevPath() (which always has the
leading slash) against pwd, so a fresh player's ls always came back
empty until their first cd command happened to normalize the format.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 17:03:30 +02:00
FrankandClaude Sonnet 5 e76d0b0f07 Fix Mercure reload spam on waiting page in Chrome
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>
2026-07-04 16:26:47 +02:00
FrankandClaude Sonnet 5 b7693bd9d0 Remove quotes around MERCURE_CORS_ALLOWED_ORIGINS in docker/.env.dist
docker/.env feeds MERCURE_EXTRA_DIRECTIVES, a Caddyfile-style config
block, via Compose variable substitution. Quoting a space-separated
value there makes Caddy treat both origins as one single malformed
token rather than two arguments, which crash-loops the Mercure
container on startup. Compose's .env parsing for values with spaces
doesn't require quotes (unlike Symfony's Dotenv), so drop them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:43:03 +02:00
FrankandClaude Sonnet 5 b4f6a531c9 Fix Mercure CORS to allow both www and bare domain
Production's MERCURE_CORS_ALLOWED_ORIGINS only allowed
https://escapepage.com, but nginx has no www redirect
(server_name _;), so the site is also reachable at
https://www.escapepage.com. Visitors on the www host got a CORS
error on the Mercure EventSource connection since the Origin header
didn't match the allow-list. Dev's .env already allowed both; bring
docker/.env.dist in line, and fix its stale MERCURE_PUBLIC_URL
(bare domain instead of the mercure. subdomain actually used).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:26:17 +02:00
FrankandClaude Sonnet 5 c4e5139ec4 Increase email header logo size from 40px to 140px
40px made the logo too small in the header banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:55:15 +02:00
FrankandClaude Sonnet 5 238705495e Add shared HTML layout for transactional emails
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>
2026-07-04 14:51:34 +02:00
FrankandClaude Sonnet 5 4a5d83ef53 Fix duplicate EmailLog rows for a single sent email
Mailer dispatches MessageEvent twice when routed through Messenger:
once when queuing (queued=true) and once on the actual send from the
worker (queued=false). EmailLoggerListener logged on both, creating
two rows per email actually sent. Skip the queued dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:44:03 +02:00
FrankandClaude Sonnet 5 0e12e7fa8f Switch docker/.env.dist mailer template from SendGrid to Mailgun
The project now sends mail via Mailgun (symfony/mailgun-mailer), not
SendGrid, so the tracked template should reflect the real transport
in use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 14:23:21 +02:00
Frank 58c9c60ad2 Mailer 2026-07-04 13:57:00 +02:00
FrankandClaude Sonnet 5 a1ff6df721 Add root .env.dist template
.env/.env.dev/.env.prod/.env.test are now gitignored, leaving no
tracked reference for what variables a fresh checkout needs. Add a
placeholder-only .env.dist (mirroring docker/.env.dist) to copy from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:49:51 +02:00
FrankandClaude Sonnet 5 487019d360 Stop tracking .env files and sanitize docker/.env.dist
.env, .env.dev, .env.prod, .env.test, and docker/.env contained real
production secrets and were tracked in git despite the Symfony
convention of keeping them local-only. Untrack them and ignore them
going forward; docker/.env.dist stays as a template but now uses
placeholder values instead of live credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:29:04 +02:00
Frank 3b1d3a5aad Fixed network v4 2026-03-11 07:59:23 +01:00
Frank 8b3bf68954 Fixed network v3 2026-03-11 07:57:49 +01:00
Frank 74dadf102d Fixed network v2 2026-03-11 07:55:47 +01:00
Frank 74d5cd15b6 Fixed network 2026-03-11 07:52:11 +01:00
Frank 4e696af231 Fixed ip addresses 2026-03-10 22:13:22 +01:00
Frank 539a243353 Fix database container creation 2026-03-10 22:04:23 +01:00
Frank eb3d36c99f Unfixed ips 2026-03-10 22:00:08 +01:00
Frank 8564257761 Fixed ips 2026-03-10 21:55:21 +01:00
Frank be01de83ed network defined 2026-03-10 21:52:05 +01:00
Frank 700160e198 Mercure ssl 2026-01-17 22:56:34 +01:00
Frank 96c2e4ca61 Mercure cors error 3 2026-01-17 19:36:01 +01:00
Frank e5f959210a Mercure cors error 2 2026-01-17 19:27:01 +01:00