Compare commits

...

4 Commits

Author SHA1 Message Date
Disco DeDisco
2c2ec16f08 Revert the Celery countdown migration — it broke local dev (no worker) — back to threading.Timer
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
a6ce207 moved the 12s polarity confirm to a Celery task (apply_async countdown).
That requires a running Celery worker to execute it — but local dev runs only
uvicorn (the dev-server skill starts no worker; the original tasks.py docstring
chose threading.Timer precisely "so no separate Celery worker is needed in
development"). So locally the confirm was queued and never ran: the countdown hit
0, no significators saved, and a refresh stayed in SIG_SELECT (no skip to the
table hex). A regression in the core flow.

Restore tasks.py + test_tasks.py to the faaa4ec threading.Timer version (still
in-process, with the {token, deadline} cache + countdown_remaining restore-on-
load intact) and drop the now-unneeded CELERY_BROKER_URL='memory://' test
override.

Kept from a6ce207: the room.js WebSocket auto-reconnect — that is the actual fix
for the dropped-socket delivery bug (the SigSelectSpec bisection proved the
client restarts the numeral fine on re-received events; the failure was delivery,
which a dead socket with no reconnect explains). Celery was a misdiagnosis of an
in-process broadcast that works fine for a single-process dev/staging server.

23 task UTs + CARTE sig ITs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:00:42 -04:00
Disco DeDisco
44bf4e626c Sig Select: regression spec — re-ready restarts the visual countdown (client bisection)
A diagnostic that replays the reported "cancel → re-ready doesn't restart the
flashing numeral" sequence purely through the client handlers (countdown_start →
countdown_cancel → countdown_start). It passes: _showCountdown re-renders the
numeral every time the event is received, so the client logic is sound.

This bisects the live bug to WS *delivery* (the re-ready countdown_start not
reaching the browser), not client state — consistent with a dropped room socket
that never reconnected (every subsequent live event lost until a refresh, which
matches the symptom exactly). The auto-reconnect added in a6ce207 is the
relevant mitigation; kept as a permanent guard on the client restart path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:54:26 -04:00
Disco DeDisco
a6ce20761b Sig countdown: run the post-countdown confirm as a Celery task + auto-reconnect the room WS — TDD
The flaky tray→thumbnail→hex animation after the 12s countdown.

Root cause: the confirm ran in a threading.Timer thread inside the web process,
and _fire broadcast polarity_room_done / pick_sky_available via
async_to_sync(group_send) from an ephemeral per-call event loop. With the Redis
channel layer that publish is unreliable across loops (the production analog of
the "broadcast must originate in daphne" test trap), so the live events reached
the client only sporadically — the server-side state (sig assignment,
SKY_SELECT) still committed, which is why a refresh always showed the concluded
hex but the animation usually didn't play.

Fix (chosen: migrate to Celery — the path the tasks.py docstring already called
for): _fire becomes the @shared_task confirm_polarity_room, enqueued by
schedule_polarity_confirm via apply_async(countdown=seconds). The worker is a
stable long-lived process whose channel-layer singleton is never shared with a
serving loop, so its group_send reaches daphne reliably; it also survives
web-worker restarts. No task revocation needed — cancellation/supersession ride
the existing cache token guard (cancel just deletes the token; a stale queued
task no-ops). Dropped threading.Timer + the _timers registry.

Test settings get CELERY_BROKER_URL='memory://' so apply_async queues without a
live Redis and without running the task (no worker) — mirrors the old timer that
was scheduled but never fired inside a sub-12s test. NOT eager: eager would
ignore the countdown and assign significators synchronously during the ready
POST. test_tasks rewritten: confirm_polarity_room called directly (task body),
schedule asserts the enqueue + countdown + fresh-token supersession; the
broadcast itself stays IT-uncoverable under InMemory (known channels limit).

Also: room.js now auto-reconnects the room WebSocket with capped exponential
backoff (1s→30s, reset on open, halted on beforeunload). A dropped socket (proxy
idle-timeout, blip, server restart) previously stayed dead until a manual
refresh, silently losing every live event — an independent reliability gap that
compounded the "sporadic" feel.

