my-sea spectate: broadcast spread on modal-close + sequence the spectator's AUTO DRAW reveal — TDD
Two follow-ons to the spectate spread-sync, both over the `mysea_<owner>` consumer:
SPREAD ON MODAL-CLOSE — the spread only reached spectators piggy-backed on the
first `sea_draw`, so a visitor sat on a stale layout until a card landed. The
owner's SPREAD modal now broadcasts her chosen spread the moment she closes it
(backdrop / Escape / guard-OK) — before any draw:
- new hand-less `sea_spread` event: view `_notify_sea_spread` → consumer relay →
visitor `_applySpread` (re-lays-out `[data-spread]` + re-captions, no hand
touched).
- new POST `/gameboard/my-sea/spread` the modal-close handler calls, guarded by
`_lastSpread` so re-opening + closing without a change doesn't re-broadcast.
When an active row has an EMPTY hand it also persists the spread onto the row
(so a fresh spectator load lands right too) — stays within the "spread locks
at first card" policy; never overwrites a drawn hand's spread.
SEQUENCED AUTO DRAW — AUTO DRAW commits all six cards in ONE POST (navigate-away
safety) → one `sea_draw` carrying the whole hand, so the spectator saw them pop
in at once ("async as intended, but not in sequence"). The visitor's `_applyHand`
now reveals only the freshly-added entries, one per ~420ms tick (in DRAW_ORDER,
first immediately) — a lone manual-draw card still reveals instantly. Already-
shown cards (`_isShown` by slot card-id) are left untouched, so a cumulative
re-broadcast never re-animates.
Coverage:
- ITs: MySeaSpreadBroadcastViewTest — login/405/unknown-spread guards, broadcast
call, empty-hand persist, no-overwrite-of-drawn-spread, broadcast-failure
resilience.
- channels: spectate consumer relays the hand-less `sea_spread` event.
- Live-verified in Firefox: a 3-card hand fills 1 slot synchronously then the
rest after the stagger; user visually confirmed the full deal sequence + the
modal-close spread propagation.
311 gameboard ITs + 7 spectate channels green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,11 @@ they witness each card land WITHOUT refreshing (user-spec 2026-05-29).
|
|||||||
|
|
||||||
Mirrors the epic `RoomConsumer` broadcast pattern (view → `group_send` →
|
Mirrors the epic `RoomConsumer` broadcast pattern (view → `group_send` →
|
||||||
consumer handler → `send_json`), but keyed on the owner (`mysea_<owner_id>`)
|
consumer handler → `send_json`), but keyed on the owner (`mysea_<owner_id>`)
|
||||||
since my-sea has no Room. Read-only: clients never send, they only receive a
|
since my-sea has no Room. Read-only: clients never send, they only receive
|
||||||
`sea_draw` event carrying the owner's full current hand; `my-sea-seats.js` /
|
events — `sea_draw` (the owner's full current hand + spread), `sea_spread` (a
|
||||||
sea.js fill the cross from it. Membership gate matches the voice consumer — the
|
spread change on modal-close, no hand), and `sea_seats` (the presence ring);
|
||||||
owner, or a present invitee (deposited, not left).
|
`my-sea-seats.js` / sea.js apply them to the cross + hex. Membership gate matches
|
||||||
|
the voice consumer — the owner, or a present invitee (deposited, not left).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
@@ -47,6 +48,11 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
"""Relay the seat ring (a deposit took / a BYE freed a seat)."""
|
"""Relay the seat ring (a deposit took / a BYE freed a seat)."""
|
||||||
await self.send_json({"type": "sea_seats", "seats": event.get("seats", [])})
|
await self.send_json({"type": "sea_seats", "seats": event.get("seats", [])})
|
||||||
|
|
||||||
|
async def sea_spread(self, event):
|
||||||
|
"""Relay a spread change the moment the owner closes her SPREAD modal, so
|
||||||
|
the spectator re-lays-out BEFORE any card lands (no hand in this event)."""
|
||||||
|
await self.send_json({"type": "sea_spread", "spread": event.get("spread", "")})
|
||||||
|
|
||||||
# ── membership gate ─────────────────────────────────────────────────
|
# ── membership gate ─────────────────────────────────────────────────
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def _can_watch(self):
|
def _can_watch(self):
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ class MySeaSpectateConsumerTest(TransactionTestCase):
|
|||||||
{"type": "sea_draw", "hand": hand, "spread": "escape-velocity"})
|
{"type": "sea_draw", "hand": hand, "spread": "escape-velocity"})
|
||||||
await comm.disconnect()
|
await comm.disconnect()
|
||||||
|
|
||||||
|
async def test_present_invitee_receives_spread_only_event(self):
|
||||||
|
# The owner closing her SPREAD modal pushes a hand-less `sea_spread` so
|
||||||
|
# the spectator re-lays-out before any card lands (2026-05-30).
|
||||||
|
await database_sync_to_async(self._invite)(present=True)
|
||||||
|
comm = self._comm(self.bud)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
await get_channel_layer().group_send(
|
||||||
|
f"mysea_{self.owner.id}",
|
||||||
|
{"type": "sea_spread", "spread": "mind-body-spirit"})
|
||||||
|
msg = await comm.receive_json_from()
|
||||||
|
self.assertEqual(
|
||||||
|
msg, {"type": "sea_spread", "spread": "mind-body-spirit"})
|
||||||
|
await comm.disconnect()
|
||||||
|
|
||||||
async def test_present_invitee_receives_seat_updates(self):
|
async def test_present_invitee_receives_seat_updates(self):
|
||||||
await database_sync_to_async(self._invite)(present=True)
|
await database_sync_to_async(self._invite)(present=True)
|
||||||
comm = self._comm(self.bud)
|
comm = self._comm(self.bud)
|
||||||
|
|||||||
@@ -2138,6 +2138,70 @@ class MySeaLockHandViewTest(TestCase):
|
|||||||
self.assertEqual(draw.significator_id, self.target.id)
|
self.assertEqual(draw.significator_id, self.target.id)
|
||||||
|
|
||||||
|
|
||||||
|
class MySeaSpreadBroadcastViewTest(TestCase):
|
||||||
|
"""POST `/gameboard/my-sea/spread` — broadcasts the owner's chosen spread to
|
||||||
|
watching spectators on modal-close, before any card is drawn (2026-05-30)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="spread@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.url = reverse("my_sea_spread")
|
||||||
|
|
||||||
|
def _post(self, spread):
|
||||||
|
import json
|
||||||
|
return self.client.post(
|
||||||
|
self.url, data=json.dumps({"spread": spread}),
|
||||||
|
content_type="application/json")
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
self.assertEqual(self._post("mind-body-spirit").status_code, 302)
|
||||||
|
|
||||||
|
def test_get_returns_405(self):
|
||||||
|
self.assertEqual(self.client.get(self.url).status_code, 405)
|
||||||
|
|
||||||
|
def test_unknown_spread_rejected(self):
|
||||||
|
self.assertEqual(self._post("not-a-spread").status_code, 400)
|
||||||
|
|
||||||
|
def test_valid_spread_broadcasts(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("apps.gameboard.views._notify_sea_spread") as mock_notify:
|
||||||
|
resp = self._post("mind-body-spirit")
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
mock_notify.assert_called_once_with(self.user.id, "mind-body-spirit")
|
||||||
|
|
||||||
|
def test_persists_spread_onto_empty_hand_row(self):
|
||||||
|
# A fresh/post-DEL row (empty hand) adopts the new spread so a spectator
|
||||||
|
# who loads fresh also lands on the right layout.
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user, spread="situation-action-outcome",
|
||||||
|
significator_id=1, hand=[])
|
||||||
|
self._post("desire-obstacle-solution")
|
||||||
|
self.assertEqual(
|
||||||
|
MySeaDraw.objects.get(user=self.user).spread,
|
||||||
|
"desire-obstacle-solution")
|
||||||
|
|
||||||
|
def test_does_not_overwrite_a_drawn_hands_spread(self):
|
||||||
|
# Never clobbers a spread once cards exist (stays within the lock policy).
|
||||||
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
MySeaDraw.objects.create(
|
||||||
|
user=self.user, spread="situation-action-outcome",
|
||||||
|
significator_id=1,
|
||||||
|
hand=[{"position": "lay", "card_id": 1,
|
||||||
|
"reversed": False, "polarity": "gravity"}])
|
||||||
|
self._post("desire-obstacle-solution")
|
||||||
|
self.assertEqual(
|
||||||
|
MySeaDraw.objects.get(user=self.user).spread,
|
||||||
|
"situation-action-outcome")
|
||||||
|
|
||||||
|
def test_succeeds_even_when_broadcast_fails(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("channels.layers.get_channel_layer",
|
||||||
|
side_effect=Exception("redis down")):
|
||||||
|
self.assertEqual(self._post("mind-body-spirit").status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class MySeaDeleteDrawViewTest(TestCase):
|
class MySeaDeleteDrawViewTest(TestCase):
|
||||||
"""Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
|
"""Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
|
||||||
but preserves the row so the 24h quota window keeps running."""
|
but preserves the row so the 24h quota window keeps running."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ urlpatterns = [
|
|||||||
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
||||||
path('my-sea/', views.my_sea, name='my_sea'),
|
path('my-sea/', views.my_sea, name='my_sea'),
|
||||||
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
||||||
|
path('my-sea/spread', views.my_sea_spread, name='my_sea_spread'),
|
||||||
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
|
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
|
||||||
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
|
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
|
||||||
path('my-sea/insert', views.my_sea_insert_token, name='my_sea_insert_token'),
|
path('my-sea/insert', views.my_sea_insert_token, name='my_sea_insert_token'),
|
||||||
|
|||||||
@@ -476,6 +476,27 @@ def _notify_sea_draw(owner_id, hand, spread):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sea_spread(owner_id, spread):
|
||||||
|
"""Best-effort push of JUST the owner's chosen spread (no hand) to watching
|
||||||
|
invitees, so the spectator's cross re-lays-out + re-captions the moment the
|
||||||
|
owner closes her SPREAD modal — before any card is drawn (user-spec
|
||||||
|
2026-05-30). Previously the spread only reached spectators piggy-backed on
|
||||||
|
the first `sea_draw`, so the visitor sat on a stale layout until a card
|
||||||
|
landed. Guarded, same as `_notify_sea_draw`."""
|
||||||
|
try:
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
layer = get_channel_layer()
|
||||||
|
if layer is None:
|
||||||
|
return
|
||||||
|
async_to_sync(layer.group_send)(
|
||||||
|
f"mysea_{owner_id}",
|
||||||
|
{"type": "sea_spread", "spread": spread},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _my_sea_seats(owner):
|
def _my_sea_seats(owner):
|
||||||
"""Table-ring seat list for `owner`'s sea — owner 1C + present invitees
|
"""Table-ring seat list for `owner`'s sea — owner 1C + present invitees
|
||||||
2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is
|
2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is
|
||||||
@@ -653,6 +674,34 @@ def my_sea_lock(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
@require_POST
|
||||||
|
def my_sea_spread(request):
|
||||||
|
"""Broadcast the owner's chosen spread to watching spectators the moment she
|
||||||
|
closes the SPREAD modal — before any card is drawn (user-spec 2026-05-30).
|
||||||
|
|
||||||
|
Body: JSON `{"spread": "<slug>"}`. No hand is touched. If an active draw row
|
||||||
|
exists with an EMPTY hand (fresh / post-DEL), the spread is persisted onto it
|
||||||
|
so a spectator who loads fresh ALSO lands on the right layout — this stays
|
||||||
|
within the existing "spread locks at first card" policy (a non-empty hand
|
||||||
|
never reaches here from the client, and we never overwrite a drawn spread).
|
||||||
|
|
||||||
|
Returns 200 `{ok}` on success; 400 for a malformed / unknown spread."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({"error": "invalid_json"}, status=400)
|
||||||
|
spread = payload.get("spread")
|
||||||
|
if spread not in HAND_SIZE_BY_SPREAD:
|
||||||
|
return JsonResponse({"error": "unknown_spread"}, status=400)
|
||||||
|
draw = active_draw_for(request.user)
|
||||||
|
if draw is not None and not draw.hand and draw.spread != spread:
|
||||||
|
draw.spread = spread
|
||||||
|
draw.save(update_fields=["spread"])
|
||||||
|
_notify_sea_spread(request.user.id, spread)
|
||||||
|
return JsonResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_delete(request):
|
def my_sea_delete(request):
|
||||||
|
|||||||
@@ -1126,8 +1126,34 @@
|
|||||||
function openModal() {
|
function openModal() {
|
||||||
modal.removeAttribute('hidden');
|
modal.removeAttribute('hidden');
|
||||||
}
|
}
|
||||||
|
// On close, push the owner's CURRENT spread to watching spectators
|
||||||
|
// so their cross re-lays-out immediately — not only once the first
|
||||||
|
// card lands (user-spec 2026-05-30). Fires on backdrop / Escape /
|
||||||
|
// guard-OK close. Guarded by `_lastSpread` so re-opening + closing
|
||||||
|
// without a change doesn't re-broadcast. The select's change handler
|
||||||
|
// already mirrors the choice onto `.my-sea-cross[data-spread]`, so
|
||||||
|
// that's the live source of truth at close time.
|
||||||
|
var crossEl = document.querySelector('.my-sea-cross');
|
||||||
|
var _lastSpread = crossEl ? crossEl.getAttribute('data-spread') : null;
|
||||||
|
function _broadcastSpread() {
|
||||||
|
if (!crossEl) return;
|
||||||
|
var spread = crossEl.getAttribute('data-spread');
|
||||||
|
if (!spread || spread === _lastSpread) return;
|
||||||
|
_lastSpread = spread;
|
||||||
|
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||||
|
fetch('{% url "my_sea_spread" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': m ? decodeURIComponent(m[1]) : '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ spread: spread }),
|
||||||
|
}).catch(function () { /* best-effort — spectate is an enhancement */ });
|
||||||
|
}
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
modal.setAttribute('hidden', '');
|
modal.setAttribute('hidden', '');
|
||||||
|
_broadcastSpread();
|
||||||
}
|
}
|
||||||
|
|
||||||
seaBtn.addEventListener('click', function () {
|
seaBtn.addEventListener('click', function () {
|
||||||
|
|||||||
@@ -190,31 +190,52 @@
|
|||||||
}
|
}
|
||||||
window._mySeaRenderSeats = _renderSeats; // test seam
|
window._mySeaRenderSeats = _renderSeats; // test seam
|
||||||
|
|
||||||
|
// Reveal a single hand entry on the cross (register + force --visible).
|
||||||
|
function _revealCard(cross, e) {
|
||||||
|
var card = byId[e.card_id];
|
||||||
|
if (!card) return;
|
||||||
|
var c = {};
|
||||||
|
for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; }
|
||||||
|
c.reversed = !!e.reversed;
|
||||||
|
window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity');
|
||||||
|
// register's _fillSlot sets --filled (opacity:0) but not --visible
|
||||||
|
// (the owner adds that on stage-dismiss; the spectator has no modal).
|
||||||
|
// Add it here so the card lands at its final opacity — matching the
|
||||||
|
// refreshed state — instead of racing the empty→filled transition
|
||||||
|
// (the long-standing my_sea ease-in/ease-out glitch).
|
||||||
|
var cell = cross.querySelector('.sea-pos-' + e.position);
|
||||||
|
var filled = cell && cell.querySelector('.sea-card-slot--filled');
|
||||||
|
if (filled) filled.classList.add('sea-card-slot--visible');
|
||||||
|
}
|
||||||
|
// True when this exact card is already shown in its slot — so a
|
||||||
|
// cumulative re-broadcast doesn't re-animate cards already on the cross.
|
||||||
|
function _isShown(cross, e) {
|
||||||
|
var cell = cross.querySelector('.sea-pos-' + e.position);
|
||||||
|
var slot = cell && cell.querySelector('.sea-card-slot--filled');
|
||||||
|
return !!(slot && String(slot.dataset.cardId) === String(e.card_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visitor witnesses cards land ONE AT A TIME. AUTO DRAW commits all six
|
||||||
|
// in a single POST (navigate-away safety) → one `sea_draw` carrying the
|
||||||
|
// whole hand; revealing them all in that tick made them pop in at once
|
||||||
|
// (user-reported 2026-05-30: "async as intended, but not in sequence").
|
||||||
|
// So we stagger the freshly-added entries — already-shown cards stay put
|
||||||
|
// (a manual single-card add reveals immediately, fresh.length === 1).
|
||||||
|
var REVEAL_STAGGER_MS = 420;
|
||||||
|
var _revealTimer = null;
|
||||||
function _applyHand(hand) {
|
function _applyHand(hand) {
|
||||||
if (!window.SeaDeal || !window.SeaDeal.register) return;
|
if (!window.SeaDeal || !window.SeaDeal.register) return;
|
||||||
var cross = document.querySelector('.my-sea-cross');
|
var cross = document.querySelector('.my-sea-cross');
|
||||||
if (!cross) return;
|
if (!cross) return;
|
||||||
|
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
|
||||||
var inHand = {};
|
var inHand = {};
|
||||||
|
var fresh = [];
|
||||||
(hand || []).forEach(function (e) {
|
(hand || []).forEach(function (e) {
|
||||||
if (!e || !e.position) return;
|
if (!e || !e.position) return;
|
||||||
inHand[e.position] = true;
|
inHand[e.position] = true;
|
||||||
var card = byId[e.card_id];
|
if (!_isShown(cross, e)) fresh.push(e); // queue only new cards
|
||||||
if (!card) return;
|
|
||||||
var c = {};
|
|
||||||
for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; }
|
|
||||||
c.reversed = !!e.reversed;
|
|
||||||
window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity');
|
|
||||||
// register's _fillSlot sets --filled (opacity:0) but not
|
|
||||||
// --visible (the owner adds that on stage-dismiss; the
|
|
||||||
// spectator has no modal). Add it here in the SAME tick so the
|
|
||||||
// card lands at its final opacity — matching the refreshed
|
|
||||||
// state — instead of racing the empty→filled opacity transition
|
|
||||||
// (the long-standing my_sea ease-in/ease-out glitch).
|
|
||||||
var cell = cross.querySelector('.sea-pos-' + e.position);
|
|
||||||
var filled = cell && cell.querySelector('.sea-card-slot--filled');
|
|
||||||
if (filled) filled.classList.add('sea-card-slot--visible');
|
|
||||||
});
|
});
|
||||||
// Re-empty any slot the owner cleared (DEL → empty-hand broadcast).
|
// Re-empty any slot the owner cleared (DEL / spread switch).
|
||||||
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
|
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
|
||||||
var pos = slot.dataset.posKey;
|
var pos = slot.dataset.posKey;
|
||||||
if (pos && !inHand[pos]) {
|
if (pos && !inHand[pos]) {
|
||||||
@@ -225,6 +246,15 @@
|
|||||||
slot.removeAttribute('data-pos-key');
|
slot.removeAttribute('data-pos-key');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Reveal fresh entries in draw order (the server stores the hand in
|
||||||
|
// DRAW_ORDER), first immediately then one per stagger tick.
|
||||||
|
var idx = 0;
|
||||||
|
function next() {
|
||||||
|
if (idx >= fresh.length) { _revealTimer = null; return; }
|
||||||
|
_revealCard(cross, fresh[idx++]);
|
||||||
|
_revealTimer = (idx < fresh.length) ? setTimeout(next, REVEAL_STAGGER_MS) : null;
|
||||||
|
}
|
||||||
|
next();
|
||||||
}
|
}
|
||||||
window._mySeaApplyHand = _applyHand; // test seam
|
window._mySeaApplyHand = _applyHand; // test seam
|
||||||
|
|
||||||
@@ -240,6 +270,7 @@
|
|||||||
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
if (msg.type === 'sea_draw') { _applySpread(msg.spread); _applyHand(msg.hand); }
|
if (msg.type === 'sea_draw') { _applySpread(msg.spread); _applyHand(msg.hand); }
|
||||||
|
else if (msg.type === 'sea_spread') _applySpread(msg.spread);
|
||||||
else if (msg.type === 'sea_seats') _renderSeats(msg.seats);
|
else if (msg.type === 'sea_seats') _renderSeats(msg.seats);
|
||||||
};
|
};
|
||||||
// Brief capped reconnect for transient blips (no infinite loop if
|
// Brief capped reconnect for transient blips (no infinite loop if
|
||||||
|
|||||||
Reference in New Issue
Block a user