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:"catchUp", sinceSeq} replay of moves missed while away
|
||||
// {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:"addBot", style?, tier?} host seats an automaton
|
||||
// {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:"watching", roomId} you are seated in 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}
|
||||
|
||||
import { createServer } from "node:http";
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
abandonRoom,
|
||||
} from "./rooms";
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { appendFeedback } from "./store";
|
||||
import { getShare, loadShares, mintShare } from "./shares";
|
||||
import { renderSharePng } from "./ogimage";
|
||||
import { BOT_LINES, type BanterTrigger } from "./banter";
|
||||
@@ -796,6 +798,26 @@ wss.on("connection", (socket) => {
|
||||
broadcastRoomState(room);
|
||||
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": {
|
||||
// A device finishes a handful of games at most; a firehose is abuse.
|
||||
if (++session.hotseatReports > 20) return;
|
||||
|
||||
@@ -148,6 +148,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
|
||||
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,
|
||||
* only moved out of the living rooms directory. */
|
||||
export function archiveRoomFile(roomId: string): void {
|
||||
|
||||
@@ -1179,6 +1179,23 @@
|
||||
|
||||
/** The preferences slip (client-side taste, saved on this device). */
|
||||
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. */
|
||||
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.abandon()}>abandon game</button>
|
||||
{: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}
|
||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||
{/if}
|
||||
@@ -1368,6 +1388,8 @@
|
||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||
{/if}
|
||||
<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}
|
||||
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
|
||||
help & rules
|
||||
@@ -1462,6 +1484,35 @@
|
||||
{/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}
|
||||
<div class="scrim attack-scrim" role="button" tabindex="-1"
|
||||
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
|
||||
@@ -2619,8 +2670,33 @@
|
||||
letter-spacing: 0.06em;
|
||||
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; }
|
||||
.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; }
|
||||
.mind-read { max-width: min(92vw, 46rem); }
|
||||
.caution-reason { padding: 0.15rem 0; }
|
||||
|
||||
@@ -594,6 +594,11 @@ class Net {
|
||||
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 {
|
||||
this.send({ type: "makeTransfer" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user