diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json
new file mode 100644
index 0000000..36b3026
--- /dev/null
+++ b/.sisyphus/boulder.json
@@ -0,0 +1,9 @@
+{
+ "active_plan": "/home/joey/Projects/rules/.sisyphus/plans/modifier-profiles-t2.md",
+ "started_at": "2026-04-19T14:42:06.329Z",
+ "session_ids": [
+ "ses_267b9d7a2ffeFkGcPFn1iv223J"
+ ],
+ "plan_name": "modifier-profiles-t2",
+ "agent": "atlas"
+}
\ No newline at end of file
diff --git a/.sisyphus/plans/modifier-profiles-t2.md b/.sisyphus/plans/modifier-profiles-t2.md
index c6aac00..a3ddbf8 100644
--- a/.sisyphus/plans/modifier-profiles-t2.md
+++ b/.sisyphus/plans/modifier-profiles-t2.md
@@ -203,7 +203,7 @@ Wave FINAL (4 parallel reviewers):
## TODOs
-- [ ] 1. **T2-ADR documentation**
+- [x] 1. **T2-ADR documentation**
**What to do**:
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts
index f993241..5741c1d 100644
--- a/packages/chess/src/net/types.ts
+++ b/packages/chess/src/net/types.ts
@@ -202,6 +202,21 @@ export interface ModifierProfileUpdatedPayload {
appliedAt: "turn-boundary";
}
+/**
+ * Server → client ack: a submitted `modifier-profile.update` passed
+ * receipt-time validation and is queued for the next turn boundary
+ * (T2-ADR-1). Sent only to the submitting client — the opponent
+ * learns about the pending swap only when it actually lands via
+ * `modifier-profile.updated`. `pendingVersion` is the version the
+ * server expects to emit when the queue drains; a subsequent
+ * `modifier-profile.updated` with a higher version means another
+ * swap beat this one on the last-write-wins queue.
+ */
+export interface ModifierProfileQueuedPayload {
+ roomCode: string;
+ pendingVersion: number;
+}
+
export interface ErrorPayload {
code: string;
message: string;
@@ -265,6 +280,7 @@ export type ServerMessage =
| MessageEnvelope<"room.created", RoomCreatedPayload>
| MessageEnvelope<"room.joined", RoomJoinedPayload>
| MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload>
+ | MessageEnvelope<"modifier-profile.queued", ModifierProfileQueuedPayload>
| MessageEnvelope<"error", ErrorPayload>;
export type ClientMessage =
diff --git a/packages/chess/src/ui/ConflictResolutionPanel.tsx b/packages/chess/src/ui/ConflictResolutionPanel.tsx
new file mode 100644
index 0000000..b36c72f
--- /dev/null
+++ b/packages/chess/src/ui/ConflictResolutionPanel.tsx
@@ -0,0 +1,104 @@
+import { useMemo } from 'react';
+import type { StartingLayout } from '../layouts/types.js';
+import type { ModifierProfile } from '../modifiers/types.js';
+import {
+ validateProfile,
+ type ValidationError,
+ type ValidationWarning,
+} from '../modifiers/validate.js';
+import { CaptureFlag } from '../schema.js';
+
+interface Props {
+ profile: ModifierProfile;
+ layout: StartingLayout | null;
+ onResolve: (nextProfile: ModifierProfile) => void;
+}
+
+function applyFix(
+ profile: ModifierProfile,
+ issue: ValidationError | ValidationWarning,
+): ModifierProfile {
+ switch (issue.code) {
+ case 'E_PROFILE_INVULN_KING':
+ return {
+ ...profile,
+ perType: profile.perType.map((tm) =>
+ tm.kind === 'capture-flags' && tm.pieceType === 'king'
+ ? { ...tm, value: (tm.value as number) & ~CaptureFlag.CANNOT_BE_CAPTURED }
+ : tm,
+ ),
+ perInstance: profile.perInstance.filter((im) => {
+ if (issue.code !== 'E_PROFILE_INVULN_KING') return true;
+ if ('square' in issue && im.square === issue.square && im.kind === 'capture-flags') {
+ const v = im.value as number;
+ return (v & CaptureFlag.CANNOT_BE_CAPTURED) === 0;
+ }
+ return true;
+ }),
+ };
+ case 'E_PROFILE_ORPHAN_INSTANCE':
+ return {
+ ...profile,
+ perInstance: profile.perInstance.filter((im) => im.square !== issue.square),
+ };
+ case 'E_PROFILE_NO_KING':
+ return profile;
+ case 'E_PROFILE_ATTR_LIMIT':
+ return profile;
+ case 'E_PROFILE_DEADLOCK':
+ return profile;
+ default:
+ return profile;
+ }
+}
+
+export function ConflictResolutionPanel({ profile, layout, onResolve }: Props) {
+ const result = useMemo(() => {
+ if (layout === null) return { errors: [], warnings: [], valid: true };
+ return validateProfile(profile, layout);
+ }, [profile, layout]);
+
+ if (result.valid && result.warnings.length === 0) return null;
+
+ return (
+
+ {result.errors.map((err) => (
+
+
⚠ {err.message}
+ {err.code !== 'E_PROFILE_NO_KING' && err.code !== 'E_PROFILE_ATTR_LIMIT' && err.code !== 'E_PROFILE_DEADLOCK' && (
+
+ )}
+
+ ))}
+ {result.warnings.map((warn) => (
+
+
⚠ {warn.message}
+
+
+ ))}
+
+ );
+}
diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx
index 9f129df..6356d44 100644
--- a/packages/chess/src/ui/ModifierProfileEditor.tsx
+++ b/packages/chess/src/ui/ModifierProfileEditor.tsx
@@ -8,7 +8,7 @@
*
* Follows the same overlay/close pattern as LayoutEditor.tsx.
*/
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess';
import {
@@ -23,6 +23,12 @@ import {
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
import { PerInstancePanel } from './PerInstancePanel';
import { PerTypePanel } from './PerTypePanel.js';
+import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
+
+export type ModifierClipboard =
+ | { kind: 'empty' }
+ | { kind: 'type-modifiers'; entries: readonly TypeModifier[] }
+ | { kind: 'instance-modifiers'; entries: readonly Omit[] };
interface Props {
isOpen: boolean;
@@ -42,17 +48,47 @@ function makeBlankProfile(): ModifierProfile {
}
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
- const [profile, setProfile] = useState(makeBlankProfile);
+ const [history, setHistory] = useState(() => [
+ makeBlankProfile(),
+ ]);
+ const [historyIndex, setHistoryIndex] = useState(0);
+ const profile = history[historyIndex]!;
+ const canUndo = historyIndex > 0;
+ const canRedo = historyIndex < history.length - 1;
+
+ const pushSnapshot = useCallback(
+ (next: ModifierProfile | ((prev: ModifierProfile) => ModifierProfile)) => {
+ setHistory((h) => {
+ const current = h[historyIndex]!;
+ const nextProfile = typeof next === 'function' ? next(current) : next;
+ const truncated = h.slice(0, historyIndex + 1);
+ truncated.push(nextProfile);
+ while (truncated.length > 50) truncated.shift();
+ return truncated;
+ });
+ setHistoryIndex((i) => Math.min(i + 1, 49));
+ },
+ [historyIndex],
+ );
+
+ const undo = useCallback(() => {
+ setHistoryIndex((i) => Math.max(0, i - 1));
+ }, []);
+
+ const redo = useCallback(() => {
+ setHistoryIndex((i) => Math.min(history.length - 1, i + 1));
+ }, [history.length]);
+
const [libraryVersion, setLibraryVersion] = useState(0);
const [boundLayout, setBoundLayout] = useState(null);
- const [perInstance, setPerInstance] = useState([]);
- const [perType, setPerType] = useState([]);
+ const [clipboard, setClipboard] = useState({ kind: 'empty' });
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
// Fresh profile whenever the modal opens.
useEffect(() => {
if (isOpen) {
- setProfile(makeBlankProfile());
+ setHistory([makeBlankProfile()]);
+ setHistoryIndex(0);
}
}, [isOpen]);
@@ -69,11 +105,23 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
if (e.key === 'Escape') {
e.stopImmediatePropagation();
onClose();
+ return;
+ }
+ const mod = e.metaKey || e.ctrlKey;
+ if (!mod) return;
+ if (e.key === 'z' && !e.shiftKey) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ undo();
+ } else if ((e.key === 'z' && e.shiftKey) || e.key === 'Z') {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ redo();
}
}
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
- }, [isOpen, onClose]);
+ }, [isOpen, onClose, undo, redo]);
if (!isOpen) return null;
@@ -94,6 +142,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
}
toast.success(`Saved "${entry.name}" to library`);
setLibraryVersion((n) => n + 1);
+ setHistory([profile]);
+ setHistoryIndex(0);
}
function handleShareProfile() {
@@ -120,7 +170,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
}
function handleLoadFromLibrary(entry: SavedModifierProfile) {
- setProfile({ ...entry.profile });
+ setHistory([{ ...entry.profile }]);
+ setHistoryIndex(0);
toast.success(`Loaded "${entry.name}"`);
}
@@ -158,7 +209,7 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
type="text"
value={profile.name}
onChange={(e) =>
- setProfile((p) => ({ ...p, name: e.target.value }))
+ pushSnapshot((p) => ({ ...p, name: e.target.value }))
}
placeholder="Profile name"
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -178,7 +229,11 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
value={boundLayout?.id ?? ''}
onChange={(e) => {
const id = e.target.value;
- setBoundLayout(id ? (LAYOUT_REGISTRY.get(id) ?? null) : null);
+ const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null;
+ setBoundLayout(newLayout);
+ // Any layout change is tracked as an action because per-instance
+ // rules are inherently bound to the layout context.
+ pushSnapshot((p) => ({ ...p }));
}}
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
>
@@ -192,6 +247,33 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
+
+
+
+
+ {clipboard.kind === 'empty'
+ ? 'Clipboard: empty'
+ : `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
+
+