Przeglądaj źródła

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 tydzień temu
rodzic
commit
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 },
   "items": { "interval": [6, 12], "weights": { "candle": 3, "smarties": 2, "whisk": 1 } },
   "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 },
   "items": { "interval": [6, 12], "weights": { "candle": 4, "smarties": 2, "whisk": 1 } },
   "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.
   - **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.
-  - **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.
 ### 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.
+- **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

+ 98 - 0
scripts/gen-maps.ts

@@ -142,6 +142,98 @@ function pieceMontee(): Canvas {
   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 {
   const png = new PNG({ width: w, height: h });
   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] },
   '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] },
+  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 {
@@ -298,6 +393,9 @@ const MAPS: Record<string, () => Canvas> = {
   'gateau-etages': gateauEtages,
   'mille-feuille': milleFeuille,
   'piece-montee': pieceMontee,
+  roule,
+  'tarte-fruits': tarteFruits,
+  crepes,
 };
 
 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 !',
     other: (name: string) => `${name} est dernier et choisit la prochaine carte…`,
     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).',
   hud: {

+ 12 - 2
src/renderer/main.ts

@@ -150,7 +150,10 @@ async function start(): Promise<void> {
     popups.container,
   );
   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 rounds = 0;
 
@@ -345,10 +348,13 @@ async function start(): Promise<void> {
         infos = new Map(msg.players.map((p) => [p.id, p]));
         resetWorld(msg.mapId, msg.events, msg.chunks);
         break;
-      case 'start':
+      case 'start': {
         round = msg.round;
         rounds = msg.rounds;
         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();
         resetWorld(msg.mapId, [], []);
         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('');
         }, 1500);
         break;
+      }
       case 'roundEnd':
         results.showRound(msg, localId);
         break;
@@ -370,6 +377,9 @@ async function start(): Promise<void> {
         lobbyPanel.update(msg);
         mapNames = new Map(msg.maps.map((m) => [m.id, m.name]));
         break;
+      case 'characterImposed':
+        results.showImposed(infos.get(msg.playerId)?.name ?? '?', fr.characters[msg.characterId]?.name ?? msg.characterId);
+        break;
       case 'mapPicked':
         results.showPicked(mapNames.get(msg.mapId) ?? msg.mapId);
         break;

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

@@ -76,7 +76,10 @@ export class ResultsScreen {
   private timer: ReturnType<typeof setInterval> | 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.hidden = true;
     document.body.appendChild(this.root);
@@ -91,30 +94,44 @@ export class ResultsScreen {
 
   showRound(msg: RoundEndMessage, localId: number): void {
     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;
     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');
-      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', () => {
-          this.onPick(m.id);
+          send(o.id);
           for (const other of buttons.querySelectorAll('button')) other.disabled = true;
           b.classList.replace('secondary', 'primary');
         });
         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 {
-      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.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. */
   showPicked(mapName: string): void {
     if (!this.pickBox) return;

+ 33 - 5
src/server/net.ts

@@ -1,6 +1,6 @@
 import { Packr } from 'msgpackr';
 import { WebSocketServer, type WebSocket } from 'ws';
-import { isCharacterId } from '../shared/characters';
+import { CHARACTER_IDS, isCharacterId } from '../shared/characters';
 import {
   HASH_CHECK_INTERVAL_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. */
   private picker: number | 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 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 === 'rotation') this.onRotation(client.player, msg.rotation);
       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') {
         console.error(
           `[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 });
   }
 
+  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 {
     const players = this.players();
     const allReady = this.phase === 'lobby' && players.length > 0 && players.every((p) => p.ready);
@@ -314,10 +325,13 @@ export class GameServer {
     this.round++;
     this.phase = 'playing';
     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.pickedMap = null;
+    this.leader = null;
+    this.imposed = null;
     this.game = this.newGame({ duration: this.opts.roundDuration ?? ROUND_DURATION_S }, mapId);
-    this.populateAndAnnounce();
+    this.populateAndAnnounce(imposed);
   }
 
   private endRound(): void {
@@ -336,8 +350,21 @@ export class GameServer {
       // Catch-up rule (readme §4): the last-placed player picks the next map.
       this.picker = lastPlaced(results);
       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);
-      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();
   }
@@ -356,8 +383,9 @@ export class GameServer {
     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.broadcastLobby();
   }

+ 1 - 1
src/shared/constants.ts

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

+ 7 - 0
src/shared/protocol.ts

@@ -174,6 +174,8 @@ export type ClientMessage =
   | { t: 'rotation'; rotation: Rotation }
   /** The last-placed player picks the next map during the round results. */
   | { 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`. */
   | { t: 'desync'; seq: number; hash: number };
 
@@ -213,6 +215,7 @@ export type ServerMessage =
       maps: MapSummary[];
     }
   | { 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. */
   | { 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). */
       picker: number | null;
       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 }
   /** 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 === 'ready') return typeof m.ready === 'boolean';
   if (m.t === 'pickMap') return typeof m.mapId === 'string';
+  if (m.t === 'imposeCharacter') return typeof m.characterId === 'string';
   if (m.t === 'rotation') {
     const r = m.rotation as Partial<Rotation> | undefined;
     return (

+ 19 - 7
tests/maps.test.ts

@@ -18,7 +18,7 @@ const catalog: MapSummary[] = [
 describe('built-in maps', () => {
   it('all load, validate, and only use known modifiers', () => {
     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) {
       const { definition, terrain } = loadMap(ASSETS, id, materials);
       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', () => {
   it('pool: the selection when valid, every map otherwise', () => {
     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) });
   }
 
-  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');
     game.teleport(id, 300, 300);
     let maxWind = 0;
@@ -87,9 +99,9 @@ describe('modifiers', () => {
     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++) {
       a.step();
       b.step();
@@ -99,7 +111,7 @@ describe('modifiers', () => {
   });
 
   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) });
     for (let i = 0; i < 10 * TICK_RATE; i++) warm.step();
     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.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: 'imposeCharacter', characterId: 'mamie' }); // nor this
     b.send({ t: 'pickMap', mapId: 'mille-feuille' });
+    b.send({ t: 'imposeCharacter', characterId: 'pyromanchot' });
     await flush();
     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);
-    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(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');
   });
 });