mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-11-07 10:45:58 -10:00
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:
1 parent
add3d7f538
commit
7acddadebc
5 files changed
+876
-3
No files matched your search
+37
-1
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user