anvilsign in

collin/mahjong · da592f4d

Let the complaint finish before the next player moves

Collin Richards · 2026-08-19 22:02 UTC · da592f4d743663f107c6d00e2b930e1089eef0d5 · parent 7a1ed833 · browse files

modifiedsrc/App.tsx+3 −2
⋯ 108 unchanged lines
109109 useEffect(() => game.onSound(play), [game]);
110110 // Watches the table for the whole session and only stirs when a seat the
111111 // computer holds is the one being waited on.
112- // The computer waits for a thrown tile to land before it reaches for it.
113- useEffect(() => new AutoPlay(game, () => pool.throwInFlight).start(), [game, pool]);
112+ // The computer waits for a thrown tile to land — and for whoever it landed on
113+ // to finish saying so — before it reaches for it.
114+ useEffect(() => new AutoPlay(game, () => pool.busy).start(), [game, pool]);
114115
115116 if (phase === 'lobby') {
116117 const round = saved ? Math.floor(saved.state.rotations / 4) % 4 : 0;
⋯ 97 unchanged lines
modifiedsrc/game/autoplay.ts+16 −10
⋯ 30 unchanged lines
3131 /** How often to look again while a thrown tile is still in the air. */
3232 const SETTLE_POLL = 70;
3333 /**
34- * The longest the table will hold for a tile to land before going on anyway.
35- * Waiting on the animation is a courtesy; a game that could not continue because
36- * something in the middle would not settle would be a game that had stopped.
34+ * The longest the table will hold before going on anyway. Waiting on the
35+ * animation is a courtesy; a game that could not continue because something in
36+ * the middle would not settle would be a game that had stopped.
37+ *
38+ * Long enough to cover the worst case rather than the usual one: a tile still
39+ * skating about, and then somebody saying a whole sentence about where it ended
40+ * up. Cut short of that and the line gets talked over by the very move it was
41+ * meant to hold up.
3742 */
38-const SETTLE_LIMIT = 2500;
43+const SETTLE_LIMIT = 4500;
3944
4045 /** Whose move it is, if it is a bot's, and how long to sit on it. */
4146 export function pendingBot(game: Game): { seat: SeatId; delay: number } | null {
⋯ 56 unchanged lines
98103 private busy: () => boolean;
99104
100105 /**
101- * `busy` is asked whether the table is mid-animation — a discard still in the
102- * air on its way to the middle. Nobody at a real table reaches across for a
103- * tile that has not landed, and a 碰 called over a tile still turning over in
104- * flight looks like the tile was snatched out of the air. The engine has no
105- * idea any of this exists; this is the same watching-from-outside trick the
106- * rest of the file plays.
106+ * `busy` is asked whether the table is mid-something: a discard still in the
107+ * air on its way to the middle, or a player still complaining about where the
108+ * last one landed. Nobody at a real table reaches across for a tile that has
109+ * not landed — a 碰 called over one still in flight looks like it was snatched
110+ * out of the air — and nobody talks over the person they just hit with it.
111+ * The engine has no idea any of this exists; this is the same
112+ * watching-from-outside trick the rest of the file plays.
107113 */
108114 constructor(game: Game, busy: () => boolean = () => false) {
109115 this.game = game;
⋯ 45 unchanged lines
modifiedsrc/game/sound.ts+19 −9
⋯ 122 unchanged lines
123123 * Speak one or more clips back to back. Calls land on top of each other at
124124 * table pace, so anything still talking is cut off: the newest call is the
125125 * only one that matters.
126+ *
127+ * Returns how long it will be talking for, in seconds — zero if it is not
128+ * going to say anything at all. That is what lets the table hold still while
129+ * somebody is speaking, and only for as long as they are.
126130 */
127-function say(...names: string[]) {
131+function say(...names: string[]): number {
128132 const c = audio();
129- if (!voiceOn || !enabled || !c || !master) return;
133+ if (!voiceOn || !enabled || !c || !master) return 0;
130134 if (!clips.size) {
131135 // Nothing to say yet: either still coming down the wire, or waiting for
132136 // this very gesture to be allowed to decode.
133137 decodeVoicePack(c);
134138 void fetchVoicePack();
135- return;
139+ return 0;
136140 }
137141
138142 for (const src of speaking) {
⋯ 12 unchanged lines
151155 voiceGain.connect(master);
152156 }
153157
154- let at = c.currentTime + 0.02;
158+ const from = c.currentTime + 0.02;
159+ let at = from;
155160 for (const name of names) {
156161 const buf = clips.get(name);
157162 if (!buf) continue;
⋯ 4 unchanged lines
162167 speaking.push(src);
163168 at += buf.duration + 0.05;
164169 }
170+ return at - from;
165171 }
166172
167173 export function setSoundEnabled(on: boolean) {
⋯ 95 unchanged lines
263269 *
264270 * Only the physics knows this happened, so like `knock` it is not a
265271 * `SoundEvent`: the engine neither knows nor cares where a discard ended up.
272+ *
273+ * Returns how long it will be talking for, so the table can wait it out — a
274+ * computer player reaching for the tile over the top of somebody complaining
275+ * about it is the whole thing landing on nobody.
266276 */
267277 let lastBarge = 0;
268-export function barge(strength: number) {
269- if (!enabled) return;
278+export function barge(strength: number): number {
279+ if (!enabled) return 0;
270280 const c = audio();
271- if (!c || !master) return;
281+ if (!c || !master) return 0;
272282 // One complaint per throw, not one per bounce.
273- if (c.currentTime - lastBarge < 1.6) return;
283+ if (c.currentTime - lastBarge < 1.6) return 0;
274284 lastBarge = c.currentTime;
275285
276286 clack(c, 0, 0.5 + strength * 0.5);
⋯ 5 unchanged lines
282292 let pick = Math.floor(Math.random() * WATCH_IT.length);
283293 if (WATCH_IT[pick] === lastWords) pick = (pick + 1) % WATCH_IT.length;
284294 lastWords = WATCH_IT[pick];
285- say(lastWords);
295+ return say(lastWords);
286296 }
287297
288298 /**
⋯ 89 unchanged lines
modifiedsrc/table/pool.ts+20 −0
⋯ 136 unchanged lines
137137 private melds = [0, 0, 0, 0];
138138 /** Tiles on their way off the table. Drawn, but out of the physics. */
139139 private leaving: Leaving[] = [];
140+ /** When the table will have stopped talking. See `heckle`. */
141+ private quietAt = 0;
140142
141143 // Which seats can throw, as a string, so the seats can re-render when it
142144 // changes. Read through useSyncExternalStore, the same way the game is.
⋯ 225 unchanged lines
368370 return !!b && !b.resting;
369371 }
370372
373+ /**
374+ * Somebody is being told off for where they put that tile. Held for as long
375+ * as the line takes to say, told to us by whoever is saying it.
376+ */
377+ heckle(seconds: number) {
378+ this.quietAt = Math.max(this.quietAt, Date.now() + seconds * 1000);
379+ }
380+
381+ /**
382+ * Whether the table is mid-something and a computer player should wait: a
383+ * tile still in the air, or a player still complaining about the last one.
384+ * Reaching for a tile over the top of somebody objecting to it lands the
385+ * objection on nobody.
386+ */
387+ get busy(): boolean {
388+ return this.throwInFlight || Date.now() < this.quietAt;
389+ }
390+
371391 /** Nothing is moving, so the render loop can stop until something changes. */
372392 get settled(): boolean {
373393 return !this.held && this.leaving.length === 0 && (!this.world || settled(this.world));
⋯ 213 unchanged lines
modifiedsrc/ui/Pool.tsx+16 −3
⋯ 72 unchanged lines
7373 // A contact quiet enough not to be worth hearing still counts as a tile
7474 // arriving in somebody's lap, which is why the two thresholds differ.
7575 if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength);
76- if (i.edge) shove(table, EDGE_SEAT[i.edge], i.seat, i.strength);
76+ if (i.edge) shove(pool, table, EDGE_SEAT[i.edge], i.seat, i.strength);
7777 }
7878 if (loudest > 0) knock(loudest);
7979 paint(ctx, canvas, pool, live.current.art);
⋯ 120 unchanged lines
200200 /** Quieter than this and the contact is not worth a clack of its own. */
201201 const AUDIBLE = 0.12;
202202
203+/** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS,
204+ * which is the other half of it: the table waits at least this long. */
205+const SHAKE = 0.34;
206+
203207 /**
204208 * A tile has ended up in this seat's tiles — knocked into them, or simply come
205209 * to a stop off the table on their side. Either way it is theirs to complain
⋯ 5 unchanged lines
211215 * about it. Not for the thrower's own edge: you cannot be barged by your own
212216 * discard, and everybody's tile passes over their own row on the way out.
213217 */
214-function shove(table: HTMLElement, seat: SeatId, thrower: SeatId | undefined, strength: number) {
218+function shove(
219+ pool: TablePool,
220+ table: HTMLElement,
221+ seat: SeatId,
222+ thrower: SeatId | undefined,
223+ strength: number,
224+) {
215225 if (seat === thrower) return;
216- barge(strength);
226+ // Hold the table while it is being said and while the row is still rocking.
227+ // The computer plays on a timer of its own, and a 碰 called over the top of
228+ // somebody objecting to the tile lands the objection on nobody.
229+ pool.heckle(Math.max(SHAKE, barge(strength)));
217230 const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`);
218231 if (!hand || hand.classList.contains('shoved')) return;
219232 hand.classList.add('shoved');
⋯ 305 unchanged lines