Add persistent AFK reserves and recovery (Refs #151)
CI / checks (push) Successful in 7m7s

This commit is contained in:
2026-10-17 16:57:26 -04:00
parent 7f2205e9c4
commit d723f23d67
22 changed files with 2646 additions and 16 deletions
+193
View File
@@ -0,0 +1,193 @@
<script lang="ts">
import type { afkProfile } from '$lib/server/fleet/afk.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
let { account }: { account: ReturnType<typeof afkProfile> } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
const label = (value: string) => value.replaceAll('_', ' ');
</script>
<section aria-labelledby="afk-title">
<h2 id="afk-title">AFK reserve</h2>
<p>
<strong>{duration(account.freeSeconds)} free</strong> and
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
Saved capacity: {duration(account.capacitySeconds)}.
</p>
{#if account.freeRenewsAt}
<p>
Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free
time does not accumulate.
</p>
{/if}
{#if account.reservedSeconds > 0}
<p>
{duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers.
</p>
{/if}
<dl>
<div>
<dt>Recorded active play</dt>
<dd>{duration(account.activeSeconds)}</dd>
</div>
<div>
<dt>Confirmed AFK time</dt>
<dd>{duration(account.confirmedAfkSeconds)}</dd>
</div>
<div>
<dt>Manual returns</dt>
<dd>{account.manualReturns}</dd>
</div>
<div>
<dt>Expirations</dt>
<dd>{account.expiredSessions}</dd>
</div>
<div>
<dt>Purchases</dt>
<dd>{account.purchaseCount} ({duration(account.purchasedSeconds)})</dd>
</div>
</dl>
{#if account.unresolvedSessions > 0}
<p>
{account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded
from confirmed AFK usage.
</p>
{/if}
{#if account.entries.length}
<details>
<summary>Recent time ledger</summary>
<div class="table-scroll">
<table>
<caption>Recent AFK grants, reservations and consumption</caption>
<thead
><tr
><th scope="col">Time / event</th><th scope="col">Free change</th><th
scope="col">Saved change</th
><th scope="col">Balances after</th><th scope="col">Server / match</th
></tr
></thead
>
<tbody>
{#each account.entries as entry (entry.id)}
<tr>
<td
>{$timeFormat.dateTime(entry.eventAt)}<small
>{label(entry.kind)}</small
><small>{entry.operationId}</small></td
>
<td>{change(entry.freeDelta)}</td>
<td>{change(entry.savedDelta)}</td>
<td
>{duration(entry.freeSeconds)} free<small
>{duration(entry.savedSeconds)} saved</small
></td
>
<td
>{serverTitle(entry.server)}<small
>{#if entry.matchAvailable}<a
href={`/matches/${entry.matchId}`}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
{:else}
<p>No AFK activity recorded yet.</p>
{/if}
{#if account.sessions.length}
<details>
<summary>Recent AFK sessions</summary>
<div class="table-scroll">
<table>
<caption>Accepted AFK transitions</caption>
<thead
><tr
><th scope="col">Started</th><th scope="col">Server</th><th scope="col"
>Duration</th
><th scope="col">Outcome</th></tr
></thead
>
<tbody>
{#each account.sessions as session (session.sessionId)}
<tr>
<td
>{session.startedAt
? $timeFormat.dateTime(session.startedAt)
: 'Start unconfirmed'}</td
>
<td>{serverTitle(session.server)}</td>
<td
>{session.consumedSeconds === null
? 'Unconfirmed'
: duration(session.consumedSeconds)}
{#if session.state === 'corrected' && session.chargedSeconds !== null}
<small
>Corrected charge: {duration(
session.chargedSeconds
)}</small
>
{/if}</td
>
<td
>{label(
session.endReason ?? session.state
)}{#if session.endedAt}<small
>{$timeFormat.dateTime(session.endedAt)}</small
>{/if}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
{/if}
</section>
<style>
section {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
h2 {
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
}
dt,
small {
color: var(--text-muted);
font-size: var(--text-sm);
}
dd {
margin: var(--space-1) 0 0;
}
small {
display: block;
overflow-wrap: anywhere;
}
details {
margin-top: var(--space-3);
}
summary {
cursor: pointer;
}
a {
color: var(--accent-primary-hover);
}
</style>