Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3191ed6
feat(engine): add resolve-all consent protocol
matthewevans Aug 16, 2026
b295b96
fix(engine): expose resolve-all consent actions
matthewevans Aug 16, 2026
7a68bee
fix(engine): materialize ready consent revocations
matthewevans Aug 16, 2026
c8ca2a2
feat(resolve-all): collapse authorized safe prefixes
matthewevans Aug 16, 2026
25f0d07
fix(resolve-all): consume ready consent locally
matthewevans Aug 16, 2026
28397a9
fix(resolve-all): resume after AI consent grant
matthewevans Aug 16, 2026
1e2761f
test(resolve-all): type consent proposals
matthewevans Aug 16, 2026
ffa3ea9
test(engine): order resolve-all consent module
matthewevans Aug 16, 2026
7ab15a3
fix(resolve-all): cover consent waiting states
matthewevans Aug 16, 2026
80e6785
fix(resolve-all): wire consent across decision surfaces
matthewevans Aug 16, 2026
accaba9
fix(resolve-all): reconcile downstream consumers
matthewevans Aug 16, 2026
4a9f5c0
fix(resolve-all): deduplicate consent candidates
matthewevans Aug 16, 2026
fecafc5
test(engine): exclude resolve-all from preflight census
matthewevans Aug 16, 2026
a707079
fix(resolve-all): preserve priority through consent
matthewevans Aug 16, 2026
77f7fd0
test(engine): count resolve-all waiting states
matthewevans Aug 16, 2026
e7f5e33
test(resolve-all): register protocol surface
matthewevans Aug 16, 2026
79f5eab
test(resolve-all): cover consent-ready transport
matthewevans Aug 16, 2026
f88e0cb
fix(resolve-all): preserve consent priority order
matthewevans Aug 16, 2026
5fe9e8c
fix(resolve-all): harden consent handoff
matthewevans Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,17 @@ import { repoRoot, rustEnumVariants } from "./rustEnumVariants";
* Engine `WaitingFor` variants that are never surfaced to a human and so
* legitimately have no frontend UI handler.
*
* This set is intentionally EMPTY. An audit of `WaitingFor::acting_player()`
* (crates/engine/src/types/game_state.rs) shows every variant routes its
* authorization to a human player — single-pending mulligan variants resolve
* to the one pending player, `VoteChoice` to `actor.resolve(player)`,
* `AssistPayment` to `chosen`, and every remaining variant to `Some(*player)`.
* The sole exception is `GameOver`, which returns `None` (terminal lifecycle
* state) and is already present in `HANDLED_WAITING_FOR_TYPES`. No
* internal-only, never-player-facing variant exists today.
* `ResolveAllReady` is deliberately inert: `WaitingFor::acting_player()` in
* `crates/engine/src/types/game_state.rs` returns `None` for it, and the
* consent submitter consumes the already-authorized run through
* `dispatchResolveAll`. `GameOver` likewise returns `None` but remains in
* `HANDLED_WAITING_FOR_TYPES` because it has a rendered terminal screen.
*
* If a future variant is genuinely never player-facing, add it here WITH a
* cited `acting_player()` reference proving it returns `None`.
*/
const INTERNAL_NEVER_PLAYER_FACING: ReadonlySet<WaitingFor["type"]> =
new Set<WaitingFor["type"]>([]);
new Set<WaitingFor["type"]>(["ResolveAllReady"]);

