Просмотр исходного кода

feat(maps): full catch-up rule and the last three maps (M7, part 1)

- Catch-up (readme §4): during the round results the last-placed player
  picks the next map and imposes a character on the leader, for one round
  (protocol v8: imposeCharacter / characterImposed, leader and characters
  in roundEnd). The help line follows the character actually played.
- Le Roulé: a Swiss roll slice with a jam spiral, held only by its
  biscuit stand (saw through it and the whole roll comes loose).
- La Tarte aux Fruits: brittle shortcrust shell, pastry cream, jelly fruit
  halves; random-cracks.
- La Pile de Crêpes: thin crêpes with chocolate/jam/caramel spreads in a
  leaning pile; wind.
- Modifiers moved off Le Mille-Feuille and La Pièce Montée to the maps the
  readme planned them for.
- Tests: imposition flow over WebSocket (only the picker may impose, it
  lasts one round), all six maps validate, the roll comes loose from its
  stand, modifier tests on their new maps.

No new dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman 1 неделя назад
Родитель
Сommit
ca8d455bcc

BIN
assets/maps/crepes/background.png


+ 59 - 0
assets/maps/crepes/map.json

@@ -0,0 +1,59 @@
+{
+  "id": "crepes",
+  "name": "La Pile de Crêpes",
+  "size": [
+    2048,
+    1024
+  ],
+  "materialPalette": {
+    "#000000": 0,
+    "#F2C46D": 1,
+    "#5A3A22": 2,
+    "#D93A5B": 4,
+    "#D98A1E": 6,
+    "#FFFFFF": 3,
+    "#8899AA": 255
+  },
+  "spawns": [
+    [
+      1000,
+      250
+    ],
+    [
+      900,
+      250
+    ],
+    [
+      400,
+      800
+    ],
+    [
+      1500,
+      800
+    ]
+  ],
+  "cherrySpawn": [
+    1000,
+    150
+  ],
+  "custard": {
+    "startY": 1000,
+    "speed": 2.5,
+    "finalRushSpeed": 8
+  },
+  "items": {
+    "interval": [
+      6,
+      12
+    ],
+    "weights": {
+      "candle": 3,
+      "smarties": 2,
+      "whisk": 1
+    }
+  },
+  "music": "birthday-flute",
+  "modifiers": [
+    "wind"
+  ]
+}

BIN
assets/maps/crepes/terrain.png


BIN
assets/maps/crepes/thumb.png


+ 1 - 1
assets/maps/mille-feuille/map.json

@@ -10,5 +10,5 @@
   "custard": { "startY": 1000, "speed": 2.5, "finalRushSpeed": 8 },
   "custard": { "startY": 1000, "speed": 2.5, "finalRushSpeed": 8 },
   "items": { "interval": [6, 12], "weights": { "candle": 3, "smarties": 2, "whisk": 1 } },
   "items": { "interval": [6, 12], "weights": { "candle": 3, "smarties": 2, "whisk": 1 } },
   "music": "birthday-flute",
   "music": "birthday-flute",
-  "modifiers": ["random-cracks"]
+  "modifiers": []
 }
 }

+ 1 - 1
assets/maps/piece-montee/map.json

@@ -10,5 +10,5 @@
   "custard": { "startY": 1000, "speed": 2.2, "finalRushSpeed": 7 },
   "custard": { "startY": 1000, "speed": 2.2, "finalRushSpeed": 7 },
   "items": { "interval": [6, 12], "weights": { "candle": 4, "smarties": 2, "whisk": 1 } },
   "items": { "interval": [6, 12], "weights": { "candle": 4, "smarties": 2, "whisk": 1 } },
   "music": "birthday-flute",
   "music": "birthday-flute",
-  "modifiers": ["wind"]
+  "modifiers": []
 }
 }

BIN
assets/maps/roule/background.png


+ 56 - 0
assets/maps/roule/map.json

@@ -0,0 +1,56 @@
+{
+  "id": "roule",
+  "name": "Le Roulé",
+  "size": [
+    2048,
+    1024
+  ],
+  "materialPalette": {
+    "#000000": 0,
+    "#F2C46D": 1,
+    "#D93A5B": 4,
+    "#FFFFFF": 3,
+    "#C8964B": 5,
+    "#8899AA": 255
+  },
+  "spawns": [
+    [
+      1024,
+      200
+    ],
+    [
+      760,
+      400
+    ],
+    [
+      1288,
+      400
+    ],
+    [
+      350,
+      800
+    ]
+  ],
+  "cherrySpawn": [
+    1024,
+    150
+  ],
+  "custard": {
+    "startY": 1000,
+    "speed": 2.5,
+    "finalRushSpeed": 8
+  },
+  "items": {
+    "interval": [
+      6,
+      12
+    ],
+    "weights": {
+      "candle": 3,
+      "smarties": 2,
+      "whisk": 2
+    }
+  },
+  "music": "birthday-flute",
+  "modifiers": []
+}

BIN
assets/maps/roule/terrain.png


BIN
assets/maps/roule/thumb.png


BIN
assets/maps/tarte-fruits/background.png


+ 58 - 0
assets/maps/tarte-fruits/map.json

