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.
This commit is contained in:
Frank
2026-08-23 21:35:05 +02:00
parent 552c71b718
commit c63af7becc
11 changed files with 614 additions and 93 deletions
+2 -1
View File
@@ -57,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 %}
+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 %}