602 epic ITs + 18 task UTs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:52:21 -04:00
Disco DeDisco
f3f509a59a Sig Select countdown numeral: enlarge via a class so it doubles at every breakpoint — TDD
The 12s countdown numeral set its size with an inline style.fontSize='2em',
but .btn-primary carries `font-size: 0.625rem !important` inside its small
landscape/short-portrait down-size media query — and an !important declaration
beats an inline style. So on phones / short viewports the numeral stayed
button-sized instead of doubling.

Fix: sig-select.js now toggles a `.sig-take-sig-btn--counting` class instead of
the inline font-size (in _showCountdown / _hideCountdown / the unready path),
and a new `.sig-stage .sig-take-sig-btn.sig-take-sig-btn--counting` rule in
_card-deck.scss re-asserts `font-size: 2em !important` at (0,3,0) specificity —
strictly beating the (0,2,0) btn-primary media-query !important at all queries.
em stays parent-relative so the doubling tracks the stage font across sizes.

2 Jasmine specs (class present + no inline override on show; class cleared on
countdown_cancel) added to SigSelectSpec; SpecRunner green; SCSS compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:39:52 -04:00
5 changed files with 126 additions and 14 deletions

View File

@@ -117,17 +117,39 @@
// multi-seat owner never receives the other polarity's countdown.
const seatParam = new URLSearchParams(window.location.search).get('seat');
const wsSeat = seatParam ? `?seat=${encodeURIComponent(seatParam)}` : '';
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/${wsSeat}`);
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
const wsUrl = `${wsScheme}://${window.location.host}/ws/room/${roomId}/${wsSeat}`;
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
};
// Auto-reconnect with capped exponential backoff. A dropped socket (proxy
// idle-timeout, brief network blip, server restart) otherwise stayed dead
// until a manual refresh, silently losing live events — most painfully the
// 12s sig countdown's polarity_room_done / pick_sky_available, so the
// tray→hex animation never played. Backoff resets on a clean open; we stop
// retrying once the page is unloading so we don't reconnect during nav.
let backoff = 1000;
const BACKOFF_MAX = 30000;
let unloading = false;
window.addEventListener('beforeunload', function () { unloading = true; });
ws.onclose = function (event) {
if (!event.wasClean) {
console.warn('Room WebSocket closed unexpectedly');
}
};
function connect() {
const ws = new WebSocket(wsUrl);
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
ws.onopen = function () { backoff = 1000; };
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
};
ws.onclose = function (event) {
if (unloading) return;
if (!event.wasClean) {
console.warn('Room WebSocket closed unexpectedly — reconnecting in ' + backoff + 'ms');
}
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, BACKOFF_MAX);
};
}
connect();
}());

View File

