gestion_table_tournante_libre/src/application/commandes_places.js
Mathieu Benoit abae4fa9b8 [ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : séance observable, façade des événements, places

Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.

Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00

717 lines
33 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent
// jamais commandes.js : le registre réuni dépend de lui, non l'inverse.
//
// Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
// intacte ; un refus lève ErreurCommande(code, details), details.remede
// compris ; un argument de forme fausse, une table inconnue, un retenu absent
// lèvent TypeError, que l'interface n'offre jamais. Un plan bloqué refuse
// tout (PLAN_BLOQUE). Les poses se contrôlent par controlerPose (pose.js),
// qui rend tous les refus ; la commande lève le premier et porte les autres
// dans details.autres.
//
// Avant la retenue, un geste de personne pose ou déplace une réservation ;
// après, il modifie le retenu, au tour visé seulement, jamais la proposition
// d'où il vient (§ 5.8). Sièges attribués, le siège est le rang dans la
// liste, une chaise vide s'y écrit null et une liste ne finit jamais par
// null : un départ libère sa chaise, et tout autre siège reste le même
// (§ 8.9). Sans attribution, la liste reste triée par identifiant, et chaque
// geste verse d'abord en réserve les présents que le plan ne montre pas
// assis aux tables qu'il touche (reduireAuxAssises) : la liste vaut ce que
// l'affichage assied. Chaque geste lit les places comme le plan les montre
// (placeDe). Une retouche qui ajoute une violation de verifierInvariants au
// retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente,
// celle d'une dérive (§ 9), ne bloque rien.
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
import { verifierInvariants } from '../moteur/verification.js';
import { capacite } from '../stockage/document.js';
import { fautes, planDepuisFichier } from '../stockage/placements.js';
import { refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { conversionsPossibles } from './listes_travail.js';
import { nomAffiche } from './personnes.js';
import { etatPlaces, instanceDe, presents } from './places.js';
import { controlerPose, leverRefus } from './pose.js';
const croissant = (a, b) => a - b;
// --- Les contrôles communs ---------------------------------------------------
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
function exigerModifiable(charge) {
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
}
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
function personneDe(charge, id) {
if (!Number.isSafeInteger(id)) throw new TypeError(`participant : entier attendu, reçu ${JSON.stringify(id)}`);
const personne = charge.participants.find((candidat) => candidat.id === id);
if (personne === undefined) throw refus('PARTICIPANT_INCONNU', { id });
return personne;
}
// L'enregistrement de la table d'identifiant id : une table inconnue est
// une faute de l'appelant.
function tableDe(charge, id) {
const table = charge.tables.find((candidate) => candidate.id === id);
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
return table;
}
// Un siège donné : null, ou un entier ≥ 1.
function exigerSiege(siege) {
if (siege !== null && !(Number.isSafeInteger(siege) && siege >= 1)) {
throw new TypeError(`siege : null ou entier ≥ 1 attendu, reçu ${JSON.stringify(siege)}`);
}
}
// Une portée et son tour : « tous » sans tour, « tour » de 1 à R.
function exigerPortee(charge, portee, tour) {
if (portee === 'tous' && tour === null) return;
if (portee === 'tour' && Number.isSafeInteger(tour) && tour >= 1 && tour <= charge.evenement.tours) return;
throw new TypeError(`portée ${JSON.stringify(portee)}, tour ${JSON.stringify(tour)} : « tous » sans tour ou « tour » de 1 à R attendus`);
}
// Le libellé d'un titre saisi : en NFC, ses blancs de bord retirés ;
// LIBELLE_VIDE quand il ne reste rien.
function libelleSaisi(valeur) {
if (typeof valeur !== 'string') throw new TypeError(`libelle : chaîne attendue, reçu ${JSON.stringify(valeur)}`);
const texte = valeur.normalize('NFC').trim();
if (texte === '') throw refus('LIBELLE_VIDE');
return texte;
}
// Vrai quand la réservation r est celle que désigne d pour ce participant :
// sa table, sa portée, son tour.
const designee = (r, participant, d) =>
r.participant === participant && r.table === d.table && r.portee === d.portee && r.tour === (d.portee === 'tous' ? null : d.tour);
// Le premier titre de la place dans l'ordre canonique, ou undefined.
const titreDe = (charge, idTable, siege) =>
charge.titres
.filter((titre) => titre.table === idTable && titre.siege === siege)
.sort((a, b) => (a.libelle < b.libelle ? -1 : a.libelle > b.libelle ? 1 : 0))[0];
// Le remède d'une table qui manque de places : une de plus que places, par
// la saisie de son champ.
const changerPlaces = (idTable, places) => ({
commande: 'changerPlacesTable',
arguments: { id: idTable, sieges: places + 1, origine: 'champ' },
});
// Le plus petit siège de la table, de 1 à places, qu'aucun titre ne porte ;
// AUCUN_SIEGE_SANS_TITRE quand tous en portent un.
function siegeSansTitre(charge, idTable, places) {
const titres = new Set(charge.titres.filter(({ table }) => table === idTable).map(({ siege }) => siege));
for (let siege = 1; siege <= places; siege += 1) if (!titres.has(siege)) return siege;
throw refus('AUCUN_SIEGE_SANS_TITRE', { table: idTable, capacite: places }, changerPlaces(idTable, places));
}
// SIEGE_INEXISTANT pour un titre au-delà de la capacité : un titre garde
// toujours un siège de sa table.
function exigerSiegeDeTitre(idTable, siege, places) {
if (siege > places) {
throw refus('SIEGE_INEXISTANT', { table: idTable, siege, capacite: places }, {
commande: 'changerPlacesTable',
arguments: { id: idTable, sieges: siege, origine: 'champ' },
});
}
}
// Les avertissements des signaux d'une pose acceptée.
const avertissementsDe = (signaux) => signaux.map(({ code, table }) => ({ code, details: { table } }));
// --- Les réservations (§ 4, § 5.9) -------------------------------------------
/**
* reserver {participant, table, siege|null, portee: 'tous'|'tour', tour|null}
* (§ 4, § 5.9) : une réservation de plus, contrôlée comme une pose. Sans
* attribution des sièges, un siège au-delà de la capacité s'enregistre null :
* la place est la table. Une réservation déjà là : SANS_EFFET.
*/
function reserver(charge, { participant, table: idTable, siege = null, portee, tour = null } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
exigerPortee(charge, portee, tour);
const places = capacite(charge, table);
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
const nouvelle = { participant, table: idTable, siege: siegeEcrit, portee, tour };
if (charge.reservations.some((r) => ordreDesReservations(r, nouvelle) === 0)) {
throw refus('SANS_EFFET', { commande: 'reserver' });
}
const { refus: refusPose, signaux } = controlerPose(charge, nouvelle);
leverRefus(refusPose);
const suivante = structuredClone(charge);
suivante.reservations.push(nouvelle);
return {
charge: suivante,
libelle: libelle('ENTREE_RESERVER', { personne: nomAffiche(personne), numero: table.numero, siege: siegeEcrit, portee, tour }),
avertissements: avertissementsDe(signaux),
};
}
// Les réservations du participant que désigne { table, portee, tour }, ou
// RESERVATION_ABSENTE.
function reservationsDesignees(charge, participant, designation) {
exigerPortee(charge, designation.portee, designation.tour ?? null);
const trouvees = charge.reservations.filter((r) => designee(r, participant, designation));
if (trouvees.length === 0) {
const { table, portee, tour = null } = designation;
throw refus('RESERVATION_ABSENTE', { participant, table, portee, tour });
}
return trouvees;
}
/** retirerReservation {participant, table, portee, tour|null} : la réservation ainsi désignée part. */
function retirerReservation(charge, { participant, table: idTable, portee, tour = null } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
const designation = { table: idTable, portee, tour };
reservationsDesignees(charge, participant, designation);
const suivante = structuredClone(charge);
suivante.reservations = suivante.reservations.filter((r) => !designee(r, participant, designation));
return {
charge: suivante,
libelle: libelle('ENTREE_RETIRER_RESERVATION', { personne: nomAffiche(personne), numero: table.numero, portee, tour }),
avertissements: [],
};
}
/**
* deplacerReservation {participant, de: {table, portee, tour|null}, vers:
* {table, siege|null}} (§ 5.9) : la réservation désignée change de place et
* garde sa portée ; la pose se contrôle sans elle. Elle garde son rang dans
* la liste des réservations.
*/
function deplacerReservation(charge, { participant, de, vers } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const depart = tableDe(charge, de.table);
const arrivee = tableDe(charge, vers.table);
const siege = vers.siege ?? null;
exigerSiege(siege);
const [premiere] = reservationsDesignees(charge, participant, de).sort(ordreDesReservations);
const places = capacite(charge, arrivee);
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
const deplacee = { participant, table: vers.table, siege: siegeEcrit, portee: premiere.portee, tour: premiere.tour };
const { refus: refusPose, signaux } = controlerPose(charge, { ...deplacee, depuis: de });
leverRefus(refusPose);
const suivante = structuredClone(charge);
let remplacee = false;
suivante.reservations = suivante.reservations.flatMap((r) => {
if (!designee(r, participant, de)) return [r];
if (remplacee) return [];
remplacee = true;
return [deplacee];
});
return {
charge: suivante,
libelle: libelle('ENTREE_DEPLACER_RESERVATION', {
personne: nomAffiche(personne),
de: depart.numero,
vers: arrivee.numero,
siege: siegeEcrit,
portee: premiere.portee,
tour: premiere.tour,
}),
avertissements: avertissementsDe(signaux),
};
}
/**
* convertirEnTous {participant, table} (§ 4.2) : les R réservations « tour »
* de la personne à cette table deviennent une seule « tous les tours », au
* siège qu'elles portaient toutes, sans siège sinon. CONVERSION_IMPOSSIBLE
* hors de conversionsPossibles.
*/
function convertirEnTous(charge, { participant, table: idTable } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
if (!conversionsPossibles(charge).some((c) => c.participant === participant && c.table === idTable)) {
throw refus('CONVERSION_IMPOSSIBLE', { participant, table: idTable });
}
const parTour = (r) => r.participant === participant && r.table === idTable && r.portee === 'tour';
const sieges = new Set(charge.reservations.filter(parTour).map(({ siege }) => siege));
const siege = sieges.size === 1 ? [...sieges][0] : null;
const suivante = structuredClone(charge);
suivante.reservations = suivante.reservations.filter((r) => !parTour(r));
suivante.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
return {
charge: suivante,
libelle: libelle('ENTREE_CONVERTIR_EN_TOUS', { personne: nomAffiche(personne), numero: table.numero }),
avertissements: [],
};
}
// --- Les titres et la désignation (§ 4, § 4.1, § 2.1 étape 6) ---------------
/**
* poserTitre {table, siege|null, libelle} (§ 4.1) : un titre à une place.
* Sans siège, le plus petit qui n'en porte pas. Une place titrée refuse
* PLACE_TITREE, remède le retrait de son titre.
*/
function poserTitre(charge, { table: idTable, siege = null, libelle: saisi } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const texte = libelleSaisi(saisi);
const places = capacite(charge, table);
const place = siege ?? siegeSansTitre(charge, idTable, places);
exigerSiegeDeTitre(idTable, place, places);
const titre = titreDe(charge, idTable, place);
if (titre !== undefined) {
throw refus('PLACE_TITREE', { table: idTable, siege: place, libelle: titre.libelle }, {
commande: 'retirerTitre',
arguments: { table: idTable, siege: place },
});
}
const suivante = structuredClone(charge);
suivante.titres.push({ table: idTable, siege: place, libelle: texte });
return {
charge: suivante,
libelle: libelle('ENTREE_POSER_TITRE', { libelle: texte, numero: table.numero, siege: place }),
avertissements: [],
};
}
/** retirerTitre {table, siege} : le premier titre de la place part ; TITRE_ABSENT sans lui. */
function retirerTitre(charge, { table: idTable, siege } = {}) {
exigerModifiable(charge);
const table = tableDe(charge, idTable);
const titre = titreDe(charge, idTable, siege);
if (titre === undefined) throw refus('TITRE_ABSENT', { table: idTable, siege });
const suivante = structuredClone(charge);
const rang = charge.titres.indexOf(titre);
suivante.titres.splice(rang, 1);
return {
charge: suivante,
libelle: libelle('ENTREE_RETIRER_TITRE', { libelle: titre.libelle, numero: table.numero, siege }),
avertissements: [],
};
}
/**
* designer {participant, table, siege|null, libelle} (§ 2.1 étape 6, § 5.8) :
* une réservation « tous les tours » et un titre au même siège, en une
* entrée. Sans siège, la place du premier titre de la table que rien ne
* pourvoit pour la soirée, sinon le plus petit siège sans titre. Un titre
* déjà posé à cette place est pourvu, et aucun second ne s'ajoute.
*/
function designer(charge, { participant, table: idTable, siege = null, libelle: saisi } = {}) {
exigerModifiable(charge);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const texte = libelleSaisi(saisi);
const places = capacite(charge, table);
let place = siege;
if (place === null) {
const pourvoyeurs = pourvoyeursDesTitres(charge);
const libres = charge.titres
.map((titre, rang) => ({ titre, rang }))
.filter(({ titre, rang }) => titre.table === idTable && pourvoyeurs[rang].soiree === null)
.sort((a, b) => a.titre.siege - b.titre.siege);
place = libres.length > 0 ? libres[0].titre.siege : siegeSansTitre(charge, idTable, places);
}
exigerSiegeDeTitre(idTable, place, places);
const reservation = { participant, table: idTable, siege: place, portee: 'tous', tour: null };
const titre = titreDe(charge, idTable, place);
const dejaReservee = charge.reservations.some((r) => ordreDesReservations(r, reservation) === 0);
if (dejaReservee && titre !== undefined) throw refus('SANS_EFFET', { commande: 'designer' });
const { refus: refusPose, signaux } = controlerPose(charge, reservation);
leverRefus(refusPose);
const suivante = structuredClone(charge);
if (!dejaReservee) suivante.reservations.push(reservation);
if (titre === undefined) suivante.titres.push({ table: idTable, siege: place, libelle: texte });
return {
charge: suivante,
libelle: libelle('ENTREE_DESIGNER', {
personne: nomAffiche(personne),
numero: table.numero,
siege: place,
libelle: titre?.libelle ?? texte,
}),
avertissements: avertissementsDe(signaux),
};
}
// --- Le retenu (§ 7.1, § 8.9, § 9) -------------------------------------------
// Le retenu de la charge à ce tour, lisible : sans retenu, ou à un tour qu'il
// ne porte pas, TypeError — la cible d'etatPlaces ne l'offre jamais ; fautif,
// RETENU_FAUTIF, remède la version précédente du fichier (§ 8.8).
function exigerRetenu(charge, tour) {
const { retenu } = charge;
if (retenu === null) throw new TypeError('retenu : aucun placement retenu');
if (fautes(retenu).length > 0) throw refus('RETENU_FAUTIF', {}, { geste: 'ouvrirSecours', source: 'precedent' });
if (!Number.isSafeInteger(tour) || tour < 1 || tour > retenu.tours) {
throw new TypeError(`tour : de 1 à ${retenu.tours} attendu, reçu ${JSON.stringify(tour)}`);
}
}
// Où le retenu place id à ce tour : { table: rang de la liste, rang } à une
// table, 'reserve', ou null quand il ne le déclare pas.
function lieuDans(retenu, tour, id) {
if (!retenu.participants.includes(id)) return null;
const { sieges } = retenu.placement[tour - 1];
for (let table = 0; table < sieges.length; table += 1) {
const rang = sieges[table].indexOf(id);
if (rang >= 0) return { table, rang };
}
return 'reserve';
}
// Retire les null de fin d'une liste, en place.
function sansNullDeFin(liste) {
while (liste.length > 0 && liste.at(-1) === null) liste.pop();
}
// Déclare id dans le retenu qui ne le place pas encore : parmi ses
// participants, et en réserve à chaque tour — l'opérateur seul décide où il
// s'assied (§ 9).
function declarer(retenu, id) {
retenu.participants = [...retenu.participants, id].sort(croissant);
for (const tour of retenu.placement) tour.reserve = [...tour.reserve, id].sort(croissant);
}
// Ôte id de sa place au tour : sa chaise devient vide sous des sièges
// attribués, la liste se resserre sinon ; ou il quitte la réserve.
function oter(retenu, tour, id) {
const lieu = lieuDans(retenu, tour, id);
const etage = retenu.placement[tour - 1];
if (lieu === 'reserve') {
etage.reserve = etage.reserve.filter((autre) => autre !== id);
} else if (lieu !== null) {
const liste = etage.sieges[lieu.table];
if (retenu.siegesAttribues) {
liste[lieu.rang] = null;
sansNullDeFin(liste);
} else {
liste.splice(lieu.rang, 1);
}
}
}
// Verse id dans la réserve du tour.
function enReserve(retenu, tour, id) {
const etage = retenu.placement[tour - 1];
etage.reserve = [...etage.reserve, id].sort(croissant);
}
// Le rang de la table idTable dans le retenu, la table ajoutée vide à chaque
// tour quand il ne la déclare pas encore — une table créée après la
// retenue (A4 l'y ajoute aussi).
function rangDeTable(retenu, idTable, places) {
const rang = retenu.tables.indexOf(idTable);
if (rang >= 0) return rang;
retenu.tables.push(idTable);
retenu.capacites.push(places);
for (const tour of retenu.placement) tour.sieges.push([]);
return retenu.tables.length - 1;
}
// Clé d'une violation : ses champs, dans un ordre fixe.
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]);
// Les violations de verifierInvariants que suivant ajoute au retenu
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
// personne une fois, et verifierInvariants n'y rend jamais deux violations
// de même clé. [] quand la configuration ne se normalise pas.
function violationsNouvelles(charge, precedent, suivant) {
const instance = instanceDe(charge);
if (instance === null) return [];
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true });
const connues = new Set(juger(precedent).map(cleDeViolation));
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
}
// La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute
// une violation au retenu reçu. Le versement en réserve des personnes que le
// plan n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
// aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle
// réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2).
function retouche(charge, suivante) {
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
if (violations.length > 0) {
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
}
return suivante;
}
// Le numéro de la table où le retenu place id à ce tour, null en réserve,
// hors du retenu, ou à une table que la charge n'a plus.
function numeroDuLieu(charge, tour, id) {
const lieu = lieuDans(charge.retenu, tour, id);
if (lieu === null || lieu === 'reserve') return null;
return charge.tables.find(({ id: idTable }) => idTable === charge.retenu.tables[lieu.table])?.numero ?? null;
}
/**
* deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) :
* la personne s'assied à cette table au tour donné, et sa place d'avant se
* libère. Sièges attribués, au siège donné — sans siège, la première chaise
* libre —, les rangs sautés à null ; la chaise d'un présent refuse
* SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la
* charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste
* triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine
* d'une liste qui porte un absent, envoie le plus petit en réserve. Une
* personne que le retenu ne place pas y entre, en réserve aux autres tours.
* Le lieu de départ est celui que le plan du tour montre (placeDe) : une
* personne qu'il ne montre pas assise part de la réserve.
*/
function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege = null } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
const personne = personneDe(charge, participant);
const table = tableDe(charge, idTable);
exigerSiege(siege);
const places = capacite(charge, table);
if (siege !== null && siege > places) throw new TypeError(`siege : de 1 à ${places} attendu, reçu ${siege}`);
leverRefus(controlerPose(charge, { participant, table: idTable, siege, portee: 'tour', tour, retenu: true }).refus);
const ids = presents(charge);
const assisesDuTour = assisesAffichees(charge, tour);
const place = placeDe(charge, tour, participant, assisesDuTour);
const de = place === null ? null : numeroDuLieu(charge, tour, participant);
const memeTable = place !== null && place.table === idTable;
const suivante = structuredClone(charge);
const { retenu } = suivante;
const rangTable = rangDeTable(retenu, idTable, places);
if (lieuDans(retenu, tour, participant) === null) declarer(retenu, participant);
// La table d'arrivée n'a rien à verser : une liste qui porte plus de
// présents que de places refuse TABLE_PLEINE, et une autre les assied tous.
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [participant]), assisesDuTour, ids);
versLaReserveAffichee(retenu, tour, participant, place);
const etage = retenu.placement[tour - 1];
let siegeLibelle = null;
if (retenu.siegesAttribues) {
const liste = etage.sieges[rangTable];
let cible = siege;
if (cible === null) {
cible = 1;
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1;
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
}
const occupant = liste[cible - 1] ?? null;
if (occupant === participant) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
if (occupant !== null && ids.has(occupant)) {
throw refus('SIEGE_OCCUPE', { table: idTable, siege: cible, tour, occupant }, {
commande: 'echangerDansRetenu',
arguments: { tour, a: participant, b: occupant },
});
}
if (occupant !== null) {
liste[cible - 1] = null;
enReserve(retenu, tour, occupant);
}
oter(retenu, tour, participant);
const garnie = etage.sieges[rangTable];
while (garnie.length < cible) garnie.push(null);
garnie[cible - 1] = participant;
sansNullDeFin(garnie);
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
if (memeTable) siegeLibelle = cible;
} else {
const liste = etage.sieges[rangTable];
if (liste.includes(participant)) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
const assises = liste.filter((id) => ids.has(id)).length;
if (assises >= places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
if (liste.length >= places) {
const absent = liste.filter((id) => !ids.has(id)).sort(croissant)[0];
if (absent !== undefined) {
liste.splice(liste.indexOf(absent), 1);
enReserve(retenu, tour, absent);
}
}
oter(retenu, tour, participant);
const garnie = etage.sieges[rangTable];
garnie.push(participant);
garnie.sort(croissant);
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
}
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_DEPLACER_DANS_RETENU', {
personne: nomAffiche(personne),
de,
vers: table.numero,
siege: siegeLibelle,
tour,
}),
avertissements: [],
};
}
// Les personnes que le plan du tour montre assises (etatPlaces, source le
// retenu). Une place du retenu à une table que la charge n'a plus, ou à un
// rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme
// la réserve.
function assisesAffichees(charge, tour) {
const assises = new Set();
for (const { chaises } of etatPlaces(charge, { tour }).tables) {
for (const { occupant } of chaises) if (occupant !== null) assises.add(occupant);
}
return assises;
}
// La place qu'une personne occupe au tour telle que le plan la montre :
// { table, siege }, ou null quand le plan ne l'assied pas — en réserve, hors
// du retenu, ou absente d'assises (assisesAffichees) : à une table que la
// charge n'a plus, à un rang au-delà de la capacité courante, ou, sans
// attribution, parmi les présents qu'une liste porte au-delà des places. Le
// siège est le rang sous des sièges attribués ; sans attribution, celui de sa
// réservation à un siège à cette table, null sans elle. Les gestes du retenu
// y lisent le lieu de départ, et la pose de l'autre sur elle s'y contrôle.
function placeDe(charge, tour, id, assises) {
const lieu = lieuDans(charge.retenu, tour, id);
if (lieu === null || lieu === 'reserve' || !assises.has(id)) return null;
const idTable = charge.retenu.tables[lieu.table];
if (charge.retenu.siegesAttribues) return { table: idTable, siege: lieu.rang + 1 };
const tenue = charge.reservations.find(
(r) => r.participant === id && r.table === idTable && r.siege !== null && (r.portee === 'tous' || r.tour === tour),
);
return { table: idTable, siege: tenue?.siege ?? null };
}
// Verse dans la réserve du tour une personne que le retenu range à une table
// alors que le plan ne la montre pas assise (place null, de placeDe) : sa
// liste la perd, et le geste part de la réserve, comme l'affichage. Personne
// n'entre ainsi dans la liste d'une table que la charge n'a plus.
function versLaReserveAffichee(retenu, tour, id, place) {
const lieu = lieuDans(retenu, tour, id);
if (place === null && lieu !== null && lieu !== 'reserve') {
oter(retenu, tour, id);
enReserve(retenu, tour, id);
}
}
// Sans attribution, verse dans la réserve du tour chaque présent (ids) que
// le plan ne montre pas assis (assises) aux tables des rangs donnés : leur
// liste vaut alors ce que l'affichage y assied, et le tri qui suit le geste
// n'assied personne d'autre que ceux qu'il nomme. Sous des sièges attribués,
// le rang est le siège et ne glisse pas : rien ne change.
function reduireAuxAssises(retenu, tour, rangs, assises, ids) {
if (retenu.siegesAttribues) return;
const etage = retenu.placement[tour - 1];
for (const rang of rangs) {
for (const id of etage.sieges[rang].filter((autre) => ids.has(autre) && !assises.has(autre))) {
oter(retenu, tour, id);
enReserve(retenu, tour, id);
}
}
}
// Les rangs des tables où le retenu place ces personnes au tour.
function rangsDesTables(retenu, tour, personnes) {
return personnes.map((id) => lieuDans(retenu, tour, id)).filter((lieu) => lieu !== null && lieu !== 'reserve').map(({ table }) => table);
}
/**
* echangerDansRetenu {tour, a, b} (§ 7.1) : a prend la place de b et b celle
* de a, au tour donné ; une place peut être la réserve. Chaque arrivée se
* contrôle comme un dépôt : PLACE_RESERVEE, RESERVEE_AILLEURS. Une personne
* que le retenu ne place pas y entre, en réserve aux autres tours.
*/
function echangerDansRetenu(charge, { tour, a, b } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
if (a === b) throw new TypeError('echangerDansRetenu : deux personnes distinctes attendues');
const personneA = personneDe(charge, a);
const personneB = personneDe(charge, b);
for (const personne of [personneA, personneB]) {
if (personne.exclu) {
throw refus('PERSONNE_EXCLUE', { participant: personne.id }, { commande: 'reintegrerParticipant', arguments: { id: personne.id } });
}
}
const assises = assisesAffichees(charge, tour);
const placeA = placeDe(charge, tour, a, assises);
const placeB = placeDe(charge, tour, b, assises);
if (placeA === null && placeB === null) throw refus('SANS_EFFET', { commande: 'echangerDansRetenu' });
const poses = [];
if (placeB !== null) poses.push(...controlerPose(charge, { participant: a, ...placeB, portee: 'tour', tour, retenu: true }).refus);
if (placeA !== null) poses.push(...controlerPose(charge, { participant: b, ...placeA, portee: 'tour', tour, retenu: true }).refus);
leverRefus(poses);
const deA = placeA === null ? null : numeroDuLieu(charge, tour, a);
const deB = placeB === null ? null : numeroDuLieu(charge, tour, b);
const suivante = structuredClone(charge);
const { retenu } = suivante;
for (const id of [a, b]) if (lieuDans(retenu, tour, id) === null) declarer(retenu, id);
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [a, b]), assises, presents(charge));
// L'autre remplace dans la réserve une personne que le plan n'y montre pas assise.
versLaReserveAffichee(retenu, tour, a, placeA);
versLaReserveAffichee(retenu, tour, b, placeB);
const lieuA = lieuDans(retenu, tour, a);
const lieuB = lieuDans(retenu, tour, b);
const etage = retenu.placement[tour - 1];
const poser = (lieu, id) => {
if (lieu === 'reserve') enReserve(retenu, tour, id);
else etage.sieges[lieu.table][lieu.rang] = id;
};
if (lieuA === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== a);
if (lieuB === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== b);
poser(lieuB, a);
poser(lieuA, b);
if (!retenu.siegesAttribues) for (const liste of etage.sieges) liste.sort(croissant);
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_ECHANGER_DANS_RETENU', { a: nomAffiche(personneA), deA, b: nomAffiche(personneB), deB, tour }),
avertissements: [],
};
}
/**
* retirerDuTour {tour, participant} (§ 7.1) : la personne quitte sa table au
* tour donné et attend dans la réserve de ce tour ; sa chaise se libère.
* Une personne que le plan du tour ne montre pas assise (placeDe) — en
* réserve, hors du retenu, à une table que la charge n'a plus ou à un rang
* au-delà de sa capacité courante — refuse SANS_EFFET.
*/
function retirerDuTour(charge, { tour, participant } = {}) {
exigerModifiable(charge);
exigerRetenu(charge, tour);
const personne = personneDe(charge, participant);
if (personne.exclu) {
throw refus('PERSONNE_EXCLUE', { participant }, { commande: 'reintegrerParticipant', arguments: { id: participant } });
}
const assises = assisesAffichees(charge, tour);
if (placeDe(charge, tour, participant, assises) === null) {
throw refus('SANS_EFFET', { commande: 'retirerDuTour' });
}
const numero = numeroDuLieu(charge, tour, participant);
const suivante = structuredClone(charge);
reduireAuxAssises(suivante.retenu, tour, rangsDesTables(suivante.retenu, tour, [participant]), assises, presents(charge));
oter(suivante.retenu, tour, participant);
enReserve(suivante.retenu, tour, participant);
return {
charge: retouche(charge, suivante),
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
avertissements: [],
};
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_PLACES = new Map([
['reserver', reserver],
['retirerReservation', retirerReservation],
['deplacerReservation', deplacerReservation],
['poserTitre', poserTitre],
['retirerTitre', retirerTitre],
['designer', designer],
['convertirEnTous', convertirEnTous],
['deplacerDansRetenu', deplacerDansRetenu],
['echangerDansRetenu', echangerDansRetenu],
['retirerDuTour', retirerDuTour],
]);