describe("WaitingFor handler parity", () => {
it("registers both interactive meld waiting states", () => {
Expand Down
8 changes: 8 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,8 @@ export type MulliganDecisionPhase =

export type WaitingFor =
| { type: "Priority"; data: { player: PlayerId } }
| { type: "ResolveAllConsent"; data: { epoch: number; representative: PlayerId } }
| { type: "ResolveAllReady"; data: { epoch: number } }
| { type: "MeldPairChoice"; data: { player: PlayerId; choices: MeldSelection[] } }
| { type: "MeldAttackTargetChoice"; data: { player: PlayerId; context: MeldSelection; valid_targets: AttackTarget[] } }
| { type: "EntryAttackTargetChoice"; data: { player: PlayerId; object_id: ObjectId; valid_targets: AttackTarget[] } }
Expand Down Expand Up @@ -2247,6 +2249,12 @@ export type PrecastCopyShortcutResponse =

export type GameAction =
| { type: "PassPriority" }
| { type: "BeginResolveAll"; data: { max_resolutions: number } }
| {
type: "RespondResolveAllConsent";
data: { epoch: number; decision: { type: "Grant" } | { type: "Decline" } };
}
| { type: "RevokeResolveAllConsent"; data: { epoch: number; representative: PlayerId } }
| { type: "ChooseMeldPair"; data: { source_id: ObjectId; partner_id: ObjectId } }
| { type: "ChooseEntryAttackTarget"; data: { target: AttackTarget } }
| { type: "RollPlanarDie" }
Expand Down
68 changes: 68 additions & 0 deletions client/src/components/modal/ResolveAllConsentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";

import { dispatchResolveAll } from "../../game/dispatch.ts";
import { useGameDispatch } from "../../hooks/useGameDispatch.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { DialogShell } from "./DialogShell.tsx";

/**
* Engine-authored Resolve All consent prompt. The browser only presents the
* representative and echoes a finite Grant/Decline action; authorization and
* the subsequent safe prefix stay entirely in the engine.
*/
export function ResolveAllConsentModal({ playerId }: { playerId: number }) {
const { t } = useTranslation("game");
const waitingFor = useGameStore((s) => s.waitingFor);
const dispatch = useGameDispatch();

const visible =
waitingFor?.type === "ResolveAllConsent" &&
waitingFor.data.representative === playerId;

const respond = useCallback(
async (decision: "Grant" | "Decline") => {
if (waitingFor?.type !== "ResolveAllConsent") return;
const grantedEpoch = waitingFor.data.epoch;
await dispatch({
type: "RespondResolveAllConsent",
data: { epoch: grantedEpoch, decision: { type: decision } },
});
const waitingForAfterSubmission = useGameStore.getState().gameState?.waiting_for;
if (
decision === "Grant" &&
waitingForAfterSubmission?.type === "ResolveAllReady" &&
waitingForAfterSubmission.data.epoch === grantedEpoch
) {
await dispatchResolveAll(playerId, []);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
[dispatch, playerId, waitingFor],
);

if (!visible) return null;

return (
<DialogShell
eyebrow={t("resolveAllConsent.eyebrow")}
title={t("resolveAllConsent.title")}
subtitle={t("resolveAllConsent.subtitle")}
size="sm"
>
<div className="flex gap-3 px-5 py-5">
<button
className="flex-1 rounded-xl bg-emerald-500 px-4 py-3 font-semibold text-emerald-950 transition hover:bg-emerald-400"
onClick={() => void respond("Grant")}
>
{t("resolveAllConsent.grant")}
</button>
<button
className="flex-1 rounded-xl bg-gray-700 px-4 py-3 font-semibold text-white transition hover:bg-gray-600"
onClick={() => void respond("Decline")}
>
{t("resolveAllConsent.decline")}
</button>
</div>
</DialogShell>
);
}
120 changes: 89 additions & 31 deletions client/src/game/__tests__/dispatchResolveAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ function stateWithStack(len: number): GameState {
});
}

function readyStateWithStack(len: number): GameState {
return buildGameState({
waiting_for: { type: "ResolveAllReady", data: { epoch: 1 } },
stack: Array.from({ length: len }, (_, index) => buildStackEntry({ id: index + 1 })),
});
}

function chunk(itemsResolved: number, total: number): BatchResolveResult {
return { events: [], waitingFor: priorityWf, logEntries: [], itemsResolved, total };
}
Expand All @@ -43,10 +50,10 @@ describe("dispatchResolveAll progress", () => {
progressCalls = [];
usePreferencesStore.setState({ animationSpeedMultiplier: 1.0 });
useAppNotificationStore.setState({ notification: null, expiresAt: 0 });
// Stack length read at each iteration start to classify pressure; keep it
// in the "Instant" band (>=100) so the rAF-yield branch is exercised.
// Keep the stack in the Instant pressure band so the ready consumer uses
// the engine's larger bounded-prefix cap.
useGameStore.setState({
gameState: stateWithStack(200),
gameState: readyStateWithStack(200),
resolutionProgress: null,
isResolvingAll: false,
// Capture every setResolutionProgress call for assertions.
Expand All @@ -61,21 +68,12 @@ describe("dispatchResolveAll progress", () => {
vi.restoreAllMocks();
});

it("latches the first chunk's total, accumulates + clamps the numerator, and clears at the end", async () => {
// Per-chunk `total` SHRINKS (engine reports remaining stack); the latch must
// keep the first chunk's 200. itemsResolved sums 80+80+80=240 > 200 → clamp.
const resolveAll = vi
.fn<EngineResolveAll>()
.mockResolvedValueOnce(chunk(80, 200))
.mockResolvedValueOnce(chunk(80, 150))
.mockResolvedValueOnce(chunk(80, 100));
it("reports the engine-proved prefix once and clears progress at the end", async () => {
const resolveAll = vi.fn<EngineResolveAll>().mockResolvedValueOnce(chunk(80, 200));

// getState reports the board after each chunk; the 3rd empties the stack → done.
const getState = vi
.fn<() => Promise<GameState>>()
.mockResolvedValueOnce(stateWithStack(200))
.mockResolvedValueOnce(stateWithStack(200))
.mockResolvedValueOnce(stateWithStack(0));
// The engine resolves the entire proved prefix in one bounded call, then
// supplies one authoritative post-prefix snapshot.
const getState = vi.fn<() => Promise<GameState>>().mockResolvedValueOnce(stateWithStack(120));

const rafSpy = vi
.spyOn(globalThis, "requestAnimationFrame")
Expand All @@ -97,25 +95,18 @@ describe("dispatchResolveAll progress", () => {
// to the SetAutoPass fallback instead of the batch drain under test.
await dispatchResolveAll(0, [{ playerId: 1, difficulty: "Medium" }]);

// Three progress updates: total latched at 200 throughout; resolved
// accumulates 80 -> 160 -> clamped 200.
expect(progressCalls.slice(0, 3)).toEqual([
{ resolved: 80, total: 200 },
{ resolved: 160, total: 200 },
{ resolved: 200, total: 200 }, // min(240, 200) clamp
]);
expect(resolveAll).toHaveBeenCalledTimes(1);
expect(progressCalls).toEqual([{ resolved: 80, total: 200 }, null]);
// Final call clears progress.
expect(progressCalls[progressCalls.length - 1]).toBeNull();
expect(useGameStore.getState().resolutionProgress).toBeNull();
expect(useGameStore.getState().isResolvingAll).toBe(false);

// rAF yield fired between the instant chunks (the load-bearing repaint fix):
// 2 yields between 3 chunks.
expect(rafSpy).toHaveBeenCalledTimes(2);
expect(rafSpy).not.toHaveBeenCalled();
});

it("uses responsive instant chunks for giant stacks and marks Resolve All busy", async () => {
useGameStore.setState({ gameState: stateWithStack(19192) });
useGameStore.setState({ gameState: readyStateWithStack(19192) });

const resolveAll = vi.fn<EngineResolveAll>(async (_requester, _aiSeats, maxResolutions) => {
expect(useGameStore.getState().isResolvingAll).toBe(true);
Expand Down Expand Up @@ -169,12 +160,79 @@ describe("dispatchResolveAll progress", () => {
);
});

it("consumes Ready consent before considering the empty-AI fallback", async () => {
const resolveAll = vi.fn<EngineResolveAll>().mockResolvedValue(chunk(1, 2));
const submitAction = vi.fn();
const getState = vi.fn().mockResolvedValue(stateWithStack(1));
useGameStore.setState({
gameState: readyStateWithStack(2),
adapter: {
resolveAll,
submitAction,
getState,
getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }),
getSnapshot: snapshotVia(getState),
} as never,
});

await dispatchResolveAll(0, []);

expect(resolveAll).toHaveBeenCalledWith(0, [], 5);
expect(submitAction).not.toHaveBeenCalled();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("begins consent before the batch drain and retains its AI seats until Ready", async () => {
const seats = [{ playerId: 1, difficulty: "Medium" }];
const phaseOneResolveAll = vi.fn<EngineResolveAll>();
const submitAction = vi.fn().mockResolvedValue({ events: [] });
const consent = buildGameState({
waiting_for: { type: "ResolveAllConsent", data: { epoch: 1, representative: 1 } },
stack: Array.from({ length: 2 }, (_, index) => buildStackEntry({ id: index + 1 })),
});
useGameStore.setState({
gameState: stateWithStack(2),
adapter: {
resolveAll: phaseOneResolveAll,
submitAction,
getSnapshot: vi.fn(async () => ({
state: consent,
legalResult: { actions: [], autoPassRecommended: false },
seq: nextSnapshotSeq(),
})),
} as never,
});

await dispatchResolveAll(0, seats);

expect(submitAction).toHaveBeenCalledWith(
{ type: "BeginResolveAll", data: { max_resolutions: 5 } },
0,
);
expect(phaseOneResolveAll).not.toHaveBeenCalled();

const resolveAll = vi.fn<EngineResolveAll>().mockResolvedValue(chunk(1, 2));
const getState = vi.fn().mockResolvedValue(stateWithStack(1));
useGameStore.setState({
gameState: readyStateWithStack(2),
adapter: {
resolveAll,
getState,
getLegalActions: vi.fn().mockResolvedValue({ actions: [], autoPassRecommended: false }),
getSnapshot: snapshotVia(getState),
} as never,
});

await dispatchResolveAll(0, []);

expect(resolveAll).toHaveBeenCalledWith(0, seats, 5);
Comment thread
matthewevans marked this conversation as resolved.
});

it("uses an empty AI-seat list when the adapter delegates native AI ownership to its server", async () => {
const resolveAll = vi.fn<EngineResolveAll>().mockResolvedValue(chunk(0, 2));
const getState = vi.fn().mockResolvedValue(stateWithStack(0));
const submitAction = vi.fn();
useGameStore.setState({
gameState: stateWithStack(2),
gameState: readyStateWithStack(2),
adapter: {
resolveAll,
resolveAllUsesServerAi: true,
Expand Down Expand Up @@ -203,7 +261,7 @@ describe("dispatchResolveAll progress", () => {
);
const getState = vi.fn().mockResolvedValue(stateWithStack(2));
useGameStore.setState({
gameState: stateWithStack(2),
gameState: readyStateWithStack(2),
adapter: {
resolveAll,
resolveAllUsesServerAi: true,
Expand All @@ -226,7 +284,7 @@ describe("dispatchResolveAll progress", () => {
.mockRejectedValue(new Error("batch snapshot rejected"));
const getState = vi.fn().mockResolvedValue(stateWithStack(2));
useGameStore.setState({
gameState: stateWithStack(2),
gameState: readyStateWithStack(2),
adapter: {
resolveAll,
resolveAllUsesServerAi: true,
Expand Down
Loading