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
7 changes: 7 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(make *)"
]
}
}
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.PHONY: ci lint format-check test install-dev run venv
# Put the project venv first on PATH so targets work the same whether or not
# the user has activated it (or has direnv loaded). `make venv` creates it.
export PATH := $(CURDIR)/.venv/bin:$(PATH)

.PHONY: ci lint format format-check test install-dev run venv

# Run all CI checks — called by GitHub Actions.
ci: lint format-check frontend-lint test
Expand All @@ -11,6 +15,10 @@ lint:
format-check:
ruff format --check .

format:
ruff format .
ruff check --fix .

test:
pytest

Expand Down
125 changes: 114 additions & 11 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from flask import Blueprint, jsonify, request
from flask import current_app as app
from sqlalchemy.exc import NoResultFound

import utils
from config import config
Expand Down Expand Up @@ -129,24 +130,32 @@ def _full_state_payload():
data["active_question"] = controller.get_active_question()

# Daily-double waiger range for the team currently in control (if any).
# Always report a usable range so the host slider is interactive even
# before any team has been put in control.
state = controller.get_complete_state()
if state.get("dailydouble") == "enabled":
_min = config.get("DAILYDOUBLE_WAIGER_MIN", 0)
_max = config.get("DAILYDOUBLE_WAIGER_MAX_MIN", 0)
if state.get("dailydouble") in ("enabled", "revealed"):
try:
ctrl_team = controller.get_team_in_control()
_min, _max = controller.get_dailydouble_waiger_range(ctrl_team.tid)
except Exception:
_min, _max = 0, 0
else:
_min = config.get("DAILYDOUBLE_WAIGER_MIN", 0)
_max = config.get("DAILYDOUBLE_WAIGER_MAX_MIN", 0)
except NoResultFound:
# No team in control yet: keep the config defaults.
pass
data["dailydouble_range"] = {"min": _min, "max": _max}
wager = controller.get_dailydouble_wager()
data["dailydouble_wager"] = {"team": wager[0], "amount": wager[1]} if wager else None
else:
data["teams"] = _teams_payload(controller)
data["categories"] = []
data["questions"] = {}
data["state"] = controller.get_complete_state()
data["active_question"] = {}
data["dailydouble_range"] = {"min": 0, "max": 0}
data["dailydouble_range"] = {
"min": config.get("DAILYDOUBLE_WAIGER_MIN", 0),
"max": config.get("DAILYDOUBLE_WAIGER_MAX_MIN", 0),
}
data["dailydouble_wager"] = None

return data

Expand Down Expand Up @@ -242,9 +251,16 @@ def finish():
pass
else:
text = "<p>That's all folks! Thanks for playing!</p>"
# Clear any active question / DD before showing the end-of-game
# overlay, otherwise stale ui_state can resurrect the DD card on the
# viewer the next time it loads.
controller.set_state("question", "")
controller.end_dailydouble()
controller.set_state("overlay-question", text)
controller.set_state("overlay-big", text)
controller.finish_game()
app.socketio.emit("dailydouble-wager", {"team": None, "amount": None}, namespace=GAME_NS)
app.socketio.emit("question-hide", {}, namespace=GAME_NS)
app.socketio.emit("overlay-big", {"id": "final", "html": text}, namespace=GAME_NS)
_broadcast_state()
return jsonify(result="success")
Expand Down Expand Up @@ -281,6 +297,24 @@ def team_select():
data = request.get_json(force=True, silent=True) or {}
tid = data.get("tid") or ""
controller.set_state("team", tid)
# If we're mid-DD and control is reassigned, drop the previous team's
# wager (a new one is needed) and re-broadcast the wager range computed
# for the new controlling team — the host's slider min/max must follow.
state = controller.get_complete_state()
if state.get("dailydouble") == "enabled" and tid:
controller.clear_dailydouble_wager()
app.socketio.emit("dailydouble-wager", {"team": None, "amount": None}, namespace=GAME_NS)
try:
dbl_min, dbl_max = controller.get_dailydouble_waiger_range(tid)
app.socketio.emit(
"dailydouble-range",
{"team": tid, "range": {"min": dbl_min, "max": dbl_max}},
namespace=GAME_NS,
)
except Exception: # noqa: BLE001
# If the range can't be computed for some reason, leave the
# client's previous range in place.
pass
app.socketio.emit("team-select", {"tid": tid or None}, namespace=GAME_NS)
return jsonify(result="success", tid=tid)

