Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions game.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,28 +270,19 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
}
return
case "prompt":
// host-only poll: while a prompt challenge is live, hand the host
// the spinner's name, the prompt text, and how many seconds have
// already elapsed so their popup can sync its countdown and enable
// the "fail" choice on time.
if state.Game.StateID != statePrompt {
w.WriteHeader(http.StatusNoContent)
return
}
if !state.isHost(cookieKey) {
w.WriteHeader(http.StatusNoContent)
return
}
spin, err := queries.SpinPendingModifier(r.Context(), gameID)
if err != nil || spin.Type != "prompt" || !spin.PlayerID.Valid {
log.Debug("no pending prompt for host poll",
log.Debug("no pending prompt for poll",
"game_id", gameID, "error", err)
w.WriteHeader(http.StatusNoContent)
return
}
elapsed, err := queries.SpinLatestElapsedSeconds(r.Context(), gameID)
if err != nil {
log.Error("measure prompt elapsed", "error", err, "game_id", gameID)
// the spinner already has the card via the newPrompt HX-Trigger
if spin.PlayerID.Int32 == int32(state.CallerID) {
w.WriteHeader(http.StatusNoContent)
return
}
Expand All @@ -303,13 +294,26 @@ func dataHandler(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"spin_id": spin.ID,
"spinner": spinnerName,
"prompt": spin.Front,
"elapsed": elapsed,
"window": promptSeconds,
})
if state.isHost(cookieKey) {
elapsed, err := queries.SpinLatestElapsedSeconds(r.Context(), gameID)
if err != nil {
log.Error("measure prompt elapsed", "error", err, "game_id", gameID)
w.WriteHeader(http.StatusNoContent)
return
}
json.NewEncoder(w).Encode(map[string]any{
"spin_id": spin.ID,
"spinner": spinnerName,
"prompt": spin.Front,
"elapsed": elapsed,
"window": promptSeconds,
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"spinner": spinnerName,
"prompt": spin.Front,
})
}
return
case "infraction":
if state.Game.StateID != stateChallenge {
Expand Down
8 changes: 8 additions & 0 deletions static/html/tmpl.game.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ <h2 id="newprompt-title">prompt challenge!</h2>
</div>
</dialog>

<dialog id="prompt-spectate-dialog">
<div class="dialog-body stack stack-centered">
<h2 id="prompt-spectate-spinner"></h2>
<div id="prompt-spectate-content" class="index-card"></div>
<button class="button" data-close-dialog="prompt-spectate-dialog" autofocus>ok</button>
</div>
</dialog>

<dialog id="prompt-decide-dialog" data-game-id="{{ .Game.ID }}">
<div class="dialog-body stack stack-centered">
<h2>prompt challenge</h2>
Expand Down
113 changes: 74 additions & 39 deletions static/js/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@

// a fresh challenge: show the countdown view, clear any prior outcome.
spinnerActive = true;
var self = document.getElementById("self");
if (self) {
var myName = self.textContent.trim();
var items = document.querySelectorAll('#event-log .event[data-event-type="prompt"]');
for (var i = items.length - 1; i >= 0; i--) {
if (items[i].getAttribute("data-target") === myName) {
lastOutcomeId = items[i].getAttribute("data-event-id");
break;
}
}
}
if (title) title.textContent = "prompt challenge!";
if (waiting) waiting.hidden = true;
if (outcome) outcome.hidden = true;
Expand Down Expand Up @@ -206,17 +217,28 @@
}
});

// handlePrompt runs after every host prompt-poll. 200 = a live challenge to
// rule on; 204 = none (close any stale dialog).
// ---- spectator side: read-only card view ----

var spectatePrompt = null; // prompt text currently shown to spectators

function closeSpectateDialog() {
var dialog = document.getElementById("prompt-spectate-dialog");
if (dialog && dialog.open) dialog.close();
spectatePrompt = null;
}

