Aller au contenu principal

Pattern State

Permet à un objet de modifier son comportement quand son état interne change, comme s'il passait à une implémentation différente.


Le Problème​

Vous développez un système de score de tennis pour Roland Garros. Le comptage au tennis est notoirement complexe : 0 → 15 → 30 → 40 → Jeu, mais à 40-40 c'est Deuce, puis Avantage, puis soit Jeu soit retour à Deuce.

L'approche naïve :

function scorePoint(server: Score, receiver: Score, whoScored: "server" | "receiver") {
if (whoScored === "server") {
if (server === "40" && receiver === "40") {
return { server: "AD", receiver: "40" };
} else if (server === "40" && receiver !== "AD") {
return { server: "Game", receiver };
} else if (server === "AD") {
return { server: "Game", receiver: "40" };
} else if (receiver === "AD") {
return { server: "40", receiver: "40" }; // back to Deuce
} else if (server === "0") {
return { server: "15", receiver };
} else if (server === "15") {
return { server: "30", receiver };
}
// ... 15 branches de plus pour toutes les combinaisons
}
// ... tout dupliquer pour le receveur
}

Des conditions imbriquées partout. Facile de rater des cas limites. Aucune garantie d'avoir couvert tous les états.


La Solution​

Modélisez chaque combinaison de score comme un état avec des transitions explicites :

import { createMachine } from "@pithos/core/eidos/state/state";

// TS infère tous les états et événements — les transitions invalides sont des erreurs de compilation
const tennisGame = createMachine({
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
// ... toutes les combinaisons de score
"40-40": { p1: { to: "AD-40" }, p2: { to: "40-AD" } }, // Deuce!
"AD-40": { p1: { to: "Game-P1" }, p2: { to: "40-40" } }, // Avantage ou retour à Deuce
"40-AD": { p1: { to: "40-40" }, p2: { to: "Game-P2" } },
// Simplifié — la démo live utilise "Deuce", "AD-P1", "AD-P2" pour la lisibilité
"Game-P1": {}, // État terminal
"Game-P2": {},
}, "0-0");

tennisGame.send("p1"); // "15-0"
tennisGame.send("p1"); // "30-0"
// ... le jeu continue

Chaque transition d'état est explicite. La boucle Deuce ↔ Avantage est claire. Les états terminaux n'ont pas de transitions.


Démo​

Marquez des points pour chaque joueur et observez la state machine gérer les transitions Deuce, Avantage et Jeu.

Code
import { createMachine } from "@pithos/core/eidos/state/state";

/**
* Tennis game scoring state machine.
*
* States represent score combinations: "P1Score-P2Score"
* Events: "p1" (player 1 scores) or "p2" (player 2 scores)
*/

export type TennisState =
| "0-0" | "15-0" | "0-15" | "30-0" | "15-15" | "0-30"
| "40-0" | "30-15" | "15-30" | "0-40" | "40-15" | "30-30"
| "15-40" | "40-30" | "30-40" | "Deuce" | "AD-P1" | "AD-P2"
| "Game-P1" | "Game-P2";

export type TennisEvent = "p1" | "p2";

/** Metadata for each state — NO if/else needed */
interface StateMetadata {
p1Score: string;
p2Score: string;
phase: "normal" | "deuce" | "gameOver";
winner: "p1" | "p2" | null;
statusLabel: string | null;
}