@@ -370,7 +370,7 @@ var SigSelect = (function () {
if (_countdownTimer !== null) {
clearInterval(_countdownTimer);
_countdownTimer = null;
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
if (_takeSigBtn) _takeSigBtn.classList.remove('sig-take-sig-btn--counting');
}
if (_takeSigBtn) _takeSigBtn.textContent = 'SAVE SIG';
_stopWaitNoGlow();
@@ -477,7 +477,12 @@ var SigSelect = (function () {
_stopWaitNoGlow();
if (_takeSigBtn) {
_takeSigBtn.textContent = _countdownSecondsLeft;
_takeSigBtn.style.fontSize = '2em';
// Enlarge via a class, NOT inline font-size: the .btn-primary
// down-size media query (font-size !important on small landscape/
// portrait) out-!importants an inline 2em, so the numeral stayed
// button-sized on phones. The .sig-take-sig-btn--counting rule
// re-asserts the doubling with matching !important + specificity.
_takeSigBtn.classList.add('sig-take-sig-btn--counting');
}
_startCountdownGlow();
if (_countdownTimer !== null) clearInterval(_countdownTimer);
@@ -499,7 +504,7 @@ var SigSelect = (function () {
}
_stopCountdownGlow();
if (_takeSigBtn) {
_takeSigBtn.style.fontSize = '';
_takeSigBtn.classList.remove('sig-take-sig-btn--counting');
if (_isReady) {
// Countdown cancelled by another gamer — restore WAIT NVM state
_takeSigBtn.textContent = 'WAIT NVM';

View File

@@ -888,6 +888,25 @@ describe("SigSelect", () => {
expect(takeSigBtn.textContent).toBe("8");
});
it("enlarges the numeral via the --counting class (not a fragile inline 2em)", () => {
// The .btn-primary media-query font-size !important beats an inline
// 2em on small queries; the class lets SCSS re-assert the doubling.
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
expect(takeSigBtn.style.fontSize).toBe(""); // no inline override
});
it("clears the --counting class when the countdown is cancelled", () => {
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
window.dispatchEvent(new CustomEvent("room:countdown_cancel", {
detail: { polarity: "levity", seconds_remaining: 5 },
}));
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(false);
});
it("the restored numeral counts down each second", () => {
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
@@ -900,6 +919,25 @@ describe("SigSelect", () => {
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.textContent).toBe("WAIT NVM");
});
it("re-renders the numeral when countdown_start arrives again after a cancel", () => {
// Diagnostic for the re-ready bug: cancel → re-ready must restart the
// visual countdown. This exercises the CLIENT handler chain only; if
// it passes, the live failure is WS delivery (socket / broadcast), not
// client logic.
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 9 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.textContent).toBe("9"); // counting
window.dispatchEvent(new CustomEvent("room:countdown_cancel", {
detail: { polarity: "levity", seconds_remaining: 7 },
}));
expect(takeSigBtn.textContent).toBe("WAIT NVM"); // cancelled
window.dispatchEvent(new CustomEvent("room:countdown_start", {
detail: { polarity: "levity", seconds: 7 },
}));
expect(takeSigBtn.textContent).toBe("7"); // restarted
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
});
});
// ── polarity_room_done → tray sequence ─────────────────────────────────── //

View File

@@ -665,6 +665,15 @@
// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle
// container query units inside min().
// The polarity-countdown numeral must read large at EVERY breakpoint. The
// .btn-primary down-size media query (`font-size: 0.625rem !important` on small
// landscape / short portrait) otherwise out-!importants the countdown's enlarge
// and the numeral stayed button-sized on phones / short viewports. Re-assert the
// 2em doubling with matching !important at (0,3,0) so it wins at all queries.
.sig-stage .sig-take-sig-btn.sig-take-sig-btn--counting {
font-size: 2em !important;
}
.sig-stage {
flex: 1;
min-height: 0;

View File

@@ -888,6 +888,25 @@ describe("SigSelect", () => {
expect(takeSigBtn.textContent).toBe("8");
});
it("enlarges the numeral via the --counting class (not a fragile inline 2em)", () => {
// The .btn-primary media-query font-size !important beats an inline
// 2em on small queries; the class lets SCSS re-assert the doubling.
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
expect(takeSigBtn.style.fontSize).toBe(""); // no inline override
});
it("clears the --counting class when the countdown is cancelled", () => {
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
window.dispatchEvent(new CustomEvent("room:countdown_cancel", {
detail: { polarity: "levity", seconds_remaining: 5 },
}));
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(false);
});
it("the restored numeral counts down each second", () => {
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 8 });
takeSigBtn = document.getElementById("id_take_sig_btn");
@@ -900,6 +919,25 @@ describe("SigSelect", () => {
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.textContent).toBe("WAIT NVM");
});
it("re-renders the numeral when countdown_start arrives again after a cancel", () => {
// Diagnostic for the re-ready bug: cancel → re-ready must restart the
// visual countdown. This exercises the CLIENT handler chain only; if
// it passes, the live failure is WS delivery (socket / broadcast), not
// client logic.
makeFixture({ reservations: '{"42":"PC"}', ready: true, countdownRemaining: 9 });
takeSigBtn = document.getElementById("id_take_sig_btn");
expect(takeSigBtn.textContent).toBe("9"); // counting
window.dispatchEvent(new CustomEvent("room:countdown_cancel", {
detail: { polarity: "levity", seconds_remaining: 7 },
}));
expect(takeSigBtn.textContent).toBe("WAIT NVM"); // cancelled
window.dispatchEvent(new CustomEvent("room:countdown_start", {
detail: { polarity: "levity", seconds: 7 },
}));
expect(takeSigBtn.textContent).toBe("7"); // restarted
expect(takeSigBtn.classList.contains("sig-take-sig-btn--counting")).toBe(true);
});
});
// ── polarity_room_done → tray sequence ─────────────────────────────────── //