Mid-game invite links and a surprise-report slip
The masthead's room code is now a button: one click copies the /join link, which seats newcomers in a waiting room and the gallery once the boards flip. And a 🐞 report opens a two-question slip — what happened, what was expected — that lands in feedback.jsonl beside the rooms with the room code, move number, round, and rules rev attached, so any surprise can be replayed to its exact moment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
a1fdb37c54
commit
553754a596
@@ -9,6 +9,7 @@
|
|||||||
// {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:"chat", text} table talk to the room
|
||||||
|
// {type:"feedback", happened, expected} a surprise report, pinned to room+seq
|
||||||
// {type:"rollDie"} the tabletop D4, published as talk
|
// {type:"rollDie"} the tabletop D4, published as talk
|
||||||
// {type:"addBot", style?, tier?} host seats an automaton
|
// {type:"addBot", style?, tier?} host seats an automaton
|
||||||
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
// {type:"chat", player, text, at} one line of table talk
|
// {type:"chat", player, text, at} one line of table talk
|
||||||
// {type:"watching", roomId} you are seated in the gallery
|
// {type:"watching", roomId} you are seated in the gallery
|
||||||
// {type:"audience", count} how many watch from the gallery
|
// {type:"audience", count} how many watch from the gallery
|
||||||
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
|
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"|"feedbackReceived"}
|
||||||
// {type:"error", message}
|
// {type:"error", message}
|
||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
@@ -62,6 +63,7 @@ import {
|
|||||||
abandonRoom,
|
abandonRoom,
|
||||||
} from "./rooms";
|
} from "./rooms";
|
||||||
import { engagementStats, recordHotseat } from "./stats";
|
import { engagementStats, recordHotseat } from "./stats";
|
||||||
|
import { appendFeedback } from "./store";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
import { getShare, loadShares, mintShare } from "./shares";
|
||||||
import { renderSharePng } from "./ogimage";
|
import { renderSharePng } from "./ogimage";
|
||||||
import { BOT_LINES, type BanterTrigger } from "./banter";
|
import { BOT_LINES, type BanterTrigger } from "./banter";
|
||||||
@@ -796,6 +798,26 @@ wss.on("connection", (socket) => {
|
|||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "feedback": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room) return send(socket, { type: "error", message: "join a room first — a report rides its ledger" });
|
||||||
|
const clean = (raw: unknown) =>
|
||||||
|
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
||||||
|
const happened = clean(msg.happened);
|
||||||
|
if (!happened) return send(socket, { type: "error", message: "say what happened" });
|
||||||
|
appendFeedback({
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
roomId: room.id,
|
||||||
|
player: session.playerId ?? "(gallery)",
|
||||||
|
seq: room.log.length,
|
||||||
|
round: room.state?.turn.round ?? null,
|
||||||
|
deckRev: room.state?.config.deckRev ?? null,
|
||||||
|
happened,
|
||||||
|
expected: clean(msg.expected),
|
||||||
|
});
|
||||||
|
send(socket, { type: "feedbackReceived" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "hotseatReport": {
|
case "hotseatReport": {
|
||||||
// A device finishes a handful of games at most; a firehose is abuse.
|
// A device finishes a handful of games at most; a firehose is abuse.
|
||||||
if (++session.hotseatReports > 20) return;
|
if (++session.hotseatReports > 20) return;
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
|||||||
return rooms;
|
return rooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Player surprise reports, one JSONL line each, beside the rooms
|
||||||
|
* directory — the room id and seq pin the exact moment to replay. */
|
||||||
|
export function appendFeedback(entry: Record<string, unknown>): void {
|
||||||
|
ensureDataDir();
|
||||||
|
appendFileSync(join(DATA_DIR, "..", "feedback.jsonl"), JSON.stringify(entry) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
/** Retire an abandoned room's ledger to the graveyard — never deleted,
|
||||||
* only moved out of the living rooms directory. */
|
* only moved out of the living rooms directory. */
|
||||||
export function archiveRoomFile(roomId: string): void {
|
export function archiveRoomFile(roomId: string): void {
|
||||||
|
|||||||
@@ -1179,6 +1179,23 @@
|
|||||||
|
|
||||||
/** The preferences slip (client-side taste, saved on this device). */
|
/** The preferences slip (client-side taste, saved on this device). */
|
||||||
let prefsOpen = $state(false);
|
let prefsOpen = $state(false);
|
||||||
|
/** The bug-report slip: what happened vs. what was expected. The room
|
||||||
|
* code and move number ride along server-side, pinning the moment in
|
||||||
|
* the ledger for replay. */
|
||||||
|
let feedbackOpen = $state(false);
|
||||||
|
let fbHappened = $state("");
|
||||||
|
let fbExpected = $state("");
|
||||||
|
let fbSent = $state(false);
|
||||||
|
function submitFeedback() {
|
||||||
|
net.sendFeedback(fbHappened.trim(), fbExpected.trim());
|
||||||
|
fbSent = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
feedbackOpen = false;
|
||||||
|
fbSent = false;
|
||||||
|
fbHappened = "";
|
||||||
|
fbExpected = "";
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
/** First tap of marching the Democratic Monster onto YOURSELF: warn once. */
|
/** First tap of marching the Democratic Monster onto YOURSELF: warn once. */
|
||||||
let dmWarnCell = $state<string | null>(null);
|
let dmWarnCell = $state<string | null>(null);
|
||||||
@@ -1360,7 +1377,10 @@
|
|||||||
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
||||||
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
||||||
{:else if net.roomId}
|
{:else if net.roomId}
|
||||||
<span class="mast-room">{net.spectating ? "watching" : "room"} <b>{net.roomId}</b></span>
|
<button class="mast-room" title="copy the invite link — friends can join, or watch once it's begun"
|
||||||
|
onclick={copyInvite}>
|
||||||
|
{net.spectating ? "watching" : "room"} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
|
||||||
|
</button>
|
||||||
{#if net.audience > 0}
|
{#if net.audience > 0}
|
||||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1368,6 +1388,8 @@
|
|||||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
|
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
|
||||||
|
<button class="mast-leave" title="something behaved unexpectedly? tell the wizards"
|
||||||
|
onclick={() => (feedbackOpen = true)}>🐞 report</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
|
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
|
||||||
help & rules
|
help & rules
|
||||||
@@ -1462,6 +1484,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if feedbackOpen && net.roomId}
|
||||||
|
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
||||||
|
onclick={() => (feedbackOpen = false)} onkeydown={() => {}}>
|
||||||
|
<div class="attack-notice prefs-slip" role="dialog" aria-label="report a surprise" tabindex="-1"
|
||||||
|
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
|
||||||
|
<div class="attack-title">Something unexpected?</div>
|
||||||
|
{#if fbSent}
|
||||||
|
<p class="fb-thanks">Recorded beside the room's ledger — thank you. The wizards will study the moment.</p>
|
||||||
|
{:else}
|
||||||
|
<label class="fb-field">
|
||||||
|
<span>What happened?</span>
|
||||||
|
<textarea bind:value={fbHappened} rows="3" maxlength="2000"
|
||||||
|
placeholder="e.g. my fireball hit but no damage was taken"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="fb-field">
|
||||||
|
<span>What did you expect?</span>
|
||||||
|
<textarea bind:value={fbExpected} rows="3" maxlength="2000"
|
||||||
|
placeholder="e.g. 5 points of damage"></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="fb-note">The room code and move number ride along automatically.</p>
|
||||||
|
<div class="attack-actions">
|
||||||
|
<button class="stamp primary" disabled={!fbHappened.trim()} onclick={submitFeedback}>Send it</button>
|
||||||
|
<button class="hint-cancel" onclick={() => (feedbackOpen = false)}>never mind</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if prefsOpen}
|
{#if prefsOpen}
|
||||||
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
||||||
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
|
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
|
||||||
@@ -2619,8 +2670,33 @@
|
|||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: #6b6454;
|
color: #6b6454;
|
||||||
}
|
}
|
||||||
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
|
.mast-room {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a49c86;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
||||||
|
.fb-field { display: block; margin: 0.5rem 0; }
|
||||||
|
.fb-field span { display: block; margin-bottom: 0.2rem; color: #6b5a41; }
|
||||||
|
.fb-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid #8a7a5e;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f3ecd8;
|
||||||
|
color: #43331f;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
|
||||||
|
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
|
||||||
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
|
||||||
.mind-read { max-width: min(92vw, 46rem); }
|
.mind-read { max-width: min(92vw, 46rem); }
|
||||||
.caution-reason { padding: 0.15rem 0; }
|
.caution-reason { padding: 0.15rem 0; }
|
||||||
|
|||||||
@@ -594,6 +594,11 @@ class Net {
|
|||||||
this.games = this.games.filter((g) => g.roomId !== roomId);
|
this.games = this.games.filter((g) => g.roomId !== roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A surprise report: the server pins it to the room and move number. */
|
||||||
|
sendFeedback(happened: string, expected: string): void {
|
||||||
|
this.send({ type: "feedback", happened, expected });
|
||||||
|
}
|
||||||
|
|
||||||
requestTransferCode(): void {
|
requestTransferCode(): void {
|
||||||
this.send({ type: "makeTransfer" });
|
this.send({ type: "makeTransfer" });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user