gestion_table_tournante_libre/src/moteur/configuration.js

474 lines
18 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Passage de la configuration, forme par identifiants, à l'instance indexée
// que consomme le moteur (§ 4, § 5.2), et conversion d'un plan entre ses deux
// formes (§ 8.9). Les formes sont décrites dans types.js.
//
// Trois familles de refus :
// - ErreurConfiguration, avec un code, pour ce qu'une saisie, un import ou un
// plan périmé peut porter : une donnée incohérente avec le reste de la
// configuration, ou une capacité, un nombre de tours, un tour désigné hors
// de son domaine ;
// - TypeError, quand la configuration sort de l'une des formes que garantit
// le code qui la bâtit — stockage, import, interface — et qu'aucune saisie
// ne produit : un identifiant de participant ou de table qui n'est pas un
// entier ≥ 1, une appartenance ni chaîne ni null, un exclu présent et non
// booléen, une portée ni « tous » ni « tour », des contraintes qui ne sont
// pas un objet de quatre booléens, une liste qui n'est pas un tableau. Ces
// formes sont les seules que normaliser contrôle par TypeError ; une valeur
// qui en sort est une faute de l'appelant, qu'aucune valeur par défaut ne
// masque ;
// - RangeError, pour un plan indexé qui n'a pas la forme de l'instance : seul
// le moteur produit cette forme, et l'écart y est une faute de code.
//
// Les champs que normaliser ne lit pas — nom, prénom, numéro de table — ne
// sont pas examinés. Aucune fonction ne modifie ce qu'elle reçoit.
import { ErreurConfiguration } from './erreurs.js';
// Statuts d'un participant (§ 4.2), valeurs de instance.statut.
const MOBILE = 0;
const PARTIELLEMENT_FIXE = 1;
const ANCRE = 2;
// Case de instance.fixe sans table imposée.
const LIBRE = -1;
// Case d'un plan indexé : la réserve.
const RESERVE = -1;
// Case d'un plan indexé qu'indexerPlan n'a pas encore remplie ; jamais rendue.
const NON_PLACE = -2;
// Plus grand entier que représente un Int32Array : une capacité au-delà s'y
// tronquerait sans bruit.
const ENTIER_32_MAX = 2 ** 31 - 1;
const NOMS_CONTRAINTES = [
'separerAppartenances',
'nouveauxVoisins',
'nouvelleTable',
'varierAppartenances',
];
// Valeur citée dans un message : une chaîne s'écrit entre guillemets, pour
// ne pas se lire comme le nombre qu'elle contient.
const decrire = (valeur) => (typeof valeur === 'string' ? JSON.stringify(valeur) : String(valeur));
function exigerListe(valeur, nom) {
if (!Array.isArray(valeur)) throw new TypeError(`configuration.${nom} : liste attendue`);
}
function exigerIdentifiant(id, quoi) {
if (!Number.isInteger(id) || id < 1) {
throw new TypeError(`${quoi} d'identifiant ${decrire(id)} : entier ≥ 1 attendu`);
}
}
function exigerAppartenance({ id, appartenance }) {
if (appartenance !== null && typeof appartenance !== 'string') {
throw new TypeError(
`participant ${id} : appartenance chaîne ou null attendue, reçu ${decrire(appartenance)}`,
);
}
}
// Vrai pour un participant exclu ; un champ exclu absent vaut false.
function estExclu({ id, exclu }) {
if (exclu === undefined) return false;
if (typeof exclu !== 'boolean') {
throw new TypeError(`participant ${id} : exclu booléen attendu, reçu ${decrire(exclu)}`);
}
return exclu;
}
// Copie des quatre contraintes : une modification ultérieure de la
// configuration n'atteint pas l'instance.
function copierContraintes(contraintes) {
if (typeof contraintes !== 'object' || contraintes === null) {
throw new TypeError('configuration.contraintes : objet attendu');
}
const copie = {};
for (const nom of NOMS_CONTRAINTES) {
if (typeof contraintes[nom] !== 'boolean') {
throw new TypeError(`contrainte ${nom} : booléen attendu, reçu ${decrire(contraintes[nom])}`);
}
copie[nom] = contraintes[nom];
}
return copie;
}
// Groupe de chaque participant présent, présents triés par identifiant
// croissant : un libellé reçoit le numéro de sa première apparition, −1
// marque l'absence d'appartenance. L'égalité de chaînes fait le groupe. La
// Map ne sert qu'à retrouver un libellé déjà numéroté.
function numeroterGroupes(presents) {
const groupe = new Int32Array(presents.length);
const groupes = [];
const numeroDe = new Map();
for (let p = 0; p < presents.length; p += 1) {
const { appartenance } = presents[p];
if (appartenance === null) {
groupe[p] = -1;
continue;
}
if (!numeroDe.has(appartenance)) {
numeroDe.set(appartenance, groupes.length);
groupes.push(appartenance);
}
groupe[p] = numeroDe.get(appartenance);
}
return { groupe, groupes };
}
// Tables imposées par les réservations : fixe[p * R + r] = index de table,
// LIBRE sinon. Chaque réservation est vérifiée dans sa forme — participant et
// table connus, tour dans 1..R —, exclus compris ; celle d'un exclu s'arrête
// là, suspendue, sans effet (§ 4.4). Une réservation qui répète la table déjà
// imposée au même tour ne change rien ; une autre table au même tour lève
// RESERVATION_CONFLIT.
function fixerReservations(reservations, contexte) {
const { connus, indexDe, indexTableDe, idsTables, N, R } = contexte;
const fixe = new Int32Array(N * R).fill(LIBRE);
for (let i = 0; i < reservations.length; i += 1) {
const { participant: id, table: idTable, portee, tour } = reservations[i];
if (!connus.has(id)) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, participant: id });
}
const t = indexTableDe.get(idTable);
if (t === undefined) {
throw new ErreurConfiguration('RESERVATION_INCONNUE', { reservation: i, table: idTable });
}
// Tours couverts, numérotés à partir de 0 : chacun des R tours pour la
// portée « tous », le tour désigné pour la portée « tour ».
let premier;
let dernier;
if (portee === 'tous') {
premier = 0;
dernier = R - 1;
} else if (portee === 'tour') {
if (!Number.isInteger(tour) || tour < 1 || tour > R) {
throw new ErreurConfiguration('RESERVATION_TOUR', { reservation: i, tour });
}
premier = tour - 1;
dernier = tour - 1;
} else {
throw new TypeError(
`réservation ${i} : portée ${decrire(portee)}, « tous » ou « tour » attendue`,
);
}
const p = indexDe.get(id);
if (p === undefined) continue;
for (let r = premier; r <= dernier; r += 1) {
const imposee = fixe[p * R + r];
if (imposee === LIBRE) {
fixe[p * R + r] = t;
} else if (imposee !== t) {
throw new ErreurConfiguration('RESERVATION_CONFLIT', {
participant: id,
tour: r + 1,
tables: [idsTables[imposee], idTable],
});
}
}
}
return fixe;
}
// Lève SURRESERVATION quand, à un tour, une table reçoit plus de personnes
// fixées que de sièges (§ 5.9). Le compte porte sur les personnes, lues dans
// fixe : une réservation répétée ne compte qu'une fois. Les tables se
// parcourent par index, chacune par tours croissants.
function verifierSurreservation(fixe, { N, T, R, capacite, idsTables }) {
const fixees = new Int32Array(T * R);
for (let p = 0; p < N; p += 1) {
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== LIBRE) fixees[t * R + r] += 1;
}
}
for (let t = 0; t < T; t += 1) {
for (let r = 0; r < R; r += 1) {
if (fixees[t * R + r] > capacite[t]) {
throw new ErreurConfiguration('SURRESERVATION', {
table: idsTables[t],
tour: r + 1,
reservees: fixees[t * R + r],
capacite: capacite[t],
});
}
}
}
}
// Statut de chaque participant, dérivé de fixe et non de la forme des
// réservations (§ 4.2) : ancré quand la même table est imposée à chacun des R
// tours, mobile quand aucune ne l'est, partiellement fixé sinon. Un ancré
// compte dans k et dans ancresParTable ; un partiellement fixé compte parmi
// les mobiles.
function deriverStatuts(fixe, N, T, R) {
const statut = new Uint8Array(N);
const ancresParTable = new Int32Array(T);
let k = 0;
for (let p = 0; p < N; p += 1) {
const premiere = fixe[p * R];
let memeTable = true;
let aucune = true;
for (let r = 0; r < R; r += 1) {
const t = fixe[p * R + r];
if (t !== premiere) memeTable = false;
if (t !== LIBRE) aucune = false;
}
if (aucune) {
statut[p] = MOBILE;
} else if (memeTable) {
statut[p] = ANCRE;
ancresParTable[premiere] += 1;
k += 1;
} else {
statut[p] = PARTIELLEMENT_FIXE;
}
}
return { statut, ancresParTable, k };
}
/**
* Instance indexée d'une configuration (§ 4, § 5.2). Les participants exclus
* en sont absents (§ 4.4) : ni index ni groupe, et leurs réservations,
* suspendues, ne fixent rien et ne prennent aucun siège.
*
* Lève ErreurConfiguration sur la première règle enfreinte — participants,
* puis tables, nombre de tours, réservations une à une, surréservation :
* PARTICIPANT_DOUBLON, TABLE_DOUBLON, CAPACITE (non entière, < 2, ou au-delà
* d'un Int32Array), TOURS (R non entier ou < 1), RESERVATION_INCONNUE
* (participant ou table), RESERVATION_TOUR (tour absent ou hors de 1..R),
* RESERVATION_CONFLIT (une personne à deux tables d'un même tour),
* SURRESERVATION (plus de personnes fixées que de sièges à une table, à un
* tour). Une salle trop petite n'est pas refusée : nombrePlacesManquantes la
* chiffre, et la recherche la refuse (§ 5.9). Les détails désignent
* participants et tables par identifiant, les tours par leur numéro à partir
* de 1, et la réservation fautive par son rang dans
* configuration.reservations, à partir de 0.
*
* Lève TypeError sur les seules formes qu'énumère l'en-tête du module :
* identifiants de participant et de table, appartenance, exclu, portée,
* contraintes, listes. Une capacité, un nombre de tours ou un tour désigné
* hors de leur forme lèvent CAPACITE, TOURS ou RESERVATION_TOUR ; une
* réservation qui désigne un participant ou une table absents de la
* configuration lève RESERVATION_INCONNUE, quelle que soit la forme de la
* valeur qui les désigne.
*
* @param {import('./types.js').Configuration} configuration
* @returns {import('./types.js').Instance}
*/
export function normaliser(configuration) {
const { participants, tables, tours: R, reservations } = configuration;
exigerListe(participants, 'participants');
exigerListe(tables, 'tables');
exigerListe(reservations, 'reservations');
const contraintes = copierContraintes(configuration.contraintes);
// connus porte tous les identifiants, exclus compris : un doublon reste un
// doublon, et la réservation d'un exclu n'est pas inconnue.
const connus = new Set();
const presents = [];
const exclus = [];
for (const participant of participants) {
exigerIdentifiant(participant.id, 'participant');
if (connus.has(participant.id)) {
throw new ErreurConfiguration('PARTICIPANT_DOUBLON', { participant: participant.id });
}
connus.add(participant.id);
exigerAppartenance(participant);
if (estExclu(participant)) exclus.push(participant.id);
else presents.push(participant);
}
presents.sort((a, b) => a.id - b.id);
exclus.sort((a, b) => a - b);
const T = tables.length;
const idsTables = [];
const indexTableDe = new Map();
const capacite = new Int32Array(T);
for (let t = 0; t < T; t += 1) {
const { id, capacite: places } = tables[t];
exigerIdentifiant(id, 'table');
if (indexTableDe.has(id)) throw new ErreurConfiguration('TABLE_DOUBLON', { table: id });
if (!Number.isInteger(places) || places < 2 || places > ENTIER_32_MAX) {
throw new ErreurConfiguration('CAPACITE', { table: id, capacite: places });
}
idsTables.push(id);
indexTableDe.set(id, t);
capacite[t] = places;
}
if (!Number.isInteger(R) || R < 1) throw new ErreurConfiguration('TOURS', { tours: R });
const N = presents.length;
const ids = presents.map((participant) => participant.id);
const indexDe = new Map(ids.map((id, p) => [id, p]));
const { groupe, groupes } = numeroterGroupes(presents);
const fixe = fixerReservations(reservations, { connus, indexDe, indexTableDe, idsTables, N, R });
verifierSurreservation(fixe, { N, T, R, capacite, idsTables });
const { statut, ancresParTable, k } = deriverStatuts(fixe, N, T, R);
return {
N,
T,
R,
ids,
indexDe,
exclus: new Set(exclus),
idsTables,
indexTableDe,
capacite,
groupe,
groupes,
fixe,
statut,
ancresParTable,
k,
n: N - k,
contraintes,
};
}
/**
* Plan indexé d'un plan par identifiants : tableDe[p * R + r] = index de
* table, −1 pour la réserve. plan.tables déclare chaque table de l'instance
* une fois, et chaque liste se rattache à sa table par l'identifiant déclaré
* à la même position ; l'ordre des tables et celui des ids dans une liste
* sont indifférents. Un plan accepté revient donc de planDepuisIndex à
* l'identique, à ces deux ordres près.
*
* La forme indexée n'a pas de case pour un participant absent d'un tour,
* placé deux fois ou étranger à l'instance, ni pour une table que le plan ne
* déclare pas : au lieu de perdre l'écart, indexerPlan lève
* ErreurConfiguration — PLAN_TOURS (tours ou réserves en nombre ≠ R),
* PLAN_TABLE_INCONNUE, PLAN_TABLE_DOUBLON, PLAN_TABLE_ABSENTE (une table de
* l'instance que plan.tables ne déclare pas), PLAN_LISTES (un tour sans
* exactement une liste par table déclarée), PLAN_INCONNU, PLAN_EXCLU_PLACE
* (participant exclu de l'instance), PLAN_DOUBLE_PLACE (deux fois dans un
* tour, réserve comprise), PLAN_NON_ASSIS (ni à une table ni en réserve à un
* tour). Les capacités et les réservations ne sont pas examinées.
*
* @param {import('./types.js').Instance} instance
* @param {import('./types.js').Plan} plan
* @returns {Int32Array}
*/
export function indexerPlan(instance, plan) {
const { N, T, R, ids, idsTables, indexDe, indexTableDe, exclus } = instance;
if (plan.tours.length !== R || plan.reserves.length !== R) {
throw new ErreurConfiguration('PLAN_TOURS', {
attendu: R,
tours: plan.tours.length,
reserves: plan.reserves.length,
});
}
// Index de table de chaque liste, par position dans plan.tables.
const tableDeListe = [];
const declarees = new Set();
for (const id of plan.tables) {
const t = indexTableDe.get(id);
if (t === undefined) throw new ErreurConfiguration('PLAN_TABLE_INCONNUE', { table: id });
if (declarees.has(id)) throw new ErreurConfiguration('PLAN_TABLE_DOUBLON', { table: id });
declarees.add(id);
tableDeListe.push(t);
}
// Sans id inconnu ni doublon, une table manque exactement quand le plan en
// déclare moins que l'instance ; la première absente, dans l'ordre de
// l'instance, est nommée.
if (tableDeListe.length !== T) {
const absente = idsTables.find((id) => !declarees.has(id));
throw new ErreurConfiguration('PLAN_TABLE_ABSENTE', { table: absente });
}
const tableDe = new Int32Array(N * R).fill(NON_PLACE);
const placer = (id, r, t) => {
const p = indexDe.get(id);
if (p === undefined) {
const code = exclus.has(id) ? 'PLAN_EXCLU_PLACE' : 'PLAN_INCONNU';
throw new ErreurConfiguration(code, { participant: id, tour: r + 1 });
}
if (tableDe[p * R + r] !== NON_PLACE) {
throw new ErreurConfiguration('PLAN_DOUBLE_PLACE', { participant: id, tour: r + 1 });
}
tableDe[p * R + r] = t;
};
for (let r = 0; r < R; r += 1) {
const listes = plan.tours[r];
if (listes.length !== tableDeListe.length) {
throw new ErreurConfiguration('PLAN_LISTES', {
tour: r + 1,
listes: listes.length,
tables: tableDeListe.length,
});
}
for (let i = 0; i < listes.length; i += 1) {
for (const id of listes[i]) placer(id, r, tableDeListe[i]);
}
for (const id of plan.reserves[r]) placer(id, r, RESERVE);
for (let p = 0; p < N; p += 1) {
if (tableDe[p * R + r] === NON_PLACE) {
throw new ErreurConfiguration('PLAN_NON_ASSIS', { participant: ids[p], tour: r + 1 });
}
}
}
return tableDe;
}
/**
* Plan par identifiants d'un plan indexé : les tables dans l'ordre de
* l'instance, chaque liste de table et chaque réserve en ids croissants
* (§ 8.9). Lève RangeError quand tableDe n'a pas N × R cases, ou qu'une case
* n'est ni −1 ni un index de table.
*
* @param {import('./types.js').Instance} instance
* @param {ArrayLike<number>} tableDe
* @returns {import('./types.js').Plan}
*/
export function planDepuisIndex(instance, tableDe) {
const { N, T, R, ids, idsTables } = instance;
if (tableDe.length !== N * R) {
throw new RangeError(`plan indexé de ${tableDe.length} cases, N × R = ${N * R} attendues`);
}
const tours = [];
const reserves = [];
for (let r = 0; r < R; r += 1) {
const listes = Array.from({ length: T }, () => []);
const reserve = [];
// Les ids croissent avec l'index : parcourir les participants par index
// range chaque liste en ids croissants, sans tri.
for (let p = 0; p < N; p += 1) {
const t = tableDe[p * R + r];
if (t === RESERVE) {
reserve.push(ids[p]);
} else if (Number.isInteger(t) && t >= 0 && t < T) {
listes[t].push(ids[p]);
} else {
throw new RangeError(
`plan indexé : participant ${ids[p]}, tour ${r + 1}, `
+ `index de table ${decrire(t)} hors de −1..${T - 1}`,
);
}
}
tours.push(listes);
reserves.push(reserve);
}
return { tables: [...idsTables], tours, reserves };
}
/**
* Places qui manquent à chaque tour pour asseoir tous les participants :
* max(0, N − Σ c_t). La quantité égale max(0, n − Σ (c_t − a_t)) et ne
* dépend d'aucune réservation (§ 5.9) : un ancré retire une personne des
* mobiles et un siège de la capacité libre, un partiellement fixé reste
* parmi les mobiles et ne retire rien.
*
* @param {import('./types.js').Instance} instance
* @returns {number}
*/
export function nombrePlacesManquantes(instance) {
let places = 0;
for (let t = 0; t < instance.T; t += 1) places += instance.capacite[t];
return Math.max(0, instance.N - places);
}