jmou/yakatak
git clone https://github.com/jmou/yakatak.git
git clone git@github.com:jmou/yakatak.git
Loading…
(top)/operation/src/operations.ts
language: TypeScript
5 KB / 142 lines / 115 loc
History
View raw
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> = 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<K extends Command[0]> = Extract<Command, [K, ...any[]]>;

// 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);
}