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.
- 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>
Every $hub->publish() call site (chat, hints, security alerts, virus
alerts, game_finished, and the pre-game lobby events) now dispatches
a PushMercureMessageEvent instead of publishing directly. Two
listeners handle it: PublishMercureMessageListener does the actual
Mercure publish exactly as before (same wire format, no client
changes needed), and LogMercureMessageListener appends it to the
activity log of whichever player(s) it was actually delivered to.
A private /chat to one agent only gets logged for that agent, never
broadcast into everyone's transcript - the event carries an explicit
targetScreen (null = everyone) rather than leaving listeners to guess
from the payload shape.
The per-player log format moved from flat text to JSON Lines (one
timestamped, structured entry per line) via a new shared
SessionActivityLogger service, since a flat string can't carry an
event's type or exact payload - both needed for a future feature to
replay a player's full session history, not just their own commands,
on page reload. The admin session log viewer now parses and
formats these entries back into readable lines.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cron (app:hints:check, every minute) now walks each playing
player through a strict dependency chain - help, communication,
verification, navigation, rapports/decode, endgame urgency - and
sends at most one hint per player per run: the first step that's
both unresolved and old enough to nudge about. Later steps genuinely
depend on earlier ones (e.g. /verify isn't usable until a player has
finished contacting everyone), so a player never gets a hint for
something they can't act on yet.
Two bits of new tracking were needed:
- Help command usage wasn't recorded anywhere, so it's now saved to
a new HelpUsedForPlayer{N} session setting.
- "All messages decoded" needed a reliable session-wide signal, so
the rm right (previously granted alongside sudo when player 1
decodes) now comes from player 3's decode instead, making
sudo+scan+rm together mean "the whole team has decoded".
The cron also now finishes sessions whose countdown has run out:
sets the session to LOST and broadcasts the same game_finished
Mercure event the win path already uses, so every connected player
gets pushed to the lost page within a minute of the game actually
ending - not just whichever player's own client-side timer happens
to notice first.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Game1 terminal: flesh out the virtual filesystem with a realistic
spread of Linux directories/files (~70 dirs, ~140 files) so it no
longer reads as an obviously small puzzle set, without touching any
win-condition or rapport files.
- Add app:hints:check command + a php-cron container (BusyBox crond)
that nudges players who haven't contacted every teammate 5 minutes
into a session, via a new 'hint' Mercure message type.
- Log the cron command's output to var/log/cron/cron.log and rotate
it (25MB / 90 days) via logrotate, run daily from the same crontab.
- Redirect PHP's error_log and Symfony's prod app/deprecation logs
from stderr-only into var/log/php/*.log (kept alongside stderr),
with the same rotation policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the 500 on admin user deletion: deleting a user with an
email_log row (i.e. basically anyone who received any email) hit an
unhandled ForeignKeyConstraintViolationException, since email_log,
reset_password_request and player all have non-nullable FKs to user
with no cascade at the DB level.
Instead of cascading the delete (which would be fine for email_log
and reset_password_request but risky for player - removing a player
row could corrupt other real players' session state), admin delete
now sets a deletedAt timestamp instead of removing the row:
- UserChecker blocks login for deleted users (checkPreAuth).
- EmailLoggerListener rejects the message before send for deleted
recipients (checked at actual send time, not at queue time).
- Added a last_login_at column + a LoginSuccessEvent listener to
populate it, and surfaced both last login and status in the admin
users list.
Added `app:users:purge-deleted`, a command intended to run on a
schedule that permanently removes users who were soft-deleted more
than 3 months ago and never played a game (join to Player via a
NOT EXISTS subquery). For that eventual hard-delete to actually
succeed, email_log and reset_password_request now cascade-delete at
the DB level (migration drops+recreates both FK constraints with ON
DELETE CASCADE) - player intentionally still isn't cascaded, so a
user with game history can never be purged this way even by mistake.