41 Commits
Author SHA1 Message Date
FrankandClaude Sonnet 5 b696461128 Remove placeholder description block from homepage
The logo + "public website" blurb read like an unfinished notice to
visitors; drop it and the now-unused home.description translation key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:22:26 +02:00
FrankandClaude Sonnet 5 d1249adeca Add "How it works" section to homepage explaining remote play
Explains that games are played online with friends anywhere in the
world, using the in-game chat instead of a separate video/voice call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:18:36 +02:00
Frank 9ad6c92759 Merge branch 'escaperoom-map': physical escape room map, reviews, website admin, OSM import 2026-08-31 00:24:33 +02:00
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
113 changed files with 8018 additions and 4887 deletions
+1
View File
@@ -8,6 +8,7 @@
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/config/reference.php
/public/bundles/
/var/
!/var/volumes/
+3 -1
View File
@@ -136,11 +136,13 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/lcobucci/jwt" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mercure-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/sendgrid-mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/webpack-encore-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/verify-email-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailgun-mailer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-php85" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
Generated
+3 -1
View File
@@ -146,7 +146,6 @@
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/symfony/sendgrid-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/webpack-encore-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure" />
<path value="$PROJECT_DIR$/vendor/symfony/mercure-bundle" />
@@ -154,6 +153,9 @@
<path value="$PROJECT_DIR$/vendor/symfonycasts/verify-email-bundle" />
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
<path value="$PROJECT_DIR$/vendor/karser/karser-recaptcha3-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/mailgun-mailer" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/rate-limiter" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.2" />
+22
View File
@@ -0,0 +1,22 @@
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
// Paginate/search/sort any admin table opted in via class="admin-datatable".
// Kept off tables that are admin-curated and stay small (games) or where row
// order is meaningful and must not be disturbed by sorting (hints).
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('table.admin-datatable').forEach((table) => {
new DataTable(table, {
perPage: 25,
perPageSelect: [10, 25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search...',
perPage: '{select} entries per page',
noRows: 'No matching entries found',
info: 'Showing {start} to {end} of {rows} entries',
},
});
});
});
+1
View File
@@ -8,3 +8,4 @@ import 'bootstrap/js/dist/dropdown';
import './game-waiting';
import './game-lobby';
import './admin-session-tabs';
import './admin-datatables';
+85
View File
@@ -0,0 +1,85 @@
/*
* Small single-purpose Leaflet maps:
* - #escaperoom-detail-map : read-only pin on a room's detail page
* - #escaperoom-suggest-map : click to drop / move a pin on the "suggest a room"
* form, writing the coordinates into the hidden latitude/longitude fields.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
const OSM = {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
opts: {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
};
function initDetailMap(el) {
const lat = parseFloat(el.dataset.lat);
const lng = parseFloat(el.dataset.lng);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return;
}
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 15);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
L.marker([lat, lng]).addTo(map);
}
function initSuggestMap(el) {
const form = el.closest('form');
const latField = form && form.querySelector('input[name$="[latitude]"]');
const lngField = form && form.querySelector('input[name$="[longitude]"]');
if (!latField || !lngField) {
return;
}
const startLat = parseFloat(latField.value);
const startLng = parseFloat(lngField.value);
const hasStart = !Number.isNaN(startLat) && !Number.isNaN(startLng);
const map = L.map(el).setView(hasStart ? [startLat, startLng] : [20, 0], hasStart ? 15 : 2);
L.tileLayer(OSM.url, OSM.opts).addTo(map);
let marker = hasStart ? L.marker([startLat, startLng], { draggable: true }).addTo(map) : null;
const write = (latlng) => {
latField.value = latlng.lat.toFixed(7);
lngField.value = latlng.lng.toFixed(7);
latField.dispatchEvent(new Event('change', { bubbles: true }));
};
if (marker) {
marker.on('dragend', () => write(marker.getLatLng()));
}
map.on('click', (e) => {
if (marker) {
marker.setLatLng(e.latlng);
} else {
marker = L.marker(e.latlng, { draggable: true }).addTo(map);
marker.on('dragend', () => write(marker.getLatLng()));
}
write(e.latlng);
});
}
document.addEventListener('DOMContentLoaded', () => {
const detail = document.getElementById('escaperoom-detail-map');
if (detail) {
initDetailMap(detail);
}
const suggest = document.getElementById('escaperoom-suggest-map');
if (suggest) {
initSuggestMap(suggest);
}
});
+151
View File
@@ -0,0 +1,151 @@
/*
* Public "physical escape rooms" map + list.
*
* One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with
* marker clustering, so a worldwide dataset stays responsive) and the searchable
* table underneath it.
*/
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { DataTable } from 'simple-datatables';
import 'simple-datatables/dist/style.css';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// Webpack rewrites the image paths, so hand them to Leaflet explicitly.
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
function slugify(name) {
return (name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'room';
}
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function ratingLabel(avg, count) {
if (!count) {
return '—';
}
return `${avg.toFixed(1)} ★ (${count})`;
}
function detailUrl(template, room) {
return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name));
}
function buildMap(el, rooms, detailTemplate) {
const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const cluster = L.markerClusterGroup({ chunkedLoading: true });
const bounds = [];
rooms.forEach((room) => {
if (typeof room.lat !== 'number' || typeof room.lng !== 'number') {
return;
}
const where = [room.city, room.country].filter(Boolean).join(', ');
const popup = `
<strong>${escapeHtml(room.name)}</strong><br>
${where ? `${escapeHtml(where)}<br>` : ''}
<span>${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</span><br>
<a href="${detailUrl(detailTemplate, room)}">View &amp; review &raquo;</a>
`;
const marker = L.marker([room.lat, room.lng]).bindPopup(popup);
cluster.addLayer(marker);
bounds.push([room.lat, room.lng]);
});
map.addLayer(cluster);
if (bounds.length) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
}
function buildList(table, rooms, detailTemplate) {
const tbody = table.querySelector('tbody');
const frag = document.createDocumentFragment();
rooms.forEach((room) => {
const tr = document.createElement('tr');
const where = room.city ? escapeHtml(room.city) : '';
const country = room.country ? escapeHtml(room.country) : '';
const ratingValue = room.reviewCount ? room.avgRating : 0;
tr.innerHTML = `
<td><a href="${detailUrl(detailTemplate, room)}">${escapeHtml(room.name)}</a></td>
<td>${where}</td>
<td>${country}</td>
<td data-order="${ratingValue}">${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}</td>
`;
frag.appendChild(tr);
});
tbody.replaceChildren(frag);
new DataTable(table, {
perPage: 25,
perPageSelect: [25, 50, 100],
searchable: true,
sortable: true,
labels: {
placeholder: 'Search rooms…',
perPage: '{select} per page',
noRows: 'No matching rooms',
info: 'Showing {start}–{end} of {rows} rooms',
},
});
}
document.addEventListener('DOMContentLoaded', () => {
const mapEl = document.getElementById('escaperoom-map');
if (!mapEl) {
return;
}
const endpoint = mapEl.dataset.endpoint;
const detailTemplate = mapEl.dataset.detailTemplate;
const listEl = document.getElementById('escaperoom-list');
const statusEl = document.getElementById('escaperoom-status');
fetch(endpoint, { headers: { Accept: 'application/json' } })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((payload) => {
const rooms = Array.isArray(payload.rooms) ? payload.rooms : [];
buildMap(mapEl, rooms, detailTemplate);
if (listEl) {
buildList(listEl, rooms, detailTemplate);
}
if (statusEl) {
statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`;
}
})
.catch((err) => {
if (statusEl) {
statusEl.textContent = 'Could not load escape rooms right now.';
}
console.error('escaperoom-map:', err);
});
});
+13 -5
View File
@@ -40,7 +40,6 @@ function subscribeToMercure(mercurePublicUrl, topic, myScreen, wonUrl, lostUrl)
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
appendResultMessage(messageContainer, data[1], data[2] || 'mainframe');
window.scrollTo(0, document.body.scrollHeight);
if(stillPlayingSound)
playSound();
console.log('[Mercure][game1] sequenceFinished status:', sequenceFinished);
@@ -144,7 +143,6 @@ async function fetchLockReveal(apiEchoUrl, messageContainer) {
const result = response && response.result;
if (result && Array.isArray(result.result)) {
result.result.forEach(text => appendResultMessage(messageContainer, text, result.messageType));
window.scrollTo(0, document.body.scrollHeight);
}
if (result && result.locked === false) {
clearLock();
@@ -378,7 +376,7 @@ document.addEventListener('DOMContentLoaded', async () => {
let messages = [
['System initializing...', 500],
['Connection established.', 200],
['Welcome agent to the mainframe.', 1000],
['Welcome agent ' + screen + ' to the mainframe.', 1000],
['Scanning...', 3000],
['Virus detected.', 500],
['Starting Mainframe help modus...', 2000],
@@ -401,7 +399,6 @@ document.addEventListener('DOMContentLoaded', async () => {
msgEl.className = 'message ' + extraClass;
msgEl.textContent = msg[0];
messageContainer.appendChild(msgEl);
window.scrollTo(0, document.body.scrollHeight);
playSound();
@@ -418,6 +415,17 @@ document.addEventListener('DOMContentLoaded', async () => {
const inputField = document.getElementById('input-message');
inputField.disabled = false;
let lastCommand = '';
// Recall the last submitted command on ArrowUp, cursor at the end
inputField.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
inputField.value = lastCommand;
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
}
});
// Add event listener for Enter key
inputField.addEventListener('keypress', async (e) => {
if (e.key === 'Enter') {
@@ -431,6 +439,7 @@ document.addEventListener('DOMContentLoaded', async () => {
messageContainer.appendChild(msgEl);
if (message && apiEchoUrl) {
lastCommand = message;
inputField.value = '';
try {
const response = await fetchJson(apiEchoUrl, {
@@ -440,7 +449,6 @@ document.addEventListener('DOMContentLoaded', async () => {
console.log('[API][game1] message sent →', response);
if (response && response.result && Array.isArray(response.result.result)) {
response.result.result.forEach(text => appendResultMessage(messageContainer, text, response.result.messageType));
window.scrollTo(0, document.body.scrollHeight);
}
if (response && response.result) {
if (response.result.gameWon === true && wonUrl) {
+362
View File
@@ -0,0 +1,362 @@
/* Pregame (commandline tutorial) entry point built with Webpack Encore */
import './styles/pregame.css';
function appendMessage(container, text, extraClass = '') {
const el = document.createElement('div');
el.className = ('message ' + extraClass).trim();
el.textContent = text;
container.appendChild(el);
window.scrollTo(0, document.body.scrollHeight);
}
function dockBackButton(inputField) {
const backButton = document.getElementById('back-button');
const inputContainer = document.getElementById('input');
if (!backButton || !inputContainer) {
return;
}
inputField.style.display = 'none';
backButton.classList.add('docked');
inputContainer.appendChild(backButton);
}
document.addEventListener('DOMContentLoaded', () => {
console.log('Pregame bundle loaded');
const messageContainer = document.getElementById('message-container');
const inputField = document.getElementById('input-message');
if (!messageContainer || !inputField) {
return;
}
const bootMessages = [
['Booting tutorial terminal...', 500],
['Loading command line basics...', 800],
['"Welcome agent. Lets go through the basics together.', 2000],
['Let us start with the help command. Type help in the terminal (the input field at the bottom of the screen and press enter.', 500],
];
// Same command help text as GameResponseService::getHelpCommand(), minus "scan".
const helpMessages = [
'Help function:',
'',
'pwd',
' This message will let you know what your current location is.',
' It will show you the folder you are in so you can continue navigating the server.',
' USAGE: pwd',
'',
'cd',
' Use cd to move to a different directory.',
' You can go into a folder by using cd {foldername}, or a folder up by using "cd ..".',
' Using cd / moves you to the root directory.',
' USAGE: cd {directory}',
'',
'ls',
' To show all the files in the current directory, use ls.',
' This will print the full list of directories and files of the current location on your screen.',
' USAGE: ls',
'',
'cat',
' To read a file, use cat {filename}.',
' This will print the full content of the file on the screen.',
' USAGE: cat {filename}',
'',
'rm',
' Use rm to delete a file.',
' Be careful with this command. It can not be undone and we do not want to lose any valuable data.',
' USAGE: rm {filename}',
'',
'sudo',
' If you do not have enough rights to execute a command, you can use sudo to execute it as root.',
' This is only possible for verified users. To verify yourself, use the /verify command.',
' USAGE: sudo {command}',
'',
];
// Narrative shown after the help output finishes, introducing the next step.
// Each entry is [delayBeforeShowing, text, cssClass].
const helpFollowUp = [
[2000, 'Well done, you now have a complete overview of the terminal commands this game uses.', 'message-mainframe'],
[500, 'Not all commands will be available immediately. But lets go through them one by one.', 'message-mainframe'],
[1000, 'Lets start with the command pwd. Enter this in the terminal (input field) now and press enter.', 'message-mainframe'],
];
const pwdFollowUp = [
[1000, 'You now know the location you are in on the terminal. /var/home/agent.', 'message-mainframe'],
[500, 'You can change directories with the cd command.', 'message-mainframe'],
[500, "If you type 'cd ..' in the terminal, you will go one directory up to /var/home", 'message-mainframe'],
[500, 'Try this now.', 'message-mainframe'],
];
const cdFollowUp = [
[1000, 'Good. Please check the directory you are in now.', 'message-mainframe'],
];
const pwd2FollowUp = [
[1000, 'As you can see, you are now in the /var/home directory', 'message-mainframe'],
[500, 'You can move between directories like this.', 'message-mainframe'],
];
const wrongPwdMessage = "That is not the command which gives you the location you are in. You can check your current folder with the pwd command.";
const lsIntro = [
[1000, 'Now lets see what is actually in this directory. The ls command lists all files and folders in your current location.', 'message-mainframe'],
[500, 'Type ls now.', 'message-mainframe'],
];
const lsListing = [
'agent,dir',
'peter,dir',
'james,dir',
'william,dir',
'system,dir',
];
const lsFollowUp = [
[1000, 'You can see several folders here, including your own: agent.', 'message-mainframe'],
[500, 'Lets go back to your own directory. Use cd agent to enter it.', 'message-mainframe'],
[250, 'Remember, cd /var/home/agent also works to get there directly from anywhere on the server.', 'message-mainframe'],
];
const cdAgentFollowUp = [
[1000, 'Lets check if you now really are in your own home folder.', 'message-mainframe'],
];
const verifyAgentFollowUp = [
[1000, "Well done, you're right back where you started.", 'message-mainframe'],
[500, 'Now lets move to the next command: cat.', 'message-mainframe'],
[500, 'First, lets see what is inside this folder.', 'message-mainframe'],
[500, 'Type ls to check the content of /var/home/agent.', 'message-mainframe'],
];
const lsAgentListing = [
'HelloWorld.txt,file',
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFinalListing = [
'notes.txt,file',
'schedule.txt,file',
'contacts.db,file',
'keycard.dat,file',
'backup.zip,file',
];
const lsAgentFollowUp = [
[1000, 'There it is: HelloWorld.txt, right there in the list.', 'message-mainframe'],
[500, 'Lets read what is inside. Type cat HelloWorld.txt to open the file.', 'message-mainframe'],
];
const helloWorldContent = [
'Day 1 at the agency. My mentor says curiosity keeps you sharp -- and alive.',
"- I keep a spare set of shoelaces in my desk. You'd be surprised how handy that is.",
'- The coffee machine on this floor is cursed. Never trust it before 9am.',
'- Jared Williams owes me a favor after the incident with the elevator. He should not have forgotten that.',
'',
'If you are reading this, agent: welcome to the team. Stay sharp out there.',
];
const catFollowUp = [
[1000, 'Nice work. cat is perfect for quickly reading any file on the server.', 'message-mainframe'],
];
const rmIntro = [
[1000, "There's one more command left in the basics: rm.", 'message-mainframe'],
[500, 'This command permanently deletes a file, so use it with caution.', 'message-mainframe'],
[500, 'Try removing HelloWorld.txt now. Type rm HelloWorld.txt.', 'message-mainframe'],
];
const rmDeniedMessages = [
'Permission denied: HelloWorld.txt is protected against removal.',
'You need sudo rights to remove this file.',
];
const rmFollowUp = [
[1000, 'sudo can technically be used for any command, at any moment.', 'message-mainframe'],
[500, 'But from a security standpoint, it is better not to use it unless it is truly necessary.', 'message-mainframe'],
[500, 'Only reach for sudo in extreme cases -- like this one, where a file is actively protected.', 'message-mainframe'],
[500, 'Try it now. Type sudo rm HelloWorld.txt.', 'message-mainframe'],
];
const sudoRmFollowUp = [
[1000, 'Well done. Since you used sudo, the protection was bypassed and the file was deleted.', 'message-mainframe'],
[500, 'Lets check the folder once more to confirm it is really gone. Type ls now.', 'message-mainframe'],
];
const tutorialEndFollowUp = [
[1000, 'As you can see, HelloWorld.txt is no longer in the list. It has been permanently removed.', 'message-mainframe'],
[500, 'That concludes the basics of the terminal, agent. You are ready for the real mission.', 'message-mainframe'],
[1000, 'Please be aware more commands can exist to help this mission succeed. These commands will show up in the help command. Good luck!', 'message-mainframe']
];
function playSequence(items, container, onDone) {
let i = 0;
const step = () => {
if (i >= items.length) {
if (onDone) onDone();
return;
}
const [delay, text, cls] = items[i];
i++;
setTimeout(() => {
appendMessage(container, text, cls);
step();
}, delay);
};
step();
}
// Tracks which command the tutorial is currently guiding the player towards.
let tutorialStage = 'help';
let currentMessageIndex = 0;
const printNextMessage = () => {
if (currentMessageIndex < bootMessages.length) {
const [text, delay] = bootMessages[currentMessageIndex];
appendMessage(messageContainer, text, 'message-mainframe');
currentMessageIndex++;
setTimeout(printNextMessage, delay);
return;
}
inputField.disabled = false;
inputField.focus();
inputField.addEventListener('keypress', (e) => {
if (e.key !== 'Enter') {
return;
}
const value = inputField.value.trim();
inputField.value = '';
if (!value) {
return;
}
appendMessage(messageContainer, value);
if (value.toLowerCase() === 'help' || value.toLowerCase() === '/help') {
// No extraClass -> default "message" color, same as the real game's help output.
helpMessages.forEach((line) => appendMessage(messageContainer, line));
if (tutorialStage === 'help') {
playSequence(helpFollowUp, messageContainer, () => {
tutorialStage = 'pwd';
});
}
return;
}
if (tutorialStage === 'pwd' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(pwdFollowUp, messageContainer, () => {
tutorialStage = 'cd';
});
return;
}
if (tutorialStage === 'cd' && value.toLowerCase() === 'cd ..') {
playSequence(cdFollowUp, messageContainer, () => {
tutorialStage = 'pwd2';
});
return;
}
if (tutorialStage === 'pwd2') {
if (value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home');
playSequence(pwd2FollowUp, messageContainer, () => {
playSequence(lsIntro, messageContainer, () => {
tutorialStage = 'ls';
});
});
} else {
appendMessage(messageContainer, wrongPwdMessage, 'message-hint');
}
return;
}
if (tutorialStage === 'ls' && value.toLowerCase() === 'ls') {
lsListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsFollowUp, messageContainer, () => {
tutorialStage = 'cd-agent';
});
return;
}
if (
tutorialStage === 'cd-agent' &&
(value.toLowerCase() === 'cd agent' || value.toLowerCase() === 'cd /var/home/agent')
) {
playSequence(cdAgentFollowUp, messageContainer, () => {
tutorialStage = 'verify-agent';
});
return;
}
if (tutorialStage === 'verify-agent' && value.toLowerCase() === 'pwd') {
appendMessage(messageContainer, '/var/home/agent');
playSequence(verifyAgentFollowUp, messageContainer, () => {
tutorialStage = 'ls-agent';
});
return;
}
if (tutorialStage === 'ls-agent' && value.toLowerCase() === 'ls') {
lsAgentListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(lsAgentFollowUp, messageContainer, () => {
tutorialStage = 'cat-file';
});
return;
}
if (tutorialStage === 'cat-file' && value.toLowerCase() === 'cat helloworld.txt') {
helloWorldContent.forEach((line) => appendMessage(messageContainer, line));
playSequence(catFollowUp, messageContainer, () => {
playSequence(rmIntro, messageContainer, () => {
tutorialStage = 'rm';
});
});
return;
}
if (tutorialStage === 'rm' && value.toLowerCase() === 'rm helloworld.txt') {
rmDeniedMessages.forEach((line) => appendMessage(messageContainer, line, 'message-hint'));
playSequence(rmFollowUp, messageContainer, () => {
tutorialStage = 'sudo-rm';
});
return;
}
if (tutorialStage === 'sudo-rm' && value.toLowerCase() === 'sudo rm helloworld.txt') {
appendMessage(messageContainer, 'File removed: HelloWorld.txt');
playSequence(sudoRmFollowUp, messageContainer, () => {
tutorialStage = 'ls-confirm';
});
return;
}
if (tutorialStage === 'ls-confirm' && value.toLowerCase() === 'ls') {
lsAgentFinalListing.forEach((name) => appendMessage(messageContainer, name));
playSequence(tutorialEndFollowUp, messageContainer, () => {
tutorialStage = 'complete';
inputField.disabled = true;
dockBackButton(inputField);
});
return;
}
// Placeholder response until the rest of the tutorial content is defined.
appendMessage(messageContainer, 'Command not recognized yet.', 'message-hint');
});
};
printNextMessage();
});
+100
View File
@@ -0,0 +1,100 @@
/* Styles for the pregame (commandline tutorial) terminal, mirroring game1's look */
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 1px;
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track {
background: #000;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb {
background: #F00;
}
body {
background-color: #000;
min-height: 100vh;
font-family: monospace;
margin: 0;
scrollbar-width: thin;
scrollbar-color: #F00 #000;
}
div#message-container {
padding: 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: calc(100vh - 80px);
box-sizing: border-box;
font-size: 14px;
}
div.message {
color: #C0C0C0;
white-space: pre-wrap;
line-height: 1.35;
margin-bottom: 2px;
}
div.message-mainframe {
color: #0F0;
}
div.message-virus {
color: #F00;
font-weight: bold;
}
div.message-hint {
color: #FF0;
font-weight: bold;
}
div#input {
padding: 20px;
}
input#input-message {
width: 100%;
padding: 6px 10px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
a#back-button {
position: fixed;
top: 16px;
left: 16px;
padding: 6px 14px;
background: #111;
border: 1px solid #A00000;
color: #C0C0C0;
font-size: 13px;
font-family: monospace;
text-decoration: none;
z-index: 200;
}
a#back-button:hover {
background: #1a1a1a;
color: #FFF;
}
a#back-button.docked {
position: static;
display: block;
width: 100%;
padding: 6px 10px;
font-size: 14px;
text-align: center;
box-sizing: border-box;
}
+4 -4
View File
@@ -7,12 +7,12 @@
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3.10.5",
"doctrine/dbal": "^4.4.4",
"doctrine/doctrine-bundle": "^2.18.3",
"doctrine/doctrine-migrations-bundle": "^3.7",
"doctrine/orm": "^3.6.7",
"karser/karser-recaptcha3-bundle": "^0.3.0",
"phpdocumentor/reflection-docblock": "^5.6.7",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpstan/phpdoc-parser": "^2.3.2",
"symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*",
@@ -27,9 +27,9 @@
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mailgun-mailer": "7.4.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/mercure-bundle": "^0.5.0",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.11.2",
"symfony/monolog-bundle": "^4.0.2",
"symfony/notifier": "7.4.*",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
Generated
+364 -360
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -15,11 +15,9 @@ doctrine:
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
+152
View File
@@ -0,0 +1,152 @@
# Test / staging environment (`test.escapepage.com`)
Runs a second copy of the full Docker stack on the same server as production,
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
`docker/.env` overrides, `compose.yaml` behaves exactly as before.
## How isolation works
`docker/compose.yaml` derives everything instance-specific from `docker/.env`:
| Concern | Mechanism | prod (no overrides) | test |
|---|---|---|---|
| Container names | `${STACK_NAME:-escapepage}-*` | `escapepage-php` … | `escapepage-test-php` … |
| Compose project (networks, labels) | `COMPOSE_PROJECT_NAME` | `docker` (unchanged) | `escapepage-test` |
| Published ports | `${NGINX_HTTP_PORT:-8080}` etc. | `0.0.0.0:8080/8443/3306/8090/8025` | `127.0.0.1:8081/8444/3307/8091/8026` |
| Database files | bind mount `../var/volumes/db` | per checkout | per checkout |
| Web reachability | `nginx` joined to external `nginx_default` | via NPM | via NPM |
`STACK_NAME` is a plain variable (not the Compose-managed `COMPOSE_PROJECT_NAME`),
so its `:-escapepage` default is reliable and **production needs no `docker/.env`
change** to keep its `escapepage-*` container names.
`nginx`, `mercure` and `mailer` all sit on the external `nginx_default` network,
so NPM forwards straight to `escapepage-test-nginx` / `-mercure` by name — no
public host port needed.
## Production checkout — optional
Nothing is required. Two optional tidy-ups, each needing a `docker compose up -d`
to recreate the affected container:
- The `nginx` service now also attaches to `nginx_default`. If you'd rather have
NPM forward to `escapepage-nginx` by name instead of `host:8080`, recreate it
and repoint the NPM proxy host. Otherwise the published `8080/8443` still work
as before and you can ignore this.
- Add `STACK_NAME=escapepage` and `COMPOSE_PROJECT_NAME=escapepage` to the
production `docker/.env` to move it off the implicit `docker` project name.
Cosmetic; do it only if you want the two stacks named symmetrically.
## Standing up the test stack
### 1. DNS
`test.escapepage.com` → server IP. (`mercure-test.escapepage.com` too if you want
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
### 2. Separate checkout
```bash
git clone <repo> /opt/escapepage-test
cd /opt/escapepage-test
git checkout <branch-to-test>
```
Use a **separate clone**, not `git worktree` — the Docker build context is the
checkout directory and full isolation avoids surprises.
### 3. Compose env
```bash
cp docker/.env.test.example docker/.env
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
```
### 4. Symfony env overrides
Create `/opt/escapepage-test/.env.local` in the checkout (git-ignored):
```
APP_SECRET=<openssl rand -hex 16>
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
# all on staging so URL generation / HTTPS detection work.
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
```
`APP_ENV=prod`, `DATABASE_URL`, `SITE_BASE_URL`, `MERCURE_*` and `MAILER_DSN` are
already supplied to the containers by `docker/.env`.
### 5. Build & start
```bash
./docker/setup.test.sh
```
The test-specific script (not `setup.sh`): it refuses to run unless
`COMPOSE_PROJECT_NAME` in `docker/.env` is a non-prod value, scopes every compose
call to that project, runs the DB/cache/console steps as `www-data`, and repairs
`var/cache` / `var/log` / `var/sessions` ownership at the end (bare `docker exec`
runs as root, which otherwise leaves php-fpm unable to read its own cache — a
silent 500). It never touches `var/volumes/db`.
Builds `escapepage-test-*` images, starts the containers, creates + migrates
`escapepage_test`, builds assets. Re-run any time; `--no-build` skips the rebuild.
### 6. Nginx Proxy Manager — proxy host
- Domain: `test.escapepage.com`
- Forward to: `escapepage-test-nginx`, port **443**, scheme **https**
(the app nginx force-redirects 80→443 and serves a self-signed cert; leave
"Verify SSL" off — same arrangement as production)
- SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
- Optional: add response header `X-Robots-Tag: noindex`
If you want live game screens, add a second proxy host
`mercure-test.escapepage.com` → `escapepage-test-mercure` port `80` (http).
### 7. Restrict access to your IP — NPM Access List
A host firewall on 80/443 can't help: prod and test share those ports on NPM.
Restrict at the proxy instead.
- **NPM → Access Lists → Add Access List**
- Name: e.g. `staging-allowlist`
- Authorisation: leave empty
- Access: `Allow <your.public.ip>` then a final `Deny all`
- Satisfy: **Any**
- Edit the `test.escapepage.com` proxy host → **Access List** → select it.
- Do the same on `mercure-test.escapepage.com` if you added it.
Everyone else gets `403` at the proxy. Update the IP in one place when it changes.
Defence in depth (optional): in `/opt/escapepage-test/docker/nginx/default.conf`,
inside the `server { listen 443 ... }` block:
```nginx
set_real_ip_from 172.16.0.0/12; # NPM's docker network
real_ip_header X-Forwarded-For;
allow <your.public.ip>;
deny all;
```
## Deploying a new version to test
```bash
cd /var/sites/escapepage-test
git fetch && git checkout <branch> && git pull
./docker/setup.test.sh --no-build # composer install, migrate, build assets, fix perms
```
`--no-build` skips the image rebuild; drop it if the Dockerfile changed.
## Restarting
```bash
./docker/restart.test.sh # down + up, keeps the DB and images
./docker/restart.test.sh --build # also rebuild images
./docker/restart.test.sh --fresh-db # also wipe var/volumes/db and re-init MySQL
```
## Safety notes
- Use the **`*.test.sh`** scripts on this checkout, not `setup.sh` / `restart.sh`.
Both refuse to run unless `docker/.env` names a non-prod
`COMPOSE_PROJECT_NAME`, and both scope every action to that project — they
can't reach the production stack.
- `restart.test.sh` **keeps the database** by default (you loaded prod data into
it); `--fresh-db` is the only thing that wipes it. It never runs a host-wide
`docker system prune` / `docker builder prune`, and it only repairs ownership
of `var/cache` / `var/log` / `var/sessions` — **never `var/volumes/db`**
(chowning the MySQL data dir is what corrupted it earlier this build).
- The test `docker/.env` uses its own `DB_NAME` and passwords so a config slip
can't reach the production database.
- `MAILER_DSN=smtp://mailer:1026` keeps staging mail inside Mailpit instead of
sending through Mailgun.
+77
View File
@@ -0,0 +1,77 @@
# =============================================================================
# docker/.env for a TEST / STAGING stack (e.g. test.escapepage.com)
# =============================================================================
# Copy this to docker/.env inside the *test* checkout and fill in the blanks.
# docker/.env is git-ignored, so it never leaves the server.
#
# Compose reads this file automatically. Anything unique per stack is derived
# from COMPOSE_PROJECT_NAME + the *_PORT vars below, so the same compose.yaml
# serves both production and this copy.
#
# Two config layers, don't mix them up:
# - THIS file -> consumed by `docker compose` (container names, ports, and the
# runtime env it injects into the php containers).
# - <checkout>/.env(.local) -> consumed by Symfony itself (APP_SECRET,
# TRUSTED_PROXIES, messenger DSN, CLI database access, ...).
# =============================================================================
## --- Instance identity -------------------------------------------------------
# Both must be unique on the host.
# STACK_NAME -> prefix for every container_name (escapepage-test-php …)
# COMPOSE_PROJECT_NAME -> compose project: isolates networks, volumes and the
# labels that `docker compose down` / *.test.sh act on.
# Must be a non-prod value or setup.test.sh / restart.test.sh
# refuse to run.
STACK_NAME=escapepage-test
COMPOSE_PROJECT_NAME=escapepage-test
## --- Published host ports ---------------------------------------------------
# Bound to localhost only: the sole public entrypoint is Nginx Proxy Manager,
# which reaches the containers over the shared `nginx_default` network by name.
# Pick ports that don't clash with the production stack (8080/8443/3306/8090/8025/1025).
NGINX_HTTP_PORT=127.0.0.1:8081
NGINX_HTTPS_PORT=127.0.0.1:8444
DB_HOST_PORT=127.0.0.1:3307
MERCURE_HTTP_PORT=127.0.0.1:8091
MAILPIT_UI_PORT=127.0.0.1:8026
MAILPIT_SMTP_PORT=127.0.0.1:1026
## --- PHP image build ------------------------------------------------------
USER_ID=1000
GROUP_ID=1000
## --- Symfony runtime (injected into php / php-worker / php-cron) ------------
APP_ENV=prod
SITE_BASE_URL=https://test.escapepage.com
# Staging should NOT send real mail. Point at the bundled Mailpit and read it
# at http://127.0.0.1:8026 on the server (or via an NPM host if you expose it).
MAILER_DSN=smtp://mailer:1026
MAILER_FROM=mailer@test.escapepage.com
## --- Database -------------------------------------------------------------
# `database` is the compose *service* name and resolves inside this stack's
# own network - keep it as-is. Use its own name + fresh credentials so a mistake
# here can never point at the production database.
DB_NAME=escapepage_test
DB_USER=escapepage
DB_PASSWORD=CHANGE_ME_test_db_password
MYSQL_ROOT_PASSWORD=CHANGE_ME_test_root_password
DATABASE_URL=pdo_mysql://escapepage:CHANGE_ME_test_db_password@database:3306/escapepage_test?serverVersion=8.0.32&charset=utf8mb4
## --- Mercure -----------------------------------------------------------------
# Internal hub URL (service name, stays the same). Public URL + CORS must be the
# test domain. Add a `mercure-test.escapepage.com` proxy host in NPM ->
# <project>-mercure:80.
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://mercure-test.escapepage.com/.well-known/mercure
MERCURE_JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32
MERCURE_CORS_ALLOWED_ORIGINS="https://test.escapepage.com"
MERCURE_TOPIC_BASE=https://test.escapepage.com
## --- reCAPTCHA v3 ----------------------------------------------------------
# Register test.escapepage.com in the reCAPTCHA admin console and paste its keys,
# or leave the placeholders and expect the contact / "suggest a room" forms to
# fail captcha validation on staging.
RECAPTCHA3_KEY=CHANGE_ME_or_reuse_prod_if_domain_added
RECAPTCHA3_SECRET=CHANGE_ME_or_reuse_prod_if_domain_added
+2 -2
View File
@@ -17,8 +17,8 @@ services:
mailer:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_UI_PORT:-8025}:8025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
+26 -15
View File
@@ -1,5 +1,14 @@
version: '3.7'
# This stack can run more than once on the same host (e.g. production + a
# test.escapepage.com staging copy). Everything that must be unique per instance
# comes from docker/.env:
# STACK_NAME -> container name prefix (default: escapepage)
# COMPOSE_PROJECT_NAME -> compose project / network namespace
# NGINX_HTTP_PORT etc. -> published host ports
# With no docker/.env overrides it behaves exactly as before: containers
# escapepage-*, ports 8080/8443/3306/8090/8025.
services:
php:
build:
@@ -8,7 +17,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php
container_name: ${STACK_NAME:-escapepage}-php
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -40,7 +49,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-worker
container_name: ${STACK_NAME:-escapepage}-php-worker
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -73,7 +82,7 @@ services:
args:
USER_ID: ${USER_ID}
GROUP_ID: ${GROUP_ID}
container_name: escapepage-php-cron
container_name: ${STACK_NAME:-escapepage}-php-cron
volumes:
- ../:/var/www/html:delegated
- /etc/hosts:/etc/hosts:ro
@@ -101,10 +110,10 @@ services:
nginx:
image: nginx:1.29.4-alpine
container_name: escapepage-nginx
container_name: ${STACK_NAME:-escapepage}-nginx
ports:
- "8080:80"
- "8443:443"
- "${NGINX_HTTP_PORT:-8080}:80"
- "${NGINX_HTTPS_PORT:-8443}:443"
volumes:
- ../:/var/www/html:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
@@ -112,16 +121,18 @@ services:
- /etc/hosts:/etc/hosts:ro
depends_on:
- php
# networks:
# backend:
# ipv4_address: 172.23.0.12
# Joined to the Nginx Proxy Manager network so NPM can forward straight to
# "<project>-nginx" without going back out to a published host port.
networks:
- default
- nginx_proxy
restart: unless-stopped
mailer:
image: axllent/mailpit:latest
container_name: escapepage-mailer
container_name: ${STACK_NAME:-escapepage}-mailer
ports:
- "8025:8025"
- "${MAILPIT_UI_PORT:-8025}:8025"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
@@ -131,7 +142,7 @@ services:
mercure:
image: dunglas/mercure:v0.21
container_name: escapepage-mercure
container_name: ${STACK_NAME:-escapepage}-mercure
environment:
SERVER_NAME: "http://:80"
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET}
@@ -143,7 +154,7 @@ services:
publish_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
anonymous
ports:
- "8090:80"
- "${MERCURE_HTTP_PORT:-8090}:80"
volumes:
- /etc/hosts:/etc/hosts:ro
networks:
@@ -154,7 +165,7 @@ services:
###> doctrine/doctrine-bundle ###
database:
image: mysql:8.0
container_name: escapepage-db
container_name: ${STACK_NAME:-escapepage}-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
@@ -173,7 +184,7 @@ services:
- /etc/hosts:/etc/hosts:ro
# Uncomment the two lines below if you need to access MySQL from your host (workbench, etc.)
ports:
- "3306:3306"
- "${DB_HOST_PORT:-3306}:3306"
# networks:
# backend:
# ipv4_address: 172.23.0.15
+39 -11
View File
@@ -1,22 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
# Script to completely restart the project as requested
# Can be run from any directory
# Completely restart ONE project stack (prod or a test/staging copy).
# Can be run from any directory. Everything is scoped to the Compose project
# name so running this from the test checkout never touches the prod stack.
#
# ./docker/restart.sh # rebuild-less restart of this checkout's stack
# ./docker/restart.sh --prune-all # also run host-wide `docker system/builder prune`
# # (old behaviour; skip it when another stack shares the host)
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
docker network rm escapepage_network || true
docker network rm $(docker network ls -q --filter name=escapepage) || true
docker network prune -f || 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
PRUNE_ALL=0
for arg in "$@"; do
case "$arg" in
--prune-all) PRUNE_ALL=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
echo "Clearing Docker build cache..."
docker builder prune -af
# Read the identifiers from docker/.env (same file Compose uses). STACK_NAME is
# the container-name prefix; COMPOSE_PROJECT_NAME is the compose project.
read_env() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"'" || true; }
STACK="$(read_env STACK_NAME)"; STACK="${STACK:-escapepage}"
PROJECT="$(read_env COMPOSE_PROJECT_NAME)"; PROJECT="${PROJECT:-$STACK}"
echo "Restarting stack: $STACK (compose project: $PROJECT)"
echo "Stopping and removing containers..."
(cd "$DOCKER_DIR" && docker compose -p "$PROJECT" -f compose.yaml -f compose.override.yaml down -v --remove-orphans) || true
# Belt-and-suspenders: drop anything still lingering for THIS stack only.
docker rm -f \
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$PRUNE_ALL" -eq 1 ]; then
echo "Host-wide prune (containers, networks, build cache)..."
docker system prune -f || true
docker builder prune -af || true
else
echo "Skipping host-wide prune (pass --prune-all to force it)."
fi
echo "Setting permissions for var/volumes/db and var directories..."
sudo chown -R 1000:1000 "$ROOT_DIR/var/volumes/db" || true
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
# Restart the TEST (staging) stack. Scoped entirely to this checkout's compose
# project, so it can never touch the production stack. Unlike docker/restart.sh
# it:
# - keeps the database by default (you loaded prod data into it) — pass
# --fresh-db to wipe var/volumes/db and let MySQL re-initialise
# - never runs a host-wide `docker system/builder prune`
# - only repairs ownership of var/cache, var/log, var/sessions — NEVER
# var/volumes/db (that dir belongs to the mysql process; chowning it is
# what corrupts the data directory)
#
# Usage:
# ./docker/restart.test.sh # down + up (keeps DB and images)
# ./docker/restart.test.sh --build # also rebuild images
# ./docker/restart.test.sh --fresh-db # also wipe + re-initialise the database
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env first." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
exit 1 ;;
esac
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
FRESH_DB=0; BUILD=0
for arg in "$@"; do
case "$arg" in
--fresh-db) FRESH_DB=1 ;;
--build) BUILD=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
echo "Restarting test stack: $STACK (compose project: $PROJECT)"
echo "Stopping containers..."
dc down --remove-orphans || true
# scoped fallback — only this stack
docker rm -f \
"${STACK}-db" "${STACK}-php" "${STACK}-nginx" "${STACK}-mercure" \
"${STACK}-mailer" "${STACK}-php-worker" "${STACK}-php-cron" 2>/dev/null || true
for net in $(docker network ls -q --filter "name=^${PROJECT}_" 2>/dev/null); do
docker network rm "$net" || true
done
if [ "$FRESH_DB" -eq 1 ]; then
echo "Wiping the test database (var/volumes/db)..."
sudo rm -rf "$ROOT_DIR/var/volumes/db"
fi
echo "Repairing var/ ownership (cache/log/sessions only)..."
sudo mkdir -p "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log/php" "$ROOT_DIR/var/log/cron" "$ROOT_DIR/var/sessions"
sudo chown -R 1000:1000 "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
sudo chmod -R u+rwX,g+rwX "$ROOT_DIR/var/cache" "$ROOT_DIR/var/log" "$ROOT_DIR/var/sessions"
echo "Bringing the stack back up..."
if [ "$BUILD" -eq 1 ]; then
"$DOCKER_DIR/setup.test.sh"
else
"$DOCKER_DIR/setup.test.sh" --no-build
fi
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap / re-provision a TEST (staging) stack — e.g. test.escapepage.com.
# Same job as docker/setup.sh, but:
# - refuses to run unless docker/.env marks this as a non-prod stack
# - scopes every compose call to COMPOSE_PROJECT_NAME
# - runs DB / cache / console steps as www-data and repairs var/ ownership at
# the end, so php-fpm (www-data) can actually read the compiled container
# (bare `docker exec` runs as root and would leave var/cache root-owned)
# - NEVER touches var/volumes/db (MySQL owns that)
#
# Usage:
# ./docker/setup.test.sh # full setup (build images)
# ./docker/setup.test.sh --no-build # skip image rebuild
# ./docker/setup.test.sh --recreate # force-recreate containers
# ./docker/setup.test.sh --down # stop and remove this stack's containers
DOCKER_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "$DOCKER_DIR/.." && pwd)
env_val() { [ -f "$DOCKER_DIR/.env" ] && grep -E "^$1=" "$DOCKER_DIR/.env" | tail -n1 | cut -d= -f2- | tr -d "\"' " || true; }
# --- safety gate: never let a *.test.sh script act on the production stack ----
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "Error: $DOCKER_DIR/.env not found. Copy docker/.env.test.example to docker/.env and fill it in." >&2
exit 1
fi
PROJECT="$(env_val COMPOSE_PROJECT_NAME)"
STACK="$(env_val STACK_NAME)"; STACK="${STACK:-$PROJECT}"
case "${PROJECT:-}" in
""|escapepage|docker)
echo "Error: COMPOSE_PROJECT_NAME in docker/.env is '${PROJECT:-<unset>}'." >&2
echo "Refusing to run a test script against the production stack." >&2
echo "Set COMPOSE_PROJECT_NAME and STACK_NAME to e.g. 'escapepage-test' in docker/.env." >&2
exit 1 ;;
esac
# --- compose command -------------------------------------------------------
if docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
else
echo "Error: neither 'docker compose' nor 'docker-compose' is available." >&2
exit 1
fi
command -v docker >/dev/null 2>&1 || { echo "Error: docker is required." >&2; exit 1; }
dc() { (cd "$DOCKER_DIR" && $DOCKER_COMPOSE -p "$PROJECT" -f compose.yaml -f compose.override.yaml "$@"); }
pexec() { dc exec -T php "$@"; } # as root (composer / npm)
pexecwww() { dc exec -T -u www-data php "$@"; } # as www-data (console / DB / cache)
REBUILD=1; RECREATE=0; DOWN_ONLY=0
for arg in "$@"; do
case "$arg" in
--no-build) REBUILD=0 ;;
--recreate) RECREATE=1 ;;
--down) DOWN_ONLY=1 ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
echo "Test stack: $STACK (compose project: $PROJECT)"
if [ "$DOWN_ONLY" -eq 1 ]; then
dc down --remove-orphans
exit 0
fi
BUILD_ARGS=()
[ "$REBUILD" -eq 1 ] && BUILD_ARGS+=("--build")
[ "$RECREATE" -eq 1 ] && BUILD_ARGS+=("--force-recreate")
dc up -d "${BUILD_ARGS[@]}"
# --- wait for the database ------------------------------------------------
printf "Waiting for database to be healthy..."
for i in $(seq 1 60); do
DB_ID=$(dc ps -q database 2>/dev/null || true)
if [ -n "$DB_ID" ] && [ "$(docker inspect -f '{{.State.Health.Status}}' "$DB_ID" 2>/dev/null || true)" = "healthy" ]; then
echo " OK"; break
fi
printf "."; sleep 2
[ "$i" -eq 60 ] && echo -e "\nWarning: database not healthy yet, continuing anyway."
done
# --- dependencies (root: writes vendor/ and node_modules/) --------------
pexec composer install --no-interaction
# --- APP_SECRET: generate into .env.local if it's set nowhere -----------
if ! grep -qsE '^APP_SECRET=.+' "$ROOT_DIR/.env" "$ROOT_DIR/.env.local"; then
echo "Generating APP_SECRET in .env.local..."
printf 'APP_SECRET=%s\n' "$(openssl rand -hex 16)" >> "$ROOT_DIR/.env.local"
fi
# --- database (www-data: keeps var/ writable by php-fpm) ---------------
echo "Creating database if it doesn't exist..."
pexecwww php bin/console doctrine:database:create --if-not-exists || {
echo "Error: database creation failed." >&2; dc logs database | tail -n 40; exit 1;
}
echo "Running migrations..."
pexecwww php bin/console doctrine:migrations:migrate -n || { echo "Error: migrations failed." >&2; exit 1; }
[ -f "$ROOT_DIR/importmap.php" ] && pexec php bin/console importmap:install || true
if [ -f "$ROOT_DIR/package.json" ]; then
echo "Installing npm dependencies..."; pexec npm ci || pexec npm install
echo "Building assets..."; pexec npm run build
fi
# --- repair var/ ownership for php-fpm (www-data), never var/volumes ----
echo "Fixing var/ ownership for php-fpm..."
pexec sh -lc 'mkdir -p var/cache var/log/php var/log/cron var/sessions && chown -R www-data:www-data var/cache var/log var/sessions'
pexecwww php bin/console cache:clear
NGINX_HTTPS="$(env_val NGINX_HTTPS_PORT)"
MAILPIT_UI="$(env_val MAILPIT_UI_PORT)"
cat <<EOT
Test stack '$PROJECT' is up.
NPM upstream: ${STACK}-nginx (scheme https, port 443, "Verify SSL" off)
Local check: curl -k https://${NGINX_HTTPS:-127.0.0.1:18443}/
Mailpit UI: http://${MAILPIT_UI:-127.0.0.1:18026}
Logs: docker logs -f ${STACK}-php
Shell: docker exec -it -u www-data ${STACK}-php sh
Restart: ./docker/restart.test.sh (add --fresh-db to wipe + re-init the DB)
Re-run this script any time. Use --no-build to skip the image rebuild.
EOT
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260810140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create hint table and seed it with the previously-hardcoded mainframe hints for every existing game';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE hint (id INT AUTO_INCREMENT NOT NULL, game_id INT NOT NULL, hint_condition VARCHAR(30) NOT NULL, threshold_seconds INT NOT NULL, message LONGTEXT NOT NULL, sort_order INT NOT NULL, INDEX IDX_HINT_GAME (game_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE hint ADD CONSTRAINT FK_HINT_GAME FOREIGN KEY (game_id) REFERENCES `game` (id) ON DELETE CASCADE');
// Seed every existing game with the hints that used to be hardcoded in
// SendMainframeHintsCommand, so behavior doesn't regress the moment the
// command switches to reading from this table.
$seed = [
['help_used', 120, 'Have you already checked which functions are available to you through the help command?', 10],
['broadcast_done', 300, 'You should establish communications with your fellow agents.', 20],
['contacted_all_privately', 420, 'The AI virus can see all communication if you are using open communication channels. Make sure you send private messages to all other agents as well, so I know you can.', 30],
['verified', 600, 'You need the help of your fellow agents to verify you. If both other agents have helped you in your verification, i can get you more rights. The help command might give you some more information.', 40],
['verified', 780, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', 50],
['left_home_directory', 900, 'You can change the folder you are working in. Check the help command for more information', 60],
['all_decoded', 1020, 'You should go to the rapports directory to check out the rapports.', 70],
['all_decoded', 1140, "Not all messages can be decoded by every agent. Every agent has their own personal decoding method. If you can't decode a message, maybe a colleague can.", 80],
['none', 1380, 'Have you checked out the help command to see what you can do?', 90],
['none', 1560, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', 100],
['none', 1680, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', 110],
];
foreach ($seed as [$condition, $threshold, $message, $sortOrder]) {
$this->addSql(
'INSERT INTO hint (game_id, hint_condition, threshold_seconds, message, sort_order) '
. 'SELECT id, ?, ?, ?, ? FROM `game`',
[$condition, $threshold, $message, $sortOrder]
);
}
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE hint DROP FOREIGN KEY FK_HINT_GAME');
$this->addSql('DROP TABLE hint');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create contact_message table for the public contact form';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contact_message (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, message LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE contact_message');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add read_at to contact_message so admin can track which messages have been read';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD read_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP read_at');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted_at to contact_message for soft-deleting messages from the admin list';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message ADD deleted_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contact_message DROP deleted_at');
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260829150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add escape_room and escape_room_review tables for the physical escape room map and reviews';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE escape_room (
id INT AUTO_INCREMENT NOT NULL,
submitted_by_id INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
venue VARCHAR(255) DEFAULT NULL,
street VARCHAR(255) DEFAULT NULL,
house_number VARCHAR(32) DEFAULT NULL,
postcode VARCHAR(32) DEFAULT NULL,
city VARCHAR(128) DEFAULT NULL,
country VARCHAR(2) DEFAULT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
website VARCHAR(511) DEFAULT NULL,
phone VARCHAR(64) DEFAULT NULL,
source VARCHAR(16) NOT NULL,
osm_type VARCHAR(8) DEFAULT NULL,
osm_id BIGINT DEFAULT NULL,
status VARCHAR(16) NOT NULL,
locked TINYINT(1) DEFAULT 0 NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
updated_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_submitted_by (submitted_by_id),
UNIQUE INDEX uniq_escape_room_osm (osm_type, osm_id),
INDEX idx_escape_room_status (status),
INDEX idx_escape_room_bbox (latitude, longitude),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE escape_room_review (
id INT AUTO_INCREMENT NOT NULL,
escape_room_id INT NOT NULL,
author_id INT DEFAULT NULL,
rating SMALLINT NOT NULL,
title VARCHAR(150) NOT NULL,
body LONGTEXT NOT NULL,
created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
deleted_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)',
INDEX idx_escape_room_review_room (escape_room_id),
INDEX idx_escape_room_review_author (author_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql('ALTER TABLE escape_room ADD CONSTRAINT fk_escape_room_submitted_by FOREIGN KEY (submitted_by_id) REFERENCES `user` (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_room FOREIGN KEY (escape_room_id) REFERENCES escape_room (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE escape_room_review ADD CONSTRAINT fk_escape_room_review_author FOREIGN KEY (author_id) REFERENCES `user` (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_room');
$this->addSql('ALTER TABLE escape_room_review DROP FOREIGN KEY fk_escape_room_review_author');
$this->addSql('ALTER TABLE escape_room DROP FOREIGN KEY fk_escape_room_submitted_by');
$this->addSql('DROP TABLE escape_room_review');
$this->addSql('DROP TABLE escape_room');
}
}
+1471 -4191
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -12,21 +12,24 @@
"devDependencies": {
"@babel/core": "^7.25.0",
"@babel/preset-env": "^7.25.0",
"@symfony/webpack-encore": "^4.6.1",
"babel-loader": "^9.1.3",
"@symfony/webpack-encore": "^6.0.0",
"babel-loader": "^10.1.1",
"core-js": "^3.37.1",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.2",
"regenerator-runtime": "^0.14.1",
"sass": "^1.101.0",
"sass-loader": "^14.2.1",
"sass-loader": "^16.0.1",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-cli": "^6.0.0",
"webpack-notifier": "^1.15.0"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1"
"bootstrap-icons": "^1.13.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"simple-datatables": "^10.3.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+182 -43
View File
@@ -3,19 +3,24 @@ declare(strict_types=1);
namespace App\Command;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Enum\GameSettingType;
use App\Game\Enum\HintCondition;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Repository\GameSettingRepository;
use App\Game\Repository\HintRepository;
use App\Game\Repository\SessionRepository;
use App\Game\Repository\SessionSettingRepository;
use App\Game\Service\PlayerService;
use Doctrine\ORM\EntityManagerInterface;
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;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[AsCommand(
name: 'app:hints:check',
@@ -29,7 +34,10 @@ final class SendMainframeHintsCommand extends Command
private readonly SessionRepository $sessionRepository,
private readonly SessionSettingRepository $sessionSettingRepository,
private readonly GameSettingRepository $gameSettingRepository,
private readonly HubInterface $hub,
private readonly HintRepository $hintRepository,
private readonly PlayerService $playerService,
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
) {
parent::__construct();
}
@@ -38,39 +46,121 @@ final class SendMainframeHintsCommand extends Command
{
$sessions = $this->sessionRepository->findBy(['status' => SessionStatus::PLAYING]);
$hintsSent = 0;
$sessionsFinished = 0;
foreach ($sessions as $session) {
if ($this->checkContactHint($session)) {
if ($this->finishSessionIfTimeUp($session)) {
$sessionsFinished++;
continue;
}
$elapsed = $this->getElapsedPlayingSeconds($session);
if ($elapsed === null) {
continue;
}
foreach ($session->getPlayers() as $player) {
if ($this->sendNextHint($session, $player, $elapsed)) {
$hintsSent++;
}
}
}
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s).</info>', count($sessions), $hintsSent));
$output->writeln(sprintf('<info>Checked %d running session(s), sent %d hint(s), finished %d session(s).</info>', count($sessions), $hintsSent, $sessionsFinished));
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.
* Catches sessions whose countdown ran out. Players' own browsers also poll for
* this independently the moment their local timer hits zero, but that's a
* per-player, best-effort check (background tabs get throttled and may lag) with
* no broadcast to the others. Running every minute, this is the reliable fallback
* that guarantees every player gets the Mercure "game_finished" push within a
* minute of the game actually ending, regardless of tab state.
*/
private function checkContactHint(Session $session): bool
private function finishSessionIfTimeUp(Session $session): bool
{
$elapsed = $this->getElapsedPlayingSeconds($session);
if ($elapsed === null || $elapsed < 300) {
$timer = $session->getTimer();
if ($timer === null || time() < $timer) {
return false;
}
if ($this->allPlayersHaveContactedEveryone($session)) {
return false;
}
$session->setStatus(SessionStatus::LOST);
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session);
$this->entityManager->flush();
$this->publishHint($session, 'Get in contact with your fellow agents to work together on defeating this AI virus.');
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
['type' => 'game_finished', 'status' => 'lost'],
'game_finished',
));
return true;
}
/**
* Walks this game's admin-configured hints in order. For the first distinct
* condition that's still unresolved for this player, fires the most-escalated
* hint sharing that condition whose threshold has been reached, then stops -
* later conditions are only reached once every one before them resolves, since
* they genuinely depend on it (e.g. /verify isn't even usable until a player has
* finished contacting everyone).
*/
private function sendNextHint(Session $session, Player $player, int $elapsed): bool
{
$hints = $this->hintRepository->findByGameOrdered($session->getGame());
$seenConditions = [];
foreach ($hints as $hint) {
$condition = $hint->getCondition();
if (in_array($condition, $seenConditions, true)) {
continue; // already handled this condition's group further up the list
}
$seenConditions[] = $condition;
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
continue; // move on to the next distinct condition
}
$group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
$best = null;
foreach ($group as $candidate) {
if ($elapsed < $candidate->getThresholdSeconds()) {
continue;
}
if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
$best = $candidate;
}
}
if ($best !== null) {
$this->publishHint($session, $best->getMessage(), $player);
return true;
}
return false; // condition unresolved, but no threshold reached yet
}
return false;
}
private function isConditionResolved(HintCondition $condition, Session $session, Player $player): bool
{
return match ($condition) {
HintCondition::HELP_USED => $this->hasUsedHelp($session, $player),
HintCondition::BROADCAST_DONE => $this->hasBroadcast($session, $player),
HintCondition::CONTACTED_ALL_PRIVATELY => $this->hasContactedEveryonePrivately($session, $player),
HintCondition::VERIFIED => $this->isFullyVerified($session, $player),
HintCondition::LEFT_HOME_DIRECTORY => !$this->isStillInHomeDirectory($player),
HintCondition::ALL_DECODED => $this->allMessagesDecoded($session, $player),
HintCondition::NONE => false,
};
}
private function getElapsedPlayingSeconds(Session $session): ?int
{
$timer = $session->getTimer();
@@ -86,49 +176,98 @@ final class SendMainframeHintsCommand extends Command
return time() - $startedAt;
}
private function allPlayersHaveContactedEveryone(Session $session): bool
private function hasUsedHelp(Session $session, Player $player): 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) {
$settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $trackingSettingName, $player);
$tracking = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
if (!in_array(0, $tracking)) {
return false;
return $setting !== null && $setting->getValue() === 'true';
}
foreach ($screens as $otherScreen) {
if ($otherScreen !== $screen && !in_array($otherScreen, $tracking)) {
return false;
private function getChatTracking(Session $session, Player $player): array
{
$settingName = SessionSettingType::tryFrom('ChatTrackingForPlayer' . $player->getScreen());
if (!$settingName) {
return [];
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
return $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
}
private function hasBroadcast(Session $session, Player $player): bool
{
return in_array(0, $this->getChatTracking($session, $player), true);
}
private function hasContactedEveryonePrivately(Session $session, Player $player): bool
{
$tracking = $this->getChatTracking($session, $player);
foreach ($session->getPlayers() as $otherPlayer) {
$otherScreen = $otherPlayer->getScreen();
if ($otherScreen === $player->getScreen()) {
continue;
}
if ($otherScreen === null || !in_array($otherScreen, $tracking, true)) {
return false;
}
}
return true;
}
private function publishHint(Session $session, string $message): void
private function isFullyVerified(Session $session, Player $player): bool
{
$topic = '/game/hub/' . $session->getId();
try {
$this->hub->publish(new Update($topic, json_encode([0, $message, 'hint'])));
} catch (\Exception $e) {
// Mercure might be down
$settingName = SessionSettingType::tryFrom('VerificationProgressForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
$progress = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
return count($progress) >= $session->getGame()->getNumberOfPlayers() - 1;
}
private function isStillInHomeDirectory(Player $player): bool
{
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
return $pwd === '/var/home/' . $player->getUser()->getUsername();
}
/**
* True once all three agents have decoded their personal message - decoding
* player 1's grants 'sudo' to everyone, player 2's grants 'scan', player 3's
* grants 'rm', so checking for all three on any player reflects the whole team.
*/
private function allMessagesDecoded(Session $session, Player $player): bool
{
$settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
if (!$settingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($session, $settingName, $player);
$rights = $setting ? (json_decode($setting->getValue() ?? '[]', true) ?? []) : [];
return in_array('sudo', $rights, true) && in_array('scan', $rights, true) && in_array('rm', $rights, true);
}
private function publishHint(Session $session, string $message, Player $player): void
{
$screen = $player->getScreen() ?? 0;
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
[$screen, $message, 'hint'],
'hint',
$screen,
));
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Website\Entity\ContactMessage;
use App\Website\Repository\ContactMessageRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/contact')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminContactController extends AbstractController
{
#[Route('', name: 'game_admin_contact', methods: ['GET'])]
public function index(ContactMessageRepository $contactMessageRepository): Response
{
return $this->render('game/admin/contact/index.html.twig', [
'messages' => $contactMessageRepository->findActive(),
]);
}
#[Route('/{id}', name: 'game_admin_contact_show', methods: ['GET'])]
public function show(ContactMessage $contactMessage, EntityManagerInterface $entityManager): Response
{
if (!$contactMessage->isRead()) {
$contactMessage->setReadAt(new \DateTimeImmutable());
$entityManager->flush();
}
return $this->render('game/admin/contact/show.html.twig', [
'message' => $contactMessage,
]);
}
#[Route('/{id}/delete', name: 'game_admin_contact_delete', methods: ['POST'])]
public function delete(ContactMessage $contactMessage, Request $request, EntityManagerInterface $entityManager): Response
{
if (!$this->isCsrfTokenValid('delete_contact_' . $contactMessage->getId(), $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('game_admin_contact');
}
if (!$contactMessage->isDeleted()) {
$contactMessage->setDeletedAt(new \DateTimeImmutable());
$entityManager->flush();
}
$this->addFlash('success', sprintf('Message from "%s" deleted.', $contactMessage->getName()));
return $this->redirectToRoute('game_admin_contact');
}
}
+59 -1
View File
@@ -58,7 +58,7 @@ final class GameAdminController extends AbstractController
$playersLogs[] = [
'username' => $player->getUser()->getUsername(),
'logs' => file_exists($logFile) ? file_get_contents($logFile) : '',
'entries' => file_exists($logFile) ? $this->parseActivityLog($logFile) : [],
];
}
@@ -68,4 +68,62 @@ final class GameAdminController extends AbstractController
'lobbyMessages' => $lobbyMessageRepository->findForSession($session),
]);
}
/**
* @return array<int, array{time: string, line: string}>
*/
private function parseActivityLog(string $logFile): array
{
$lines = [];
foreach (file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $rawLine) {
$entry = json_decode($rawLine, true);
if (!is_array($entry) || !isset($entry['ts'], $entry['source'])) {
// Fall back to showing whatever couldn't be parsed, rather than dropping it silently.
$lines[] = ['time' => '', 'line' => $rawLine];
continue;
}
$time = (\DateTimeImmutable::createFromFormat(DATE_ATOM, $entry['ts']) ?: null)?->format('Y-m-d H:i:s') ?? $entry['ts'];
$lines[] = ['time' => $time, 'line' => $this->formatActivityLogLine($entry)];
}
return $lines;
}
private function formatActivityLogLine(array $entry): string
{
$source = $entry['source'] ?? '';
if ($source === 'player') {
return 'PLAYER: ' . (string)($entry['content'] ?? '');
}
if ($source === 'server') {
return 'SERVER: ' . (string)($entry['content'] ?? '');
}
if ($source === 'push') {
return $this->formatPushLine((string)($entry['eventType'] ?? ''), $entry['payload'] ?? null);
}
return json_encode($entry);
}
private function formatPushLine(string $eventType, mixed $payload): string
{
return match ($eventType) {
'chat' => is_array($payload) && ($payload[0] ?? 0) !== 0
? 'CHAT (private): ' . ($payload[1] ?? '')
: 'CHAT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'hint' => 'HINT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'security_alert' => 'SECURITY ALERT: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'virus_alert' => 'VIRUS: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'help_modus_activated' => 'MAINFRAME: ' . (is_array($payload) ? ($payload[1] ?? '') : ''),
'game_finished' => 'GAME FINISHED: ' . (is_array($payload) ? ($payload['status'] ?? '') : ''),
default => 'PUSH (' . $eventType . '): ' . json_encode($payload),
};
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Service\FeedbackService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/feedback')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminFeedbackController extends AbstractController
{
#[Route('', name: 'game_admin_feedback', methods: ['GET'])]
public function index(FeedbackService $feedbackService): Response
{
return $this->render('game/admin/feedback/index.html.twig', [
'entries' => $feedbackService->getFeedbackEntries(),
'summary' => $feedbackService->getFeedbackSummary(),
]);
}
}
@@ -6,6 +6,7 @@ namespace App\Game\Controller;
use App\Game\Entity\GameSetting;
use App\Game\Enum\GameSettingType;
use App\Game\Enum\GameStatus;
use App\Game\Form\AdminGameType;
use App\Game\Entity\Game;
use App\Game\Repository\GameRepository;
@@ -29,6 +30,41 @@ final class GameAdminGameController extends AbstractController
]);
}
#[Route('/new', name: 'game_admin_game_new', methods: ['GET', 'POST'])]
public function new(
Request $request,
EntityManagerInterface $em,
): Response {
$game = new Game();
$game->setStatus(GameStatus::IN_DEVELOPMENT);
$form = $this->createForm(AdminGameType::class, $game);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$totalTime = $form->get('totalTime')->getData();
$em->persist($game);
if ($totalTime !== null) {
$totalTimeSetting = new GameSetting();
$totalTimeSetting->setGame($game);
$totalTimeSetting->setName(GameSettingType::TOTAL_TIME);
$totalTimeSetting->setValue((string) $totalTime);
$em->persist($totalTimeSetting);
}
$em->flush();
$this->addFlash('success', sprintf('Game "%s" created.', $game->getName()));
return $this->redirectToRoute('game_admin_games');
}
return $this->render('game/admin/games/new.html.twig', [
'form' => $form,
]);
}
#[Route('/{id}/edit', name: 'game_admin_game_edit', methods: ['GET', 'POST'])]
public function edit(
Game $game,
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Game;
use App\Game\Entity\Hint;
use App\Game\Form\AdminHintType;
use App\Game\Repository\HintRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/games/{game}/hints')]
#[IsGranted('ROLE_ADMIN')]
final class GameAdminHintController extends AbstractController
{
#[Route('', name: 'game_admin_hints', methods: ['GET'])]
public function index(Game $game, HintRepository $hintRepository): Response
{
return $this->render('game/admin/hints/index.html.twig', [
'game' => $game,
'hints' => $hintRepository->findByGameOrdered($game),
]);
}
#[Route('/new', name: 'game_admin_hint_new', methods: ['GET', 'POST'])]
public function new(Game $game, Request $request, EntityManagerInterface $em): Response
{
$hint = new Hint();
$hint->setGame($game);
$form = $this->createForm(AdminHintType::class, $hint);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($hint);
$em->flush();
$this->addFlash('success', 'Hint created.');
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
}
return $this->render('game/admin/hints/new.html.twig', [
'game' => $game,
'form' => $form,
]);
}
#[Route('/{id}/edit', name: 'game_admin_hint_edit', methods: ['GET', 'POST'])]
public function edit(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(AdminHintType::class, $hint);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', 'Hint updated.');
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
}
return $this->render('game/admin/hints/edit.html.twig', [
'game' => $game,
'hint' => $hint,
'form' => $form,
]);
}
#[Route('/{id}/delete', name: 'game_admin_hint_delete', methods: ['POST'])]
public function delete(Game $game, Hint $hint, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_hint_' . $hint->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Invalid CSRF token.');
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
}
$em->remove($hint);
$em->flush();
$this->addFlash('success', 'Hint deleted.');
return $this->redirectToRoute('game_admin_hints', ['game' => $game->getId()]);
}
}
+11 -1
View File
@@ -5,6 +5,7 @@ namespace App\Game\Controller;
use App\Game\Entity\Session;
use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Service\GameResponseService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Route('/game/api', name: 'game_api_')]
final class GameApiController extends AbstractController
@@ -19,7 +21,8 @@ final class GameApiController extends AbstractController
public function __construct(
protected GameResponseService $gameResponseService,
private EntityManagerInterface $entityManager
private EntityManagerInterface $entityManager,
private EventDispatcherInterface $eventDispatcher,
) {
}
@@ -46,6 +49,13 @@ final class GameApiController extends AbstractController
$session->setFinishedAt(new \DateTime());
$this->entityManager->persist($session);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
['type' => 'game_finished', 'status' => 'lost'],
'game_finished',
));
$isFinished = true;
}
} elseif ($session->getStatus() === SessionStatus::LOST || $session->getStatus() === SessionStatus::WON) {
+3 -1
View File
@@ -282,7 +282,8 @@ final class GameController extends AbstractController
Session $session,
Request $request,
Security $security,
PlayerRepository $playerRepository
PlayerRepository $playerRepository,
GameResponseService $gameResponseService
): Response {
/** @var User $user */
$user = $security->getUser();
@@ -304,6 +305,7 @@ final class GameController extends AbstractController
return $this->render('game/won.html.twig', [
'session' => $session,
'agentsText' => $gameResponseService->getSpecialReportAgentsText($session),
]);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Game\Controller;
use App\Game\Entity\Session;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Twig\Environment;
final class PreGameController extends AbstractController
{
public function __construct(
private readonly Environment $twig,
) {
}
#[Route(path: '/pregame/{session}', name: 'game_pregame', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('ROLE_PLAYER') or is_granted('ROLE_ADMIN')"))]
#[IsGranted('SESSION_VIEW', subject: 'session')]
public function index(Session $session): Response
{
$gameId = $session->getGame()?->getId();
$template = sprintf('pregame/game%d.html.twig', $gameId ?? 0);
if (!$this->twig->getLoader()->exists($template)) {
$template = 'pregame/default.html.twig';
}
return $this->render($template, [
'session' => $session,
]);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Game\Entity;
use App\Game\Enum\HintCondition;
use App\Game\Repository\HintRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: HintRepository::class)]
#[ORM\Table(name: 'hint')]
class Hint
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Game::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Game $game = null;
#[ORM\Column(name: 'hint_condition', type: 'string', length: 30, enumType: HintCondition::class)]
private ?HintCondition $condition = null;
#[ORM\Column]
private int $thresholdSeconds = 0;
#[ORM\Column(type: 'text')]
private ?string $message = null;
#[ORM\Column]
private int $sortOrder = 0;
public function getId(): ?int
{
return $this->id;
}
public function getGame(): ?Game
{
return $this->game;
}
public function setGame(?Game $game): static
{
$this->game = $game;
return $this;
}
public function getCondition(): ?HintCondition
{
return $this->condition;
}
public function setCondition(HintCondition $condition): static
{
$this->condition = $condition;
return $this;
}
public function getThresholdSeconds(): int
{
return $this->thresholdSeconds;
}
public function setThresholdSeconds(int $thresholdSeconds): static
{
$this->thresholdSeconds = $thresholdSeconds;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getSortOrder(): int
{
return $this->sortOrder;
}
public function setSortOrder(int $sortOrder): static
{
$this->sortOrder = $sortOrder;
return $this;
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ enum DecodeMessage: string
{
case PLAYER_1 = 'Sudo is now available';
case PLAYER_2 = 'AI virus protects its own files by replacing them';
case PLAYER_3 = 'The locked up bash files should be removed to lock it up';
case PLAYER_3 = 'The AI virus is having a lock on certain files. Remove these to disable it.';
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Game\Enum;
/**
* The fixed set of game-progress checks a hint can be gated behind. A hint fires
* only once its condition is still unresolved for that player - admins compose,
* order, and word hints freely, but the underlying checks are real game state and
* stay in code (see SendMainframeHintsCommand::isConditionResolved()).
*/
enum HintCondition: string
{
case HELP_USED = 'help_used';
case BROADCAST_DONE = 'broadcast_done';
case CONTACTED_ALL_PRIVATELY = 'contacted_all_privately';
case VERIFIED = 'verified';
case LEFT_HOME_DIRECTORY = 'left_home_directory';
case ALL_DECODED = 'all_decoded';
case NONE = 'none';
public function label(): string
{
return match ($this) {
self::HELP_USED => "Player hasn't used the help command yet",
self::BROADCAST_DONE => "Player hasn't sent an open (broadcast) chat message yet",
self::CONTACTED_ALL_PRIVATELY => "Player hasn't privately messaged every other agent yet",
self::VERIFIED => "Player isn't fully verified yet",
self::LEFT_HOME_DIRECTORY => "Player hasn't left their home directory",
self::ALL_DECODED => "Player hasn't decoded all messages",
self::NONE => 'No condition - always eligible once earlier hints are resolved (e.g. endgame nudges)',
};
}
}
+15 -3
View File
@@ -57,9 +57,11 @@ enum SessionSettingType: string
case VERIFICATION_PROGRESS_FOR_PLAYER9 = 'VerificationProgressForPlayer9';
case VERIFICATION_PROGRESS_FOR_PLAYER10 = 'VerificationProgressForPlayer10';
case EVERYONE_VERIFIED = 'EveryoneVerified';
case SPECIAL_REPORT_CODE_DOYLE = 'SpecialReportCodeDoyle';
case SPECIAL_REPORT_CODE_VEGA = 'SpecialReportCodeVega';
case SPECIAL_REPORT_CODE_LENNOX = 'SpecialReportCodeLennox';
case SPECIAL_REPORT_CODE_1 = 'SpecialReportCode1';
case SPECIAL_REPORT_CODE_2 = 'SpecialReportCode2';
case SPECIAL_REPORT_CODE_3 = 'SpecialReportCode3';
case SPECIAL_REPORT_ASSIGNMENTS = 'SpecialReportAssignments';
case DECOY_USERNAMES = 'DecoyUsernames';
case READY_AT_FOR_PLAYER1 = 'ReadyAtForPlayer1';
case READY_AT_FOR_PLAYER2 = 'ReadyAtForPlayer2';
case READY_AT_FOR_PLAYER3 = 'ReadyAtForPlayer3';
@@ -85,4 +87,14 @@ enum SessionSettingType: string
case LOCK_FOR_PLAYER9 = 'LockForPlayer9';
case LOCK_FOR_PLAYER10 = 'LockForPlayer10';
case LOCKED_FILES_REMOVAL_DEADLINE = 'LockedFilesRemovalDeadline';
case HELP_USED_FOR_PLAYER1 = 'HelpUsedForPlayer1';
case HELP_USED_FOR_PLAYER2 = 'HelpUsedForPlayer2';
case HELP_USED_FOR_PLAYER3 = 'HelpUsedForPlayer3';
case HELP_USED_FOR_PLAYER4 = 'HelpUsedForPlayer4';
case HELP_USED_FOR_PLAYER5 = 'HelpUsedForPlayer5';
case HELP_USED_FOR_PLAYER6 = 'HelpUsedForPlayer6';
case HELP_USED_FOR_PLAYER7 = 'HelpUsedForPlayer7';
case HELP_USED_FOR_PLAYER8 = 'HelpUsedForPlayer8';
case HELP_USED_FOR_PLAYER9 = 'HelpUsedForPlayer9';
case HELP_USED_FOR_PLAYER10 = 'HelpUsedForPlayer10';
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Game\Event;
use App\Game\Entity\Session;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Fired everywhere the game used to publish straight to the Mercure hub. Two
* listeners handle it: one publishes it to Mercure exactly as before, the other
* appends it to the affected player(s)' session activity log, so a player's full
* terminal history - not just their own commands - can be reconstructed later.
*/
final class PushMercureMessageEvent extends Event
{
/**
* @param mixed $payload The exact value that will be json_encode()'d as the
* Mercure update's data - unchanged from what used to be
* passed straight to `new Update($topic, json_encode(...))`.
* @param string $eventType Short machine-readable label for the log (e.g. 'chat',
* 'hint', 'security_alert', 'game_finished').
* @param int|null $targetScreen Which player this is for: null means everyone in
* the session, an int targets that one player's
* screen only (e.g. a private /chat message).
*/
public function __construct(
public readonly Session $session,
public readonly mixed $payload,
public readonly string $eventType,
public readonly ?int $targetScreen = null,
) {
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Game\EventListener;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Service\SessionActivityLogger;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
/**
* Appends the pushed message to the activity log of every player it was actually
* delivered to - everyone in the session for a broadcast, or just the one player
* for a targeted message (e.g. a private /chat, or a security alert). This is what
* lets a player's full terminal history be reconstructed later, not just the
* commands they typed themselves.
*/
#[AsEventListener]
final class LogMercureMessageListener
{
public function __construct(
private readonly SessionActivityLogger $sessionActivityLogger,
) {
}
public function __invoke(PushMercureMessageEvent $event): void
{
try {
foreach ($event->session->getPlayers() as $player) {
if ($event->targetScreen !== null && $player->getScreen() !== $event->targetScreen) {
continue;
}
$this->sessionActivityLogger->log($player, 'push', [
'eventType' => $event->eventType,
'payload' => $event->payload,
]);
}
} catch (\Throwable $e) {
// A logging hiccup should never break gameplay
}
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Game\EventListener;
use App\Game\Event\PushMercureMessageEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
/**
* Actually publishes the event's payload to the Mercure hub - this is the part
* that used to happen inline, directly at every $hub->publish() call site.
*/
#[AsEventListener]
final class PublishMercureMessageListener
{
public function __construct(
private readonly HubInterface $hub,
) {
}
public function __invoke(PushMercureMessageEvent $event): void
{
try {
$topic = '/game/hub/' . $event->session->getId();
$this->hub->publish(new Update($topic, json_encode($event->payload)));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Game\Form;
use App\Game\Entity\Hint;
use App\Game\Enum\HintCondition;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\PositiveOrZero;
class AdminHintType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('condition', ChoiceType::class, [
'label' => 'Fires while...',
'choices' => array_combine(
array_map(fn (HintCondition $c) => $c->label(), HintCondition::cases()),
HintCondition::cases(),
),
])
->add('thresholdSeconds', IntegerType::class, [
'label' => 'Threshold (seconds since game start)',
'constraints' => [new NotBlank(), new PositiveOrZero()],
])
->add('message', TextareaType::class, [
'constraints' => [new NotBlank()],
'attr' => ['rows' => 4],
])
->add('sortOrder', IntegerType::class, [
'label' => 'Order (lower fires first)',
'constraints' => [new NotBlank()],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Hint::class,
]);
}
}
+6
View File
@@ -3,6 +3,7 @@
namespace App\Game\Repository;
use App\Game\Entity\Game;
use App\Game\Enum\GameStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -15,4 +16,9 @@ class GameRepository extends ServiceEntityRepository
{
parent::__construct($registry, Game::class);
}
public function hasOpenGame(): bool
{
return $this->count(['status' => GameStatus::OPEN]) > 0;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Game\Repository;
use App\Game\Entity\Game;
use App\Game\Entity\Hint;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Hint>
*/
class HintRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Hint::class);
}
/**
* @return Hint[]
*/
public function findByGameOrdered(Game $game): array
{
return $this->findBy(['game' => $game], ['sortOrder' => 'ASC']);
}
}
@@ -36,4 +36,22 @@ class SessionSettingRepository extends ServiceEntityRepository
return $qb->getQuery()->getOneOrNullResult();
}
/**
* @return SessionSetting[]
*/
public function findFeedbackSettings(): array
{
return $this->createQueryBuilder('s')
->andWhere('s.name IN (:names)')
->setParameter('names', [
SessionSettingType::FEEDBACK_DIFFICULTY->value,
SessionSettingType::FEEDBACK_ENTERTAINING->value,
SessionSettingType::FEEDBACK_THEME->value,
SessionSettingType::FEEDBACK_TEXT->value,
])
->orderBy('s.player', 'DESC')
->getQuery()
->getResult();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Game\Service;
use App\Game\Enum\SessionSettingType;
use App\Game\Repository\SessionSettingRepository;
final class FeedbackService
{
public function __construct(
private readonly SessionSettingRepository $sessionSettingRepository,
) {
}
/**
* One row per player who submitted feedback, most recent first.
*
* @return array<int, array{session: \App\Game\Entity\Session, player: \App\Game\Entity\Player, difficulty: ?int, entertaining: ?int, theme: ?int, text: ?string}>
*/
public function getFeedbackEntries(): array
{
$entries = [];
foreach ($this->sessionSettingRepository->findFeedbackSettings() as $setting) {
$player = $setting->getPlayer();
if (!$player) {
continue;
}
$key = $player->getId();
if (!isset($entries[$key])) {
$entries[$key] = [
'session' => $setting->getSession(),
'player' => $player,
'difficulty' => null,
'entertaining' => null,
'theme' => null,
'text' => null,
];
}
match ($setting->getName()) {
SessionSettingType::FEEDBACK_DIFFICULTY => $entries[$key]['difficulty'] = (int) $setting->getValue(),
SessionSettingType::FEEDBACK_ENTERTAINING => $entries[$key]['entertaining'] = (int) $setting->getValue(),
SessionSettingType::FEEDBACK_THEME => $entries[$key]['theme'] = (int) $setting->getValue(),
SessionSettingType::FEEDBACK_TEXT => $entries[$key]['text'] = $setting->getValue(),
default => null,
};
}
return array_values($entries);
}
/**
* Aggregate numbers only - free-text comments are left out since they're
* unmoderated player input and this summary is meant for the public
* briefing page. Use getFeedbackEntries() for the admin-only raw view.
*
* @return array{count: int, avgDifficulty: ?float, avgEntertaining: ?float, avgTheme: ?float}
*/
public function getFeedbackSummary(): array
{
$entries = $this->getFeedbackEntries();
return [
'count' => count($entries),
'avgDifficulty' => $this->average(array_column($entries, 'difficulty')),
'avgEntertaining' => $this->average(array_column($entries, 'entertaining')),
'avgTheme' => $this->average(array_column($entries, 'theme')),
];
}
/**
* @param array<int, int|null> $values
*/
private function average(array $values): ?float
{
$values = array_filter($values, static fn ($v) => $v !== null);
return $values ? round(array_sum($values) / count($values), 1) : null;
}
}
+87 -15
View File
@@ -11,14 +11,14 @@ use App\Game\Entity\SessionSetting;
use App\Game\Enum\GameStatus;
use App\Game\Enum\SessionSettingType;
use App\Game\Enum\SessionStatus;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Repository\GameRepository;
use App\Game\Repository\LobbyMessageRepository;
use App\Game\Repository\SessionRepository;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final class GameDashboardService
{
@@ -26,12 +26,43 @@ final class GameDashboardService
private const LOBBY_MESSAGE_MAX_LENGTH = 500;
private const LOBBY_CHAT_GRACE_PERIOD_SECONDS = 3600;
private const DECOY_USERNAME_POOL = [
'Luke', 'Charles', 'William', 'Peter', 'Simon',
'Oliver', 'Daniel', 'Thomas', 'Edward', 'George',
];
private const DECOY_USERNAME_COUNT = 6;
// File => cover-identity agent name, mirrors assets/game1/filesystem/var/rapports/index.txt
private const RAPPORT_AGENTS = [
'/var/rapports/095_07-14.txt' => 'Mason',
'/var/rapports/007_19-52.txt' => 'Harper',
'/var/rapports/083_25-39.txt' => 'Doyle',
'/var/rapports/019_31-11.txt' => 'Vega',
'/var/rapports/075_46-77.txt' => 'Ellis',
'/var/rapports/031_53-28.txt' => 'Navarro',
'/var/rapports/072_61-05.txt' => 'Carter',
'/var/rapports/064_72-90.txt' => 'Quinn',
'/var/rapports/091_81-33.txt' => 'Raines',
'/var/rapports/079_89-47.txt' => 'Dalton',
'/var/rapports/098_92-14.txt' => 'Langston',
'/var/rapports/012_94-31.txt' => 'Donovan',
'/var/rapports/016_98-07.txt' => 'Fletcher',
'/var/rapports/087_102-45.txt' => 'Sanders',
'/var/rapports/094_110-19.txt' => 'Mitchell',
'/var/rapports/063_117-56.txt' => 'O’Connor',
'/var/rapports/017_123-88.txt' => 'Holloway',
'/var/rapports/011_130-62.txt' => 'Lennox',
'/var/rapports/093_138-24.txt' => 'Bennett',
'/var/rapports/001_145-93.txt' => 'Carver',
];
private const SPECIAL_REPORT_COUNT = 3;
public function __construct(
private readonly GameRepository $gameRepository,
private readonly SessionRepository $sessionRepository,
private readonly LobbyMessageRepository $lobbyMessageRepository,
private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub,
private readonly EventDispatcherInterface $eventDispatcher,
) {
}
@@ -257,6 +288,50 @@ final class GameDashboardService
}
}
// Picks decoy usernames for /var/home and verifyCodes.txt, excluding any name
// already taken by a real player in this session so they can never collide.
private function initializeDecoyUsernames(Session $session): void
{
$realUsernames = array_map(
static fn (Player $p) => $p->getUser()->getUsername(),
$session->getPlayers()->toArray()
);
$availableDecoys = array_values(array_diff(self::DECOY_USERNAME_POOL, $realUsernames));
shuffle($availableDecoys);
$selected = array_slice($availableDecoys, 0, self::DECOY_USERNAME_COUNT);
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setName(SessionSettingType::DECOY_USERNAMES);
$setting->setValue(json_encode($selected));
$this->entityManager->persist($setting);
}
// Picks which 3 rapports carry the coded messages this session, so it isn't
// always the same Doyle/Vega/Lennox trio.
private function initializeSpecialReportAssignments(Session $session): void
{
$files = array_keys(self::RAPPORT_AGENTS);
shuffle($files);
$chosen = array_slice($files, 0, self::SPECIAL_REPORT_COUNT);
$assignments = [];
foreach ($chosen as $index => $file) {
$assignments[] = [
'file' => $file,
'agent' => self::RAPPORT_AGENTS[$file],
'digit' => $index + 1,
];
}
$setting = new SessionSetting();
$setting->setSession($session);
$setting->setName(SessionSettingType::SPECIAL_REPORT_ASSIGNMENTS);
$setting->setValue(json_encode($assignments));
$this->entityManager->persist($setting);
}
public function startSession(Session $session): bool
{
if ($session->getStatus() !== SessionStatus::CREATED) {
@@ -286,6 +361,9 @@ final class GameDashboardService
$this->entityManager->persist($player);
}
$this->initializeDecoyUsernames($session);
$this->initializeSpecialReportAssignments($session);
$session->setStatus(SessionStatus::READY);
$this->entityManager->persist($session);
$this->entityManager->flush();
@@ -480,12 +558,11 @@ final class GameDashboardService
private function publishLobbyEvent(Session $session, string $type, array $extra = []): void
{
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(array_merge(['type' => $type], $extra))));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
array_merge(['type' => $type], $extra),
$type,
));
}
public function checkAllPlayersReady(Session $session): void
@@ -559,12 +636,7 @@ final class GameDashboardService
$this->entityManager->flush();
try {
$topic = '/game/hub/' . $session->getId();
$this->hub->publish(new Update($topic, json_encode(['type' => 'all_ready'])));
} catch (\Exception $e) {
// Mercure might be down, but we don't want to crash the game
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, ['type' => 'all_ready'], 'all_ready'));
}
}
+195 -150
View File
@@ -8,12 +8,12 @@ use App\Game\Enum\SessionStatus;
use App\Game\Entity\Player;
use App\Game\Entity\Session;
use App\Game\Entity\SessionSetting;
use App\Game\Event\PushMercureMessageEvent;
use App\Game\Repository\SessionSettingRepository;
use App\Tech\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class GameResponseService
{
@@ -25,8 +25,9 @@ class GameResponseService
private Security $security,
private PlayerService $playerService,
private SessionSettingRepository $sessionSettingRepository,
private HubInterface $hub,
private EntityManagerInterface $entityManager,
private EventDispatcherInterface $eventDispatcher,
private SessionActivityLogger $sessionActivityLogger,
private string $projectDir,
) {
}
@@ -72,7 +73,7 @@ class GameResponseService
$this->enforceLockedFilesRemovalDeadline($player->getSession());
$this->logSessionActivity($player, 'PLAYER: ' . $message);
$this->sessionActivityLogger->log($player, 'player', ['content' => $message]);
$data = $this->handleLockedPlayer($message, $player);
@@ -98,28 +99,12 @@ class GameResponseService
}
if ($responseLog !== '') {
$this->logSessionActivity($player, 'SERVER: ' . trim($responseLog));
$this->sessionActivityLogger->log($player, 'server', ['content' => trim($responseLog)]);
}
return $data;
}
private function logSessionActivity(Player $player, string $content): void
{
$sessionId = $player->getSession()->getId();
$logDir = $this->projectDir . '/var/log/sessions/' . $sessionId;
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
}
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
$timestamp = date('Y-m-d H:i:s');
$logMessage = sprintf("[%s] %s\n", $timestamp, $content);
file_put_contents($logFile, $logMessage, FILE_APPEND);
}
private function getRechten(Player $player): array
{
$settingName = SessionSettingType::tryFrom('RightsForPlayer' . $player->getScreen());
@@ -136,6 +121,35 @@ class GameResponseService
return json_decode($setting->getValue(), true) ?? [];
}
/**
* Records that a player has used the help command, so SendMainframeHintsCommand
* can stop nudging them towards it. Absence of this setting means "not used yet".
*/
private function markHelpCommandUsed(Player $player): void
{
$settingName = SessionSettingType::tryFrom('HelpUsedForPlayer' . $player->getScreen());
if (!$settingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $settingName, $player);
if ($setting && $setting->getValue() === 'true') {
return;
}
if (!$setting) {
$setting = new SessionSetting();
$setting->setSession($player->getSession());
$setting->setPlayer($player);
$setting->setName($settingName);
}
$setting->setValue('true');
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
private function checkGameCommando(string $message, Player $player) : array
{
$messagePart = explode(' ', $message);
@@ -152,7 +166,8 @@ class GameResponseService
else
return ['result' => ['Error sending']];
case '/help':
return ['result' => $this->getHelpCommand($rechten)];
$this->markHelpCommandUsed($player);
return ['result' => $this->getHelpCommand($rechten, $player)];
case '/decode':
if(!in_array('decode', $rechten))
return ['result' => ['Unknown command']];
@@ -164,6 +179,14 @@ class GameResponseService
$result = $this->handleVerifyMessage($message, $player);
return ['result' => [$result]];
case '/pwd':
case '/ls':
case '/scan':
case '/sudo':
case '/rm':
case '/cd':
case '/cat':
return $this->checkConsoleCommando(substr($message, 1), $player);
default:
return ['result' => ['Unknown command']];
}
@@ -175,7 +198,8 @@ class GameResponseService
$rechten = $this->getRechten($player);
switch($messagePart[0]) {
case 'help':
return ['result' => $this->getHelpCommand($rechten)];
$this->markHelpCommandUsed($player);
return ['result' => $this->getHelpCommand($rechten, $player)];
case 'ls':
if(!in_array('ls', $rechten))
return ['result' => ['Unknown command']];
@@ -224,7 +248,9 @@ class GameResponseService
}
$filename = $messagePart[1];
$fullPath = ($pwd === '/' ? '' : $pwd) . '/' . $filename;
$fullPath = str_starts_with($filename, '/')
? $filename
: ($pwd === '/' ? '' : $pwd) . '/' . $filename;
if(!$this->isAllowedToRemove($fullPath, $player, $sudo))
return ['result' => ['You are not allowed to remove this file.']];
@@ -276,14 +302,18 @@ class GameResponseService
$result[] = 'These files are protected by the AI virus. Use sudo rm {file} to remove them.';
return ['result' => $result];
case 'chat':
case 'verify':
case 'decode':
return $this->checkGameCommando('/'.$message, $player);
default:
return ['result' => ['Unknown command']];
}
}
private function getHelpCommand(mixed $rechten) : array
private function getHelpCommand(mixed $rechten, Player $player) : array
{
$messages = [];
$messages = ['Help function for Agent ' . $player->getScreen() . ':'];
foreach($rechten as $recht) {
switch($recht) {
@@ -399,26 +429,28 @@ class GameResponseService
$message = trim($message);
$activeGame = $player->getSession()?->getId();
$session = $player->getSession();
if(is_null($activeGame))
if(is_null($session))
return false;
$topic = '/game/hub/' . $activeGame;
try {
$this->hub->publish(new Update($topic, json_encode([$sendTo, $message])));
} catch (\Exception $e) {
// Mercure might be down
}
// A /chat targeted at one agent (e.g. "/chat 3 ...") must only ever be
// logged for that agent, not broadcast into everyone's transcript.
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
[$sendTo, $message],
'chat',
(int)$sendTo === 0 ? null : (int)$sendTo,
));
$this->updateChatTracking($player, (int)$sendTo);
$this->checkAndRegenerateVerifyCodes($player, $chatMessage . ' ' . implode(' ', $messageParts));
$this->checkAndRegenerateVerifyCodes($player, $chatMessage . ' ' . implode(' ', $messageParts), (int)$sendTo);
return true;
}
private function checkAndRegenerateVerifyCodes(Player $player, string $messageContent): void
private function checkAndRegenerateVerifyCodes(Player $player, string $messageContent, int $sendTo): void
{
$screen = $player->getScreen();
$session = $player->getSession();
@@ -438,26 +470,33 @@ class GameResponseService
$regenerated = false;
foreach ($codes as $targetPlayerScreen => $code) {
if (str_contains($messageContent, (string)$code)) {
if (!str_contains($messageContent, (string)$code)) {
continue;
}
// Sending a code via a targeted /chat {agent-id} is fine, even to the
// wrong player - only broadcasting it in the open chat is a real leak.
if ($sendTo !== 0) {
continue;
}
$codes[$targetPlayerScreen] = bin2hex(random_bytes(3));
$regenerated = true;
}
}
if ($regenerated) {
$setting->setValue(json_encode($codes));
$this->entityManager->persist($setting);
$this->entityManager->flush();
// Notify the player that their codes have changed
$topic = '/game/hub/' . $session->getId();
// Notify the player that their codes have changed - only this one (screen)
$notification = "Security Alert: One of your verify codes was shared and has been regenerated.";
// We send it only to this player (screen)
try {
$this->hub->publish(new Update($topic, json_encode([$screen, $notification])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
[$screen, $notification],
'security_alert',
$screen,
));
}
}
@@ -512,33 +551,7 @@ class GameResponseService
}
// Grant verify right
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if (!$rightsSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
if (!$setting) {
return; // Should have been initialized
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
$newRights = ['verify', 'cat'];
$updated = false;
foreach ($newRights as $newRight) {
if (!in_array($newRight, $rights)) {
$rights[] = $newRight;
$updated = true;
}
}
if ($updated) {
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$this->entityManager->flush();
}
$this->grantRights($player, ['verify', 'cat']);
}
private function handleDecodeMessage(string $message, Player $player): string
@@ -566,43 +579,74 @@ class GameResponseService
if ($decodeMessage === DecodeMessage::PLAYER_1) {
$this->grantRightToAllPlayers($player->getSession(), 'sudo');
$this->grantRightToAllPlayers($player->getSession(), 'rm');
}
if ($decodeMessage === DecodeMessage::PLAYER_2) {
$this->grantRightToAllPlayers($player->getSession(), 'scan');
}
if ($decodeMessage === DecodeMessage::PLAYER_3) {
$this->grantRightToAllPlayers($player->getSession(), 'rm');
}
return $decodeMessage->value;
}
private function grantRightToAllPlayers(Session $session, string $right): void
{
$updated = false;
foreach ($session->getPlayers() as $sessionPlayer) {
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $sessionPlayer->getScreen());
if (!$rightsSettingName) {
continue;
$this->grantRights($sessionPlayer, [$right]);
}
}
$setting = $this->sessionSettingRepository->getSetting($session, $rightsSettingName, $sessionPlayer);
/**
* The single place a player's rights ever get granted. Persists any rights the
* player doesn't already have and, if anything actually changed, notifies that
* player (and only that player) over Mercure that they've gained access.
*
* @param string[] $newRights
* @return bool Whether any right was actually newly granted.
*/
private function grantRights(Player $player, array $newRights): bool
{
$screen = $player->getScreen();
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if (!$rightsSettingName) {
return false;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
if (!$setting) {
continue;
return false; // Should have been initialized
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
if (!in_array($right, $rights)) {
$rights[] = $right;
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$updated = true;
$granted = [];
foreach ($newRights as $newRight) {
if (!in_array($newRight, $rights)) {
$rights[] = $newRight;
$granted[] = $newRight;
}
}
if ($updated) {
$this->entityManager->flush();
if (empty($granted)) {
return false;
}
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$player->getSession(),
[$screen, 'MAINFRAME: You have received new access: ' . implode(', ', $granted) . '.', 'mainframe'],
'rights_granted',
$screen,
));
return true;
}
/**
@@ -764,7 +808,9 @@ class GameResponseService
private function generateSpecialCode(int $firstDigit, int $min, int $max): string
{
$code = $this->generateRandomString($min, $max);
// No spaces: this code gets typed back in as a single /decode argument,
// which is split on spaces, so a space here would silently truncate it.
$code = str_replace(' ', '', $this->generateRandomString($min, $max));
// Ensure the first numeric digit is the specified $firstDigit
$found = false;
@@ -860,34 +906,7 @@ class GameResponseService
private function grantVerificationRights(Player $player): void
{
$screen = $player->getScreen();
$rightsSettingName = SessionSettingType::tryFrom('RightsForPlayer' . $screen);
if (!$rightsSettingName) {
return;
}
$setting = $this->sessionSettingRepository->getSetting($player->getSession(), $rightsSettingName, $player);
if (!$setting) {
return;
}
$rights = json_decode($setting->getValue() ?? '[]', true) ?? [];
$newRights = ['cd', 'decode'];
$updated = false;
foreach ($newRights as $newRight) {
if (!in_array($newRight, $rights)) {
$rights[] = $newRight;
$updated = true;
}
}
if ($updated) {
$setting->setValue(json_encode($rights));
$this->entityManager->persist($setting);
$this->entityManager->flush();
if ($this->grantRights($player, ['cd', 'decode'])) {
$this->checkIfAllPlayersVerified($player);
}
}
@@ -929,14 +948,50 @@ class GameResponseService
$this->entityManager->persist($everyoneVerifiedSetting);
$this->entityManager->flush();
$topic = '/game/hub/' . $session->getId();
$message = "Mainframe Help Modus: Agents Doyle, Vega and Lennox rapports have been updated with coded messages.";
try {
$this->hub->publish(new Update($topic, json_encode([0, $message])));
} catch (\Exception $e) {
// Mercure might be down
$agentNames = $this->getSpecialReportAgentNames($session);
$message = "Mainframe Help Modus: Agents " . $this->formatNameList($agentNames) . " rapports have been updated with coded messages.";
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, [0, $message], 'help_modus_activated'));
}
}
private function getDecoyUsernames(Session $session): array
{
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::DECOY_USERNAMES);
$decoded = json_decode($setting?->getValue() ?? '[]', true);
return is_array($decoded) ? $decoded : [];
}
private function getSpecialReportAssignments(Session $session): array
{
$setting = $this->sessionSettingRepository->getSetting($session, SessionSettingType::SPECIAL_REPORT_ASSIGNMENTS);
$decoded = json_decode($setting?->getValue() ?? '[]', true);
return is_array($decoded) ? $decoded : [];
}
public function getSpecialReportAgentNames(Session $session): array
{
return array_column($this->getSpecialReportAssignments($session), 'agent');
}
public function getSpecialReportAgentsText(Session $session): string
{
return $this->formatNameList($this->getSpecialReportAgentNames($session));
}
private function formatNameList(array $names): string
{
if (count($names) === 0) {
return '';
}
if (count($names) === 1) {
return $names[0];
}
$last = array_pop($names);
return implode(', ', $names) . ' and ' . $last;
}
private function goToNewDir(string $pwd, string $newPwd, Player $player) : string|bool
@@ -1055,7 +1110,7 @@ class GameResponseService
$paths[] = '/var/home';
$playerNames = ['root', 'Luke', 'Charles', 'William', 'Peter'];
$playerNames = array_merge(['root'], $this->getDecoyUsernames($player->getSession()));
$players = $player->getSession()->getPlayers();
@@ -1121,12 +1176,11 @@ class GameResponseService
$this->entityManager->persist($session);
$this->entityManager->flush();
$topic = '/game/hub/' . $session->getId();
try {
$this->hub->publish(new Update($topic, json_encode(['type' => 'game_finished', 'status' => 'won'])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent(
$session,
['type' => 'game_finished', 'status' => 'won'],
'game_finished',
));
return true;
}
@@ -1211,13 +1265,8 @@ class GameResponseService
}
if (!empty($stillLocked)) {
$topic = '/game/hub/' . $session->getId();
$message = 'AI VIRUS: Integrity check complete. Restored files that were not fully purged in time.';
try {
$this->hub->publish(new Update($topic, json_encode([0, $message, 'virus'])));
} catch (\Exception $e) {
// Mercure might be down
}
$this->eventDispatcher->dispatch(new PushMercureMessageEvent($session, [0, $message, 'virus'], 'virus_alert'));
}
}
}
@@ -1471,20 +1520,16 @@ class GameResponseService
return ['Error reading file'];
}
$specialFiles = [
'/var/rapports/083_25-39.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_DOYLE,
'digit' => 1
],
'/var/rapports/019_31-11.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_VEGA,
'digit' => 2
],
'/var/rapports/011_130-62.txt' => [
'setting' => SessionSettingType::SPECIAL_REPORT_CODE_LENNOX,
'digit' => 3
],
$specialFiles = [];
foreach ($this->getSpecialReportAssignments($player->getSession()) as $assignment) {
$settingType = SessionSettingType::tryFrom('SpecialReportCode' . $assignment['digit']);
if ($settingType) {
$specialFiles[$assignment['file']] = [
'setting' => $settingType,
'digit' => $assignment['digit'],
];
}
}
if (isset($specialFiles[$file])) {
$everyoneVerifiedSetting = $this->sessionSettingRepository->getSetting($player->getSession(), SessionSettingType::EVERYONE_VERIFIED);
@@ -1552,7 +1597,7 @@ class GameResponseService
$codes = json_decode($setting->getValue() ?? '[]', true) ?? [];
$playerNames = ['Luke', 'Charles', 'William', 'Peter'];
$playerNames = $this->getDecoyUsernames($player->getSession());
foreach ($player->getSession()->getPlayers() as $p) {
$playerNames[] = $p->getUser()->getUsername();
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Game\Service;
use App\Game\Entity\Player;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Appends structured (JSON Lines) entries to a player's per-session activity log
* at var/log/sessions/{sessionId}/{player}.txt. Used both for a player's own
* commands (source: player/server) and for anything pushed to them over Mercure
* (source: push), so the file ends up as a single chronological, replayable
* transcript of everything that player saw.
*/
class SessionActivityLogger
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
}
public function log(Player $player, string $source, array $data): void
{
$session = $player->getSession();
if ($session === null) {
return;
}
$logDir = $this->projectDir . '/var/log/sessions/' . $session->getId();
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
}
$logFile = $logDir . '/' . $player->getLogFileBasename() . '.txt';
$entry = array_merge([
'ts' => (new \DateTimeImmutable())->format(DATE_ATOM),
'source' => $source,
], $data);
file_put_contents($logFile, json_encode($entry) . "\n", FILE_APPEND | LOCK_EX);
}
}
@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace App\Website\Command;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Repository\EscapeRoomRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsCommand(
name: 'app:escaperooms:import-osm',
description: 'Import physical escape rooms (leisure=escape_game) from OpenStreetMap via the Overpass API',
)]
final class ImportEscapeRoomsFromOsmCommand extends Command
{
private const DEFAULT_ENDPOINT = 'https://overpass-api.de/api/interpreter';
/**
* Continental-ish tiles covering inhabited land. Used with --tiled so no single
* Overpass response has to carry the whole planet at once.
*
* @var array<string, array{float, float, float, float}> name => [south, west, north, east]
*/
private const WORLD_TILES = [
'Europe' => [34.0, -25.0, 72.0, 45.0],
'Africa' => [-36.0, -20.0, 38.0, 52.0],
'Middle East' => [12.0, 26.0, 43.0, 63.0],
'North Asia' => [40.0, 45.0, 78.0, 180.0],
'South Asia' => [5.0, 63.0, 40.0, 93.0],
'East Asia' => [18.0, 93.0, 54.0, 146.0],
'SE Asia/Oceania' => [-48.0, 93.0, 18.0, 180.0],
'North America' => [10.0, -168.0, 72.0, -52.0],
'Central America' => [7.0, -92.0, 33.0, -58.0],
'South America' => [-56.0, -82.0, 13.0, -34.0],
];
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EntityManagerInterface $em,
private readonly EscapeRoomRepository $rooms,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('area', null, InputOption::VALUE_REQUIRED, 'Restrict to one country by ISO 3166-1 alpha-2 code, e.g. --area=NL')
->addOption('bbox', null, InputOption::VALUE_REQUIRED, 'Restrict to a bounding box: --bbox=south,west,north,east')
->addOption('tiled', null, InputOption::VALUE_NONE, 'Split a worldwide import into continental tiles (recommended for the first run)')
->addOption('endpoint', null, InputOption::VALUE_REQUIRED, 'Overpass API endpoint', self::DEFAULT_ENDPOINT)
->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'Overpass server-side timeout in seconds', '600')
->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Seconds to wait between requests', '5')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Fetch and parse but write nothing to the database');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$endpoint = (string) $input->getOption('endpoint');
$timeout = max(30, (int) $input->getOption('timeout'));
$sleep = max(0, (int) $input->getOption('sleep'));
$dryRun = (bool) $input->getOption('dry-run');
try {
$queries = $this->buildQueries($input, $timeout);
} catch (\InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::INVALID;
}
$io->title('Importing escape rooms from OpenStreetMap');
$io->writeln(sprintf('Endpoint: <info>%s</info> · batches: <info>%d</info>%s', $endpoint, \count($queries), $dryRun ? ' · <comment>dry run</comment>' : ''));
$totals = ['created' => 0, 'updated' => 0, 'locked' => 0, 'noname' => 0, 'nocoords' => 0];
$seen = 0;
$first = true;
foreach ($queries as $label => $ql) {
if (!$first && $sleep > 0) {
$io->writeln(sprintf(' <comment>sleeping %ds…</comment>', $sleep));
sleep($sleep);
}
$first = false;
$io->section((string) $label);
$elements = $this->fetch($endpoint, $ql, $timeout, $io);
if ($elements === null) {
$io->warning('Skipping this batch after a failed request.');
continue;
}
$io->writeln(sprintf(' %d element(s) returned', \count($elements)));
$batch = 0;
foreach ($elements as $element) {
++$seen;
$outcome = $this->upsert($element, $dryRun);
++$totals[$outcome];
if (!$dryRun && $outcome !== 'noname' && $outcome !== 'nocoords' && ++$batch % 400 === 0) {
$this->em->flush();
$this->em->clear();
}
}
if (!$dryRun) {
$this->em->flush();
$this->em->clear();
}
}
$io->newLine();
$io->success(sprintf(
"%d element(s) processed\n created: %d\n updated: %d\n skipped (locked): %d\n skipped (no name): %d\n skipped (no coords): %d",
$seen,
$totals['created'],
$totals['updated'],
$totals['locked'],
$totals['noname'],
$totals['nocoords'],
));
return Command::SUCCESS;
}
/**
* @return array<string, string> label => Overpass QL
*/
private function buildQueries(InputInterface $input, int $timeout): array
{
$area = $input->getOption('area');
$bbox = $input->getOption('bbox');
$tiled = (bool) $input->getOption('tiled');
$header = sprintf('[out:json][timeout:%d];', $timeout);
$footer = 'out center tags;';
if ($area !== null) {
$code = strtoupper(trim((string) $area));
if (!preg_match('/^[A-Z]{2}$/', $code)) {
throw new \InvalidArgumentException('--area expects a two-letter ISO country code, e.g. NL');
}
return [
'Country '.$code => sprintf('%s area["ISO3166-1"="%s"][admin_level=2]->.a; nwr["leisure"="escape_game"](area.a); %s', $header, $code, $footer),
];
}
if ($bbox !== null) {
$box = $this->parseBbox((string) $bbox);
return [
'Bounding box' => sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer),
];
}
if ($tiled) {
$queries = [];
foreach (self::WORLD_TILES as $name => $box) {
$queries[$name] = sprintf('%s nwr["leisure"="escape_game"](%F,%F,%F,%F); %s', $header, $box[0], $box[1], $box[2], $box[3], $footer);
}
return $queries;
}
return [
'Worldwide' => sprintf('%s nwr["leisure"="escape_game"]; %s', $header, $footer),
];
}
/**
* @return array{float, float, float, float} south, west, north, east
*/
private function parseBbox(string $raw): array
{
$parts = array_map('trim', explode(',', $raw));
if (\count($parts) !== 4 || array_filter($parts, static fn ($p) => !is_numeric($p))) {
throw new \InvalidArgumentException('--bbox expects four comma-separated numbers: south,west,north,east');
}
return [(float) $parts[0], (float) $parts[1], (float) $parts[2], (float) $parts[3]];
}
/**
* @return array<int, array<string, mixed>>|null
*/
private function fetch(string $endpoint, string $ql, int $timeout, SymfonyStyle $io): ?array
{
for ($attempt = 1; $attempt <= 2; ++$attempt) {
try {
$response = $this->httpClient->request('POST', $endpoint, [
'body' => ['data' => $ql],
'headers' => ['Accept' => 'application/json'],
'timeout' => $timeout + 60,
]);
$data = $response->toArray();
if (isset($data['remark']) && str_contains((string) $data['remark'], 'error')) {
$io->warning('Overpass reported: '.$data['remark']);
return null;
}
return $data['elements'] ?? [];
} catch (\Throwable $e) {
$io->writeln(sprintf(' <comment>request failed (attempt %d/2): %s</comment>', $attempt, $e->getMessage()));
if ($attempt < 2) {
sleep(15);
}
}
}
return null;
}
/**
* @param array<string, mixed> $element
*
* @return 'created'|'updated'|'locked'|'noname'|'nocoords'
*/
private function upsert(array $element, bool $dryRun): string
{
$tags = $element['tags'] ?? [];
$name = mb_substr(trim((string) ($tags['name'] ?? '')), 0, 255);
if ($name === '') {
return 'noname';
}
[$lat, $lng] = $this->coords($element);
if ($lat === null || $lng === null) {
return 'nocoords';
}
$osmType = (string) ($element['type'] ?? 'node');
$osmId = (string) ($element['id'] ?? '');
$room = $this->rooms->findOneByOsm($osmType, $osmId);
$isNew = $room === null;
if (!$isNew && $room->isLocked()) {
return 'locked';
}
if ($isNew) {
$room = (new EscapeRoom())
->setSource(EscapeRoomSource::OSM)
->setStatus(EscapeRoomStatus::PUBLISHED)
->setOsmType($osmType)
->setOsmId($osmId);
}
// Clamp each value to its column width - OSM address tags are free text
// and occasionally blow past these (e.g. addr:housenumber "12-14, 16, 18").
$room
->setName($name)
->setVenue($this->firstTag($tags, ['brand', 'operator'], 255))
->setStreet($this->firstTag($tags, ['addr:street'], 255))
->setHouseNumber($this->firstTag($tags, ['addr:housenumber'], 32))
->setPostcode($this->firstTag($tags, ['addr:postcode'], 32))
->setCity($this->firstTag($tags, ['addr:city', 'addr:town', 'addr:suburb'], 128))
->setCountry($this->normaliseCountry($this->firstTag($tags, ['addr:country'], 8)))
->setWebsite($this->firstTag($tags, ['website', 'contact:website', 'url'], 511))
->setPhone($this->firstTag($tags, ['phone', 'contact:phone'], 64))
->setLatitude($lat)
->setLongitude($lng);
if (!$isNew) {
$room->touch();
}
if (!$dryRun) {
$this->em->persist($room);
}
return $isNew ? 'created' : 'updated';
}
/**
* @param array<string, mixed> $element
*
* @return array{?float, ?float}
*/
private function coords(array $element): array
{
if (isset($element['lat'], $element['lon'])) {
return [(float) $element['lat'], (float) $element['lon']];
}
if (isset($element['center']['lat'], $element['center']['lon'])) {
return [(float) $element['center']['lat'], (float) $element['center']['lon']];
}
return [null, null];
}
/**
* First non-empty value among $keys, trimmed and clamped to $maxLen chars.
*
* @param array<string, mixed> $tags
* @param string[] $keys
*/
private function firstTag(array $tags, array $keys, int $maxLen = 255): ?string
{
foreach ($keys as $key) {
$value = trim((string) ($tags[$key] ?? ''));
if ($value !== '') {
return mb_substr($value, 0, $maxLen);
}
}
return null;
}
private function normaliseCountry(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = strtoupper(trim($value));
return preg_match('/^[A-Z]{2}$/', $value) === 1 ? $value : null;
}
}
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\AdminEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/escape-rooms')]
#[IsGranted('ROLE_ADMIN')]
final class AdminEscapeRoomController extends AbstractController
{
#[Route('', name: 'website_admin_escaperooms', methods: ['GET'])]
public function index(Request $request, EscapeRoomRepository $rooms): Response
{
$filter = EscapeRoomStatus::tryFrom((string) $request->query->get('status'));
return $this->render('website/admin/escape_room/index.html.twig', [
'rooms' => $rooms->findForAdmin($filter),
'reviewCounts' => $rooms->reviewCounts(),
'filter' => $filter,
'counts' => [
'all' => \count($rooms->findForAdmin()),
EscapeRoomStatus::PENDING->value => $rooms->countByStatus(EscapeRoomStatus::PENDING),
EscapeRoomStatus::PUBLISHED->value => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
EscapeRoomStatus::HIDDEN->value => $rooms->countByStatus(EscapeRoomStatus::HIDDEN),
EscapeRoomStatus::REJECTED->value => $rooms->countByStatus(EscapeRoomStatus::REJECTED),
],
]);
}
#[Route('/new', name: 'website_admin_escaperoom_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($room);
$em->flush();
$this->addFlash('success', sprintf('"%s" added.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'New escape room',
]);
}
#[Route('/{id}/edit', name: 'website_admin_escaperoom_edit', methods: ['GET', 'POST'], requirements: ['id' => '\d+'])]
public function edit(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
$form = $this->createForm(AdminEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->touch();
$em->flush();
$this->addFlash('success', 'Changes saved.');
return $this->redirectToRoute('website_admin_escaperooms');
}
return $this->render('website/admin/escape_room/form.html.twig', [
'form' => $form,
'room' => $room,
'heading' => 'Edit: '.$room->getName(),
]);
}
#[Route('/{id}/status/{status}', name: 'website_admin_escaperoom_moderate', methods: ['POST'], requirements: ['id' => '\d+'])]
public function moderate(EscapeRoom $room, string $status, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('moderate_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
$target = EscapeRoomStatus::tryFrom($status);
if ($target === null) {
throw $this->createNotFoundException();
}
$room->setStatus($target)->touch();
$em->flush();
$this->addFlash('success', sprintf('"%s" is now %s.', $room->getName(), $target->label()));
return $this->redirectToRoute('website_admin_escaperooms', ['status' => $request->request->get('return_to')]);
}
#[Route('/{id}/delete', name: 'website_admin_escaperoom_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(EscapeRoom $room, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_room_'.$room->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperooms');
}
if (!$room->isDeleted()) {
$room->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', sprintf('"%s" deleted.', $room->getName()));
return $this->redirectToRoute('website_admin_escaperooms');
}
#[Route('/reviews', name: 'website_admin_escaperoom_reviews', methods: ['GET'])]
public function reviews(EscapeRoomReviewRepository $reviews): Response
{
return $this->render('website/admin/review/index.html.twig', [
'reviews' => $reviews->findRecent(),
]);
}
#[Route('/reviews/{id}/delete', name: 'website_admin_escaperoom_review_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function deleteReview(EscapeRoomReview $review, Request $request, EntityManagerInterface $em): Response
{
if (!$this->isCsrfTokenValid('delete_review_'.$review->getId(), (string) $request->request->get('_token'))) {
$this->addFlash('danger', 'Invalid CSRF token.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
if (!$review->isDeleted()) {
$review->setDeletedAt(new \DateTimeImmutable());
$em->flush();
}
$this->addFlash('success', 'Review removed.');
return $this->redirectToRoute('website_admin_escaperoom_reviews');
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Website\Form\ContactFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class ContactController extends AbstractController
{
#[Route(path: '/contact', name: 'website_contact', methods: ['GET', 'POST'])]
public function contact(Request $request, EntityManagerInterface $entityManager): Response
{
$form = $this->createForm(ContactFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($form->getData());
$entityManager->flush();
$this->addFlash('success', 'Thanks for reaching out — your message has been sent.');
return $this->redirectToRoute('website_contact');
}
return $this->render('website/contact.html.twig', [
'contactForm' => $form->createView(),
]);
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Website\Controller;
use App\Tech\Entity\User;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Form\EscapeRoomReviewType;
use App\Website\Form\SuggestEscapeRoomType;
use App\Website\Repository\EscapeRoomRepository;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class EscapeRoomController extends AbstractController
{
#[Route(path: '/escape-rooms', name: 'website_escaperooms', methods: ['GET'])]
public function index(EscapeRoomRepository $rooms): Response
{
return $this->render('website/escape_room/index.html.twig', [
'publishedCount' => $rooms->countByStatus(EscapeRoomStatus::PUBLISHED),
]);
}
/**
* Slim feed the map and the list are both built from. Cached at the edge for
* a few minutes; the dataset only changes when the importer runs or an admin
* edits a room.
*/
#[Route(path: '/escape-rooms/data.json', name: 'website_escaperooms_data', methods: ['GET'])]
public function data(EscapeRoomRepository $rooms): JsonResponse
{
$response = new JsonResponse([
'generatedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
'rooms' => $rooms->findMapData(),
]);
$response->setPublic();
$response->setMaxAge(600);
$response->setSharedMaxAge(600);
return $response;
}
#[Route(path: '/escape-rooms/suggest', name: 'website_escaperoom_suggest', methods: ['GET', 'POST'])]
public function suggest(Request $request, EntityManagerInterface $em): Response
{
$room = new EscapeRoom();
$form = $this->createForm(SuggestEscapeRoomType::class, $room);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$room->setSource(EscapeRoomSource::USER)
->setStatus(EscapeRoomStatus::PENDING);
if ($this->getUser() instanceof User) {
$room->setSubmittedBy($this->getUser());
}
$em->persist($room);
$em->flush();
$this->addFlash('success', 'Thanks! Your suggestion has been sent for review and will appear on the map once approved.');
return $this->redirectToRoute('website_escaperooms');
}
return $this->render('website/escape_room/suggest.html.twig', [
'form' => $form,
]);
}
#[Route(path: '/escape-rooms/{id}-{slug}', name: 'website_escaperoom_show', methods: ['GET'], requirements: ['id' => '\d+', 'slug' => '[a-z0-9-]*'])]
public function show(EscapeRoom $room, EscapeRoomReviewRepository $reviews): Response
{
$this->guardVisible($room);
return $this->renderRoom($room, $reviews, $this->createForm(EscapeRoomReviewType::class));
}
#[Route(path: '/escape-rooms/{id}/reviews', name: 'website_escaperoom_review', methods: ['POST'], requirements: ['id' => '\d+'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function review(
EscapeRoom $room,
Request $request,
EntityManagerInterface $em,
EscapeRoomReviewRepository $reviews,
): Response {
$this->guardVisible($room);
$review = new EscapeRoomReview();
$form = $this->createForm(EscapeRoomReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var User $user */
$user = $this->getUser();
$review->setEscapeRoom($room)->setAuthor($user);
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Thanks for your review!');
return $this->redirectToRoute('website_escaperoom_show', [
'id' => $room->getId(),
'slug' => $room->getSlug(),
]);
}
return $this->renderRoom($room, $reviews, $form);
}
private function guardVisible(EscapeRoom $room): void
{
if ($room->isDeleted() || !$room->getStatus()->isPubliclyVisible()) {
throw $this->createNotFoundException();
}
}
private function renderRoom(EscapeRoom $room, EscapeRoomReviewRepository $reviews, FormInterface $reviewForm): Response
{
return $this->render('website/escape_room/show.html.twig', [
'room' => $room,
'reviews' => $reviews->findVisibleForRoom($room),
'rating' => $reviews->ratingSummary($room),
'reviewForm' => $reviewForm,
]);
}
}
+18 -6
View File
@@ -3,6 +3,8 @@ declare(strict_types=1);
namespace App\Website\Controller;
use App\Game\Repository\GameRepository;
use App\Game\Service\FeedbackService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -10,17 +12,20 @@ use Symfony\Component\Routing\Annotation\Route;
final class HomeController extends AbstractController
{
#[Route(path: '', name: 'website_home')]
public function index(): Response
public function index(GameRepository $gameRepository): Response
{
return $this->render(
'website/home/index.html.twig');
return $this->render('website/home/index.html.twig', [
'showDevBanner' => !$gameRepository->hasOpenGame(),
]);
}
#[Route(path: '/briefing', name: 'website_intro')]
public function intro(): Response
public function intro(FeedbackService $feedbackService, GameRepository $gameRepository): Response
{
return $this->render(
'website/home/intro.html.twig');
return $this->render('website/home/intro.html.twig', [
'feedbackSummary' => $feedbackService->getFeedbackSummary(),
'showDevBanner' => !$gameRepository->hasOpenGame(),
]);
}
#[Route(path: '/looking-for-help', name: 'website_help_wanted')]
@@ -30,6 +35,13 @@ final class HomeController extends AbstractController
'website/home/help_wanted.html.twig');
}
#[Route(path: '/terms', name: 'website_terms')]
public function terms(): Response
{
return $this->render(
'website/home/terms.html.twig');
}
#[Route(path: '/donation/cancel', name: 'website_donation_cancel')]
public function donationCancel(): Response
{
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Website\Entity;
use App\Website\Repository\ContactMessageRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ContactMessageRepository::class)]
#[ORM\Table(name: 'contact_message')]
class ContactMessage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $message = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $readAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getReadAt(): ?\DateTimeImmutable
{
return $this->readAt;
}
public function setReadAt(?\DateTimeImmutable $readAt): static
{
$this->readAt = $readAt;
return $this;
}
public function isRead(): bool
{
return $this->readAt !== null;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
}
+378
View File
@@ -0,0 +1,378 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use App\Website\Repository\EscapeRoomRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EscapeRoomRepository::class)]
#[ORM\Table(name: 'escape_room')]
#[ORM\UniqueConstraint(name: 'uniq_escape_room_osm', columns: ['osm_type', 'osm_id'])]
#[ORM\Index(name: 'idx_escape_room_status', columns: ['status'])]
#[ORM\Index(name: 'idx_escape_room_bbox', columns: ['latitude', 'longitude'])]
#[ORM\Index(name: 'idx_escape_room_submitted_by', columns: ['submitted_by_id'])]
class EscapeRoom
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
/** Operating company / brand, when it differs from the room name. */
#[ORM\Column(length: 255, nullable: true)]
private ?string $venue = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $street = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $houseNumber = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $postcode = null;
#[ORM\Column(length: 128, nullable: true)]
private ?string $city = null;
/** ISO 3166-1 alpha-2, upper case. */
#[ORM\Column(length: 2, nullable: true)]
private ?string $country = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $latitude = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 7)]
private ?string $longitude = null;
#[ORM\Column(length: 511, nullable: true)]
private ?string $website = null;
#[ORM\Column(length: 64, nullable: true)]
private ?string $phone = null;
#[ORM\Column(length: 16, enumType: EscapeRoomSource::class)]
private EscapeRoomSource $source = EscapeRoomSource::ADMIN;
/** OSM element type: node | way | relation. Set together with osmId for imported rooms. */
#[ORM\Column(length: 8, nullable: true)]
private ?string $osmType = null;
#[ORM\Column(type: Types::BIGINT, nullable: true)]
private ?string $osmId = null;
#[ORM\Column(length: 16, enumType: EscapeRoomStatus::class)]
private EscapeRoomStatus $status = EscapeRoomStatus::PENDING;
/** Kept for imported rooms so a re-import never clobbers manual corrections. */
#[ORM\Column(options: ['default' => false])]
private bool $locked = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $submittedBy = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
/** @var Collection<int, EscapeRoomReview> */
#[ORM\OneToMany(mappedBy: 'escapeRoom', targetEntity: EscapeRoomReview::class, cascade: ['remove'])]
private Collection $reviews;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getVenue(): ?string
{
return $this->venue;
}
public function setVenue(?string $venue): static
{
$this->venue = $venue;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getHouseNumber(): ?string
{
return $this->houseNumber;
}
public function setHouseNumber(?string $houseNumber): static
{
$this->houseNumber = $houseNumber;
return $this;
}
public function getPostcode(): ?string
{
return $this->postcode;
}
public function setPostcode(?string $postcode): static
{
$this->postcode = $postcode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): static
{
$this->country = $country ? strtoupper($country) : null;
return $this;
}
public function getLatitude(): ?string
{
return $this->latitude;
}
public function setLatitude(string|float|null $latitude): static
{
$this->latitude = $latitude === null ? null : (string) $latitude;
return $this;
}
public function getLongitude(): ?string
{
return $this->longitude;
}
public function setLongitude(string|float|null $longitude): static
{
$this->longitude = $longitude === null ? null : (string) $longitude;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getSource(): EscapeRoomSource
{
return $this->source;
}
public function setSource(EscapeRoomSource $source): static
{
$this->source = $source;
return $this;
}
public function getOsmType(): ?string
{
return $this->osmType;
}
public function setOsmType(?string $osmType): static
{
$this->osmType = $osmType;
return $this;
}
public function getOsmId(): ?string
{
return $this->osmId;
}
public function setOsmId(int|string|null $osmId): static
{
$this->osmId = $osmId === null ? null : (string) $osmId;
return $this;
}
public function getStatus(): EscapeRoomStatus
{
return $this->status;
}
public function setStatus(EscapeRoomStatus $status): static
{
$this->status = $status;
return $this;
}
public function isLocked(): bool
{
return $this->locked;
}
public function setLocked(bool $locked): static
{
$this->locked = $locked;
return $this;
}
public function getSubmittedBy(): ?User
{
return $this->submittedBy;
}
public function setSubmittedBy(?User $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function touch(): static
{
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
/** @return Collection<int, EscapeRoomReview> */
public function getReviews(): Collection
{
return $this->reviews;
}
/** @return EscapeRoomReview[] */
public function getVisibleReviews(): array
{
return $this->reviews
->filter(static fn (EscapeRoomReview $r) => !$r->isDeleted())
->toArray();
}
public function getSlug(): string
{
$slug = strtolower((string) $this->name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-') ?: 'room';
}
public function getDisplayAddress(): string
{
$line = trim(sprintf('%s %s', (string) $this->street, (string) $this->houseNumber));
$parts = array_filter([
$line !== '' ? $line : null,
trim(sprintf('%s %s', (string) $this->postcode, (string) $this->city)) ?: null,
$this->country,
]);
return implode(', ', $parts);
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Website\Entity;
use App\Tech\Entity\User;
use App\Website\Repository\EscapeRoomReviewRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: EscapeRoomReviewRepository::class)]
#[ORM\Table(name: 'escape_room_review')]
#[ORM\Index(name: 'idx_escape_room_review_room', columns: ['escape_room_id'])]
#[ORM\Index(name: 'idx_escape_room_review_author', columns: ['author_id'])]
class EscapeRoomReview
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: EscapeRoom::class, inversedBy: 'reviews')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?EscapeRoom $escapeRoom = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?User $author = null;
#[ORM\Column(type: Types::SMALLINT)]
#[Assert\Range(min: 1, max: 5)]
private int $rating = 0;
#[ORM\Column(length: 150)]
#[Assert\NotBlank(message: 'Give your review a short title')]
#[Assert\Length(max: 150)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'Write a few words about your experience')]
#[Assert\Length(max: 5000)]
private ?string $body = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEscapeRoom(): ?EscapeRoom
{
return $this->escapeRoom;
}
public function setEscapeRoom(?EscapeRoom $escapeRoom): static
{
$this->escapeRoom = $escapeRoom;
return $this;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
return $this;
}
public function getRating(): int
{
return $this->rating;
}
public function setRating(int $rating): static
{
$this->rating = $rating;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomSource: string
{
case OSM = 'osm';
case USER = 'user';
case ADMIN = 'admin';
public function label(): string
{
return match ($this) {
self::OSM => 'OpenStreetMap',
self::USER => 'Visitor submission',
self::ADMIN => 'Added by admin',
};
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Website\Enum;
enum EscapeRoomStatus: string
{
/** Submitted by a visitor, awaiting admin moderation. */
case PENDING = 'pending';
/** Live: shown on the public map and list. */
case PUBLISHED = 'published';
/** Reviewed by an admin and turned down; kept for de-duplication. */
case REJECTED = 'rejected';
/** Was public, hidden again by an admin (e.g. room closed down). */
case HIDDEN = 'hidden';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending review',
self::PUBLISHED => 'Published',
self::REJECTED => 'Rejected',
self::HIDDEN => 'Hidden',
};
}
public function isPubliclyVisible(): bool
{
return $this === self::PUBLISHED;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomSource;
use App\Website\Enum\EscapeRoomStatus;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
class AdminEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, ['constraints' => [new NotBlank()]])
->add('venue', TextType::class, ['required' => false])
->add('street', TextType::class, ['required' => false])
->add('houseNumber', TextType::class, ['label' => 'House number', 'required' => false])
->add('postcode', TextType::class, ['required' => false])
->add('city', TextType::class, ['required' => false])
->add('country', CountryType::class, ['placeholder' => '—', 'required' => false])
->add('latitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -90, max: 90)],
])
->add('longitude', NumberType::class, [
'scale' => 7,
'html5' => true,
'constraints' => [new NotBlank(), new Range(min: -180, max: 180)],
])
->add('website', UrlType::class, ['required' => false, 'default_protocol' => 'https'])
->add('phone', TelType::class, ['required' => false])
->add('status', EnumType::class, [
'class' => EscapeRoomStatus::class,
'choice_label' => fn (EscapeRoomStatus $s) => $s->label(),
])
->add('source', EnumType::class, [
'class' => EscapeRoomSource::class,
'choice_label' => fn (EscapeRoomSource $s) => $s->label(),
])
->add('locked', CheckboxType::class, [
'required' => false,
'label' => 'Locked (protect from OSM re-import)',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Website\Form;
use App\Website\Entity\ContactMessage;
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class ContactFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'constraints' => [
new NotBlank(message: 'Please enter your name'),
new Length(max: 255, maxMessage: 'Your name cannot be longer than {{ limit }} characters'),
],
])
->add('email', EmailType::class, [
'constraints' => [
new NotBlank(message: 'Please enter your email address'),
],
])
->add('message', TextareaType::class, [
'attr' => ['rows' => 6],
'constraints' => [
new NotBlank(message: 'Please enter a message'),
new Length(max: 5000, maxMessage: 'Your message cannot be longer than {{ limit }} characters'),
],
])
->add('captcha', Recaptcha3Type::class, [
'constraints' => new Recaptcha3(),
'action_name' => 'contact',
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContactMessage::class,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoomReview;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;
class EscapeRoomReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('rating', ChoiceType::class, [
'placeholder' => 'Choose a rating',
'choices' => [
'★★★★★ — Outstanding' => 5,
'★★★★ — Very good' => 4,
'★★★ — Decent' => 3,
'★★ — Poor' => 2,
'★ — Terrible' => 1,
],
'constraints' => [new NotBlank(message: 'Pick a rating')],
])
->add('title', TextType::class, [
'attr' => ['maxlength' => 150, 'placeholder' => 'Sum it up in a few words'],
])
->add('body', TextareaType::class, [
'attr' => ['rows' => 5, 'maxlength' => 5000, 'placeholder' => 'What worked, what didn\'t, would you recommend it?'],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoomReview::class,
]);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Website\Form;
use App\Website\Entity\EscapeRoom;
use Karser\Recaptcha3Bundle\Form\Recaptcha3Type;
use Karser\Recaptcha3Bundle\Validator\Constraints\Recaptcha3;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Regex;
class SuggestEscapeRoomType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Room / venue name',
'constraints' => [new NotBlank(message: 'Enter the room name'), new Length(max: 255)],
])
->add('venue', TextType::class, [
'label' => 'Operating company (optional)',
'required' => false,
'constraints' => [new Length(max: 255)],
])
->add('street', TextType::class, ['required' => false, 'constraints' => [new Length(max: 255)]])
->add('houseNumber', TextType::class, ['label' => 'No.', 'required' => false, 'constraints' => [new Length(max: 32)]])
->add('postcode', TextType::class, ['required' => false, 'constraints' => [new Length(max: 32)]])
->add('city', TextType::class, ['required' => false, 'constraints' => [new Length(max: 128)]])
->add('country', CountryType::class, [
'placeholder' => 'Select a country',
'required' => false,
])
->add('website', UrlType::class, [
'required' => false,
'default_protocol' => 'https',
'constraints' => [new Length(max: 511)],
])
->add('phone', TelType::class, ['required' => false, 'constraints' => [new Length(max: 64)]])
->add('latitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid latitude'),
new Range(min: -90, max: 90),
],
])
->add('longitude', HiddenType::class, [
'constraints' => [
new NotBlank(message: 'Drop a pin on the map to set the location'),
new Regex(pattern: '/^-?\d{1,3}(\.\d+)?$/', message: 'Invalid longitude'),
new Range(min: -180, max: 180),
],
])
->add('captcha', Recaptcha3Type::class, [
'mapped' => false,
'constraints' => new Recaptcha3(),
'action_name' => 'escaperoom_suggest',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => EscapeRoom::class,
]);
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\ContactMessage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ContactMessage>
*/
class ContactMessageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ContactMessage::class);
}
/**
* @return ContactMessage[]
*/
public function findActive(): array
{
return $this->findBy(['deletedAt' => null], ['createdAt' => 'DESC']);
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Enum\EscapeRoomStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoom>
*/
class EscapeRoomRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoom::class);
}
public function save(EscapeRoom $room, bool $flush = true): void
{
$this->getEntityManager()->persist($room);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function findOneByOsm(string $osmType, string $osmId): ?EscapeRoom
{
return $this->findOneBy(['osmType' => $osmType, 'osmId' => $osmId]);
}
/**
* Lightweight rows for the public map / list. One row per published room with
* its aggregated rating. Optionally clipped to a lat/lng bounding box.
*
* @param array{south: float, west: float, north: float, east: float}|null $bbox
*
* @return list<array{
* id: int, name: string, city: ?string, country: ?string,
* lat: float, lng: float, avgRating: ?float, reviewCount: int
* }>
*/
public function findMapData(?array $bbox = null, int $limit = 20000): array
{
$qb = $this->createQueryBuilder('r')
->select('r.id, r.name, r.city, r.country, r.latitude, r.longitude')
->addSelect('AVG(rev.rating) AS avgRating')
->addSelect('COUNT(rev.id) AS reviewCount')
->leftJoin('r.reviews', 'rev', 'WITH', 'rev.deletedAt IS NULL')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :published')
->setParameter('published', EscapeRoomStatus::PUBLISHED)
->groupBy('r.id')
->setMaxResults($limit);
if ($bbox !== null) {
$qb->andWhere('r.latitude BETWEEN :south AND :north')
->andWhere('r.longitude BETWEEN :west AND :east')
->setParameter('south', $bbox['south'])
->setParameter('north', $bbox['north'])
->setParameter('west', $bbox['west'])
->setParameter('east', $bbox['east']);
}
$rows = $qb->getQuery()->getArrayResult();
return array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'city' => $row['city'],
'country' => $row['country'],
'lat' => (float) $row['latitude'],
'lng' => (float) $row['longitude'],
'avgRating' => $row['reviewCount'] > 0 ? round((float) $row['avgRating'], 1) : null,
'reviewCount' => (int) $row['reviewCount'],
];
}, $rows);
}
public function countByStatus(EscapeRoomStatus $status): int
{
return (int) $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.deletedAt IS NULL')
->andWhere('r.status = :status')
->setParameter('status', $status)
->getQuery()
->getSingleScalarResult();
}
/**
* All non-deleted rooms, newest first. For the admin list.
*
* @return EscapeRoom[]
*/
public function findForAdmin(?EscapeRoomStatus $status = null): array
{
$qb = $this->createQueryBuilder('r')
->where('r.deletedAt IS NULL')
->orderBy('r.createdAt', 'DESC');
if ($status !== null) {
$qb->andWhere('r.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
/**
* Visible review count per room, in one query, for the admin list.
*
* @return array<int, int> roomId => count
*/
public function reviewCounts(): array
{
$rows = $this->getEntityManager()
->createQuery(
'SELECT IDENTITY(rev.escapeRoom) AS rid, COUNT(rev.id) AS c '
.'FROM App\Website\Entity\EscapeRoomReview rev '
.'WHERE rev.deletedAt IS NULL GROUP BY rid'
)
->getScalarResult();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['rid']] = (int) $row['c'];
}
return $map;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Website\Repository;
use App\Website\Entity\EscapeRoom;
use App\Website\Entity\EscapeRoomReview;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EscapeRoomReview>
*/
class EscapeRoomReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EscapeRoomReview::class);
}
public function save(EscapeRoomReview $review, bool $flush = true): void
{
$this->getEntityManager()->persist($review);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return EscapeRoomReview[]
*/
public function findVisibleForRoom(EscapeRoom $room): array
{
return $this->createQueryBuilder('rev')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->orderBy('rev.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* @return array{avg: ?float, count: int}
*/
public function ratingSummary(EscapeRoom $room): array
{
$row = $this->createQueryBuilder('rev')
->select('AVG(rev.rating) AS avg, COUNT(rev.id) AS count')
->where('rev.escapeRoom = :room')
->andWhere('rev.deletedAt IS NULL')
->setParameter('room', $room)
->getQuery()
->getSingleResult();
$count = (int) $row['count'];
return [
'avg' => $count > 0 ? round((float) $row['avg'], 1) : null,
'count' => $count,
];
}
/**
* Recent reviews across all rooms, for admin moderation.
*
* @return EscapeRoomReview[]
*/
public function findRecent(int $limit = 200): array
{
return $this->createQueryBuilder('rev')
->addSelect('room')
->join('rev.escapeRoom', 'room')
->where('rev.deletedAt IS NULL')
->orderBy('rev.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ 'site.name'|trans }}{% endblock %}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="{{ asset('images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ asset('images/favicon-16x16.png') }}">
<link rel="apple-touch-icon" href="{{ asset('images/apple-touch-icon.png') }}">
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
+41
View File
@@ -48,6 +48,40 @@
overflow-x: auto;
}
/* simple-datatables theming to match the admin panel */
.datatable-top,
.datatable-bottom {
padding: 0.75rem 1rem;
font-size: 0.85rem;
color: #64748b;
}
.datatable-input {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
}
.datatable-selector {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.25rem 0.4rem;
font-size: 0.85rem;
}
.datatable-pagination a,
.datatable-pagination button {
border-radius: 4px;
color: #475569;
}
.datatable-pagination a:hover,
.datatable-pagination button:hover {
background: #f1f5f9;
}
.datatable-pagination .datatable-active a,
.datatable-pagination .datatable-active button {
background: #3b82f6;
color: #fff;
}
@media (max-width: 767.98px) {
.admin-shell {
flex-direction: column;
@@ -90,6 +124,7 @@
<div class="admin-shell">
{# ── Sidebar ──────────────────────────────────────────────────────── #}
{% block admin_sidebar %}
<nav class="admin-sidebar">
<div class="admin-sidebar-title">Game Admin</div>
@@ -101,6 +136,8 @@
{ route: 'game_admin_users', label: 'Users', icon: '👤' },
{ route: 'game_admin_games', label: 'Games', icon: '🎮' },
{ route: 'game_admin_sessions', label: 'Sessions', icon: '▶' },
{ route: 'game_admin_feedback', label: 'Feedback', icon: '💬' },
{ route: 'game_admin_contact', label: 'Contact', icon: '📨' },
{ route: 'game_admin_email_log', label: 'Email Log', icon: '✉' },
] %}
@@ -120,11 +157,15 @@
</ul>
<div class="admin-sidebar-footer">
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
Website admin →
</a>
<a href="{{ path('game_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to game
</a>
</div>
</nav>
{% endblock %}
{# ── Content ──────────────────────────────────────────────────────── #}
<main class="admin-main">
@@ -0,0 +1,54 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Contact Messages — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Contact Messages</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ messages|length }} message{{ messages|length != 1 ? 's' : '' }}</span>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Received</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Name</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Email</th>
<th data-type="boolean" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Read</th>
<th data-sortable="false" style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for entry in messages %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ entry.createdAt|date('Y-m-d H:i:s') }}</td>
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.name }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">
<a href="mailto:{{ entry.email }}" style="color: #3b82f6; text-decoration: none;">{{ entry.email }}</a>
</td>
<td style="padding: 0.75rem 1rem; color: #16a34a; font-weight: 600;">{{ entry.read ? '✓' : '' }}</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_contact_show', {id: entry.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">View</a>
<form method="post" action="{{ path('game_admin_contact_delete', {id: entry.id}) }}" style="display: inline;" onsubmit="return confirm('Delete message from {{ entry.name }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ entry.id) }}">
<button type="submit" style="
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No messages yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,53 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Message from {{ message.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Message from {{ message.name }}</h1>
<a href="{{ path('game_admin_contact') }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">&larr; Back to Contact Messages</a>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 720px;">
<dl style="display: grid; grid-template-columns: 140px 1fr; row-gap: 0.75rem; margin: 0 0 1.5rem;">
<dt style="color: #64748b; font-weight: 600;">Name</dt>
<dd style="margin: 0; color: #0f172a;">{{ message.name }}</dd>
<dt style="color: #64748b; font-weight: 600;">Email</dt>
<dd style="margin: 0;">
<a href="mailto:{{ message.email }}" style="color: #3b82f6; text-decoration: none;">{{ message.email }}</a>
</dd>
<dt style="color: #64748b; font-weight: 600;">Received</dt>
<dd style="margin: 0; color: #0f172a;">{{ message.createdAt|date('Y-m-d H:i:s') }}</dd>
<dt style="color: #64748b; font-weight: 600;">Read at</dt>
<dd style="margin: 0; color: #0f172a;">
{% if message.readAt %}
{{ message.readAt|date('Y-m-d H:i:s') }}
{% else %}
<span style="color: #94a3b8;">Just now</span>
{% endif %}
</dd>
</dl>
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
<div style="white-space: pre-wrap; color: #0f172a; line-height: 1.6; margin-bottom: 1.5rem;">{{ message.message }}</div>
<hr style="border-color: #f1f5f9; margin-bottom: 1.5rem;">
<form method="post" action="{{ path('game_admin_contact_delete', {id: message.id}) }}" onsubmit="return confirm('Delete message from {{ message.name }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_contact_' ~ message.id) }}">
<button type="submit" style="
background: none;
border: 1px solid #fca5a5;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
border-radius: 6px;
">Delete Message</button>
</form>
</div>
{% endblock %}
@@ -9,7 +9,7 @@
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
<table class="admin-datatable" style="width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<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>
@@ -0,0 +1,67 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Feedback — Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Feedback</h1>
<span style="color: #64748b; font-size: 0.9rem;">{{ entries|length }} submission{{ entries|length != 1 ? 's' : '' }}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
{% set stats = [
{ label: 'Submissions', value: summary.count, color: '#64748b' },
{ label: 'Avg. Difficulty', value: summary.avgDifficulty is not null ? summary.avgDifficulty ~ '/10' : '—', color: '#3b82f6' },
{ label: 'Avg. Entertainment', value: summary.avgEntertaining is not null ? summary.avgEntertaining ~ '/10' : '—', color: '#8b5cf6' },
{ label: 'Avg. Theme', value: summary.avgTheme is not null ? summary.avgTheme ~ '/10' : '—', color: '#10b981' },
] %}
{% for stat in stats %}
<div style="
background: #fff;
border-radius: 8px;
padding: 1.25rem;
border-left: 4px solid {{ stat.color }};
box-shadow: 0 1px 3px rgba(0,0,0,.07);
">
<div style="font-size: 1.75rem; font-weight: 700; color: {{ stat.color }};">{{ stat.value }}</div>
<div style="font-size: 0.8rem; color: #64748b; margin-top: 0.25rem;">{{ stat.label }}</div>
</div>
{% endfor %}
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Game</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Session</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Player</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Difficulty</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Entertainment</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Theme</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Comment</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; font-weight: 500; color: #0f172a;">{{ entry.session.game.name }}</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_view_session', {session: entry.session.id}) }}" style="color: #3b82f6; text-decoration: none;">#{{ entry.session.id }}</a>
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.player.user.username }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.difficulty ?? '—' }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.entertaining ?? '—' }}</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ entry.theme ?? '—' }}</td>
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 320px; white-space: pre-wrap;">{{ entry.text ?? '' }}</td>
</tr>
{% else %}
<tr>
<td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No feedback submitted yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+13 -1
View File
@@ -5,7 +5,18 @@
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Games</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<span style="color: #64748b; font-size: 0.9rem;">{{ games|length }} total</span>
<a href="{{ path('game_admin_game_new') }}" style="
padding: 0.5rem 1rem;
background: #3b82f6;
color: #fff;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
">+ New Game</a>
</div>
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
@@ -46,7 +57,8 @@
</td>
<td style="padding: 0.75rem 1rem; color: #475569;">{{ game.sessions|length }}</td>
<td style="padding: 0.75rem 1rem;">
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Edit</a>
<a href="{{ path('game_admin_game_edit', {id: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem; margin-right: 0.75rem;">Edit</a>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #3b82f6; text-decoration: none; font-size: 0.85rem;">Hints</a>
</td>
</tr>
{% else %}
+64
View File
@@ -0,0 +1,64 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}New Game — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New game</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 500px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ form_label(form.name, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.name, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.name) }}
</div>
<div>
{{ form_label(form.numberOfPlayers, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.numberOfPlayers, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.numberOfPlayers) }}
</div>
<div>
{{ form_label(form.status, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.status, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.status) }}
</div>
<div>
{{ form_label(form.totalTime, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.totalTime, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">In seconds — e.g. 3600 = 1 hour</small>
{{ form_errors(form.totalTime) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Create game</button>
<a href="{{ path('game_admin_games') }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Edit Hint — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Edit hint</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.condition) }}
</div>
<div>
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
{{ form_errors(form.thresholdSeconds) }}
</div>
<div>
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.message) }}
</div>
<div>
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
{{ form_errors(form.sortOrder) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Save changes</button>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,78 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}Hints — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_games') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Games</a>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Hints — {{ game.name }}</h1>
<div style="display: flex; align-items: center; gap: 1rem;">
<span style="color: #64748b; font-size: 0.9rem;">{{ hints|length }} total</span>
<a href="{{ path('game_admin_hint_new', {game: game.id}) }}" style="
padding: 0.5rem 1rem;
background: #3b82f6;
color: #fff;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
">+ New Hint</a>
</div>
</div>
<p style="color: #64748b; font-size: 0.85rem; margin-top: -1rem; margin-bottom: 1.5rem;">
Hints are checked in order. For each row, if its condition is still unresolved for a player, the
highest threshold reached among hints sharing that same condition fires and the check stops there
for that pass — later rows are only reached once every condition before them is resolved.
</p>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Order</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Condition</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Threshold</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Message</th>
<th style="padding: 0.75rem 1rem; text-align: left; color: #475569; font-weight: 600;">Actions</th>
</tr>
</thead>
<tbody>
{% for hint in hints %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.75rem 1rem; color: #94a3b8;">{{ hint.sortOrder }}</td>
<td style="padding: 0.75rem 1rem; color: #0f172a;">{{ hint.condition.label }}</td>
<td style="padding: 0.75rem 1rem; color: #475569; white-space: nowrap;">{{ (hint.thresholdSeconds / 60)|round(1, 'floor') }} min</td>
<td style="padding: 0.75rem 1rem; color: #475569; max-width: 420px;">{{ hint.message|length > 90 ? hint.message|slice(0, 90) ~ '…' : hint.message }}</td>
<td style="padding: 0.75rem 1rem; white-space: nowrap;">
<a href="{{ path('game_admin_hint_edit', {game: game.id, id: hint.id}) }}" style="
color: #3b82f6;
text-decoration: none;
font-size: 0.85rem;
margin-right: 0.75rem;
">Edit</a>
<form method="post" action="{{ path('game_admin_hint_delete', {game: game.id, id: hint.id}) }}" style="display: inline;" onsubmit="return confirm('Delete this hint?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_hint_' ~ hint.id) }}">
<button type="submit" style="
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" style="padding: 2rem; text-align: center; color: #94a3b8;">No hints configured for this game yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends 'game/admin/base.html.twig' %}
{% block title %}New Hint — {{ game.name }} — Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.5rem;">
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Hints — {{ game.name }}</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">New hint</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem; max-width: 560px;">
{{ form_start(form, {attr: {style: 'display: flex; flex-direction: column; gap: 1.25rem;'}}) }}
<div>
{{ form_label(form.condition, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.condition, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.condition) }}
</div>
<div>
{{ form_label(form.thresholdSeconds, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.thresholdSeconds, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Seconds since the game started — e.g. 300 = 5 minutes</small>
{{ form_errors(form.thresholdSeconds) }}
</div>
<div>
{{ form_label(form.message, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.message, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
{{ form_errors(form.message) }}
</div>
<div>
{{ form_label(form.sortOrder, null, {label_attr: {style: 'display: block; font-weight: 600; margin-bottom: 0.35rem; color: #374151; font-size: 0.875rem;'}}) }}
{{ form_widget(form.sortOrder, {attr: {style: 'width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box;'}}) }}
<small style="color: #64748b; font-size: 0.8rem;">Hints are evaluated in ascending order — use gaps (10, 20, 30…) to leave room to insert later</small>
{{ form_errors(form.sortOrder) }}
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" style="
padding: 0.6rem 1.25rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
">Create hint</button>
<a href="{{ path('game_admin_hints', {game: game.id}) }}" style="
padding: 0.6rem 1.25rem;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -9,7 +9,7 @@
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
<table class="admin-datatable" style="width: 100%; min-width: 700px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<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>
+2 -1
View File
@@ -98,7 +98,8 @@
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
">{{ playerLog.logs ?: 'No logs found for this player.' }}</pre>
">{% if playerLog.entries is empty %}No logs found for this player.{% else %}{% for entry in playerLog.entries %}{% if entry.time %}[{{ entry.time }}] {% endif %}{{ entry.line }}
{% endfor %}{% endif %}</pre>
</div>
{% endfor %}
{% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<table class="admin-datatable" style="width: 100%; min-width: 760px; border-collapse: collapse; font-size: 0.9rem;">
<thead>
<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>
+8 -1
View File
@@ -20,8 +20,15 @@
<input type="hidden" name="_token" value="{{ csrf_token('create_session') }}">
<select name="game_id" class="form-select mb-3">
{% for game in availableGames %}
{% set totalTime = null %}
{% for setting in game.settings %}
{% if setting.name.value == 'totalTime' %}
{% set totalTime = setting.value %}
{% endif %}
{% endfor %}
<option value="{{ game.id }}">
{{ game.name }} ({{ game.numberOfPlayers }} players)
{{ game.name }} ({{ game.numberOfPlayers }} players{% if totalTime %}, {{ (totalTime / 60)|round }} min{% endif %})
{% if is_granted('ROLE_ADMIN') %}
[{{ game.status.value }}]
{% endif %}
+10
View File
@@ -27,6 +27,16 @@
{% endfor %}
</ul>
<div class="alert alert-secondary">
<p class="mb-2"><strong>Unknown with running commands on a pcs's command lines?</strong></p>
<p class="mb-3">
It's a small tutorial that gives you the information you'll need for the game to run.
You can go through it as many times as you like &mdash; just make sure you feel at least a little confident
with the commands it covers before you start the game.
</p>
<a href="{{ path('game_pregame', {session: session.id}) }}" target="_blank" rel="noopener" class="btn btn-primary">Start the command line tutorial</a>
</div>
{% 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) }}">
+12
View File
@@ -13,6 +13,8 @@
<h4>{{ session.game.name }}</h4>
<p>Welcome to the game! Please wait for all players to join and signal they are ready to start.</p>
<p><strong>This game will end when you disable the virus by having its source files deleted from the server.</strong></p>
<div class="game-info">
Please keep the following things in mind:
<ul>
@@ -30,6 +32,16 @@
</ul>
</div>
<div class="alert alert-secondary">
<p class="mb-2"><strong>Unknown with running commands on a pcs's command lines?</strong></p>
<p class="mb-3">
It's a small tutorial that gives you the information you'll need for the game to run.
You can go through it as many times as you like &mdash; just make sure you feel at least a little confident
with the commands it covers before you start the game.
</p>
<a href="{{ path('game_pregame', {session: session.id}) }}" target="_blank" rel="noopener" class="btn btn-primary">Start the command line tutorial</a>
</div>
<div class="mt-4">
<h5>Players status:</h5>
<ul class="list-group">
+1 -1
View File
@@ -62,7 +62,7 @@
Then the screens flood green: containment confirmed. The AI virus's grip on the server unravels file by file.
</p>
<p>
Agents Doyle, Vega and Lennox are safe. Their identities were never decoded. In the days that follow,
Agents {{ agentsText|default('Doyle, Vega and Lennox') }} are safe. Their identities were never decoded. In the days that follow,
the agency quietly credits an anonymous team of specialists for the save - you.
</p>
</div>
+7
View File
@@ -20,6 +20,9 @@
<li class="nav-item">
<a class="nav-link" href="{{ path('game_dashboard') }}">{{ 'nav.game'|trans }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('website_escaperooms') }}">{{ 'nav.escaperooms'|trans }}</a>
</li>
{% if app.user %}
{% if is_granted('ROLE_ADMIN') %}
<li class="nav-item">
@@ -67,7 +70,11 @@
<footer class="site-footer py-4">
<div class="container text-center small">
<div class="mb-1">
<a href="{{ path('website_contact') }}" class="link-light">{{ 'footer.contact'|trans }}</a>
&middot;
<a href="{{ path('website_help_wanted') }}" class="link-light">{{ 'footer.help_wanted'|trans }}</a>
&middot;
<a href="{{ path('website_terms') }}" class="link-light">{{ 'footer.terms'|trans }}</a>
</div>
&copy; {{ "now"|date("Y") }} {{ 'site.name'|trans }}
</div>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale|default(app.request.defaultLocale|default('en')) }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Command Line Tutorial</title>
{{ encore_entry_link_tags('app') }}
{{ encore_entry_link_tags('pregame') }}
</head>
<body>
<div id="game-container">
<a href="{{ path('game', {session: session.id}) }}" id="back-button">&larr; Back</a>
<div id="message-container">
<div class="message">No tutorial is available for this game yet.</div>
</div>
</div>
</body>
{{ encore_entry_script_tags('app') }}
{{ encore_entry_script_tags('pregame') }}
</html>
+35
View File
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale|default(app.request.defaultLocale|default('en')) }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Command Line Tutorial</title>
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_link_tags('pregame') }}
{% endblock %}
</head>
<body>
<div id="game-container">
<div id="pregame-config"
data-session-id="{{ session.id|e('html_attr') }}"
style="display:none">
</div>
<a href="{{ path('game', {session: session.id}) }}" id="back-button">&larr; Back</a>
<div id="message-container">
</div>
<div id="input">
<input type="text" disabled id="input-message">
</div>
</div>
</body>
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{{ encore_entry_script_tags('pregame') }}
{% endblock %}
</html>
@@ -10,6 +10,9 @@
{{ form_row(registrationForm.username) }}
{{ form_row(registrationForm.plainPassword) }}
{{ form_row(registrationForm.agreeTerms) }}
<p class="small text-muted mt-n2 mb-3">
<a href="{{ path('website_terms') }}" target="_blank" rel="noopener">Read the Terms and Conditions</a>
</p>
{{ form_row(registrationForm.marketingOptIn) }}
{{ form_row(registrationForm.captcha) }}
+110
View File
@@ -0,0 +1,110 @@
{% extends 'game/admin/base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<style>
.er-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem 1.25rem;
max-width: 720px;
}
.er-form .er-field-wide { grid-column: 1 / -1; }
.er-form label {
display: block;
font-weight: 600;
margin-bottom: 0.3rem;
color: #374151;
font-size: 0.8rem;
}
.er-form input[type=text],
.er-form input[type=number],
.er-form input[type=url],
.er-form input[type=tel],
.er-form select,
.er-form textarea {
width: 100%;
padding: 0.5rem 0.7rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
box-sizing: border-box;
}
.er-form .form-error,
.er-form ul { color: #b91c1c; font-size: 0.8rem; margin: 0.25rem 0 0; padding-left: 1rem; }
.er-btn {
padding: 0.55rem 1.2rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.er-btn--ghost { background: #f1f5f9; color: #475569; }
.er-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.er-badge--published { background: #dcfce7; color: #166534; }
.er-badge--pending { background: #fef9c3; color: #854d0e; }
.er-badge--rejected { background: #fee2e2; color: #991b1b; }
.er-badge--hidden { background: #e2e8f0; color: #475569; }
.er-actions form { display: inline; }
.er-actions button,
.er-actions a {
background: none;
border: none;
padding: 0;
margin-right: 0.6rem;
font-size: 0.82rem;
color: #3b82f6;
cursor: pointer;
text-decoration: none;
}
.er-actions button.er-danger { color: #ef4444; }
</style>
{% endblock %}
{% block admin_sidebar %}
<nav class="admin-sidebar">
<div class="admin-sidebar-title">Website Admin</div>
<ul class="admin-nav-list">
{% set current = app.request.attributes.get('_route') %}
{% set navItems = [
{ route: 'website_admin_escaperooms', label: 'Escape Rooms', icon: '📍' },
{ route: 'website_admin_escaperoom_reviews', label: 'Reviews', icon: '⭐' },
] %}
{% for item in navItems %}
{% set isActive = current starts with item.route %}
<li>
<a href="{{ path(item.route) }}" class="admin-nav-link" style="
color: {{ isActive ? '#f1f5f9' : '#94a3b8' }};
background: {{ isActive ? '#334155' : 'transparent' }};
border-left: 3px solid {{ isActive ? '#3b82f6' : 'transparent' }};
">
<span>{{ item.icon }}</span>
{{ item.label }}
</a>
</li>
{% endfor %}
</ul>
<div class="admin-sidebar-footer">
<a href="{{ path('game_admin_dashboard') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none; display: block; margin-bottom: 0.4rem;">
← Game admin
</a>
<a href="{{ path('website_home') }}" style="color: #64748b; font-size: 0.8rem; text-decoration: none;">
← Back to site
</a>
</div>
</nav>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}{{ heading }} — Website Admin{% endblock %}
{% block admin_body %}
<div style="margin-bottom: 1.25rem;">
<a href="{{ path('website_admin_escaperooms') }}" style="color: #64748b; text-decoration: none; font-size: 0.9rem;">← Escape Rooms</a>
</div>
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">{{ heading }}</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); padding: 1.5rem;">
{{ form_start(form, {attr: {class: 'er-form'}}) }}
<div class="er-field-wide">{{ form_row(form.name) }}</div>
<div class="er-field-wide">{{ form_row(form.venue) }}</div>
{{ form_row(form.street) }}
{{ form_row(form.houseNumber) }}
{{ form_row(form.postcode) }}
{{ form_row(form.city) }}
{{ form_row(form.country) }}
<div></div>
{{ form_row(form.latitude) }}
{{ form_row(form.longitude) }}
{{ form_row(form.website) }}
{{ form_row(form.phone) }}
{{ form_row(form.status) }}
{{ form_row(form.source) }}
<div class="er-field-wide">{{ form_row(form.locked) }}</div>
<div class="er-field-wide" style="display: flex; gap: 0.75rem; margin-top: 0.5rem;">
<button type="submit" class="er-btn">Save</button>
<a href="{{ path('website_admin_escaperooms') }}" class="er-btn er-btn--ghost">Cancel</a>
</div>
{{ form_end(form) }}
</div>
{% endblock %}
@@ -0,0 +1,93 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}Escape Rooms — Website Admin{% endblock %}
{% block admin_body %}
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h1 style="margin: 0; font-size: 1.5rem; color: #0f172a;">Escape Rooms</h1>
<a href="{{ path('website_admin_escaperoom_new') }}" class="er-btn">+ New room</a>
</div>
{% set tabs = {
'': 'All (' ~ counts.all ~ ')',
'pending': 'Pending (' ~ counts.pending ~ ')',
'published': 'Published (' ~ counts.published ~ ')',
'hidden': 'Hidden (' ~ counts.hidden ~ ')',
'rejected': 'Rejected (' ~ counts.rejected ~ ')',
} %}
<div style="margin-bottom: 1rem; display: flex; gap: 0.4rem; flex-wrap: wrap;">
{% for value, label in tabs %}
{% set active = (filter is null and value == '') or (filter and filter.value == value) %}
<a href="{{ path('website_admin_escaperooms', value ? {status: value} : {}) }}"
style="padding: 0.3rem 0.7rem; border-radius: 6px; font-size: 0.82rem; text-decoration: none;
background: {{ active ? '#3b82f6' : '#e2e8f0' }}; color: {{ active ? '#fff' : '#475569' }};">
{{ label }}
</a>
{% endfor %}
</div>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 900px; border-collapse: collapse; font-size: 0.88rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Name</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">City</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Country</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Source</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Status</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Reviews</th>
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
</tr>
</thead>
<tbody>
{% for room in rooms %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.7rem 0.9rem; font-weight: 500; color: #0f172a;">
{% if room.status.isPubliclyVisible %}
<a href="{{ path('website_escaperoom_show', {id: room.id, slug: room.slug}) }}" target="_blank" rel="noopener" style="color: #0f172a;">{{ room.name }}</a>
{% else %}
{{ room.name }}
{% endif %}
</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.city }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.country }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ room.source.value }}</td>
<td style="padding: 0.7rem 0.9rem;">
<span class="er-badge er-badge--{{ room.status.value }}">{{ room.status.label }}</span>
</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ reviewCounts[room.id]|default(0) }}</td>
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
<a href="{{ path('website_admin_escaperoom_edit', {id: room.id}) }}">Edit</a>
{% if not room.status.isPubliclyVisible %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'published'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Publish</button>
</form>
{% else %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'hidden'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Hide</button>
</form>
{% endif %}
{% if room.status.value != 'rejected' %}
<form method="post" action="{{ path('website_admin_escaperoom_moderate', {id: room.id, status: 'rejected'}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('moderate_room_' ~ room.id) }}">
<input type="hidden" name="return_to" value="{{ filter ? filter.value : '' }}">
<button type="submit">Reject</button>
</form>
{% endif %}
<form method="post" action="{{ path('website_admin_escaperoom_delete', {id: room.id}) }}" onsubmit="return confirm('Delete {{ room.name|e('js') }}?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_room_' ~ room.id) }}">
<button type="submit" class="er-danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="7" style="padding: 2rem; text-align: center; color: #94a3b8;">No rooms.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends 'website/admin/base.html.twig' %}
{% block title %}Reviews — Website Admin{% endblock %}
{% block admin_body %}
<h1 style="margin: 0 0 1.5rem; font-size: 1.5rem; color: #0f172a;">Recent reviews</h1>
<div style="background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;">
<table class="admin-datatable" style="width: 100%; min-width: 820px; border-collapse: collapse; font-size: 0.88rem;">
<thead>
<tr style="background: #f1f5f9; border-bottom: 1px solid #e2e8f0;">
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Date</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Room</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Rating</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Title</th>
<th style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Author</th>
<th data-sortable="false" style="padding: 0.7rem 0.9rem; text-align: left; color: #475569;">Actions</th>
</tr>
</thead>
<tbody>
{% for review in reviews %}
<tr style="border-bottom: 1px solid #f1f5f9;">
<td style="padding: 0.7rem 0.9rem; color: #475569; white-space: nowrap;">{{ review.createdAt|date('Y-m-d') }}</td>
<td style="padding: 0.7rem 0.9rem; color: #0f172a;">
<a href="{{ path('website_admin_escaperoom_edit', {id: review.escapeRoom.id}) }}" style="color: #0f172a;">{{ review.escapeRoom.name }}</a>
</td>
<td style="padding: 0.7rem 0.9rem;" data-order="{{ review.rating }}">{{ review.rating }} ★</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.title }}</td>
<td style="padding: 0.7rem 0.9rem; color: #475569;">{{ review.author ? review.author.username : '—' }}</td>
<td style="padding: 0.7rem 0.9rem;" class="er-actions">
<form method="post" action="{{ path('website_admin_escaperoom_review_delete', {id: review.id}) }}" onsubmit="return confirm('Remove this review?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}">
<button type="submit" class="er-danger">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" style="padding: 2rem; text-align: center; color: #94a3b8;">No reviews yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More