feat(client): Delay/Resume controls in the initiative tracker (loop 3, iteration 16)

Surfaces iteration 15's engine Delay action: sky-toned hourglass badge for
every id in combat.delayed (trusted verbatim from the role-projected
snapshot, never re-filtered client-side), Delay/Resume buttons gated to
entities this seat controls mirroring the engine's may_control_entity
RBAC, engine rejections (ALREADY_DELAYED, NOT_DELAYED, ...) surfaced
verbatim in an alert strip. Success re-pulls the combat snapshot — no
local mutation of order/turn_index.

Gates: vitest 616 passed / 46 files (+28); tsc+vite clean in 4.7s; relay suite 66 passed.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John SmithandClaude committed 2026-08-26 05:10:58 -04:00
1 parent add3d7f538
commit 7acddadebc
5 files changed
+876 -3

No files matched your search

+37 -1
View File
@@ -125,6 +125,7 @@ import {
parseEntityStatusFromSessionState,
type EntityCombatStatus,
} from './api/entity_status_state';
import { delayedIdsFromSnapshot } from './api/combat_delay';
import {
canEnterStreamerView,
exitStreamerView,
@@ -143,6 +144,12 @@ interface EngineCombatSnapshot {
round: number;
turn_index: number;
order: CombatantEntry[];
/**
* Iteration 16 — ids the engine currently has parked out of turn order
* (verbatim `combat.delayed` from the projected snapshot). Empty when the
* projection said nothing; never derived client-side.
*/
delayed: string[];
}
const IDLE_COMBAT_SNAPSHOT: EngineCombatSnapshot = {
@@ -150,6 +157,7 @@ const IDLE_COMBAT_SNAPSHOT: EngineCombatSnapshot = {
round: 0,
turn_index: 0,
order: [],
delayed: [],
};
// --- Roll history persistence ---------------------------------------------
@@ -828,6 +836,21 @@ export function App() {
const selectedToken =
visibleTokens.find((t) => t.id === spectatorSelectedId) || visibleTokens[0];
/**
* Iteration 16 — entity ids THIS seat may act for, feeding the tracker's
* Delay/Resume gating. Mirrors the engine's own `may_control_entity` RBAC:
* GM/admin act on anyone; players only the tokens bound to their seat
* (assignedTokenIds, '*' = authority without a body); spectators nothing.
* The engine re-verifies every call — this list only shapes the UI.
*/
const controlledEntityIds = useMemo(() => {
if (userRole === 'gm') return visibleTokens.map((t) => t.id);
if (userRole !== 'player') return [];
return visibleTokens
.filter((t) => t.isPlayer && currentUser.assignedTokenIds.includes(t.id))
.map((t) => t.id);
}, [userRole, visibleTokens, currentUser.assignedTokenIds]);
// Private-channel chat is excluded from what NarrativeChat receives. The
// channel tab UI stays (NarrativeChat owns it) — it just renders nothing
// private. Every private-tagging path in the app is covered:
@@ -1129,6 +1152,9 @@ export function App() {
round: Number(c.round ?? 0),
turn_index: Number(c.turn_index ?? 0),
order: Array.isArray(c.order) ? c.order : [],
// Iteration 16: the engine's parked-out-of-turn-order list, read
// verbatim from this same snapshot (role-projected server-side).
delayed: delayedIdsFromSnapshot(snap),
});
// Iteration 58: same snapshot also mirrors per-entity concentration
// (`entities[id].concentration = {spell_id, started_round}`). Parsed
@@ -1188,7 +1214,13 @@ export function App() {
const data = await resp.json();
const order: CombatantEntry[] = Array.isArray(data.order) ? data.order : [];
const round = Number(data.round ?? 1);
setCombat({ in_combat: true, round, turn_index: Number(data.turn_index ?? 0), order });
setCombat({
in_combat: true,
round,
turn_index: Number(data.turn_index ?? 0),
order,
delayed: delayedIdsFromSnapshot(data),
});
setRoundNumber(round || 1);
setCurrentTurnIndex(Number(data.turn_index ?? 0));
const first = order[0];
@@ -2282,6 +2314,10 @@ export function App() {
entityStatusByEntity={entityStatusByEntity}
concentrationByEntity={concentrationByEntity}
sessionStateRaw={sessionStateRaw}
delayedIds={combat.delayed}
combatSessionIdForActions={combatSessionId}
controlledEntityIds={controlledEntityIds}
onRefreshCombatState={() => void refreshCombatState()}
/>
{/* Center Tactical Canvas */}
@@ -0,0 +1,228 @@
/**
* Iteration 16 (Loop 3) — SRD-optional Delay wire contract.
*
* Pinned contracts:
* - POST /api/v1/engine/delay and POST /api/v1/engine/delay/resume carry the
* ids-only pair {session_id, entity_id} — exactly the engine's
* SimpleActionReq (deny_unknown_fields). Round economy, turn re-seating and
* the delayed list itself are all engine-owned: the client sends NO combat
* math and mutates NO order locally.
* - Every call rides the stored token as Authorization: Bearer; signed-out
* callers get NOT_AUTHENTICATED without touching the network.
* - Engine rejections surface VERBATIM as machine codes — ALREADY_DELAYED,
* NOT_DELAYED, NOT_IN_COMBAT, ENTITY_CANNOT_ACT, ENTITY_NOT_FOUND,
* ENTITY_NOT_OWNED — never rewritten into friendlier fiction.
* - delayedIdsFromSnapshot reads the engine's `combat.delayed` array out of a
* RAW projected session-state body defensively: whatever the projection did
* not expose simply means "nobody parked", never a guessed list.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
delayedIdsFromSnapshot,
delayEntity,
resumeEntity,
} from '../combat_delay';
const TOKEN = 'sig.payload.token';
const store = new Map<string, string>();
type FetchCall = { url: string; init: RequestInit };
function stubFetch(respond: () => { ok: boolean; status?: number; json: () => Promise<unknown> }) {
const calls: FetchCall[] = [];
const fn = vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init: (init ?? {}) as RequestInit });
return respond();
});
vi.stubGlobal('fetch', fn);
return calls;
}
beforeEach(() => {
store.clear();
(globalThis as unknown as { sessionStorage: Storage }).sessionStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
get length() {
return store.size;
},
} as Storage;
});
describe('delayEntity wire contract', () => {
it('posts the exact SimpleActionReq body with the Bearer header', async () => {
store.set('aethertable_token', TOKEN);
const calls = stubFetch(() => ({
ok: true,
json: async () => ({
status: 'DELAY_TAKEN',
entity_id: 'thorin',
delayed: ['thorin'],
round: 3,
turn_index: 1,
order: [{ entity_id: 'goblin' }, { entity_id: 'thorin' }],
event_sequence: 41,
}),
}));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome.kind).toBe('applied');
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/v1/engine/delay');
expect(JSON.parse(String(calls[0].init.body))).toEqual({
session_id: 'sess-1',
entity_id: 'thorin',
});
expect((calls[0].init.headers as Record<string, string>).Authorization).toBe(
`Bearer ${TOKEN}`,
);
});
it('passes the engine success payload through verbatim', async () => {
store.set('aethertable_token', TOKEN);
const body = {
status: 'DELAY_TAKEN',
entity_id: 'thorin',
delayed: ['thorin'],
round: 3,
order: [],
event_sequence: 7,
};
stubFetch(() => ({ ok: true, json: async () => body }));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toEqual({ kind: 'applied', data: body });
});
it('surfaces ALREADY_DELAYED verbatim (409)', async () => {
store.set('aethertable_token', TOKEN);
stubFetch(() => ({
ok: false,
status: 409,
json: async () => ({
detail: { error: 'ALREADY_DELAYED', message: 'already parked out of turn order' },
}),
}));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({
kind: 'rejected',
status: 409,
code: 'ALREADY_DELAYED',
message: 'already parked out of turn order',
});
});
it.each([
['NOT_IN_COMBAT', 409],
['ENTITY_CANNOT_ACT', 409],
['ENTITY_NOT_FOUND', 404],
['ENTITY_NOT_OWNED', 403],
] as const)('surfaces %s verbatim (%i)', async (code, status) => {
store.set('aethertable_token', TOKEN);
stubFetch(() => ({
ok: false,
status,
json: async () => ({ detail: { error: code, message: `${code} happened` } }),
}));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({ kind: 'rejected', status, code });
});
it('refuses to hit the gateway when signed out', async () => {
const calls = stubFetch(() => ({ ok: true, json: async () => ({}) }));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({ kind: 'rejected', code: 'NOT_AUTHENTICATED' });
expect(calls).toHaveLength(0);
});
it('reports an unreachable engine without inventing an outcome', async () => {
store.set('aethertable_token', TOKEN);
stubFetch(() => ({
ok: false,
status: 503,
json: async () => ({ detail: 'unavailable' }),
}));
const outcome = await delayEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({ kind: 'unreachable' });
});
});
describe('resumeEntity wire contract', () => {
it('posts the exact SimpleActionReq body to the resume path', async () => {
store.set('aethertable_token', TOKEN);
const calls = stubFetch(() => ({
ok: true,
json: async () => ({
status: 'DELAY_RESUMED',
entity_id: 'thorin',
delayed: [],
round: 3,
turn_index: 1,
order: [],
event_sequence: 42,
}),
}));
const outcome = await resumeEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome.kind).toBe('applied');
expect(calls[0].url).toBe('/api/v1/engine/delay/resume');
expect(JSON.parse(String(calls[0].init.body))).toEqual({
session_id: 'sess-1',
entity_id: 'thorin',
});
expect((calls[0].init.headers as Record<string, string>).Authorization).toBe(
`Bearer ${TOKEN}`,
);
});
it('surfaces NOT_DELAYED verbatim (409)', async () => {
store.set('aethertable_token', TOKEN);
stubFetch(() => ({
ok: false,
status: 409,
json: async () => ({
detail: { error: 'NOT_DELAYED', message: 'this combatant is not delaying' },
}),
}));
const outcome = await resumeEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({
kind: 'rejected',
status: 409,
code: 'NOT_DELAYED',
});
});
it('refuses to hit the gateway when signed out', async () => {
const calls = stubFetch(() => ({ ok: true, json: async () => ({}) }));
const outcome = await resumeEntity({ sessionId: 'sess-1', entityId: 'thorin' });
expect(outcome).toMatchObject({ kind: 'rejected', code: 'NOT_AUTHENTICATED' });
expect(calls).toHaveLength(0);
});
});
describe('delayedIdsFromSnapshot (defensive projection read)', () => {
it('reads combat.delayed out of a raw session-state body', () => {
expect(
delayedIdsFromSnapshot({
entities: {},
combat: { in_combat: true, order: [], delayed: ['a', 'b'] },
}),
).toEqual(['a', 'b']);
});
it('also accepts a bare combat object (the delay-route response shape)', () => {
expect(delayedIdsFromSnapshot({ status: 'DELAY_TAKEN', delayed: ['x'] })).toEqual(['x']);
});
it('drops non-string entries instead of coercing them into fake ids', () => {
expect(delayedIdsFromSnapshot({ delayed: ['ok', 7, null, {}] })).toEqual(['ok']);
});
it('returns empty for absent/malformed payloads — never a guessed list', () => {
expect(delayedIdsFromSnapshot(undefined)).toEqual([]);
expect(delayedIdsFromSnapshot(null)).toEqual([]);
expect(delayedIdsFromSnapshot({})).toEqual([]);
expect(delayedIdsFromSnapshot({ delayed: 'nope' })).toEqual([]);
expect(delayedIdsFromSnapshot([1, 2, 3])).toEqual([]);
});
});
+139
View File
@@ -0,0 +1,139 @@
/**
* Iteration 16 (Loop 3) — SRD-optional Delay action client surface.
*
* The engine routes landed in commit 4f7b365:
* POST /api/v1/sessions/{id}/action/delay {entity_id} → DELAY_TAKEN
* POST /api/v1/sessions/{id}/action/delay/resume {entity_id} → DELAY_RESUMED
*
* Delaying is FREE — no Action is spent; the cost is forfeiting this round's
* slot until /action/delay/resume re-seats the combatant right after the
* current actor at the current initiative count.
*
* Wire honesty: both bodies are exactly the engine's SimpleActionReq
* (`{entity_id}` plus the session id the proxy needs to build the path) with
* `deny_unknown_fields` upstream, so nothing else is ever sent — no round, no
* order, no turn index. Every number stays engine-owned and every rejection
* surfaces VERBATIM as its machine code: NOT_IN_COMBAT, ENTITY_NOT_FOUND,
* ENTITY_CANNOT_ACT (dying/dead/incapacitated), ALREADY_DELAYED / NOT_DELAYED
* (409) and the route-level FORBIDDEN_ROLE / ENTITY_NOT_OWNED (403).
*
* These calls carry the stored token as Authorization: Bearer like every other
* mutating call in api/* — the gateway 401s anonymous requests rather than
* resolving combat under its service principal.
*/
import { authHeaders, getStoredToken } from './auth_headers';
import type { EngineActionOutcome } from './rules_engine';
/** Verbatim body of POST /api/v1/sessions/{id}/action/delay[/resume]. */
export interface EngineDelayResult {
status?: string;
entity_id?: string;
/** Who is parked out of turn order after this call (engine truth). */
delayed?: string[];
round?: number;
turn_index?: number;
order?: unknown[];
event_sequence?: number;
}
const NOT_SIGNED_IN: EngineActionOutcome<never> = {
kind: 'rejected',
status: 401,
code: 'NOT_AUTHENTICATED',
message: 'Sign in to act through the authoritative engine.',
};
async function delayPost<T extends EngineDelayResult>(
path: '/api/v1/engine/delay' | '/api/v1/engine/delay/resume',
params: { sessionId: string; entityId: string },
): Promise<EngineActionOutcome<T>> {
const token = getStoredToken();
if (!token) return NOT_SIGNED_IN;
try {
const resp = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
session_id: params.sessionId,
entity_id: params.entityId,
}),
});
const payload: unknown = await resp.json().catch(() => null);
if (!resp.ok) {
// 5xx means nothing was decided; 4xx carries an engine code worth
// quoting verbatim in the UI.
if (resp.status >= 500) return { kind: 'unreachable' };
return rejectionFrom(resp.status, payload);
}
return { kind: 'applied', data: payload as T };
} catch {
console.warn('Rules engine unreachable; the delay was not applied.');
return { kind: 'unreachable' };
}
}
function rejectionFrom(
status: number,
payload: unknown,
): EngineActionOutcome<never> {
// FastAPI wraps handler detail in {detail}; the gateway already unwraps the
// engine JSON there, but 422 validation failures arrive as an array instead.
const raw = (payload as { detail?: unknown } | null)?.detail ?? payload;
if (Array.isArray(raw)) {
const first = raw[0] as Record<string, unknown> | undefined;
const msg = typeof first?.msg === 'string' ? first.msg : 'invalid request';
return { kind: 'rejected', status, code: null, message: msg };
}
if (typeof raw === 'string') {
return { kind: 'rejected', status, code: null, message: raw };
}
if (raw && typeof raw === 'object') {
const d = raw as Record<string, unknown>;
return {
kind: 'rejected',
status,
code: typeof d.error === 'string' ? d.error : null,
message: typeof d.message === 'string' ? d.message : null,
};
}
return { kind: 'rejected', status, code: null, message: `HTTP ${status}` };
}
/**
* Park a controlled combatant out of turn order (SRD-optional Delay). Free —
* no Action spent — but their slot this round is passed over until resume.
*/
export async function delayEntity(params: {
sessionId: string;
entityId: string;
}): Promise<EngineActionOutcome<EngineDelayResult>> {
return delayPost('/api/v1/engine/delay', params);
}
/** Re-seat a delaying combatant immediately after the current actor. */
export async function resumeEntity(params: {
sessionId: string;
entityId: string;
}): Promise<EngineActionOutcome<EngineDelayResult>> {
return delayPost('/api/v1/engine/delay/resume', params);
}
/**
* Ids of parked combatants read defensively from a RAW projected session-state
* body (`combat.delayed`) or a bare delay-route response body (`delayed`).
* Whatever the projection did not expose simply yields an empty list — never a
* guessed one. Non-string entries are dropped rather than coerced into fake ids.
*/
export function delayedIdsFromSnapshot(raw: unknown): string[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
const top = raw as Record<string, unknown>;
const list =
Array.isArray(top.delayed)
? top.delayed
: top.combat && typeof top.combat === 'object' && !Array.isArray(top.combat)
? (top.combat as Record<string, unknown>).delayed
: undefined;
if (!Array.isArray(list)) return [];
return list.filter((v): v is string => typeof v === 'string');
}
+156 -2
View File
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Swords,
ChevronRight,
@@ -11,7 +11,9 @@ import {
Play,
Square,
Zap,
Hourglass,
} from 'lucide-react';
import { delayEntity, resumeEntity } from '../api/combat_delay';
import { Token } from './TacticalCanvas';
import { ConcentrationBadge } from './ConcentrationBadge';
import {
@@ -174,6 +176,31 @@ export function parseTurnAdvancement(raw: unknown): TurnAdvancementNotice | null
const EMPTY_STATUS_MAP: Record<string, EntityCombatStatus> = {};
const EMPTY_CONCENTRATION_MAP: Record<string, ConcentrationInfo> = {};
const EMPTY_DELAYED: string[] = [];
/**
* Iteration 16 — the visible "Delayed" badge. Rendered ONLY for ids present in
* the engine-projected `combat.delayed` list; presence in that array is the
* whole truth, so there is nothing here to derive or guess.
*/
const DelayedMarker: React.FC<{ variant?: 'row' | 'rail'; entityId?: string }> = ({
variant = 'row',
entityId,
}) => (
<span
data-testid={`delayed-marker-${entityId ?? (variant === 'rail' ? 'slot' : '')}`.replace(/-$/, '')}
title="Delayed — acting later this round at the current initiative count"
aria-label="Delayed"
className={`inline-flex items-center gap-0.5 rounded bg-sky-950/60 border border-sky-400/40 text-sky-200 font-mono font-bold ${
variant === 'rail'
? 'absolute -top-1 -right-1 w-3 h-3 justify-center text-[7px]'
: 'px-1.5 py-px text-[9px] uppercase tracking-wide'
}`}
>
<Hourglass className={variant === 'rail' ? 'w-2 h-2' : 'w-2.5 h-2.5'} />
{variant !== 'rail' && 'Delayed'}
</span>
);
interface InitiativeTrackerProps {
tokens: Token[];
@@ -215,6 +242,27 @@ interface InitiativeTrackerProps {
* inside `entities[id].conditions` (`Exhaustion(u8)` → `{"exhaustion": N}`).
*/
sessionStateRaw?: unknown;
/**
* Iteration 16 — ids the authoritative engine currently has parked out of
* turn order (`combat.delayed` from the projected snapshot, verbatim). The
* server projection already filtered this list per role; it is trusted as
* given and NEVER re-filtered client-side. Absent entries simply mean "not
* delaying".
*/
delayedIds?: string[];
/** Engine session id these delay/resume calls act through (null = no engine). */
combatSessionIdForActions?: string | null;
/**
* Entity ids THIS seat may act for — the caller's ownership projection.
* GMs pass every visible entity; players only their own bound token(s);
* spectators an empty list. Delay/Resume buttons render only for these.
*/
controlledEntityIds?: string[];
/**
* Called after a delay/resume resolves so the caller re-pulls the
* authoritative snapshot. The tracker never mutates order/turn_index itself.
*/
onRefreshCombatState?: () => void;
}
export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
@@ -236,12 +284,54 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
entityStatusByEntity = EMPTY_STATUS_MAP,
concentrationByEntity = EMPTY_CONCENTRATION_MAP,
sessionStateRaw,
delayedIds = EMPTY_DELAYED,
combatSessionIdForActions = null,
controlledEntityIds = EMPTY_DELAYED,
onRefreshCombatState,
}) => {
// Additive turn report: whatever the engine's last advance actually
// disclosed (OA provocations, concentration saves). Nothing here is derived
// or remembered across advances beyond what the caller hands us.
const turnNotice = useMemo(() => parseTurnAdvancement(lastTurnResponse), [lastTurnResponse]);
// Iteration 16 — who is parked out of turn order, straight from the
// projection. Set membership only; never re-filtered client-side.
const delayedSet = useMemo(() => new Set(delayedIds), [delayedIds]);
const controlledSet = useMemo(() => new Set(controlledEntityIds), [controlledEntityIds]);
// Transient verbatim engine rejection from the most recent delay/resume try.
// Cleared on the next attempt; display-only, it never gates anything.
const [delayError, setDelayError] = useState<string | null>(null);
const runDelayAction = useCallback(
async (entityId: string, mode: 'take' | 'resume') => {
if (!combatSessionIdForActions) {
setDelayError('Rules engine unavailable — delay needs a live session.');
return;
}
setDelayError(null);
const outcome =
mode === 'take'
? await delayEntity({ sessionId: combatSessionIdForActions, entityId })
: await resumeEntity({ sessionId: combatSessionIdForActions, entityId });
if (outcome.kind === 'applied') {
// Optimistic-state discipline: no hand-mutation of order — re-pull the
// authoritative snapshot and let its combat.delayed/order render.
onRefreshCombatState?.();
return;
}
if (outcome.kind === 'rejected') {
setDelayError(
outcome.code
? `${outcome.code}${outcome.message ? ` — ${outcome.message}` : ''}`
: (outcome.message ?? `HTTP ${outcome.status}`),
);
return;
}
setDelayError('Rules engine unreachable — nothing was changed.');
},
[combatSessionIdForActions, onRefreshCombatState],
);
/** Best-effort id → display name using ONLY data we already have. */
const displayNameFor = (entityId?: string): string => {
if (!entityId) return 'unknown combatant';
@@ -325,15 +415,17 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
<div className="flex flex-col items-center gap-2">
{combatOrder.map((entry) => {
const isActiveTurn = entry.entity_id === activeEntityId;
const isDelayed = delayedSet.has(entry.entity_id);
return (
<div
key={entry.entity_id}
onClick={() => onSelectToken(entry.entity_id)}
className={`w-7 h-7 rounded-full flex items-center justify-center cursor-pointer border transition-transform ${
className={`w-7 h-7 rounded-full flex items-center justify-center cursor-pointer border transition-transform relative ${
isActiveTurn ? 'border-[var(--tavern-accent)] scale-110 shadow-md shadow-amber-900/50' : 'border-[var(--tavern-border)]'
}`}
>
{renderAvatar(entry.entity_id, entry.name)}
{isDelayed && <DelayedMarker variant="rail" entityId={entry.entity_id} />}
</div>
);
})}
@@ -417,6 +509,13 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
const isSelected = selectedTokenId === entry.entity_id;
const isDead = token ? token.hp <= 0 : false;
const status = rowStatus.get(entry.entity_id);
// Iteration 16 — Delay/Resume affordances. Visibility mirrors the
// tracker's existing per-entity gating discipline: only entities
// THIS seat controls get controls (GM passes everyone; players
// their own bound tokens; spectators an empty list), while the
// marker renders from the projection for all seats to see.
const canControl = controlledSet.has(entry.entity_id);
const isDelayed = delayedSet.has(entry.entity_id);
return (
<div
@@ -437,6 +536,7 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
<div className="font-semibold text-xs text-[var(--rp-parchment-100)] flex items-center gap-1.5 truncate">
{entry.name}
{isDead && <Skull className="w-3 h-3 text-rose-500 shrink-0" />}
{isDelayed && <DelayedMarker entityId={entry.entity_id} />}
</div>
<div className="text-[10px] text-[var(--rp-parchment-300)] font-mono">
DEX {entry.dexterity >= 0 ? `+${entry.dexterity}` : entry.dexterity}
@@ -528,6 +628,47 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
))}
</div>
)}
{/* Iteration 16 — Delay (SRD-optional, FREE: the cost is
forfeiting this round's slot) and Resume. Offered ONLY
for entities this seat controls; success never mutates
order locally — onRefreshCombatState re-pulls the
authoritative snapshot instead. Engine rejections
surface verbatim (ALREADY_DELAYED, NOT_DELAYED,
NOT_IN_COMBAT, ENTITY_CANNOT_ACT, …). */}
{inCombat && canControl && (
<div className="mt-1.5 flex items-center gap-1.5">
{isDelayed ? (
<button
data-testid={`delay-resume-${entry.entity_id}`}
onClick={(e) => {
e.stopPropagation();
void runDelayAction(entry.entity_id, 'resume');
}}
disabled={!combatSessionIdForActions}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-sky-950/60 hover:bg-sky-900/70 disabled:opacity-40 disabled:cursor-not-allowed border border-sky-400/40 text-sky-200 text-[9px] font-mono font-bold uppercase tracking-wide transition"
title="Re-seat this combatant right after the current actor"
>
<Hourglass className="w-2.5 h-2.5" />
<span>Resume</span>
</button>
) : (
<button
data-testid={`delay-action-${entry.entity_id}`}
onClick={(e) => {
e.stopPropagation();
void runDelayAction(entry.entity_id, 'take');
}}
disabled={!combatSessionIdForActions || isDead}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-black/40 hover:bg-[var(--rp-leather-700)]/60 disabled:opacity-40 disabled:cursor-not-allowed border border-[var(--tavern-border)] text-[var(--rp-parchment-200)] text-[9px] font-mono font-bold uppercase tracking-wide transition"
title="Delay — step out of turn order until a better initiative count (free, forfeits this round's slot)"
>
<Hourglass className="w-2.5 h-2.5" />
<span>Delay</span>
</button>
)}
</div>
)}
</div>
);
})}
@@ -578,6 +719,19 @@ export const InitiativeTracker: React.FC<InitiativeTrackerProps> = ({
</div>
)}
{/* Iteration 16 — verbatim engine rejection from the last delay/resume
attempt. Display-only honesty: the engine's machine code is quoted,
never rewritten into friendlier fiction. */}
{inCombat && delayError && (
<div
data-testid="delay-error"
role="alert"
className="mx-3 mb-2 px-2.5 py-1.5 rounded-lg border border-rose-400/40 bg-rose-950/40 text-[10px] font-mono text-rose-200"
>
⏳ {delayError}
</div>
)}
{/* Footer Controls */}
<div className="p-3 border-t border-[var(--tavern-border)] bg-black/30 space-y-2">
{inCombat && (
@@ -0,0 +1,316 @@
/**
* Iteration 16 (Loop 3) — Delay / Resume controls in the InitiativeTracker DOM.
*
* Pinned contracts:
* - A visible "Delayed" marker renders for every id the engine's
* `combat.delayed` array carries, and for nothing else. The tracker trusts
* the server projection verbatim — it does NOT re-filter client-side
* (hidden combatants were already dropped engine-side).
* - Delay / Resume buttons exist ONLY for entities this seat controls:
* GMs may delay/resume anyone; a player only their own bound token;
* spectators get neither — mirroring how the tracker already gates its
* Begin/End combat controls on isGm.
* - Firing either control posts through the ids-only wire contract and then
* asks the CALLER to refresh the authoritative snapshot: no local mutation
* of order/turn_index ever happens here (optimistic-state discipline).
*
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { InitiativeTracker } from '../InitiativeTracker';
import type { Token } from '../TacticalCanvas';
afterEach(cleanup);
const TOKEN = 'sig.payload.token';
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
store.set('aethertable_token', TOKEN);
(globalThis as unknown as { sessionStorage: Storage }).sessionStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: () => null,
get length() {
return store.size;
},
} as Storage;
});
type FetchCall = { url: string; init: RequestInit; body: unknown };
function stubFetch(
respond: (url: string) => { ok: boolean; status?: number; json: () => Promise<unknown> },
) {
const calls: FetchCall[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
url,
init: (init ?? {}) as RequestInit,
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return respond(url);
}),
);
return calls;
}
const THORIN: Token = {
id: 'thorin_1',
name: 'Thorin',
x: 4,
y: 4,
hp: 42,
maxHp: 42,
ac: 18,
color: '#3b82f6',
isPlayer: true,
};
const GOBLIN: Token = { ...THORIN, id: 'goblin_1', name: 'Goblin', hp: 12, maxHp: 12, ac: 14, color: '#f59e0b', isPlayer: false };
const ORDER = [
{ entity_id: 'thorin_1', name: 'Thorin', dexterity: 2, initiative_total: 18 },
{ entity_id: 'goblin_1', name: 'Goblin', dexterity: 3, initiative_total: 15 },
];
interface Opts {
delayed?: string[];
isGm?: boolean;
/** Defaults to "everyone" (GM semantics); pass [] explicitly for nobody. */
controlledIds?: string[] | 'everyone';
}
const ALL_IDS = ORDER.map((o) => o.entity_id);
function renderTracker(opts: Opts = {}, onNextTurn = () => undefined) {
const props = {
tokens: [THORIN, GOBLIN],
onNextTurn,
onSelectToken: () => undefined,
selectedTokenId: null as string | null,
roundNumber: 3,
isCollapsed: false,
onToggleCollapse: () => undefined,
inCombat: true,
combatOrder: ORDER,
activeEntityId: 'thorin_1',
isGm: opts.isGm ?? false,
isCombatBusy: false,
onBeginCombat: () => undefined,
onEndCombat: () => undefined,
delayedIds: opts.delayed ?? [],
combatSessionIdForActions: 'sess-1',
controlledEntityIds:
opts.controlledIds === undefined || opts.controlledIds === 'everyone'
? ALL_IDS
: opts.controlledIds,
onRefreshCombatState: () => undefined,
};
return render(<InitiativeTracker {...props} />);
}
describe('Delayed marker rendering', () => {
it('renders a Delayed marker for each id in the engine delayed list', () => {
renderTracker({ delayed: ['goblin_1'] });
expect(screen.getByTestId('delayed-marker-goblin_1')).toBeTruthy();
expect(screen.queryByTestId('delayed-marker-thorin_1')).toBeNull();
});
it('renders markers for multiple parked combatants and none when nobody delayed', () => {
const { unmount } = renderTracker({ delayed: ['thorin_1', 'goblin_1'] });
expect(screen.getByTestId('delayed-marker-thorin_1')).toBeTruthy();
expect(screen.getByTestId('delayed-marker-goblin_1')).toBeTruthy();
unmount();
renderTracker({ delayed: [] });
expect(screen.queryByTestId(/delayed-marker-/)).toBeNull();
});
it('never invents markers for entities the projection did not park', () => {
renderTracker({ delayed: ['someone-else-entirely'] });
expect(screen.queryByTestId(/delayed-marker-/)).toBeNull();
});
it('shows the marker in collapsed rail mode too', () => {
const props = {
tokens: [THORIN, GOBLIN],
onNextTurn: () => undefined,
onSelectToken: () => undefined,
selectedTokenId: null as string | null,
roundNumber: 3,
isCollapsed: true,
onToggleCollapse: () => undefined,
inCombat: true,
combatOrder: ORDER,
activeEntityId: 'thorin_1',
isGm: false,
isCombatBusy: false,
onBeginCombat: () => undefined,
onEndCombat: () => undefined,
delayedIds: ['thorin_1'],
controlledEntityIds: [],
onRefreshCombatState: () => undefined,
};
render(<InitiativeTracker {...props} />);
expect(screen.getByTestId('delayed-marker-thorin_1')).toBeTruthy();
});
});
describe('Delay / Resume control gating', () => {
it('offers Delay for an own controlled entity that is not yet delayed', async () => {
const calls = stubFetch(() => ({
ok: true,
json: async () => ({ status: 'DELAY_TAKEN', entity_id: 'thorin_1', delayed: ['thorin_1'], order: [], round: 3 }),
}));
let refreshed = 0;
const { rerender } = render(
<InitiativeTracker
tokens={[THORIN, GOBLIN]}
onNextTurn={() => undefined}
onSelectToken={() => undefined}
selectedTokenId={null}
roundNumber={3}
isCollapsed={false}
onToggleCollapse={() => undefined}
inCombat
combatOrder={ORDER}
activeEntityId="thorin_1"
isGm={false}
isCombatBusy={false}
onBeginCombat={() => undefined}
onEndCombat={() => undefined}
delayedIds={[]}
combatSessionIdForActions="sess-1"
controlledEntityIds={['thorin_1']}
onRefreshCombatState={() => {
refreshed += 1;
}}
/>,
);
fireEvent.click(screen.getByTestId('delay-action-thorin_1'));
await waitFor(() =>
expect(calls.find((c) => c.url === '/api/v1/engine/delay')).toBeTruthy(),
);
const call = calls.find((c) => c.url === '/api/v1/engine/delay')!;
expect(call.body).toEqual({ session_id: 'sess-1', entity_id: 'thorin_1' });
expect((call.init.headers as Record<string, string>).Authorization).toBe(`Bearer ${TOKEN}`);
// Optimistic-state discipline: the tracker never mutates order itself —
// it asks the caller to re-pull the authoritative snapshot.
await waitFor(() => expect(refreshed).toBeGreaterThan(0));
// After the caller's re-pull lands the new snapshot, the marker appears
// from SNAPSHOT data (not from any local bookkeeping).
rerender(
<InitiativeTracker
tokens={[THORIN, GOBLIN]}
onNextTurn={() => undefined}
onSelectToken={() => undefined}
selectedTokenId={null}
roundNumber={3}
isCollapsed={false}
onToggleCollapse={() => undefined}
inCombat
combatOrder={ORDER}
activeEntityId="thorin_1"
isGm={false}
isCombatBusy={false}
onBeginCombat={() => undefined}
onEndCombat={() => undefined}
delayedIds={['thorin_1']}
combatSessionIdForActions="sess-1"
controlledEntityIds={['thorin_1']}
onRefreshCombatState={() => {
refreshed += 1;
}}
/>,
);
expect(screen.getByTestId('delayed-marker-thorin_1')).toBeTruthy();
});
it('swaps Delay for Resume while the entity is parked', async () => {
const calls = stubFetch(() => ({
ok: true,
json: async () => ({ status: 'DELAY_RESUMED', entity_id: 'thorin_1', delayed: [], order: [], round: 3 }),
}));
renderTracker({ delayed: ['thorin_1'], controlledIds: ['thorin_1'] });
expect(screen.queryByTestId('delay-action-thorin_1')).toBeNull();
fireEvent.click(screen.getByTestId('delay-resume-thorin_1'));
await waitFor(() =>
expect(calls.find((c) => c.url === '/api/v1/engine/delay/resume')).toBeTruthy(),
);
expect(calls.find((c) => c.url === '/api/v1/engine/delay/resume')!.body).toEqual({
session_id: 'sess-1',
entity_id: 'thorin_1',
});
});
it('gives the GM controls for every combatant (GMs act on anyone)', () => {
stubFetch(() => ({ ok: true, json: async () => ({}) }));
renderTracker({ isGm: true });
expect(screen.getByTestId('delay-action-thorin_1')).toBeTruthy();
expect(screen.getByTestId('delay-action-goblin_1')).toBeTruthy();
});
it('gives players NO controls over combatants they do not control', () => {
stubFetch(() => ({ ok: true, json: async () => ({}) }));
renderTracker({ controlledIds: ['thorin_1'] });
expect(screen.getByTestId('delay-action-thorin_1')).toBeTruthy();
expect(screen.queryByTestId('delay-action-goblin_1')).toBeNull();
expect(screen.queryByTestId('delay-resume-goblin_1')).toBeNull();
});
it('gives spectators no delay or resume controls at all', () => {
stubFetch(() => ({ ok: true, json: async () => ({}) }));
renderTracker({ controlledIds: [], isGm: false });
expect(screen.queryByTestId(/delay-action-/)).toBeNull();
expect(screen.queryByTestId(/delay-resume-/)).toBeNull();
});
it('surfaces an engine rejection code verbatim instead of faking success', async () => {
const calls = stubFetch(() => ({
ok: false,
status: 409,
json: async () => ({ detail: { error: 'ALREADY_DELAYED', message: 'already parked' } }),
}));
renderTracker({ controlledIds: ['thorin_1'] });
fireEvent.click(screen.getByTestId('delay-action-thorin_1'));
await waitFor(() => expect(calls.length).toBeGreaterThan(0));
await waitFor(() =>
expect(screen.getByTestId('delay-error').textContent).toContain('ALREADY_DELAYED'),
);
});
it('renders no error line before anything has been attempted', () => {
stubFetch(() => ({ ok: true, json: async () => ({}) }));
renderTracker({ controlledIds: ['thorin_1'] });
expect(screen.queryByTestId('delay-error')).toBeNull();
});
it('hides all delay affordances outside of combat', () => {
const props = {
tokens: [THORIN],
onNextTurn: () => undefined,
onSelectToken: () => undefined,
selectedTokenId: null as string | null,
roundNumber: 0,
isCollapsed: false,
onToggleCollapse: () => undefined,
inCombat: false,
combatOrder: [],
activeEntityId: null as string | null,
isGm: true,
isCombatBusy: false,
onBeginCombat: () => undefined,
onEndCombat: () => undefined,
delayedIds: [],
controlledEntityIds: ['thorin_1'],
onRefreshCombatState: () => undefined,
};
render(<InitiativeTracker {...props} />);
expect(screen.queryByTestId(/delay-action-/)).toBeNull();
});
});