The wheel no longer zooms during a gesture, so a drop lands under the ghost. A seat is a target the size of its pitch. A floor drop refuses to remove an every-tour reservation. A seat reservation the retained plan does not hold is flagged at the seat, with its swap as remedy. DERIVE names the person; capacities have an upper bound; Generate waits for a pending setting; a worker that fails to load shows a refusal; § 5.3 constraints get their switches. Commands are not re-entrant and a double click does not confirm. Checked: 2755 node, 550 browser tests, from the index alone. --- FR --- [FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme. Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse de retirer une réservation de tous les tours. Une réservation à un siège que le retenu ne tient pas est relevée au siège, l'échange en remède. La dérive nomme la personne ; les capacités ont une borne ; Générer attend un réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le § 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul. Assisted-by: Claude Opus 5.5
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
|
||
// (§ 4, § 6, § 7.1, § 8.2, § 9), 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, calculé avant toute modification ; un argument de forme fausse ou
|
||
// une table inconnue lèvent TypeError, que l'interface n'offre jamais. Un
|
||
// plan bloqué refuse tout (PLAN_BLOQUE). Une saisie de places ou de tours se
|
||
// lit par lireEntier, la seule lecture d'un entier saisi.
|
||
//
|
||
// Une table a un identifiant, tiré de prochainsIds.table, qui ne revient
|
||
// jamais, et un numéro affiché, le plus grand plus un, qui ne se renumérote
|
||
// pas (§ 4). Les capacités passent par controlerCapacite (capacite.js) ; le
|
||
// retenu les suit, ses surnuméraires versés en réserve, et une table créée y
|
||
// entre, vide à chaque tour : il ne gagne aucune raison de dérive. Une
|
||
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
|
||
// table et changer les tours laissent propositions et retenu en dérive.
|
||
import { caseLibre } from '../geometrie/tables.js';
|
||
import { SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
|
||
import { fautes } from '../stockage/placements.js';
|
||
import { bilanChangementDefaut } from './bilans.js';
|
||
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
|
||
import { ErreurCommande, refus } from './erreurs.js';
|
||
import { libelle } from './libelles.js';
|
||
import { leverRefus } from './pose.js';
|
||
|
||
const FORMES = new Set(['ronde', 'carree']);
|
||
const ORIGINES = new Set(['champ', 'poignee']);
|
||
|
||
// Le plus grand identifiant de table qu'admet la règle du schéma : le
|
||
// compteur, qui la suit, n'aurait plus de valeur au-delà de lui.
|
||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||
const IDENTIFIANT_MAX = regleDuChamp(regleDuChamp(regleDuChamp(SCHEMA, 'charge'), 'tables').element, 'id').max;
|
||
|
||
// Les réglages que modifierReglage change : un interrupteur, ou un entier ≥ 1
|
||
// de la génération (§ 5.3, § 5.10).
|
||
const INTERRUPTEURS = new Set(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges']);
|
||
const ENTIERS_DE_GENERATION = new Set(['generation.nombre', 'generation.arret', 'generation.historique']);
|
||
|
||
// 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' });
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// La valeur d'un entier saisi, ou le refus NON_ENTIER levé.
|
||
function entierSaisi(saisie) {
|
||
const lu = lireEntier(saisie);
|
||
if (lu.refus) throw new ErreurCommande(lu.refus.code, lu.refus.details);
|
||
return lu.valeur;
|
||
}
|
||
|
||
// La charge reçue, copiée, chaque table de changements (Map identifiant →
|
||
// sieges) à son nombre de sièges, null pour une table qui suit le défaut, et
|
||
// le retenu aux capacités qui en résultent.
|
||
function avecSieges(charge, changements, defaut = charge.evenement.siegesParDefaut) {
|
||
const suivante = structuredClone(charge);
|
||
suivante.evenement.siegesParDefaut = defaut;
|
||
for (const table of suivante.tables) if (changements.has(table.id)) table.sieges = changements.get(table.id);
|
||
const capacites = new Map(suivante.tables.map((table) => [table.id, capacite(suivante, table)]));
|
||
const touchees = new Map(
|
||
charge.tables
|
||
.filter((table) => changements.has(table.id) || capacites.get(table.id) !== capacite(charge, table))
|
||
.map(({ id }) => [id, capacites.get(id)]),
|
||
);
|
||
suivante.retenu = structuredClone(retenuAuxCapacites(charge, touchees));
|
||
return suivante;
|
||
}
|
||
|
||
// Le rendu d'un contrôle de capacité accepté : ses avertissements, ou son
|
||
// refus levé.
|
||
function exigerCapacite(charge, id, sieges, origine) {
|
||
const { refus: refuse, avertissements } = controlerCapacite(charge, id, sieges, { origine });
|
||
if (refuse !== null) throw new ErreurCommande(refuse.code, refuse.details);
|
||
return avertissements;
|
||
}
|
||
|
||
/**
|
||
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant
|
||
* prochainsIds.table, numérotée après la plus grande, posée sur la première
|
||
* case libre (caseLibre) de la grille des démonstrations, au pas que donnent
|
||
* les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu
|
||
* lisible, elle y entre avec sa capacité et une liste vide à chaque tour.
|
||
*/
|
||
function creerTable(charge, { forme } = {}) {
|
||
exigerModifiable(charge);
|
||
if (!FORMES.has(forme)) throw new TypeError(`forme : 'ronde' ou 'carree' attendue, reçu ${JSON.stringify(forme)}`);
|
||
const id = charge.prochainsIds.table;
|
||
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
|
||
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
|
||
const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) }));
|
||
const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut });
|
||
const suivante = structuredClone(charge);
|
||
suivante.tables.push({ id, numero, sieges: null, forme, position });
|
||
suivante.prochainsIds.table = id + 1;
|
||
const { retenu } = suivante;
|
||
if (retenu !== null && fautes(retenu).length === 0) {
|
||
retenu.tables.push(id);
|
||
retenu.capacites.push(charge.evenement.siegesParDefaut);
|
||
for (const etage of retenu.placement) etage.sieges.push([]);
|
||
}
|
||
return { charge: suivante, libelle: libelle('ENTREE_CREER_TABLE', { numero }), avertissements: [] };
|
||
}
|
||
|
||
/**
|
||
* supprimerTable {id} (§ 6.2, § 9) : la table part, et avec elle, dans la
|
||
* même entrée, ses réservations — suspendues comprises — et ses titres ; les
|
||
* autres tables gardent leur numéro, propositions et retenu restent, en
|
||
* dérive TABLE_SUPPRIMEE.
|
||
*/
|
||
function supprimerTable(charge, { id } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
const suivante = structuredClone(charge);
|
||
suivante.tables = suivante.tables.filter((candidate) => candidate.id !== id);
|
||
suivante.reservations = suivante.reservations.filter((reservation) => reservation.table !== id);
|
||
suivante.titres = suivante.titres.filter((titre) => titre.table !== id);
|
||
const details = {
|
||
numero: table.numero,
|
||
reservations: charge.reservations.length - suivante.reservations.length,
|
||
titres: charge.titres.length - suivante.titres.length,
|
||
};
|
||
return { charge: suivante, libelle: libelle('ENTREE_SUPPRIMER_TABLE', details), avertissements: [] };
|
||
}
|
||
|
||
/** deplacerTable {id, position: {x, y}} (§ 7.1) : la position du centre, arrondie au centimètre. */
|
||
function deplacerTable(charge, { id, position } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) {
|
||
throw new TypeError(`position : {x, y} finis attendus, reçu ${JSON.stringify(position)}`);
|
||
}
|
||
const suivante = structuredClone(charge);
|
||
// + 0 écrit 0 là où l'arrondi d'une petite valeur négative rend −0.
|
||
suivante.tables.find((candidate) => candidate.id === id).position = { x: Math.round(position.x) + 0, y: Math.round(position.y) + 0 };
|
||
return { charge: suivante, libelle: libelle('ENTREE_DEPLACER_TABLE', { numero: table.numero }), avertissements: [] };
|
||
}
|
||
|
||
/**
|
||
* changerPlacesTable {id, sieges, origine: 'champ'|'poignee'} (§ 6.2, § 7.1) :
|
||
* la table est surchargée à sieges places, lues par lireEntier, contrôlées
|
||
* par controlerCapacite selon leur origine ; le retenu suit.
|
||
*/
|
||
function changerPlacesTable(charge, { id, sieges: saisie, origine } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
if (!ORIGINES.has(origine)) throw new TypeError(`origine : 'champ' ou 'poignee' attendue, reçu ${JSON.stringify(origine)}`);
|
||
const sieges = entierSaisi(saisie);
|
||
const avertissements = exigerCapacite(charge, id, sieges, origine);
|
||
return {
|
||
charge: avecSieges(charge, new Map([[id, sieges]])),
|
||
libelle: libelle('ENTREE_CHANGER_PLACES_TABLE', { numero: table.numero, sieges }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* realignerTable {id} (§ 6.1) : la table suit de nouveau le défaut ; une
|
||
* table surchargée au-dessus de lui subit une réduction, sous la règle du
|
||
* champ (§ 6.2). Le retenu suit.
|
||
*/
|
||
function realignerTable(charge, { id } = {}) {
|
||
exigerModifiable(charge);
|
||
const table = tableDe(charge, id);
|
||
const sieges = charge.evenement.siegesParDefaut;
|
||
const avertissements = exigerCapacite(charge, id, sieges, 'champ');
|
||
return {
|
||
charge: avecSieges(charge, new Map([[id, null]])),
|
||
libelle: libelle('ENTREE_REALIGNER_TABLE', { numero: table.numero, sieges }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* changerDefaut {sieges, realigner: boolean} (§ 6.1) : le défaut change, et
|
||
* avec lui chaque table qui le suit ; realigner, les surchargées le suivent
|
||
* aussi. Une seule entrée. Chaque table touchée se contrôle avant
|
||
* d'appliquer (bilanChangementDefaut) : un seul refus refuse le tout, le
|
||
* premier levé, les suivants dans details.autres. Les avertissements sont
|
||
* ceux du bilan ; AU_DELA_LISIBLE, de mêmes détails à chaque table, n'est
|
||
* dit qu'une fois. Le retenu suit.
|
||
*/
|
||
function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
|
||
exigerModifiable(charge);
|
||
if (typeof realigner !== 'boolean') throw new TypeError(`realigner : booléen attendu, reçu ${JSON.stringify(realigner)}`);
|
||
const sieges = entierSaisi(saisie);
|
||
const bilan = bilanChangementDefaut(charge, sieges, { realigner });
|
||
leverRefus(bilan.refus.map(({ code, details }) => ({ code, details })));
|
||
const avertissements = [];
|
||
for (const { code, details } of bilan.avertissements) {
|
||
if (code === 'AU_DELA_LISIBLE' && avertissements.some((deja) => deja.code === code)) continue;
|
||
avertissements.push({ code, details });
|
||
}
|
||
const realignees = realigner ? charge.tables.filter((table) => table.sieges !== null) : [];
|
||
return {
|
||
charge: avecSieges(charge, new Map(realignees.map((table) => [table.id, null])), sieges),
|
||
libelle: libelle('ENTREE_CHANGER_DEFAUT', { sieges, realignees: realignees.length }),
|
||
avertissements,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
|
||
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà
|
||
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
|
||
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
|
||
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
|
||
* dérive TOURS_CHANGES, jamais tronqués.
|
||
*/
|
||
function changerTours(charge, { tours: saisie } = {}) {
|
||
exigerModifiable(charge);
|
||
const tours = entierSaisi(saisie);
|
||
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
|
||
if (tours > TOURS_MAX) throw refus('TROP_DE_TOURS', { max: TOURS_MAX });
|
||
const suivante = structuredClone(charge);
|
||
suivante.evenement.tours = tours;
|
||
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);
|
||
return {
|
||
charge: suivante,
|
||
libelle: libelle('ENTREE_CHANGER_TOURS', { tours, retirees: charge.reservations.length - suivante.reservations.length }),
|
||
avertissements: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* modifierReglage {cle, valeur} (§ 5.3, § 15.4) : un interrupteur des
|
||
* contraintes ou de l'attribution des sièges, un booléen ; ou generation.nombre,
|
||
* generation.arret, generation.historique, un entier ≥ 1, que le contrôleur
|
||
* de génération a lu et borné. Une autre clé, ou une autre valeur, est une
|
||
* faute du code.
|
||
*/
|
||
function modifierReglage(charge, { cle, valeur } = {}) {
|
||
exigerModifiable(charge);
|
||
const suivante = structuredClone(charge);
|
||
if (INTERRUPTEURS.has(cle)) {
|
||
if (typeof valeur !== 'boolean') throw new TypeError(`${cle} : booléen attendu, reçu ${JSON.stringify(valeur)}`);
|
||
suivante.reglages[cle] = valeur;
|
||
} else if (ENTIERS_DE_GENERATION.has(cle)) {
|
||
if (!Number.isSafeInteger(valeur) || valeur < 1) throw new TypeError(`${cle} : entier ≥ 1 attendu, reçu ${JSON.stringify(valeur)}`);
|
||
suivante.reglages.generation[cle.slice('generation.'.length)] = valeur;
|
||
} else {
|
||
throw new TypeError(`réglage inconnu : ${JSON.stringify(cle)}`);
|
||
}
|
||
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
|
||
}
|
||
|
||
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||
export const COMMANDES_MOBILIER = new Map([
|
||
['creerTable', creerTable],
|
||
['supprimerTable', supprimerTable],
|
||
['deplacerTable', deplacerTable],
|
||
['changerPlacesTable', changerPlacesTable],
|
||
['realignerTable', realignerTable],
|
||
['changerDefaut', changerDefaut],
|
||
['changerTours', changerTours],
|
||
['modifierReglage', modifierReglage],
|
||
]);
|