/** All state metadata in a single record — the State pattern way */
const STATE_METADATA: Record<TennisState, StateMetadata> = {
// Normal scoring
"0-0": { p1Score: "0", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-0": { p1Score: "15", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"0-15": { p1Score: "0", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-0": { p1Score: "30", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"15-15": { p1Score: "15", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"0-30": { p1Score: "0", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"40-0": { p1Score: "40", p2Score: "0", phase: "normal", winner: null, statusLabel: null },
"30-15": { p1Score: "30", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"15-30": { p1Score: "15", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"0-40": { p1Score: "0", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-15": { p1Score: "40", p2Score: "15", phase: "normal", winner: null, statusLabel: null },
"30-30": { p1Score: "30", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"15-40": { p1Score: "15", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
"40-30": { p1Score: "40", p2Score: "30", phase: "normal", winner: null, statusLabel: null },
"30-40": { p1Score: "30", p2Score: "40", phase: "normal", winner: null, statusLabel: null },
// Deuce phase
"Deuce": { p1Score: "40", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Deuce" },
"AD-P1": { p1Score: "AD", p2Score: "40", phase: "deuce", winner: null, statusLabel: "Advantage P1" },
"AD-P2": { p1Score: "40", p2Score: "AD", phase: "deuce", winner: null, statusLabel: "Advantage P2" },
// Terminal states
"Game-P1": { p1Score: "Game", p2Score: "", phase: "gameOver", winner: "p1", statusLabel: "Game" },
"Game-P2": { p1Score: "", p2Score: "Game", phase: "gameOver", winner: "p2", statusLabel: "Game" },
};

/** State transitions — the machine definition */
const TENNIS_TRANSITIONS = {
"0-0": { p1: { to: "15-0" }, p2: { to: "0-15" } },
"15-0": { p1: { to: "30-0" }, p2: { to: "15-15" } },
"0-15": { p1: { to: "15-15" }, p2: { to: "0-30" } },
"30-0": { p1: { to: "40-0" }, p2: { to: "30-15" } },
"15-15": { p1: { to: "30-15" }, p2: { to: "15-30" } },
"0-30": { p1: { to: "15-30" }, p2: { to: "0-40" } },
"40-0": { p1: { to: "Game-P1" }, p2: { to: "40-15" } },
"30-15": { p1: { to: "40-15" }, p2: { to: "30-30" } },
"15-30": { p1: { to: "30-30" }, p2: { to: "15-40" } },
"0-40": { p1: { to: "15-40" }, p2: { to: "Game-P2" } },
"40-15": { p1: { to: "Game-P1" }, p2: { to: "40-30" } },
"30-30": { p1: { to: "40-30" }, p2: { to: "30-40" } },
"15-40": { p1: { to: "30-40" }, p2: { to: "Game-P2" } },
"40-30": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"30-40": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Deuce and Advantage loop
"Deuce": { p1: { to: "AD-P1" }, p2: { to: "AD-P2" } },
"AD-P1": { p1: { to: "Game-P1" }, p2: { to: "Deuce" } },
"AD-P2": { p1: { to: "Deuce" }, p2: { to: "Game-P2" } },
// Terminal states (no transitions)
"Game-P1": {},
"Game-P2": {},
} as const;

export function createTennisGame() {
return createMachine(TENNIS_TRANSITIONS, "0-0");
}

/** Get metadata for a state — just a lookup, no conditionals */
export function getStateMetadata(state: TennisState): StateMetadata {
return STATE_METADATA[state];
}

export interface ScoreDirections {
p1: "forward" | "backward";
p2: "forward" | "backward";
}

/** Determine flip direction per player between two states */
export function getScoreDirections(from: TennisState, to: TennisState): ScoreDirections {
const prev = STATE_METADATA[from];
const next = STATE_METADATA[to];

return {
p1: isScoreBackward(prev.p1Score, next.p1Score) ? "backward" : "forward",
p2: isScoreBackward(prev.p2Score, next.p2Score) ? "backward" : "forward",
};
}

const SCORE_ORDER = ["", "0", "15", "30", "40", "AD", "Game"];

function isScoreBackward(from: string, to: string): boolean {
const fi = SCORE_ORDER.indexOf(from);
const ti = SCORE_ORDER.indexOf(to);
if (fi === -1 || ti === -1) return false;
return ti < fi;
}
Result

Analogie​

Le tableau de score d'un arbitre de tennis. L'arbitre ne pense pas « si le joueur 1 a 40 et le joueur 2 a 30, alors... ». Il sait simplement : depuis ce score, un point pour le joueur 1 mène à tel score. Les règles sont encodées dans les transitions, pas dans de la logique conditionnelle.


Quand l'Utiliser​

  • Le comportement d'un objet dépend de son état
  • Vous avez de la logique conditionnelle complexe basée sur l'état
  • Les transitions d'état doivent être explicites et validées
  • Construction de workflows, logique de jeu, ou processus multi-étapes

Quand NE PAS l'Utiliser​

Si votre état est juste un flag booléen (on/off, activé/désactivé), une state machine ajoute de la cérémonie inutile. Utilisez-la quand vous avez 3+ états avec des règles de transition non triviales.


API​

  • createMachine — Créer une state machine finie avec des états et événements typés