feat(game): race exit warnings in the turn report (#12)
Tests · Go / test (push) Successful in 2m29s
Tests · UI / test (push) Waiting to run
Tests · Go / test (pull_request) Successful in 2m17s
Tests · Integration / integration (pull_request) Successful in 1m53s
Tests · UI / test (pull_request) Successful in 3m37s

Surface the inactivity-removal countdown the rules promise but the
engine never reported. A race within five turns of being auto-removed
for inactivity gets a personal warning in its own report; every race
within three turns is listed publicly to all participants.

- model: Report.PersonalExitWarning + RacesLeavingSoon ([]RaceExitNotice)
- fbs: RaceExitNotice table + Report.personal_exit_warning /
  races_leaving_soon (regenerated Go + TS bindings)
- transcoder: encode/decode both fields
- engine: ReportExitWarnings fills the recipient's TTL (1..5) and lists
  other non-extinct races with TTL 1..3, excluding the recipient itself
- ui: danger-styled personal banner + "races leaving soon" section
  (hidden when empty), wired into the report view, EN/RU i18n
- docs: rules.txt report-section list, FUNCTIONAL.md 6.4 + RU mirror

Voluntary quit and idle timeout share the TTL countdown and are not
distinguished, per the agreed scope.
This commit is contained in:
Ilia Denisov
2026-05-31 10:34:50 +02:00
parent 9dce15c7bb
commit 9e9977d5f1
28 changed files with 908 additions and 22 deletions
+10 -3
View File
@@ -2,7 +2,7 @@
Phase 23 turn-report active view.
Composes the table of contents (`report/report-toc.svelte`) and the
twenty section components that render each `GameReport` array. Each
section components that render each `GameReport` array. Each
section is its own component under `lib/active-view/report/` — the
data shapes are too varied for one generic table, and the
component-per-section seam matches Phase 23's targeted-test contract.
@@ -11,8 +11,10 @@ Active-section highlighting lands here: an `IntersectionObserver`
rooted on the viewport watches every `<section id="report-<slug>">`
and updates a local `activeSlug` rune that drives the TOC highlight.
The 20-section list lives here as a single source of truth so the
TOC and the body iterate the same data.
The section list lives here as a single source of truth so the
TOC and the body iterate the same data. One entry —
`race-exit-warnings` — renders nothing when its list is empty, so its
TOC item resolves to a no-op scroll on the rare turns it is hidden.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -20,7 +22,9 @@ TOC and the body iterate the same data.
import ReportToc, {
type TocEntry,
} from "./report/report-toc.svelte";
import PersonalExitBanner from "./report/personal-exit-banner.svelte";
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
import SectionRaceExitWarnings from "./report/section-race-exit-warnings.svelte";
import SectionVotes from "./report/section-votes.svelte";
import SectionPlayerStatus from "./report/section-player-status.svelte";
import SectionMySciences from "./report/section-my-sciences.svelte";
@@ -43,6 +47,7 @@ TOC and the body iterate the same data.
const ENTRIES: readonly TocEntry[] = [
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
{ slug: "race-exit-warnings", titleKey: "game.report.section.race_exit_warnings.title" },
{ slug: "votes", titleKey: "game.report.section.votes.title" },
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
@@ -107,10 +112,12 @@ TOC and the body iterate the same data.
</script>
<div class="report-view" data-testid="active-view-report">
<PersonalExitBanner />
<ReportToc entries={ENTRIES} {activeSlug} />
<div class="report-body" bind:this={bodyEl}>
<SectionGalaxySummary />
<SectionRaceExitWarnings />
<SectionVotes />
<SectionPlayerStatus />
<SectionMySciences />
@@ -0,0 +1,64 @@
<!--
Personal exit-warning banner for the turn-report view. Mirrors the
`lib/header/history-banner.svelte` alert pattern (sticky `aside`,
design-token styling) but carries the local race's own inactivity
countdown rather than a history notice.
Renders only when `report.personalExitWarning > 0`: the engine reports
`1..5` turns remaining before the local race is auto-removed for
inactivity, `0` when there is no warning. The copy is a personal alarm
("you will be removed in N turns unless you submit orders"), distinct
from the public `section-race-exit-warnings.svelte` list of other
races leaving soon. Uses the danger token rather than the warning
token because it is the recipient's own removal that is at stake.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const turnsLeft = $derived(report?.personalExitWarning ?? 0);
</script>
{#if turnsLeft > 0}
<aside
class="exit-banner"
data-testid="report-personal-exit-banner"
role="alert"
>
<span class="message">
{i18n.t("game.report.personal_exit_warning", {
turns: String(turnsLeft),
})}
</span>
</aside>
{/if}
<style>
.exit-banner {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 1.25rem;
padding: 0.6rem 0.9rem;
background: var(--color-surface);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: 6px;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
font-weight: 600;
}
.message {
flex: 1;
min-width: 0;
}
</style>
@@ -0,0 +1,70 @@
<!--
Report View — races leaving soon section. Surfaces the public
`racesLeavingSoon` projection: every other race within three turns of
being auto-removed for inactivity, with the number of turns each has
left. This is the public counterpart to the personal exit-warning
banner the report view renders at the top for the local race.
Unlike the other report sections, this one hides entirely when the
list is empty rather than showing an empty-state line: an absent
notice is the normal, healthy case for an active game, so a permanent
"no races leaving" row would be noise. The section's TOC entry stays
registered; clicking it while the section is hidden is a silent no-op
through the table-of-contents' existing `getElementById` guard.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const report = $derived(rendered?.report ?? null);
const rows = $derived(report?.racesLeavingSoon ?? []);
</script>
{#if rows.length > 0}
<section
id="report-race-exit-warnings"
class="grid-section"
data-testid="report-section-race-exit-warnings"
>
<h2>{i18n.t("game.report.section.race_exit_warnings.title")}</h2>
<ul class="notices" data-testid="race-exit-warnings-list">
{#each rows as r (r.race)}
<li data-testid="race-exit-warnings-row" data-race={r.race}>
{i18n.t("game.report.section.race_exit_warnings.notice", {
race: r.race,
turns: String(r.turnsLeft),
})}
</li>
{/each}
</ul>
</section>
{/if}
<style>
.grid-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--color-text);
}
.notices {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--color-warning);
}
.notices li {
margin: 0;
}
</style>
+5
View File
@@ -657,11 +657,16 @@ const en = {
"game.report.toc.title": "sections",
"game.report.toc.open": "show section list",
"game.report.toc.close": "hide section list",
"game.report.personal_exit_warning":
"Inactivity warning: your race will be removed in {turns} turn(s) unless you submit orders.",
"game.report.section.galaxy_summary.title": "galaxy summary",
"game.report.section.galaxy_summary.field.turn": "turn",
"game.report.section.galaxy_summary.field.size": "map size",
"game.report.section.galaxy_summary.field.planets": "planet count",
"game.report.section.galaxy_summary.field.race": "your race",
"game.report.section.race_exit_warnings.title": "races leaving soon",
"game.report.section.race_exit_warnings.notice":
"{race} will be removed for inactivity in {turns} turn(s).",
"game.report.section.votes.title": "votes",
"game.report.section.votes.mine": "my votes",
"game.report.section.votes.target": "I vote for",
+5
View File
@@ -658,11 +658,16 @@ const ru: Record<keyof typeof en, string> = {
"game.report.toc.title": "разделы",
"game.report.toc.open": "показать список разделов",
"game.report.toc.close": "скрыть список разделов",
"game.report.personal_exit_warning":
"Предупреждение о неактивности: ваша раса будет удалена через {turns} ход(ов), если вы не отправите приказы.",
"game.report.section.galaxy_summary.title": "общие сведения о галактике",
"game.report.section.galaxy_summary.field.turn": "ход",
"game.report.section.galaxy_summary.field.size": "размер карты",
"game.report.section.galaxy_summary.field.planets": "всего планет",
"game.report.section.galaxy_summary.field.race": "ваша раса",
"game.report.section.race_exit_warnings.title": "расы скоро покинут игру",
"game.report.section.race_exit_warnings.notice":
"{race} будет удалена за неактивность через {turns} ход(ов).",
"game.report.section.votes.title": "голоса",
"game.report.section.votes.mine": "мои голоса",
"game.report.section.votes.target": "голосую за",