@@ -0,0 +1,58 @@
+{
+  "id": "tarte-fruits",
+  "name": "La Tarte aux Fruits",
+  "size": [
+    2048,
+    1024
+  ],
+  "materialPalette": {
+    "#000000": 0,
+    "#C8964B": 5,
+    "#F2C46D": 1,
+    "#D93A5B": 4,
+    "#FFFFFF": 3,
+    "#8899AA": 255
+  },
+  "spawns": [
+    [
+      600,
+      500
+    ],
+    [
+      1450,
+      500
+    ],
+    [
+      1024,
+      500
+    ],
+    [
+      200,
+      800
+    ]
+  ],
+  "cherrySpawn": [
+    1024,
+    400
+  ],
+  "custard": {
+    "startY": 1000,
+    "speed": 2.2,
+    "finalRushSpeed": 7
+  },
+  "items": {
+    "interval": [
+      6,
+      12
+    ],
+    "weights": {
+      "candle": 3,
+      "smarties": 2,
+      "whisk": 1
+    }
+  },
+  "music": "birthday-flute",
+  "modifiers": [
+    "random-cracks"
+  ]
+}

BIN
assets/maps/tarte-fruits/terrain.png


BIN
assets/maps/tarte-fruits/thumb.png


+ 6 - 1
readme.md

@@ -324,11 +324,16 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
   - **Validation :** au démarrage, le serveur valide chaque dossier de `assets/maps/`, modificateurs inconnus compris. Une carte invalide est écartée avec un message explicite dans le journal.
   - **Validation :** au démarrage, le serveur valide chaque dossier de `assets/maps/`, modificateurs inconnus compris. Une carte invalide est écartée avec un message explicite dans le journal.
   - **Rotation :** l'hôte (👑, le premier arrivé) la règle dans le panneau d'échauffement : toutes dans l'ordre, au hasard, ou une sélection. L'échauffement se fait sur la première carte de la rotation.
   - **Rotation :** l'hôte (👑, le premier arrivé) la règle dans le panneau d'échauffement : toutes dans l'ordre, au hasard, ou une sélection. L'échauffement se fait sur la première carte de la rotation.
   - **Choix par le dernier :** pendant l'écran de fin de manche, le dernier choisit la carte suivante parmi la rotation (logique dans `server/rotation.ts`). Sans choix, la rotation décide.
   - **Choix par le dernier :** pendant l'écran de fin de manche, le dernier choisit la carte suivante parmi la rotation (logique dans `server/rotation.ts`). Sans choix, la rotation décide.
-  - **Modificateurs** (`server/modifiers/`) : `wind` (rafales latérales, indicateur dans le bandeau) et `random-cracks` (fissures en zigzag dans le biscuit). Ils ne jouent que pendant les manches. Pour les tester dès maintenant, `random-cracks` est sur le Mille-Feuille et `wind` sur la Pièce Montée ; à redistribuer sur la Tarte et les Crêpes en M7.
+  - **Modificateurs** (`server/modifiers/`) : `wind` (rafales latérales, indicateur dans le bandeau) et `random-cracks` (fissures en zigzag dans le biscuit). Ils ne jouent que pendant les manches. Depuis M7, `random-cracks` est sur la Tarte aux Fruits et `wind` sur la Pile de Crêpes, comme prévu au §6.
   - **Cartes :** générées par `npm run gen:maps`. Sur la Pièce Montée, chaque chou n'est collé qu'à celui du dessous, donc en retirer un fait tomber toute la colonne au-dessus.
   - **Cartes :** générées par `npm run gen:maps`. Sur la Pièce Montée, chaque chou n'est collé qu'à celui du dessous, donc en retirer un fait tomber toute la colonne au-dessus.
 ### M7 et suite
 ### M7 et suite
  
  
 - Les 5 autres personnages, les 3 autres cartes, le rattrapage complet, les trophées, la DA finale, l'audio, l'éditeur de cartes, l'installeur Windows.
 - Les 5 autres personnages, les 3 autres cartes, le rattrapage complet, les trophées, la DA finale, l'audio, l'éditeur de cartes, l'installeur Windows.
+- **Fait :**
+  - **Rattrapage complet :** en fin de manche, le dernier choisit la carte et impose un personnage au premier, pour une manche.
+  - **Trophées :** depuis M4.
+  - **Les 3 cartes :** le Roulé (spirale de confiture ; le roulé ne tient que par son socle en biscuit), la Tarte aux Fruits (fond en biscuit cassant, `random-cracks`) et la Pile de Crêpes (fines couches, pile qui penche, `wind`).
+- **Reste :** les 5 personnages (Gégé le Ver, Bébé Pompier, Captain Prout, Le Chat, Magicien Raté), la DA finale, l'audio, l'éditeur de cartes et l'installeur Windows.
 ---
 ---
  
  
 ## 10. Pour Claude : comment travailler sur ce projet
 ## 10. Pour Claude : comment travailler sur ce projet

+ 98 - 0
scripts/gen-maps.ts

@@ -142,6 +142,98 @@ function pieceMontee(): Canvas {
   return c;
   return c;
 }
 }
 
 