// handlePrompt runs after every prompt-poll. 200 = a live challenge;
// 204 = none (close any stale dialog). The payload shape determines
// the role: spin_id present → host; absent → spectator.
window.handlePrompt = function (e) {
var dialog = document.getElementById("prompt-decide-dialog");
if (!dialog) return;
var decideDialog = document.getElementById("prompt-decide-dialog");
if (e.detail.xhr.status !== 200) {
if (dialog.open && hostSpinId !== null) {
if (decideDialog && decideDialog.open && hostSpinId !== null) {
clearHostTimer();
dialog.close();
decideDialog.close();
hostSpinId = null;
}
closeSpectateDialog();
return;
}
var data;
Expand All @@ -225,44 +247,57 @@
} catch (err) {
return;
}
if (String(data.spin_id) === String(lastRuledSpinId)) return; // already ruled
if (String(data.spin_id) === String(hostSpinId)) return; // already showing

// a new challenge: populate and open. anchor the countdown to the spin
// time the server reported (now minus the elapsed seconds) so it stays
// accurate however late this host opened the popup.
hostSpinId = data.spin_id;
var spinnerEl = document.getElementById("prompt-decide-spinner");
var contentEl = document.getElementById("prompt-decide-content");
if (spinnerEl) spinnerEl.textContent = data.spinner + "'s challenge:";
if (contentEl) contentEl.textContent = data.prompt;
if (spinnerActive) return;

var window_ = data.window || 60;
var anchor = Date.now() - (data.elapsed || 0) * 1000;
var countdown = document.getElementById("prompt-decide-countdown");
var failBtn = document.getElementById("prompt-fail-btn");
if (failBtn) failBtn.disabled = true;
if (data.spin_id != null) {
// host path
if (String(data.spin_id) === String(lastRuledSpinId)) return;
if (String(data.spin_id) === String(hostSpinId)) return;

clearHostTimer();
function tick() {
var remaining = Math.ceil((window_ * 1000 - (Date.now() - anchor)) / 1000);
if (remaining > 0) {
if (countdown) {
countdown.textContent = remaining;
countdown.classList.remove("prompt-countdown-done");
}
if (failBtn) failBtn.disabled = true;
} else {
if (countdown) {
countdown.textContent = "0";
countdown.classList.add("prompt-countdown-done");
hostSpinId = data.spin_id;
var spinnerEl = document.getElementById("prompt-decide-spinner");
var contentEl = document.getElementById("prompt-decide-content");
if (spinnerEl) spinnerEl.textContent = data.spinner + "'s challenge:";
if (contentEl) contentEl.textContent = data.prompt;

var window_ = data.window || 60;
var anchor = Date.now() - (data.elapsed || 0) * 1000;
var countdown = document.getElementById("prompt-decide-countdown");
var failBtn = document.getElementById("prompt-fail-btn");
if (failBtn) failBtn.disabled = true;

clearHostTimer();
function tick() {
var remaining = Math.ceil((window_ * 1000 - (Date.now() - anchor)) / 1000);
if (remaining > 0) {
if (countdown) {
countdown.textContent = remaining;
countdown.classList.remove("prompt-countdown-done");
}
if (failBtn) failBtn.disabled = true;
} else {
if (countdown) {
countdown.textContent = "0";
countdown.classList.add("prompt-countdown-done");
}
if (failBtn) failBtn.disabled = false;
clearHostTimer();
}
if (failBtn) failBtn.disabled = false;
clearHostTimer();
}
tick();
hostTimer = setInterval(tick, 250);
if (!decideDialog.open) decideDialog.showModal();
} else {
// spectator path
if (data.prompt === spectatePrompt) return;
spectatePrompt = data.prompt;
var dialog = document.getElementById("prompt-spectate-dialog");
var spinnerEl = document.getElementById("prompt-spectate-spinner");
var contentEl = document.getElementById("prompt-spectate-content");
if (spinnerEl) spinnerEl.textContent = data.spinner + "’s challenge";
if (contentEl) contentEl.textContent = data.prompt;
if (dialog && !dialog.open) dialog.showModal();
}
tick();
hostTimer = setInterval(tick, 250);
if (!dialog.open) dialog.showModal();
};
})();
Loading