Table talk: chat woven into the chronicle

A say-box under the chronicle sends table talk to the room; messages
land in the event log itself, styled as parchment asides — banter
between the battle lines, the way it happens at a real table. Talk is
seat-authenticated, stripped and capped at 300 chars, rate-limited by
the existing bucket, persisted as chat lines in the room file (the
game replay ignores them; the room keeps its last 500), and replayed
to anyone joining. Async games get unread badges in the lobby ledger
(💬3), cleared by watching the table, and catch-up replays speak the
lines at the step where they were said. Public to the whole room, no
whispers — and no chat in hotseat, where the table talks for itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 14:24:22 -04:00
co-authored by Claude Fable 5
parent ed9585aa59
commit df52e9b53a
7 changed files with 242 additions and 7 deletions
@@ -344,3 +344,104 @@
[ 1452368ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1 [ 1452368ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1458783ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1 [ 1458783ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1462262ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1 [ 1462262ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1467373ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1470884ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1474064ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1477555ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1480103ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1483605ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1488167ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1492812ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1497873ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1501672ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1506179ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1509169ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1511741ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1517646ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1520993ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1525701ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1531664ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1534270ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1540398ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1545631ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1551430ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1555883ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1561789ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1565419ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1571348ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1577540ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1580171ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1583175ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1587237ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1590726ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1595865ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1601873ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1603377ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1604882ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1606385ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1607886ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1609391ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1610897ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1612401ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1613918ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1615437ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1617018ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1618600ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1620186ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1622078ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1624517ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1626966ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1630727ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1636685ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1641233ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1646675ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1650627ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1656147ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1661012ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1664297ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1669486ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1675362ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1678087ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1681784ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1684714ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1690197ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1694231ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1696742ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1702523ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1707245ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1710064ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1712824ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1716844ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1721241ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1727490ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1731235ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1737620ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1740738ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1745610ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1749658ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1752580ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1757381ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1763004ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1766308ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1769306ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1774715ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1780095ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1784290ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1788290ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1794315ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1798954ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1804568ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1810874ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1814383ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1818248ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1823554ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1828717ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1831520ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1837969ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1841753ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1846062ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1848849ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1854446ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1857424ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1862532ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
[ 1865457ms] [ERROR] WebSocket connection to 'ws://127.0.0.1:8899/ws' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:8899/assets/index-CAp618mF.js:1
+15
View File
@@ -8,6 +8,7 @@
// {type:"makeTransfer"} mint a seat-transfer phrase // {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device // {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away // {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"myGames", seats} summaries for held seats // {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally // {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts // {type:"hotseatReport", ...} anonymous hotseat game counts
@@ -17,6 +18,8 @@
// {type:"room", roomId, players, hostId, started, colors} // {type:"room", roomId, players, hostId, started, colors}
// {type:"events", events} redacted for this recipient // {type:"events", events} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change) // {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk
// {type:"chatHistory", messages} the room's talk so far, on join
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
// {type:"error", message} // {type:"error", message}
@@ -33,6 +36,7 @@ import {
getRoom, getRoom,
joinRoom, joinRoom,
loadPersistedRooms, loadPersistedRooms,
addChat,
makeTransferCode, makeTransferCode,
redactFor, redactFor,
roomCount, roomCount,
@@ -246,6 +250,9 @@ wss.on("connection", (socket) => {
if (room.state) { if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) }); send(socket, { type: "events", events: redactFor(room.events, name) });
} }
if (room.chat.length > 0) {
send(socket, { type: "chatHistory", messages: room.chat.slice(-100) });
}
broadcastRoomState(room); broadcastRoomState(room);
break; break;
} }
@@ -271,6 +278,14 @@ wss.on("connection", (socket) => {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
break; break;
} }
case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = addChat(room, session.playerId, String(msg.text ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "makeTransfer": { case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+30
View File
@@ -39,6 +39,8 @@ export interface Room {
state: GameState | null; // null until started state: GameState | null; // null until started
log: LoggedCommand[]; log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient) events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
} }
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
@@ -95,6 +97,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
state: null, state: null,
log: [], log: [],
events: [], events: [],
chat: [],
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room); recordRoom(room);
@@ -210,6 +213,20 @@ export function runCommand(
return { events: result.events }; return { events: result.events };
} }
const CHAT_MAX_LENGTH = 300;
const CHAT_KEEP = 500;
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
if (!text) return { error: "say something" };
const at = new Date().toISOString();
room.chat.push({ player: playerId, text, at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
appendLine(room.id, { kind: "chat", player: playerId, text, at });
return { text, at };
}
export interface GameSummary { export interface GameSummary {
roomId: string; roomId: string;
name: PlayerId; name: PlayerId;
@@ -223,6 +240,8 @@ export interface GameSummary {
attention: "turn" | "counteract" | "discard" | "interrupt" | null; attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null; round: number | null;
lastMoveAt: string | null; lastMoveAt: string | null;
/** Total table-talk messages; the client tracks which it has seen. */
chatCount: number;
} }
/** A seat-holder's one-line view of a room, for the lobby ledger. */ /** A seat-holder's one-line view of a room, for the lobby ledger. */
@@ -252,6 +271,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
attention, attention,
round: s?.turn.round ?? null, round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length,
}; };
} }
@@ -264,6 +284,8 @@ export interface CatchUpStep {
actor: PlayerId; actor: PlayerId;
events: GameEvent[]; events: GameEvent[];
view: GameView; view: GameView;
/** Table talk uttered between the previous command and this one. */
chat?: { player: PlayerId; text: string }[];
} }
/** /**
@@ -284,11 +306,16 @@ export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, f
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` }; if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
current = result.state; current = result.state;
if (entry.seq >= from) { if (entry.seq >= from) {
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
.map((c) => ({ player: c.player, text: c.text }));
steps.push({ steps.push({
seq: entry.seq, seq: entry.seq,
actor: entry.playerId, actor: entry.playerId,
events: redactFor(result.events, playerId), events: redactFor(result.events, playerId),
view: viewFor(current, playerId), view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
}); });
} }
} }
@@ -396,6 +423,7 @@ export function loadPersistedRooms(): void {
state: null, state: null,
log: [], log: [],
events: [], events: [],
chat: [],
}; };
for (const line of lines.slice(1)) { for (const line of lines.slice(1)) {
if (line.kind === "join") { if (line.kind === "join") {
@@ -406,6 +434,8 @@ export function loadPersistedRooms(): void {
} else if (line.kind === "start") { } else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev); const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`); if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "chat") {
room.chat.push({ player: line.player, text: line.text, at: line.at });
} else if (line.kind === "command") { } else if (line.kind === "command") {
if (!room.state) throw new Error("command before start in log"); if (!room.state) throw new Error("command before start in log");
const result = applyCommand(room.state, line.playerId, line.command as Command); const result = applyCommand(room.state, line.playerId, line.command as Command);
+8 -1
View File
@@ -43,7 +43,14 @@ export interface CommandLine {
at: string; at: string;
} }
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine; export interface ChatLine {
kind: "chat";
player: string;
text: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
+36 -3
View File
@@ -28,6 +28,7 @@
let punchWallMode = $state(false); let punchWallMode = $state(false);
/** Leafing through the face-up discard pile. */ /** Leafing through the face-up discard pile. */
let showDiscards = $state(false); let showDiscards = $state(false);
let chatDraft = $state("");
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -961,9 +962,9 @@
{:else if !g.started} {:else if !g.started}
waiting to start · {g.players.join(", ")} waiting to start · {g.players.join(", ")}
{:else if g.yourTurn} {:else if g.yourTurn}
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)} {attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{:else} {:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)} {g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{/if} {/if}
</span> </span>
</button> </button>
@@ -1150,9 +1151,21 @@
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}> <div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)} {#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
<div>{line}</div> <div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
{/each} {/each}
</div> </div>
{#if !local.active && net.roomId}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (t) net.sendChat(t);
chatDraft = "";
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
</form>
{/if}
</aside> </aside>
</div> </div>
@@ -1894,6 +1907,26 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk {
background: #efe8d4;
color: #43331f;
border-radius: 3px;
padding: 0.1rem 0.4rem;
margin: 0.15rem 0;
font-style: italic;
}
.say-box { display: flex; gap: 0.35rem; margin-top: 0.4rem; }
.say-input {
flex: 1;
min-width: 0;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.35rem 0.55rem;
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
}
.discard-link { .discard-link {
background: none; background: none;
border: none; border: none;
+6 -2
View File
@@ -7,7 +7,7 @@
steps, steps,
onclose, onclose,
}: { }: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[]; steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void; onclose: () => void;
} = $props(); } = $props();
@@ -52,7 +52,10 @@
<div class="replay-caption"> <div class="replay-caption">
<strong>{step.actor}</strong> <strong>{step.actor}</strong>
{#each lines as line, i (i)}<div>{line}</div>{/each} {#each lines as line, i (i)}<div>{line}</div>{/each}
{#if lines.length === 0}<div>…considers the maze.</div>{/if} {#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
{#each step.chat ?? [] as c, i (i)}
<div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each}
</div> </div>
<div class="replay-controls"> <div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button> <button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
@@ -122,6 +125,7 @@
width: auto; width: auto;
max-width: 100%; max-width: 100%;
} }
.reel-talk { font-style: italic; opacity: 0.85; }
.replay-caption { .replay-caption {
background: #efe8d4; background: #efe8d4;
color: #3a2f1f; color: #3a2f1f;
+46 -1
View File
@@ -169,6 +169,12 @@ export function humanize(e: GameEvent): string | null {
const SEAT_KEY = "wizwar-seat"; const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats"; const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
function loadChatSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
const SEEN_KEY = "wizwar-seen"; const SEEN_KEY = "wizwar-seen";
function loadSeen(): Record<string, number> { function loadSeen(): Record<string, number> {
@@ -189,6 +195,7 @@ export interface GameSummary {
attention: "turn" | "counteract" | "discard" | "interrupt" | null; attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null; round: number | null;
lastMoveAt: string | null; lastMoveAt: string | null;
chatCount: number;
} }
export function attentionLabel(a: GameSummary["attention"]): string { export function attentionLabel(a: GameSummary["attention"]): string {
@@ -224,6 +231,9 @@ class Net {
seats = $state<Seat[]>(loadSeats()); seats = $state<Seat[]>(loadSeats());
/** Lobby ledger: one summary per live seat. */ /** Lobby ledger: one summary per live seat. */
stats = $state<Record<string, number | string | null> | null>(null); stats = $state<Record<string, number | string | null> | null>(null);
chatSeen = $state<Record<string, number>>(loadChatSeen());
/** Messages in the current room this session (history + live). */
chatCount = 0;
games = $state<GameSummary[]>([]); games = $state<GameSummary[]>([]);
notificationsEnabled = $state( notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted", typeof Notification !== "undefined" && Notification.permission === "granted",
@@ -233,7 +243,7 @@ class Net {
/** Moves you haven't watched yet in the current room. */ /** Moves you haven't watched yet in the current room. */
missedMoves = $state(0); missedMoves = $state(0);
/** A catch-up reel delivered by the server. */ /** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen(); private seen: Record<string, number> = loadSeen();
private currentSeq = 0; private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>(); private lastYourTurn = new Map<string, boolean>();
@@ -325,6 +335,23 @@ class Net {
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token }); this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break; break;
} }
case "chat": {
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
}
case "chatHistory": {
this.log = [
...this.log,
...(msg.messages as { player: string; text: string }[]).map(
(m) => `\u{1F4AC} ${m.player}: ${m.text}`,
),
];
this.chatCount = msg.messages.length;
if (this.roomId) this.markChatSeen();
break;
}
case "stats": { case "stats": {
this.stats = msg.stats; this.stats = msg.stats;
break; break;
@@ -399,6 +426,24 @@ class Net {
} }
/** Ask the server how all our games are doing. */ /** Ask the server how all our games are doing. */
sendChat(text: string): void {
this.send({ type: "chat", text });
}
/** Watching the table counts as reading the talk. */
markChatSeen(): void {
if (!this.roomId) return;
const games = this.games.find((g) => g.roomId === this.roomId);
const count = Math.max(this.chatCount, games?.chatCount ?? 0);
if ((this.chatSeen[this.roomId] ?? 0) >= count) return;
this.chatSeen = { ...this.chatSeen, [this.roomId]: count };
localStorage.setItem(CHAT_SEEN_KEY, JSON.stringify(this.chatSeen));
}
unreadChat(roomId: string, chatCount: number): number {
return Math.max(0, chatCount - (this.chatSeen[roomId] ?? 0));
}
requestStats(): void { requestStats(): void {
this.send({ type: "stats" }); this.send({ type: "stats" });
} }