import type { CardLocation, PileData } from "./types"; import { assert, checked } from "./utils"; // Operations are mutations to workspace state; they should not interact // directly with the user nor have other side effects. They return a // reciprocable undo Command that restores any nontrivial state. // https://stackoverflow.com/a/67605309/13773246 // eslint-disable-next-line @typescript-eslint/no-explicit-any type ParametersExceptFirst = F extends (arg0: any, ...rest: infer R) => any ? R : never; // Command serializes an operation as defined in opsByName. It is a type-checked // tuple of the form [opName, ...opArgs]. export type Command = { [K in keyof typeof opsByName]: [K, ...ParametersExceptFirst<(typeof opsByName)[K]>]; }[keyof typeof opsByName]; export type CommandOf = Extract; // TODO refine and simplify interface StoreInterface { piles: PileData[]; activePileIndex: number; readonly activePile: PileData; insertPile(pileIndex: number): void; } export interface OperationContext { store: StoreInterface; makeCard: (uid: string) => { uid: string }; } // TODO options for presentation seem inappropriate at this layer function moveCard( ctx: OperationContext, source: CardLocation, target: CardLocation, options: { restorePicked?: number; followTarget?: boolean } = {}, ): Command { const { restorePicked, followTarget = false } = options; const sourcePile = checked(ctx.store.piles[source[0]]); const targetPile = checked(ctx.store.piles[target[0]]); const [sourceCardIndex, targetCardIndex] = [source[1], target[1]]; // TODO reenable assertion after removing xform noops // assert(sourcePile != targetPile || sourceCardIndex != targetCardIndex); checked(sourcePile.cards[sourceCardIndex]); assert(targetCardIndex >= 0); assert(targetCardIndex < targetPile.cards.length + (sourcePile === targetPile ? 0 : 1)); const originalTargetPicked = targetPile.pickedCardIndex; const card = checked(sourcePile.cards.splice(sourceCardIndex, 1)[0]); if (restorePicked !== undefined) sourcePile.pickedCardIndex = restorePicked; targetPile.cards.splice(targetCardIndex, 0, card); targetPile.pickedCardIndex = targetCardIndex; // followTarget exists for cosmetic reasons. When swapping cards within // a pile, the selection follows the picked card to its target location. // While our caller could arrange for this update, it would happen after // the view transition. To avoid artifacts, we push it down here. if (followTarget) [ctx.store.activePileIndex, ctx.store.activePile.pickedCardIndex] = target; return ["moveCard", target, source, { followTarget, restorePicked: originalTargetPicked }]; } function createPile(ctx: OperationContext, pileIndex: number): Command { ctx.store.insertPile(pileIndex); ctx.store.activePileIndex = pileIndex; return ["removeEmptyPile", pileIndex]; } function removeEmptyPile(ctx: OperationContext, pileIndex: number): Command { if (ctx.store.piles[pileIndex]?.cards?.length !== 0) { throw new Error("Cannot remove non-empty pile"); } ctx.store.piles.splice(pileIndex, 1); if (pileIndex <= ctx.store.activePileIndex) ctx.store.activePileIndex--; return ["createPile", pileIndex]; } function namePile(ctx: OperationContext, pileIndex: number, name: string): Command { const pile = checked(ctx.store.piles[pileIndex]); const oldName = pile.name; pile.name = name; return ["namePile", pileIndex, oldName]; } function movePile(ctx: OperationContext, fromIndex: number, toIndex: number): Command { const pile = checked(ctx.store.piles.splice(fromIndex, 1)[0]); ctx.store.piles.splice(toIndex, 0, pile); return ["movePile", toIndex, fromIndex]; } function spliceCards( ctx: OperationContext, pileIndex: number, cardIndex: number, deleteCount: number, cardUids: string[], ): Command { const pile = checked(ctx.store.piles[pileIndex]); const cards = cardUids.map((uid) => ctx.makeCard(uid)); const oldCards = pile.cards.splice(cardIndex, deleteCount, ...cards); pile.pickedCardIndex = Math.max(cardIndex, pile.cards.length - 1); const oldCardUids = oldCards.map(({ uid }) => uid); return ["spliceCards", pileIndex, cardIndex, cardUids.length, oldCardUids]; } const opsByName = { createPile, removeEmptyPile, namePile, movePile, spliceCards, moveCard, }; // Perform the operation requested by the Command. Returns the reverse operation // Command to undo. export function invokeCommand(ctx: OperationContext, cmd: Command) { const [opName, ...opArgs] = cmd; // This partially elided type exists only to check that all operations return // Command. We still use the discriminated type elsewhere. type FunctionReturnsCommand = ( ctx: OperationContext, // eslint-disable-next-line @typescript-eslint/no-explicit-any ...args: any[] ) => Command; // If type checking has an error on this line, there probably exists an // operation in opsByName that does not return Command. const opFn: FunctionReturnsCommand = opsByName[opName]; return opFn(ctx, ...opArgs); }