24 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
80 changed files with 6496 additions and 4782 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/
+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);
});
});
+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"
}
}
+44 -84
View File
@@ -6,10 +6,12 @@ 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;
@@ -28,22 +30,11 @@ final class SendMainframeHintsCommand extends Command
{
private const DEFAULT_TOTAL_TIME = 3600;
private const HELP_THRESHOLD_SECONDS = 2 * 60;
private const BROADCAST_CHAT_THRESHOLD_SECONDS = 5 * 60;
private const PRIVATE_CHAT_THRESHOLD_SECONDS = 7 * 60;
private const VERIFY_THRESHOLD_SECONDS = 10 * 60;
private const VERIFY_ESCALATION_THRESHOLD_SECONDS = 13 * 60;
private const CD_THRESHOLD_SECONDS = 15 * 60;
private const RAPPORTS_THRESHOLD_SECONDS = 17 * 60;
private const RAPPORTS_ESCALATION_THRESHOLD_SECONDS = 19 * 60;
private const ENDGAME_NUDGE_THRESHOLD_SECONDS = 23 * 60;
private const ENDGAME_HURRY_THRESHOLD_SECONDS = 26 * 60;
private const ENDGAME_URGENT_THRESHOLD_SECONDS = 28 * 60;
public function __construct(
private readonly SessionRepository $sessionRepository,
private readonly SessionSettingRepository $sessionSettingRepository,
private readonly GameSettingRepository $gameSettingRepository,
private readonly HintRepository $hintRepository,
private readonly PlayerService $playerService,
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
@@ -110,90 +101,66 @@ final class SendMainframeHintsCommand extends Command
}
/**
* Walks this player's progress in strict dependency order and sends at most one
* hint: the one for 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 even
* usable until a player has finished contacting everyone), so we stop at the
* first unresolved step instead of also evaluating - and possibly firing - hints
* for steps that aren't actionable yet.
* 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
{
if (!$this->hasUsedHelp($session, $player)) {
if ($elapsed >= self::HELP_THRESHOLD_SECONDS) {
$this->publishHint($session, 'Have you already checked which functions are available to you through the help command?', $player);
return true;
$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
}
return false;
$seenConditions[] = $condition;
if ($condition !== HintCondition::NONE && $this->isConditionResolved($condition, $session, $player)) {
continue; // move on to the next distinct condition
}
$hasBroadcast = $this->hasBroadcast($session, $player);
$hasContactedEveryonePrivately = $this->hasContactedEveryonePrivately($session, $player);
$group = array_filter($hints, static fn ($h) => $h->getCondition() === $condition);
if (!$hasBroadcast || !$hasContactedEveryonePrivately) {
if ($elapsed >= self::PRIVATE_CHAT_THRESHOLD_SECONDS) {
$this->publishHint($session, 'The AI virus can see all communication if you are using open communication channels. Contact each other privately as well.', $player);
return true;
$best = null;
foreach ($group as $candidate) {
if ($elapsed < $candidate->getThresholdSeconds()) {
continue;
}
if (!$hasBroadcast && $elapsed >= self::BROADCAST_CHAT_THRESHOLD_SECONDS) {
$this->publishHint($session, 'You should establish communications with your fellow agents.', $player);
return true;
if ($best === null || $candidate->getThresholdSeconds() > $best->getThresholdSeconds()) {
$best = $candidate;
}
return false;
}
if (!$this->isFullyVerified($session, $player)) {
if ($elapsed >= self::VERIFY_ESCALATION_THRESHOLD_SECONDS) {
$this->publishHint($session, 'You are still not verified! Please get your verification codes from your colleagues so you can verify.', $player);
return true;
}
if ($elapsed >= self::VERIFY_THRESHOLD_SECONDS) {
$this->publishHint($session, '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.', $player);
return true;
}
return false;
}
if ($this->isStillInHomeDirectory($player)) {
if ($elapsed >= self::CD_THRESHOLD_SECONDS) {
$this->publishHint($session, 'You can change the folder you are working in. Check the help command for more information', $player);
return true;
}
return false;
}
if (!$this->allMessagesDecoded($session, $player)) {
if ($elapsed >= self::RAPPORTS_ESCALATION_THRESHOLD_SECONDS) {
$this->publishHint($session, "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.", $player);
return true;
}
if ($elapsed >= self::RAPPORTS_THRESHOLD_SECONDS && !$this->isInRapportsFolder($player)) {
$this->publishHint($session, 'You should go to the rapports directory to check out the rapports.', $player);
return true;
}
return false;
}
// Every earlier step is resolved for this player - only the endgame urgency
// nudges are left, gated purely on elapsed time.
if ($elapsed >= self::ENDGAME_URGENT_THRESHOLD_SECONDS) {
$this->publishHint($session, 'Please remove the files soon! The AI virus can not win! It will be a disaster if it does!', $player);
if ($best !== null) {
$this->publishHint($session, $best->getMessage(), $player);
return true;
}
if ($elapsed >= self::ENDGAME_HURRY_THRESHOLD_SECONDS) {
$this->publishHint($session, 'HURRY! The AI virus is almost done decoding the last files before it will send everything out!', $player);
return true;
}
if ($elapsed >= self::ENDGAME_NUDGE_THRESHOLD_SECONDS) {
$this->publishHint($session, 'Have you checked out the help command to see what you can do?', $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();
@@ -275,13 +242,6 @@ final class SendMainframeHintsCommand extends Command
return $pwd === '/var/home/' . $player->getUser()->getUsername();
}
private function isInRapportsFolder(Player $player): bool
{
$pwd = $this->playerService->getCurrentPwdOfPlayer($player);
return $pwd !== null && str_starts_with($pwd, '/var/rapports');
}
/**
* 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
@@ -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');
}
}
@@ -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()]);
}
}
+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)',
};
}
}
+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']);
}
}
+59 -70
View File
@@ -179,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']];
}
@@ -240,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.']];
@@ -292,6 +302,10 @@ 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']];
}
@@ -537,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
@@ -606,31 +594,59 @@ class GameResponseService
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;
}
/**
@@ -890,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);
}
}
@@ -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,
]);
}
}
+14 -4
View File
@@ -3,6 +3,7 @@ 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;
@@ -11,17 +12,19 @@ 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(FeedbackService $feedbackService): Response
public function intro(FeedbackService $feedbackService, GameRepository $gameRepository): Response
{
return $this->render('website/home/intro.html.twig', [
'feedbackSummary' => $feedbackService->getFeedbackSummary(),
'showDevBanner' => !$gameRepository->hasOpenGame(),
]);
}
@@ -32,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();
}
}
+40
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>
@@ -102,6 +137,7 @@
{ 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: '✉' },
] %}
@@ -121,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>
@@ -31,7 +31,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;">Game</th>
+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>
+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 %}
+2
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>
+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>
@@ -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 %}
+31
View File
@@ -0,0 +1,31 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Contact | {{ 'site.name'|trans }}{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="text-center mb-4">
<h1 class="display-6 fw-bold">Contact</h1>
<p class="lead text-secondary">Got a question, a bug to report, or just want to say hi?</p>
</div>
<div class="card shadow-sm">
<div class="card-body">
{{ form_errors(contactForm) }}
{{ form_start(contactForm) }}
{{ form_row(contactForm.name) }}
{{ form_row(contactForm.email) }}
{{ form_row(contactForm.message) }}
{{ form_row(contactForm.captcha) }}
<div class="d-grid mt-3">
<button type="submit" class="btn btn-primary">Send Message</button>
</div>
{{ form_end(contactForm) }}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,74 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Escape room map | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-map') }}
<style>
#escaperoom-map {
height: 70vh;
min-height: 420px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
.escaperoom-list-wrap {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, .07);
overflow: hidden;
}
.escaperoom-list-wrap table {
width: 100%;
border-collapse: collapse;
font-size: .9rem;
}
.escaperoom-list-wrap th,
.escaperoom-list-wrap td {
padding: .6rem .9rem;
text-align: left;
}
</style>
{% endblock %}
{% block body %}
{% set detailTemplate = path('website_escaperoom_show', {id: 987654321, slug: 'zzslugzz'})|replace({'987654321': '__ID__', 'zzslugzz': '__SLUG__'}) %}
<div class="d-flex flex-wrap align-items-baseline justify-content-between mb-3">
<div>
<h1 class="display-6 fw-bold mb-1">Physical escape rooms</h1>
<p class="text-secondary mb-0">Zoom in to find rooms near you, then open one to read and leave reviews.</p>
</div>
<div class="text-end">
<div id="escaperoom-status" class="small text-secondary">{{ publishedCount }} rooms on the map</div>
<a href="{{ path('website_escaperoom_suggest') }}" class="btn btn-sm btn-outline-primary mt-1">Suggest a room</a>
</div>
</div>
<div id="escaperoom-map"
data-endpoint="{{ path('website_escaperooms_data') }}"
data-detail-template="{{ detailTemplate }}"
class="mb-4"></div>
<div class="escaperoom-list-wrap">
<table id="escaperoom-list">
<thead>
<tr>
<th>Name</th>
<th>City</th>
<th>Country</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center text-secondary py-4">Loading rooms…</td></tr>
</tbody>
</table>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-map') }}
{% endblock %}
@@ -0,0 +1,122 @@
{% extends 'layout/site.html.twig' %}
{% block title %}{{ room.name }} | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-detail') }}
<style>
#escaperoom-detail-map {
height: 260px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
</style>
{% endblock %}
{% block body %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ room.name }}</li>
</ol>
</nav>
<div class="row g-4">
<div class="col-lg-7">
<h1 class="h3 fw-bold mb-1">{{ room.name }}</h1>
{% if room.venue %}<p class="text-secondary mb-2">{{ room.venue }}</p>{% endif %}
<p class="mb-1">
{% if rating.count > 0 %}
<span class="fw-semibold">{{ rating.avg }} ★</span>
<span class="text-secondary">· {{ rating.count }} review{{ rating.count != 1 ? 's' : '' }}</span>
{% else %}
<span class="text-secondary">No reviews yet</span>
{% endif %}
</p>
{% if room.displayAddress %}
<p class="mb-1">{{ room.displayAddress }}</p>
{% endif %}
<p class="mb-3 small">
{% if room.website %}<a href="{{ room.website }}" target="_blank" rel="noopener nofollow">Website</a> · {% endif %}
{% if room.phone %}{{ room.phone }} · {% endif %}
<a href="https://www.openstreetmap.org/?mlat={{ room.latitude }}&mlon={{ room.longitude }}#map=17/{{ room.latitude }}/{{ room.longitude }}"
target="_blank" rel="noopener">Open in OpenStreetMap</a>
</p>
<hr>
<h2 class="h5 mb-3">Reviews</h2>
{% if app.user %}
<div class="card shadow-sm mb-4">
<div class="card-body">
<h3 class="h6 mb-3">Leave a review</h3>
{{ form_start(reviewForm, {action: path('website_escaperoom_review', {id: room.id})}) }}
<div class="mb-2">
{{ form_label(reviewForm.rating) }}
{{ form_widget(reviewForm.rating, {attr: {class: 'form-select'}}) }}
{{ form_errors(reviewForm.rating) }}
</div>
<div class="mb-2">
{{ form_label(reviewForm.title) }}
{{ form_widget(reviewForm.title, {attr: {class: 'form-control'}}) }}
{{ form_errors(reviewForm.title) }}
</div>
<div class="mb-3">
{{ form_label(reviewForm.body) }}
{{ form_widget(reviewForm.body, {attr: {class: 'form-control'}}) }}
{{ form_errors(reviewForm.body) }}
</div>
<button type="submit" class="btn btn-primary btn-sm">Post review</button>
{{ form_end(reviewForm) }}
</div>
</div>
{% else %}
<p class="small"><a href="{{ path('app_login') }}">Log in</a> to leave a review.</p>
{% endif %}
{% for review in reviews %}
<article class="mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between">
<strong>{{ review.title }}</strong>
<span>
{%- for i in 1..5 -%}
<span class="{{ i <= review.rating ? 'text-warning' : 'text-secondary' }}">★</span>
{%- endfor -%}
</span>
</div>
<div class="small text-secondary mb-1">
{{ review.author ? review.author.username : 'Anonymous' }} · {{ review.createdAt|date('Y-m-d') }}
</div>
<p class="mb-0">{{ review.body|nl2br }}</p>
</article>
{% else %}
<p class="text-secondary">Be the first to review this room.</p>
{% endfor %}
</div>
<div class="col-lg-5">
<div id="escaperoom-detail-map"
data-lat="{{ room.latitude }}"
data-lng="{{ room.longitude }}"
class="mb-3"></div>
<div class="card shadow-sm">
<div class="card-body small">
<div class="text-secondary">Coordinates</div>
<div class="mb-2">{{ room.latitude }}, {{ room.longitude }}</div>
<div class="text-secondary">Listing source</div>
<div>{{ room.source.label }}</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-detail') }}
{% endblock %}
@@ -0,0 +1,105 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Suggest an escape room | {{ 'site.name'|trans }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('escaperoom-detail') }}
<style>
#escaperoom-suggest-map {
height: 340px;
width: 100%;
border-radius: 8px;
z-index: 0;
}
</style>
{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-lg-8">
<nav aria-label="breadcrumb">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="{{ path('website_escaperooms') }}">Escape room map</a></li>
<li class="breadcrumb-item active" aria-current="page">Suggest a room</li>
</ol>
</nav>
<h1 class="h3 fw-bold mb-1">Suggest an escape room</h1>
<p class="text-secondary">Know a physical escape room that isn't on the map? Add it here — an admin will review it before it goes live.</p>
<div class="card shadow-sm">
<div class="card-body">
{{ form_errors(form) }}
{{ form_start(form) }}
<div class="mb-3">
{{ form_label(form.name) }}
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.name) }}
</div>
<div class="mb-3">
{{ form_label(form.venue) }}
{{ form_widget(form.venue, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.venue) }}
</div>
<div class="row g-2">
<div class="col-9 mb-3">
{{ form_label(form.street) }}
{{ form_widget(form.street, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-3 mb-3">
{{ form_label(form.houseNumber) }}
{{ form_widget(form.houseNumber, {attr: {class: 'form-control'}}) }}
</div>
</div>
<div class="row g-2">
<div class="col-4 mb-3">
{{ form_label(form.postcode) }}
{{ form_widget(form.postcode, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-8 mb-3">
{{ form_label(form.city) }}
{{ form_widget(form.city, {attr: {class: 'form-control'}}) }}
</div>
</div>
<div class="mb-3">
{{ form_label(form.country) }}
{{ form_widget(form.country, {attr: {class: 'form-select'}}) }}
</div>
<div class="row g-2">
<div class="col-sm-6 mb-3">
{{ form_label(form.website) }}
{{ form_widget(form.website, {attr: {class: 'form-control'}}) }}
</div>
<div class="col-sm-6 mb-3">
{{ form_label(form.phone) }}
{{ form_widget(form.phone, {attr: {class: 'form-control'}}) }}
</div>
</div>
<label class="form-label">Location <span class="text-secondary fw-normal">— click the map to drop a pin</span></label>
<div id="escaperoom-suggest-map" class="mb-2"></div>
{{ form_widget(form.latitude) }}
{{ form_widget(form.longitude) }}
{{ form_errors(form.latitude) }}
{{ form_errors(form.longitude) }}
<div class="mt-3">
{{ form_row(form.captcha) }}
</div>
<div class="d-grid mt-3">
<button type="submit" class="btn btn-primary">Submit for review</button>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('escaperoom-detail') }}
{% endblock %}
+92 -3
View File
@@ -44,6 +44,40 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, .35);
pointer-events: none;
}
.how-it-works-kicker {
letter-spacing: .1em;
color: var(--brand-teal-dark, #1c5a70);
}
.how-step-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(47, 143, 174, .12);
color: var(--brand-teal-dark, #1c5a70);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
}
.how-step-connector {
position: relative;
}
@media (min-width: 768px) {
.how-step-connector:not(:last-child)::after {
content: "";
position: absolute;
top: 32px;
left: calc(50% + 44px);
right: calc(-50% + 44px);
height: 2px;
background: rgba(47, 143, 174, .25);
}
}
.world-map-strip {
background: rgba(47, 143, 174, .06);
border: 1px solid rgba(47, 143, 174, .2);
border-radius: .75rem;
}
</style>
{% endblock %}
@@ -66,14 +100,69 @@
<div class="col-lg-5 text-center">
<div class="dev-watermark-wrap">
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 260px;">
{% if showDevBanner %}
<span class="dev-watermark-badge">In Development</span>
{% endif %}
</div>
</div>
</div>
</div>
<div class="text-center py-4">
<img src="{{ asset('images/logo.png') }}" alt="{{ 'site.name'|trans }}" height="100" class="mb-3">
<p class="col-lg-8 mx-auto text-secondary">{{ 'home.description'|trans }}</p>
<div class="mb-5">
<div class="text-center mb-4">
<span class="text-uppercase small fw-semibold how-it-works-kicker">How it works</span>
<h2 class="fw-bold">One team &mdash; even if you're not in the same room</h2>
<p class="col-lg-8 mx-auto text-secondary">
Every game on EscapePage is built to be played online, together. You don't need to travel,
book a room, or even hop on a call &mdash; everyone plays from their own screen, and the
game has a built-in chat to keep your team talking.
</p>
</div>
<div class="row g-4">
<div class="col-md-3 col-6 how-step-connector">
<div class="text-center h-100 p-3">
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-people-fill"></i></div>
<h3 class="h6 fw-bold">1. Invite your team</h3>
<p class="small text-secondary mb-0">
Get two or three friends to join the same game &mdash; no matter where in the
world they are.
</p>
</div>
</div>
<div class="col-md-3 col-6 how-step-connector">
<div class="text-center h-100 p-3">
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-display"></i></div>
<h3 class="h6 fw-bold">2. Everyone logs in</h3>
<p class="small text-secondary mb-0">
Each player opens the game in their own browser, from wherever they are. No app,
no download, no travel.
</p>
</div>
</div>
<div class="col-md-3 col-6 how-step-connector">
<div class="text-center h-100 p-3">
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-chat-dots-fill"></i></div>
<h3 class="h6 fw-bold">3. Chat in the game</h3>
<p class="small text-secondary mb-0">
Each of you sees different clues. Use the built-in chat to share what you find and
connect the dots &mdash; no separate call needed.
</p>
</div>
</div>
<div class="col-md-3 col-6 how-step-connector">
<div class="text-center h-100 p-3">
<div class="how-step-icon mx-auto mb-3"><i class="bi bi-hourglass-split"></i></div>
<h3 class="h6 fw-bold">4. Beat the clock</h3>
<p class="small text-secondary mb-0">
Work together in real time to crack it before the timer runs out &mdash; you win
or lose as one team.
</p>
</div>
</div>
</div>
<div class="world-map-strip text-center p-3 p-md-4 mt-4">
<i class="bi bi-globe-americas me-2" style="color: var(--brand-teal-dark, #1c5a70);"></i>
<span class="text-secondary">Amsterdam, Auckland, or anywhere in between &mdash; as long as you're online, you can play together.</span>
</div>
</div>
{% endblock %}
+2
View File
@@ -81,7 +81,9 @@
<div class="text-center mb-4">
<div class="dev-watermark-wrap">
<img src="{{ asset('images/AI_virus.png') }}" alt="AI Virus" class="img-fluid" style="max-height: 220px;">
{% if showDevBanner %}
<span class="dev-watermark-badge">In Development</span>
{% endif %}
</div>
</div>
+107
View File
@@ -0,0 +1,107 @@
{% extends 'layout/site.html.twig' %}
{% block title %}Terms and Conditions | {{ 'site.name'|trans }}{% endblock %}
{% block body %}
<div class="row justify-content-center">
<div class="col-lg-9">
<div class="mb-4">
<h1 class="display-6 fw-bold">Terms and Conditions</h1>
<p class="text-secondary">Last updated: 29 August 2026</p>
</div>
<div class="card shadow-sm">
<div class="card-body">
<p>
Welcome to EscapePage. These Terms and Conditions ("Terms") govern your use of this
website and the online escape-room game(s) offered on it (together, the "Service"). By
creating an account or otherwise using the Service, you agree to these Terms.
</p>
<h2 class="h5 mt-4">1. The Service</h2>
<p>
EscapePage is a hobby project offering collaborative, browser-based escape-room
experiences. The Service is under active development: games, features, and content may
change, be added, or be removed at any time, without notice.
</p>
<h2 class="h5 mt-4">2. Accounts</h2>
<p>
You need an account to play. You are responsible for keeping your login details
confidential and for all activity under your account. You must provide accurate
information when registering, and you must be at least 16 years old, or the minimum age
required in your country to consent to online services without parental approval,
whichever is higher.
</p>
<h2 class="h5 mt-4">3. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<li>attempt to cheat, exploit bugs, or bypass the intended puzzle mechanics in a way that spoils the game for other players;</li>
<li>harass, abuse, or send unlawful or offensive content to other players or the operator;</li>
<li>use automated tools to scrape, overload, or attack the Service;</li>
<li>use the Service for any unlawful purpose.</li>
</ul>
<p>
Accounts that break these rules may be suspended or removed at the operator's discretion.
</p>
<h2 class="h5 mt-4">4. Feedback and Submitted Content</h2>
<p>
If you submit feedback, ratings, or written comments through the Service, you grant the
operator a non-exclusive, royalty-free right to use, store, and display that content
(for example, as aggregate feedback scores or anonymised testimonials) in connection with
the Service.
</p>
<h2 class="h5 mt-4">5. Donations</h2>
<p>
The Service may offer an optional way to support its development through a third-party
payment provider (currently PayPal). Donations are voluntary, are not required to play,
do not unlock any in-game advantage, and are non-refundable except where required by law.
</p>
<h2 class="h5 mt-4">6. Availability and "As Is" Service</h2>
<p>
The Service is provided free of charge, "as is" and "as available," without warranties of
any kind, express or implied. As a small, self-hosted hobby project, uptime, data
retention, and long-term availability are not guaranteed.
</p>
<h2 class="h5 mt-4">7. Intellectual Property</h2>
<p>
The game stories, puzzles, artwork, and source code are the property of the operator or
used under licence, except where third-party or open-source components state otherwise.
You may not copy, redistribute, or republish game content without permission.
</p>
<h2 class="h5 mt-4">8. Limitation of Liability</h2>
<p>
To the fullest extent permitted by law, the operator is not liable for any indirect,
incidental, or consequential damages arising from your use of, or inability to use, the
Service.
</p>
<h2 class="h5 mt-4">9. Changes to These Terms</h2>
<p>
These Terms may be updated from time to time; the "Last updated" date above will reflect
the most recent change. Continuing to use the Service after a change means you accept the
updated Terms.
</p>
<h2 class="h5 mt-4">10. Governing Law</h2>
<p>
These Terms are governed by the laws of the Netherlands, without regard to its conflict
of law principles.
</p>
<h2 class="h5 mt-4">11. Contact</h2>
<p class="mb-0">
Questions about these Terms are welcome. Please reach out to the operator through the
contact details available on this site.
</p>
</div>
</div>
</div>
</div>
{% endblock %}
+3 -1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Home
nav.game: Game
nav.escaperooms: Rooms
nav.admin: Admin
nav.profile: Profile
nav.login: Login
@@ -9,12 +10,13 @@ nav.register: Register
nav.logout: Logout
link.enter_game: Enter the Game Area
link.back_to_website: Back to Website
footer.contact: Contact
footer.help_wanted: Looking for Help
footer.terms: Terms and Conditions
# Home page
home.title: "Welcome | EscapePage"
home.h1: "Welcome to EscapePage"
home.description: "This is the public website. Minimal interaction, information about the game, and links."
# Game hub
game.title: "Game Hub | EscapePage"
+3 -1
View File
@@ -2,6 +2,7 @@
site.name: EscapePage
nav.home: Start
nav.game: Spel
nav.escaperooms: Rooms
nav.admin: Beheer
nav.profile: Profiel
nav.login: Inloggen
@@ -9,12 +10,13 @@ nav.register: Registreren
nav.logout: Uitloggen
link.enter_game: Ga naar het Speelgedeelte
link.back_to_website: Terug naar Website
footer.contact: Contact
footer.help_wanted: Op zoek naar hulp
footer.terms: Algemene Voorwaarden
# Home pagina
home.title: "Welkom | %{site}%"
home.h1: "Welkom bij %{site}%"
home.description: "Dit is de publieke website. Beperkte interactie, informatie over het spel en links."
# Spel hub
game.title: "Spel Hub | %{site}%"
+2
View File
@@ -15,6 +15,8 @@ Encore
.addEntry('app', './assets/app.js')
.addEntry('game1', './assets/game1.js')
.addEntry('pregame', './assets/pregame.js')
.addEntry('escaperoom-map', './assets/escaperoom-map.js')
.addEntry('escaperoom-detail', './assets/escaperoom-detail.js')
// split entry chunks for better caching
.splitEntryChunks()
// will require an extra script tag for runtime.js