Expand Down Expand Up @@ -314,7 +348,19 @@ def question_select():
answer = controller.get_answer(col, row)

if question["dailydouble"] is True:
ctrl_team = controller.get_team_in_control()
# A Daily Double normally requires a team to be in control (they had
# to pick the clue to trigger it). If that invariant is broken — most
# likely because the operator forgot to use the roulette — fall back
# to team1 and log loudly so it's noticed.
try:
ctrl_team = controller.get_team_in_control()
except NoResultFound:
app.logger.warning(
"Daily Double selected with no team in control; falling back to team1. "
"Normally, you should use the roulette to assign control before picking a clue."
)
controller.set_state("team", "team1")
ctrl_team = controller.get_team_in_control()
dbl_min, dbl_max = controller.get_dailydouble_waiger_range(ctrl_team.tid)

controller.set_state("question", qid)
Expand All @@ -324,7 +370,12 @@ def question_select():
app.socketio.emit("question-hide", {}, namespace=GAME_NS)
app.socketio.emit(
"dailydouble",
{"qid": qid, "category": question["category"]},
{
"qid": qid,
"category": question["category"],
"team": ctrl_team.tid,
"range": {"min": dbl_min, "max": dbl_max},
},
namespace=GAME_NS,
)
return jsonify(
Expand All @@ -336,7 +387,7 @@ def question_select():
)