+/**
+ * Le Roulé: a slice of Swiss roll lying on the plate, a continuous spiral of jam (jelly) through
+ * the sponge. Cut the sponge strip and everything inside that turn of the spiral comes loose.
+ */
+function roule(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  const cx = 1024;
+  const R = 330;
+  const cy = 960 - R - 14;
+  // Biscuit stand under the roll so it does not just touch the plate at one point.
+  c.rect(cx - 180, 900, cx + 181, 960, BISCUIT);
+  const SPACING = 64; // distance between two turns of the spiral
+  const JAM = 12;
+  for (let y = cy - R; y <= cy + R; y++) {
+    for (let x = cx - R; x <= cx + R; x++) {
+      const dx = x - cx;
+      const dy = y - cy;
+      const r = Math.hypot(dx, dy);
+      if (r > R) continue;
+      if (r > R - 10) {
+        c.put(x, y, CREAM); // thin whipped cream coat
+        continue;
+      }
+      // Archimedean spiral r = SPACING * (θ / 2π): distance to the nearest turn.
+      const theta = (Math.atan2(dy, dx) + Math.PI * 2) % (Math.PI * 2);
+      const phase = (r - (SPACING * theta) / (Math.PI * 2) + SPACING * 100) % SPACING;
+      c.put(x, y, phase < JAM && r > 24 ? JELLY : SPONGE);
+    }
+  }
+  return c;
+}
+
+/**
+ * La Tarte aux Fruits: a brittle shortcrust shell (biscuit) holding custard cream, topped with
+ * jelly fruit halves. The base cracks at random (modifier).
+ */
+function tarteFruits(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  const x0 = 300;
+  const x1 = 1748;
+  const bottom = 960;
+  // Shell: base and fluted rims.
+  c.rect(x0, bottom - 70, x1, bottom, BISCUIT);
+  for (const [rx0, rx1] of [
+    [x0, x0 + 46],
+    [x1 - 46, x1],
+  ]) {
+    for (let y = 640; y < bottom; y++) {
+      const flute = Math.round(4 * Math.sin(y / 9));
+      c.rect(rx0 + (rx0 === x0 ? flute : 0), y, rx1 - (rx0 !== x0 ? flute : 0), y + 1, BISCUIT);
+    }
+  }
+  // Pastry cream.
+  c.rect(x0 + 46, 740, x1 - 46, bottom - 70, SPONGE);
+  // Fruit halves: rows of jelly domes on the cream.
+  for (let i = 0; i < 18; i++) {
+    const fx = x0 + 100 + i * 75;
+    const r = 30 + (i % 3) * 6;
+    for (let y = 740 - r; y < 742; y++) for (let x = fx - r; x <= fx + r; x++) if ((x - fx) ** 2 + (y - 742) ** 2 <= r * r) c.put(x, y, JELLY);
+  }
+  // Glaze specks of whipped cream on the rims.
+  for (const x of [x0 + 23, x1 - 23]) c.disc(x, 632, 16, CREAM);
+  return c;
+}
+
+/**
+ * La Pile de Crêpes: dozens of thin crêpes with spreads in between (chocolate, jam, caramel),
+ * stacked into a pile that leans. Side wind (modifier).
+ */
+function crepes(): Canvas {
+  const c = new Canvas();
+  c.plate();
+  const SPREADS = [CHOCOLATE, JELLY, CARAMEL];
+  const CREPE = 9;
+  const SPREAD = 3;
+  const lean = 0.22; // horizontal shift per pixel of height
+  let layer = 0;
+  for (let top = 960; top > 340; top -= CREPE + SPREAD, layer++) {
+    const shift = Math.round((960 - top) * lean);
+    const halfWidth = 330 - Math.round(layer * 1.5) + Math.round(6 * Math.sin(layer * 1.7));
+    const left = 860 - halfWidth + shift;
+    const right = 860 + halfWidth + shift;
+    c.rect(left, top - CREPE, right, top, SPONGE);
+    if (top - CREPE - SPREAD > 340) c.rect(left + 12, top - CREPE - SPREAD, right - 12, top - CREPE, SPREADS[layer % 3]);
+  }
+  // Whipped cream on top.
+  c.disc(860 + Math.round(620 * lean), 345, 40, CREAM);
+  return c;
+}
+
 function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
 function writePng(dir: string, name: string, w: number, h: number, pixel: (x: number, y: number) => number): void {
   const png = new PNG({ width: w, height: h });
   const png = new PNG({ width: w, height: h });
   for (let y = 0; y < h; y++) {
   for (let y = 0; y < h; y++) {
@@ -182,6 +274,9 @@ const THEMES: Record<string, Theme> = {
   'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
   'gateau-etages': { stripe: 0xffdbe3, cloth: [0xe85d75, 0xf7a8b8, 0xfff3f5], flags: [0xff6f91, 0xffe14d, 0x4dc4ff, 0x9be15d, 0xb18cff] },
   'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
   'mille-feuille': { stripe: 0xd9ecff, cloth: [0x4d8fd9, 0xa8cdf7, 0xf3f8ff], flags: [0x4dc4ff, 0xffffff, 0xffe14d, 0x9be15d, 0x4dc4ff] },
   'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
   'piece-montee': { stripe: 0xfff0c2, cloth: [0xd9a01e, 0xf7d98a, 0xfffaf0], flags: [0xffe14d, 0xff9a2e, 0xffffff, 0xff6f91, 0xffd27a] },
+  roule: { stripe: 0xe3f5d9, cloth: [0x5fae4a, 0xb8e0a8, 0xf6fff2], flags: [0x9be15d, 0xff6f91, 0xffffff, 0xffe14d, 0x9be15d] },
+  'tarte-fruits': { stripe: 0xefe3ff, cloth: [0x8a5fc9, 0xcdb8f0, 0xfaf6ff], flags: [0xb18cff, 0xff6f91, 0x9be15d, 0xffffff, 0xffe14d] },
+  crepes: { stripe: 0xffe8d0, cloth: [0xe0862e, 0xf5c79a, 0xfff8f0], flags: [0xff9a2e, 0x5a3a22, 0xffe14d, 0xffffff, 0xff6f91] },
 };
 };
 
 
 function paintBackground(theme: Theme): Uint32Array {
 function paintBackground(theme: Theme): Uint32Array {
@@ -298,6 +393,9 @@ const MAPS: Record<string, () => Canvas> = {
   'gateau-etages': gateauEtages,
   'gateau-etages': gateauEtages,
   'mille-feuille': milleFeuille,
   'mille-feuille': milleFeuille,
   'piece-montee': pieceMontee,
   'piece-montee': pieceMontee,
+  roule,
+  'tarte-fruits': tarteFruits,
+  crepes,
 };
 };
 
 
 const TW = 256;
 const TW = 256;

+ 3 - 0
src/renderer/i18n/fr.ts

@@ -52,6 +52,9 @@ export const fr = {
     you: 'Tu es dernier : à toi de choisir la prochaine carte !',
     you: 'Tu es dernier : à toi de choisir la prochaine carte !',
     other: (name: string) => `${name} est dernier et choisit la prochaine carte…`,
     other: (name: string) => `${name} est dernier et choisit la prochaine carte…`,
     picked: (map: string) => `Prochaine carte : ${map}`,
     picked: (map: string) => `Prochaine carte : ${map}`,
+    imposeYou: (leader: string) => `Et tu imposes un personnage à ${leader}, le premier :`,
+    imposeOnYou: (picker: string) => `Tu es premier : ${picker} va t’imposer un personnage…`,
+    imposed: (leader: string, character: string) => `${leader} jouera ${character} !`,
   },
   },
   desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
   desync: 'Oups, ton gâteau ne ressemble plus à celui du serveur (désynchronisation).',
   hud: {
   hud: {

+ 12 - 2
src/renderer/main.ts

@@ -150,7 +150,10 @@ async function start(): Promise<void> {
     popups.container,
     popups.container,
   );
   );
   const hud = new RoundHud();
   const hud = new RoundHud();
-  const results = new ResultsScreen((mapId) => conn.send({ t: 'pickMap', mapId }));
+  const results = new ResultsScreen(
+    (mapId) => conn.send({ t: 'pickMap', mapId }),
+    (characterId) => conn.send({ t: 'imposeCharacter', characterId }),
+  );
   let round = 0;
   let round = 0;
   let rounds = 0;
   let rounds = 0;
 
 
@@ -345,10 +348,13 @@ async function start(): Promise<void> {
         infos = new Map(msg.players.map((p) => [p.id, p]));
         infos = new Map(msg.players.map((p) => [p.id, p]));
         resetWorld(msg.mapId, msg.events, msg.chunks);
         resetWorld(msg.mapId, msg.events, msg.chunks);
         break;
         break;
-      case 'start':
+      case 'start': {
         round = msg.round;
         round = msg.round;
         rounds = msg.rounds;
         rounds = msg.rounds;
         infos = new Map(msg.players.map((p) => [p.id, p]));
         infos = new Map(msg.players.map((p) => [p.id, p]));
+        // The character may differ from the one chosen in the menu (imposed for this round).
+        const mine = infos.get(localId)?.characterId ?? choice.characterId;
+        helpEl.textContent = fr.help[mine] ?? '';
         results.hide();
         results.hide();
         resetWorld(msg.mapId, [], []);
         resetWorld(msg.mapId, [], []);
         if (round === 0) break; // back to the warm-up: no fanfare
         if (round === 0) break; // back to the warm-up: no fanfare
@@ -357,6 +363,7 @@ async function start(): Promise<void> {
           if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
           if (statusEl.textContent === fr.hud.round(round, rounds)) setStatus('');
         }, 1500);
         }, 1500);
         break;
         break;
+      }
       case 'roundEnd':
       case 'roundEnd':
         results.showRound(msg, localId);
         results.showRound(msg, localId);
         break;
         break;
@@ -370,6 +377,9 @@ async function start(): Promise<void> {
         lobbyPanel.update(msg);
         lobbyPanel.update(msg);
         mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
         mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
         break;
         break;
+      case 'characterImposed':
+        results.showImposed(infos.get(msg.playerId)?.name ?? '?', fr.characters[msg.characterId]?.name ?? msg.characterId);
+        break;
       case 'mapPicked':
       case 'mapPicked':
         results.showPicked(mapNames.get(msg.mapId) ?? msg.mapId);
         results.showPicked(mapNames.get(msg.mapId) ?? msg.mapId);
         break;
         break;

+ 27 - 10
src/renderer/ui/hud.ts

@@ -76,7 +76,10 @@ export class ResultsScreen {
   private timer: ReturnType<typeof setInterval> | null = null;
   private timer: ReturnType<typeof setInterval> | null = null;
   private pickBox: HTMLDivElement | null = null;
   private pickBox: HTMLDivElement | null = null;
 
 
-  constructor(private readonly onPick: (mapId: string) => void) {
+  constructor(
+    private readonly onPick: (mapId: string) => void,
+    private readonly onImpose: (characterId: string) => void,
+  ) {
     this.root.id = 'results';
     this.root.id = 'results';
     this.root.hidden = true;
     this.root.hidden = true;
     document.body.appendChild(this.root);
     document.body.appendChild(this.root);
@@ -91,30 +94,44 @@ export class ResultsScreen {
 
 
   showRound(msg: RoundEndMessage, localId: number): void {
   showRound(msg: RoundEndMessage, localId: number): void {
     this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next);
     this.show(fr.results.roundTitle(msg.round, msg.rounds), msg.results, localId, null, msg.nextIn, fr.results.next);
-    // Catch-up (readme §4): the last-placed player picks the next map.
+    // Catch-up (readme §4): the last-placed player picks the next map and imposes a character on the leader.
     if (msg.picker === null || msg.choices.length === 0) return;
     if (msg.picker === null || msg.choices.length === 0) return;
     const box = el('div', 'map-pick');
     const box = el('div', 'map-pick');
-    const pickerName = msg.results.find((r) => r.id === msg.picker)?.name ?? '?';
-    if (msg.picker === localId) {
-      box.appendChild(el('p', 'pick-title', fr.pick.you));
+    const nameOf = (id: number | null): string => msg.results.find((r) => r.id === id)?.name ?? '?';
+    const choiceRow = (options: { id: string; label: string }[], send: (id: string) => void): HTMLDivElement => {
       const buttons = el('div', 'pick-buttons');
       const buttons = el('div', 'pick-buttons');
-      for (const m of msg.choices) {
-        const b = el('button', 'secondary', m.name);
+      for (const o of options) {
+        const b = el('button', 'secondary', o.label);
         b.addEventListener('click', () => {
         b.addEventListener('click', () => {
-          this.onPick(m.id);
+          send(o.id);
           for (const other of buttons.querySelectorAll('button')) other.disabled = true;
           for (const other of buttons.querySelectorAll('button')) other.disabled = true;
           b.classList.replace('secondary', 'primary');
           b.classList.replace('secondary', 'primary');
         });
         });
         buttons.appendChild(b);
         buttons.appendChild(b);
       }
       }
-      box.appendChild(buttons);
+      return buttons;
+    };
+    if (msg.picker === localId) {
+      box.appendChild(el('p', 'pick-title', fr.pick.you));
+      box.appendChild(choiceRow(msg.choices.map((m) => ({ id: m.id, label: m.name })), this.onPick));
+      if (msg.leader !== null) {
+        box.appendChild(el('p', 'pick-title', fr.pick.imposeYou(nameOf(msg.leader))));
+        box.appendChild(choiceRow(msg.characters.map((c) => ({ id: c, label: fr.characters[c]?.name ?? c })), this.onImpose));
+      }
     } else {
     } else {
-      box.appendChild(el('p', 'pick-title', fr.pick.other(pickerName)));
+      box.appendChild(el('p', 'pick-title', fr.pick.other(nameOf(msg.picker))));
+      if (msg.leader === localId) box.appendChild(el('p', 'pick-title', fr.pick.imposeOnYou(nameOf(msg.picker))));
     }
     }
     this.pickBox = box;
     this.pickBox = box;
     this.root.querySelector('.footer')?.before(box);
     this.root.querySelector('.footer')?.before(box);
   }
   }
 
 
+  /** Shows which character the picker imposed on the leader. */
+  showImposed(leaderName: string, characterName: string): void {
+    if (!this.pickBox) return;
+    this.pickBox.appendChild(el('p', 'picked', fr.pick.imposed(leaderName, characterName)));
+  }
+
   /** Shows which map the picker chose. */
   /** Shows which map the picker chose. */
   showPicked(mapName: string): void {
   showPicked(mapName: string): void {
     if (!this.pickBox) return;
     if (!this.pickBox) return;

+ 33 - 5
src/server/net.ts

@@ -1,6 +1,6 @@
 import { Packr } from 'msgpackr';
 import { Packr } from 'msgpackr';
 import { WebSocketServer, type WebSocket } from 'ws';
 import { WebSocketServer, type WebSocket } from 'ws';
-import { isCharacterId } from '../shared/characters';
+import { CHARACTER_IDS, isCharacterId } from '../shared/characters';
 import {
 import {
   HASH_CHECK_INTERVAL_S,
   HASH_CHECK_INTERVAL_S,
   MATCH_END_PAUSE_S,
   MATCH_END_PAUSE_S,
@@ -78,6 +78,9 @@ export class GameServer {
   /** Who picks the next map during the current round results, and what they picked. */
   /** Who picks the next map during the current round results, and what they picked. */
   private picker: number | null = null;
   private picker: number | null = null;
   private pickedMap: string | null = null;
   private pickedMap: string | null = null;
+  /** Catch-up: the leader plays the character the last-placed player imposed, for one round. */
+  private leader: number | null = null;
+  private imposed: string | null = null;
   private nextPlayerId = 1;
   private nextPlayerId = 1;
   private timer: NodeJS.Timeout | null = null;
   private timer: NodeJS.Timeout | null = null;
 
 
@@ -212,6 +215,7 @@ export class GameServer {
       else if (msg.t === 'ready') this.onReady(client.player, msg.ready);
       else if (msg.t === 'ready') this.onReady(client.player, msg.ready);
       else if (msg.t === 'rotation') this.onRotation(client.player, msg.rotation);
       else if (msg.t === 'rotation') this.onRotation(client.player, msg.rotation);
       else if (msg.t === 'pickMap') this.onPickMap(client.player, msg.mapId);
       else if (msg.t === 'pickMap') this.onPickMap(client.player, msg.mapId);
+      else if (msg.t === 'imposeCharacter') this.onImposeCharacter(client.player, msg.characterId);
       else if (msg.t === 'desync') {
       else if (msg.t === 'desync') {
         console.error(
         console.error(
           `[net] DESYNC: player ${client.player.id} (${client.player.name}) has hash ${msg.hash} at seq ${msg.seq}; ` +
           `[net] DESYNC: player ${client.player.id} (${client.player.name}) has hash ${msg.hash} at seq ${msg.seq}; ` +
@@ -293,6 +297,13 @@ export class GameServer {
     this.broadcast({ t: 'mapPicked', mapId, by: player.id });
     this.broadcast({ t: 'mapPicked', mapId, by: player.id });
   }
   }
 
 
+  private onImposeCharacter(player: LobbyPlayer, characterId: string): void {
+    if (this.phase !== 'roundEnd' || player.id !== this.picker || this.leader === null || this.imposed !== null) return;
+    if (!isCharacterId(characterId)) return;
+    this.imposed = characterId;
+    this.broadcast({ t: 'characterImposed', playerId: this.leader, characterId, by: player.id });
+  }
+
   private updateCountdown(): void {
   private updateCountdown(): void {
     const players = this.players();
     const players = this.players();
     const allReady = this.phase === 'lobby' && players.length > 0 && players.every((p) => p.ready);
     const allReady = this.phase === 'lobby' && players.length > 0 && players.every((p) => p.ready);
@@ -314,10 +325,13 @@ export class GameServer {
     this.round++;
     this.round++;
     this.phase = 'playing';
     this.phase = 'playing';
     const mapId = this.pickedMap ?? defaultMap(this.rotation, this.opts.maps, this.round, this.rng);
     const mapId = this.pickedMap ?? defaultMap(this.rotation, this.opts.maps, this.round, this.rng);
+    const imposed = this.leader !== null && this.imposed !== null ? { id: this.leader, characterId: this.imposed } : null;
     this.picker = null;
     this.picker = null;
     this.pickedMap = null;
     this.pickedMap = null;
+    this.leader = null;
+    this.imposed = null;
     this.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S }, mapId);
     this.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S }, mapId);
-    this.populateAndAnnounce();
+    this.populateAndAnnounce(imposed);
   }
   }
 
 
   private endRound(): void {
   private endRound(): void {
@@ -336,8 +350,21 @@ export class GameServer {
       // Catch-up rule (readme §4): the last-placed player picks the next map.
       // Catch-up rule (readme §4): the last-placed player picks the next map.
       this.picker = lastPlaced(results);
       this.picker = lastPlaced(results);
       this.pickedMap = null;
       this.pickedMap = null;
+      // The leader (results are sorted by total) gets a character imposed, unless they are also last.
+      this.leader = results[0] && results[0].id !== this.picker ? results[0].id : null;
+      this.imposed = null;
       const choices = rotationPool(this.rotation, this.opts.maps);
       const choices = rotationPool(this.rotation, this.opts.maps);
-      this.broadcast({ t: 'roundEnd', round: this.round, rounds: this.rounds, results, nextIn, picker: this.picker, choices });
+      this.broadcast({
+        t: 'roundEnd',
+        round: this.round,
+        rounds: this.rounds,
+        results,
+        nextIn,
+        picker: this.picker,
+        choices,
+        leader: this.leader,
+        characters: CHARACTER_IDS.filter((id) => id !== 'generic'),
+      });
     }
     }
     this.broadcastLobby();
     this.broadcastLobby();
   }
   }
@@ -356,8 +383,9 @@ export class GameServer {
     this.populateAndAnnounce();
     this.populateAndAnnounce();
   }
   }
 
 
-  private populateAndAnnounce(): void {
-    for (const p of this.players()) this.game.addPlayer(p.name, p.characterId, p.id);
+  /** Adds every player to the fresh game (with a one-round imposed character, if any) and announces it. */
+  private populateAndAnnounce(imposed: { id: number; characterId: string } | null = null): void {
+    for (const p of this.players()) this.game.addPlayer(p.name, imposed?.id === p.id ? imposed.characterId : p.characterId, p.id);
     this.broadcast({ t: 'start', mapId: this.game.map.id, seed: this.game.seed, players: this.game.playerInfos(), round: this.round, rounds: this.rounds });
     this.broadcast({ t: 'start', mapId: this.game.map.id, seed: this.game.seed, players: this.game.playerInfos(), round: this.round, rounds: this.rounds });
     this.broadcastLobby();
     this.broadcastLobby();
   }
   }

+ 1 - 1
src/shared/constants.ts

@@ -2,7 +2,7 @@
 // (characters, maps, materials) whenever they belong to a specific entity.
 // (characters, maps, materials) whenever they belong to a specific entity.
 
 
 /** Bumped on every incompatible protocol change. */
 /** Bumped on every incompatible protocol change. */
-export const PROTOCOL_VERSION = 7;
+export const PROTOCOL_VERSION = 8;
 
 
 export const NET_PORT = 47100;
 export const NET_PORT = 47100;
 export const DISCOVERY_PORT = 47101;
 export const DISCOVERY_PORT = 47101;

+ 7 - 0
src/shared/protocol.ts

@@ -174,6 +174,8 @@ export type ClientMessage =
   | { t: 'rotation'; rotation: Rotation }
   | { t: 'rotation'; rotation: Rotation }
   /** The last-placed player picks the next map during the round results. */
   /** The last-placed player picks the next map during the round results. */
   | { t: 'pickMap'; mapId: string }
   | { t: 'pickMap'; mapId: string }
+  /** ...and imposes a character on the leader for the next round. */
+  | { t: 'imposeCharacter'; characterId: string }
   /** The client's bitmap hash differs from the server's at `seq`. */
   /** The client's bitmap hash differs from the server's at `seq`. */
   | { t: 'desync'; seq: number; hash: number };
   | { t: 'desync'; seq: number; hash: number };
 
 
@@ -213,6 +215,7 @@ export type ServerMessage =
       maps: MapSummary[];
       maps: MapSummary[];
     }
     }
   | { t: 'mapPicked'; mapId: string; by: number }
   | { t: 'mapPicked'; mapId: string; by: number }
+  | { t: 'characterImposed'; playerId: number; characterId: string; by: number }
   /** A round (or the warm-up, round 0) starts on a freshly loaded map: clients drop their world and reload. */
   /** A round (or the warm-up, round 0) starts on a freshly loaded map: clients drop their world and reload. */
   | { t: 'start'; mapId: string; seed: number; players: PlayerInfo[]; round: number; rounds: number }
   | { t: 'start'; mapId: string; seed: number; players: PlayerInfo[]; round: number; rounds: number }
   | {
   | {
@@ -224,6 +227,9 @@ export type ServerMessage =
       /** Last-placed player, who picks the next map among `choices` (readme §4). */
       /** Last-placed player, who picks the next map among `choices` (readme §4). */
       picker: number | null;
       picker: number | null;
       choices: MapSummary[];
       choices: MapSummary[];
+      /** Leader, on whom the picker imposes a character among `characters` (null when it is the picker). */
+      leader: number | null;
+      characters: string[];
     }
     }
   | { t: 'matchEnd'; results: RoundResult[]; trophies: Trophy[]; lobbyIn: number }
   | { t: 'matchEnd'; results: RoundResult[]; trophies: Trophy[]; lobbyIn: number }
   /** Consistency check: the server's bitmap hash once `seq` terrain events have been applied. */
   /** Consistency check: the server's bitmap hash once `seq` terrain events have been applied. */
@@ -240,6 +246,7 @@ export function isClientMessage(msg: unknown): msg is ClientMessage {
   if (m.t === 'input') return typeof m.buttons === 'number' && typeof m.aimX === 'number' && typeof m.aimY === 'number';
   if (m.t === 'input') return typeof m.buttons === 'number' && typeof m.aimX === 'number' && typeof m.aimY === 'number';
   if (m.t === 'ready') return typeof m.ready === 'boolean';
   if (m.t === 'ready') return typeof m.ready === 'boolean';
   if (m.t === 'pickMap') return typeof m.mapId === 'string';
   if (m.t === 'pickMap') return typeof m.mapId === 'string';
+  if (m.t === 'imposeCharacter') return typeof m.characterId === 'string';
   if (m.t === 'rotation') {
   if (m.t === 'rotation') {
     const r = m.rotation as Partial<Rotation> | undefined;
     const r = m.rotation as Partial<Rotation> | undefined;
     return (
     return (

+ 19 - 7
tests/maps.test.ts

@@ -18,7 +18,7 @@ const catalog: MapSummary[] = [
 describe('built-in maps', () => {
 describe('built-in maps', () => {
   it('all load, validate, and only use known modifiers', () => {
   it('all load, validate, and only use known modifiers', () => {
     const ids = listMapIds(ASSETS);
     const ids = listMapIds(ASSETS);
-    expect(ids).toEqual(expect.arrayContaining(['gateau-etages', 'mille-feuille', 'piece-montee']));
+    expect(ids).toEqual(expect.arrayContaining(['gateau-etages', 'mille-feuille', 'piece-montee', 'roule', 'tarte-fruits', 'crepes']));
     for (const id of ids) {
     for (const id of ids) {
       const { definition, terrain } = loadMap(ASSETS, id, materials);
       const { definition, terrain } = loadMap(ASSETS, id, materials);
       expect(terrain.width).toBe(definition.size[0]);
       expect(terrain.width).toBe(definition.size[0]);
@@ -38,6 +38,18 @@ describe('built-in maps', () => {
   });
   });
 });
 });
 
 
+describe('le roulé', () => {
+  it('only its biscuit stand holds it: saw through the stand and the whole roll comes loose', () => {
+    const { definition, terrain } = loadMap(ASSETS, 'roule', materials);
+    const game = new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
+    for (let x = 836; x <= 1214; x += 10) game.queueOp({ kind: 'carve', x, y: 930, r: 32, power: 99 });
+    const res = game.step();
+    const freed = res.terrain.flatMap((e) => (e.ev.kind === 'detach' && !e.ev.debris ? [e.ev.w] : []));
+    expect(Math.max(0, ...freed)).toBeGreaterThan(600); // the whole roll, ~660 px wide
+    expect(game.chunkStates().length).toBeGreaterThanOrEqual(1);
+  });
+});
+
 describe('rotation', () => {
 describe('rotation', () => {
   it('pool: the selection when valid, every map otherwise', () => {
   it('pool: the selection when valid, every map otherwise', () => {
     expect(rotationPool({ mode: 'all', selection: ['b'] }, catalog)).toEqual(catalog);
     expect(rotationPool({ mode: 'all', selection: ['b'] }, catalog)).toEqual(catalog);
@@ -71,8 +83,8 @@ describe('modifiers', () => {
     return new Game({ map: definition, terrain, materials, seed, rules: { duration: 180 }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
     return new Game({ map: definition, terrain, materials, seed, rules: { duration: 180 }, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
   }
   }
 
 
-  it('wind blows players sideways (piece-montee)', () => {
-    const game = round('piece-montee');
+  it('wind blows players sideways (crepes)', () => {
+    const game = round('crepes');
     const { id } = game.addPlayer('Plume', 'generic');
     const { id } = game.addPlayer('Plume', 'generic');
     game.teleport(id, 300, 300);
     game.teleport(id, 300, 300);
     let maxWind = 0;
     let maxWind = 0;
@@ -87,9 +99,9 @@ describe('modifiers', () => {
     expect(Math.abs(drift)).toBeGreaterThan(0);
     expect(Math.abs(drift)).toBeGreaterThan(0);
   });
   });
 
 
-  it('random cracks open in the brittle pastry over time (mille-feuille), deterministically', () => {
-    const a = round('mille-feuille', 4);
-    const b = round('mille-feuille', 4);
+  it('random cracks open in the brittle shortcrust over time (tarte-fruits), deterministically', () => {
+    const a = round('tarte-fruits', 4);
+    const b = round('tarte-fruits', 4);
     for (let i = 0; i < 10 * TICK_RATE; i++) {
     for (let i = 0; i < 10 * TICK_RATE; i++) {
       a.step();
       a.step();
       b.step();
       b.step();
@@ -99,7 +111,7 @@ describe('modifiers', () => {
   });
   });
 
 
   it('modifiers are off during the warm-up', () => {
   it('modifiers are off during the warm-up', () => {
-    const { definition, terrain } = loadMap(ASSETS, 'mille-feuille', materials);
+    const { definition, terrain } = loadMap(ASSETS, 'tarte-fruits', materials);
     const warm = new Game({ map: definition, terrain, materials, seed: 4, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
     const warm = new Game({ map: definition, terrain, materials, seed: 4, loadCharacter: (id) => loadCharacterStats(ASSETS, id) });
     for (let i = 0; i < 10 * TICK_RATE; i++) warm.step();
     for (let i = 0; i < 10 * TICK_RATE; i++) warm.step();
     expect(warm.events).toHaveLength(0);
     expect(warm.events).toHaveLength(0);

+ 19 - 2
tests/net.test.ts

@@ -277,13 +277,30 @@ describe('LAN server (M3)', () => {
     expect(end.picker).toBe(b.playerId);
     expect(end.picker).toBe(b.playerId);
     expect(end.choices).toHaveLength(3);
     expect(end.choices).toHaveLength(3);
 
 
+    // The leader is Alice (tie broken by id), the picker Bob: he imposes her character.
+    expect(end.leader).toBe(a.playerId);
+    expect(end.characters).toEqual(expect.arrayContaining(['castor', 'mamie', 'pyromanchot']));
+
     a.send({ t: 'pickMap', mapId: 'piece-montee' }); // not Alice's call
     a.send({ t: 'pickMap', mapId: 'piece-montee' }); // not Alice's call
+    a.send({ t: 'imposeCharacter', characterId: 'mamie' }); // nor this
     b.send({ t: 'pickMap', mapId: 'mille-feuille' });
     b.send({ t: 'pickMap', mapId: 'mille-feuille' });
+    b.send({ t: 'imposeCharacter', characterId: 'pyromanchot' });
     await flush();
     await flush();
     expect(a.last('mapPicked')).toEqual({ t: 'mapPicked', mapId: 'mille-feuille', by: b.playerId });
     expect(a.last('mapPicked')).toEqual({ t: 'mapPicked', mapId: 'mille-feuille', by: b.playerId });
+    expect(a.last('characterImposed')).toEqual({ t: 'characterImposed', playerId: a.playerId, characterId: 'pyromanchot', by: b.playerId });
     tick(TICK_RATE);
     tick(TICK_RATE);
-    await flush();
-    expect(a.last('start')).toMatchObject({ round: 2, mapId: 'mille-feuille' });
+    await until(() => a.last('start')?.round === 2);
+    const start = a.last('start')!;
+    expect(start.mapId).toBe('mille-feuille');
     expect(b.mapId).toBe('mille-feuille');
     expect(b.mapId).toBe('mille-feuille');
+    expect(start.players.find((p) => p.id === a.playerId)!.characterId).toBe('pyromanchot');
+    expect(start.players.find((p) => p.id === b.playerId)!.characterId).toBe('generic');
+
+    // The imposed character lasts one round: back in the lobby Alice has her own again.
+    tick(TICK_RATE + 2);
+    await flush();
+    tick(Math.round(0.25 * TICK_RATE) + 1);
+    await until(() => a.last('start')?.round === 0);
+    expect(a.last('start')!.players.find((p) => p.id === a.playerId)!.characterId).toBe('generic');
   });
   });
 });
 });