controller.set_state("question", qid)
controller.set_state("dailydouble", "")
controller.end_dailydouble()
app.socketio.emit(
"question-show",
{
Expand All @@ -355,11 +406,61 @@ def question_select():
)


@api_bp.route("/dailydouble/reveal", methods=["POST"])
def dailydouble_reveal():
"""Reveal the DD clue: flip state to 'revealed' and broadcast the text."""
controller = _controller()
state = controller.get_complete_state()
if state.get("dailydouble") != "enabled":
return jsonify(result="failure", error="Daily Double not active or already revealed"), 400
qid = state.get("question") or ""
try:
col, row = utils.parse_question_id(qid)
except utils.InvalidQuestionId:
return jsonify(result="failure", error="No active DD question"), 400
question = controller.get_question(col, row)
controller.set_state("dailydouble", "revealed")
app.socketio.emit(
"dailydouble-reveal",
{"qid": qid, "text": question["text"], "category": question["category"]},
namespace=GAME_NS,
)
return jsonify(result="success")


@api_bp.route("/dailydouble/wager", methods=["POST"])
def dailydouble_wager():
"""Persist + broadcast the controlling team's live DD wager."""
controller = _controller()
data = request.get_json(force=True, silent=True) or {}
try:
amount = int(data.get("amount"))
except (TypeError, ValueError):
return jsonify(result="failure", error="Invalid amount"), 400
try:
ctrl_team = controller.get_team_in_control()
except NoResultFound:
return jsonify(result="failure", error="No team in control"), 400
# Late import to dodge circular imports (controller imports model, etc.).
from controller import GameProblem

try:
tid, amount = controller.set_dailydouble_wager(ctrl_team.tid, amount)
except GameProblem as e:
return jsonify(result="failure", error=str(e)), 400
app.socketio.emit(
"dailydouble-wager",
{"team": tid, "amount": amount},
namespace=GAME_NS,
)
return jsonify(result="success", team=tid, amount=amount)


@api_bp.route("/question/deselect", methods=["POST"])
def question_deselect():
controller = _controller()
controller.set_state("question", "")
controller.set_state("dailydouble", "")
controller.end_dailydouble()
app.socketio.emit("question-hide", {}, namespace=GAME_NS)
_broadcast_board_update()
return jsonify(result="success")
Expand Down Expand Up @@ -393,6 +494,8 @@ def submit_answer():
waiger = answers.get(f"{tid}-waiger-dailydouble", 0)
if not controller.answer_dailydouble(col, row, team, answer, waiger):
return jsonify(result="failure", error="Answer submission failed"), 500
controller.end_dailydouble()
app.socketio.emit("dailydouble-wager", {"team": None, "amount": None}, namespace=GAME_NS)

# Team that ultimately "won" this question stays in control.
ctl_team = controller.get_good_answer_team(col, row)
Expand Down
31 changes: 31 additions & 0 deletions controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,37 @@ def get_dailydouble_waiger_range(team_id):
_max = config.get("DAILYDOUBLE_WAIGER_MAX_MIN")
return (_min, _max)

@staticmethod
def set_dailydouble_wager(tid: str, amount: int) -> tuple[str, int]:
"""Validate and persist the DD wager for the controlling team."""
_min, _max = Controller.get_dailydouble_waiger_range(tid)
if not (_min <= amount <= _max):
raise GameProblem(f"Wager {amount} outside [{_min}, {_max}] for {tid}")
Controller.set_state("dailydouble-wager", f"{tid}:{amount}")
return tid, amount

@staticmethod
def get_dailydouble_wager() -> tuple[str, int] | None:
"""Return the persisted (tid, amount) wager, or None."""
raw = Controller.get_state("dailydouble-wager") or ""
if ":" not in raw:
return None
tid, amount = raw.split(":", 1)
try:
return tid, int(amount)
except ValueError:
return None

@staticmethod
def clear_dailydouble_wager() -> None:
Controller.set_state("dailydouble-wager", "")

@staticmethod
def end_dailydouble() -> None:
"""Turn DD off and clear any wager. Use everywhere DD ends."""
Controller.set_state("dailydouble", "")
Controller.clear_dailydouble_wager()

@staticmethod
def teams_exists():
if Team.query.all():
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export const api = {
request("/team/select", { method: "POST", body: { tid } }),
roulette: () => request("/team/roulette", { method: "POST" }),

setWager: (amount) =>
request("/dailydouble/wager", { method: "POST", body: { amount } }),
revealDailyDouble: () => request("/dailydouble/reveal", { method: "POST" }),

showMessage: (id, text) =>
request("/message/show", { method: "POST", body: { id, text } }),
hideMessage: () => request("/message/hide", { method: "POST" }),
Expand Down
27 changes: 8 additions & 19 deletions frontend/src/components/DailyDoubleAnimation.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
<script setup>
import { onMounted, ref } from "vue";

const emit = defineEmits(["done"]);
const visible = ref(true);

onMounted(() => {
// Match CSS animation duration (2s) plus a small tail.
setTimeout(() => {
visible.value = false;
emit("done");
}, 2200);
});
// Visibility is controlled by the parent: it mounts this component when DD
// becomes active and unmounts it when DD ends. The CSS animation runs once
// (`forwards`) so the card stays put after the flip.
</script>

<template>
<Transition name="fade">
<div v-if="visible" class="dailydouble-overlay">
<div class="dailydouble-inner">
<span>DAILY</span>
<span>DOUBLE</span>
</div>
<div class="dailydouble-overlay">
<div class="dailydouble-inner">
<span>DAILY</span>
<span>DOUBLE</span>
</div>
</Transition>
</div>
</template>
8 changes: 7 additions & 1 deletion frontend/src/components/QuestionOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { useGameStore } from "@/stores/game";

const game = useGameStore();

const visible = computed(() => !!game.activeQuestionId && !game.isDailyDouble);
// Show the clue overlay for normal questions, and for DDs once the operator
// has revealed the clue. Hidden during the DD wager phase.
const visible = computed(
() =>
!!game.activeQuestionId &&
(!game.isDailyDouble || game.isDailyDoubleRevealed),
);
const html = computed(() => game.active_question?.text ?? "");
</script>

Expand Down
33 changes: 33 additions & 0 deletions frontend/src/components/TeamPanelViewer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup>
defineProps({
team: { type: Object, required: true },
idx: { type: Number, required: true },
selected: { type: Boolean, default: false },
});
</script>

<template>
<div
:id="team.tid"
class="black-box flex-pad"
:class="{ 'team-selected': selected }"
style="width: 80%; max-width: 250px; margin: auto"
>
<div class="col-player flex-horizontal-pad">
<div class="row-player flex-vertical-pad" style="height: 30%">
<div :id="`${team.tid}-score`" class="box-ceopardy box-score">
<p>${{ team.score }}</p>
</div>
</div>
<div class="row-player flex-vertical-pad" style="height: 70%">
<div
:id="`${team.tid}-name`"
class="box-ceopardy box-team"
:class="`team${idx + 1}-font`"
>
<p>{{ team.name }}</p>
</div>
</div>
</div>
</div>
</template>
22 changes: 22 additions & 0 deletions frontend/src/components/TeamRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup>
import { useGameStore } from "@/stores/game";
import TeamPanelViewer from "@/components/TeamPanelViewer.vue";

const game = useGameStore();
</script>

<template>
<div class="container-results">
<div
v-for="(team, idx) in game.teams"
:key="team.tid"
class="container-team"
>
<TeamPanelViewer
:team="team"
:idx="idx"
:selected="game.selectedTeam === team.tid"
/>
</div>
</div>
</template>
Loading
Loading