diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..61eb5e1 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(make *)" + ] + } +} diff --git a/Makefile b/Makefile index caee3f8..f274bf4 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -11,6 +15,10 @@ lint: format-check: ruff format --check . +format: + ruff format . + ruff check --fix . + test: pytest diff --git a/api/routes.py b/api/routes.py index acdbc42..1977180 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 @@ -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 @@ -242,9 +251,16 @@ def finish(): pass else: text = "

That's all folks! Thanks for playing!

" + # 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") @@ -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) @@ -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) @@ -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( @@ -336,7 +387,7 @@ def question_select(): ) controller.set_state("question", qid) - controller.set_state("dailydouble", "") + controller.end_dailydouble() app.socketio.emit( "question-show", { @@ -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") @@ -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) diff --git a/controller.py b/controller.py index b50c961..dfe31bb 100644 --- a/controller.py +++ b/controller.py @@ -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(): diff --git a/frontend/src/api.js b/frontend/src/api.js index 2f941e5..5a18c15 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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" }), diff --git a/frontend/src/components/DailyDoubleAnimation.vue b/frontend/src/components/DailyDoubleAnimation.vue index 6ba1dde..7a30d8b 100644 --- a/frontend/src/components/DailyDoubleAnimation.vue +++ b/frontend/src/components/DailyDoubleAnimation.vue @@ -1,25 +1,14 @@ diff --git a/frontend/src/components/QuestionOverlay.vue b/frontend/src/components/QuestionOverlay.vue index e5237e3..b8a7e09 100644 --- a/frontend/src/components/QuestionOverlay.vue +++ b/frontend/src/components/QuestionOverlay.vue @@ -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 ?? ""); diff --git a/frontend/src/components/TeamPanelViewer.vue b/frontend/src/components/TeamPanelViewer.vue new file mode 100644 index 0000000..0b7133e --- /dev/null +++ b/frontend/src/components/TeamPanelViewer.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/TeamRow.vue b/frontend/src/components/TeamRow.vue new file mode 100644 index 0000000..c9ff721 --- /dev/null +++ b/frontend/src/components/TeamRow.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/TeamRowDailyDouble.vue b/frontend/src/components/TeamRowDailyDouble.vue new file mode 100644 index 0000000..9fd67bc --- /dev/null +++ b/frontend/src/components/TeamRowDailyDouble.vue @@ -0,0 +1,52 @@ + + + diff --git a/frontend/src/components/TeamScoringPanel.vue b/frontend/src/components/TeamScoringPanel.vue index 427983b..e7ec1c1 100644 --- a/frontend/src/components/TeamScoringPanel.vue +++ b/frontend/src/components/TeamScoringPanel.vue @@ -33,6 +33,22 @@ function updateDouble(val) { function updateWaiger(val) { const key = `${tid.value}-waiger-dailydouble`; emit("update:answers", { ...props.answers, [key]: val }); + // Live-broadcast the wager so the viewer can show it. Throttled so a slider + // drag doesn't flood Socket.IO; trailing edge is preserved. + scheduleWagerBroadcast(val); +} + +let wagerTimer = null; +let wagerPending = null; +function scheduleWagerBroadcast(val) { + wagerPending = val; + if (wagerTimer) return; + api.setWager(wagerPending).catch(() => {}); + wagerPending = null; + wagerTimer = setTimeout(() => { + wagerTimer = null; + if (wagerPending !== null) scheduleWagerBroadcast(wagerPending); + }, 100); } function selectTeam() { @@ -43,11 +59,12 @@ const answerVal = computed(() => props.answers[tid.value] ?? 0); const doubleVal = computed( () => props.answers[`${tid.value}-dailydouble`] ?? -1, ); -const waigerVal = computed( - () => +const waigerVal = computed({ + get: () => props.answers[`${tid.value}-waiger-dailydouble`] ?? game.dailydouble_range.max, -); + set: (val) => updateWaiger(Number(val)), +}); const teamFontClass = `team${props.idx + 1}-font`; @@ -58,7 +75,6 @@ const teamFontClass = `team${props.idx + 1}-font`; class="black-box flex-small-pad" :class="{ 'team-selected': teamSelected, - disabled: disabled, }" style="width: 100%; margin: auto" > @@ -74,14 +90,14 @@ const teamFontClass = `team${props.idx + 1}-font`; >
-
+

{{ team.name }}

@@ -108,14 +124,13 @@ const teamFontClass = `team${props.idx + 1}-font`;

Minimum

diff --git a/frontend/src/components/ViewerBoard.vue b/frontend/src/components/ViewerBoard.vue index 4161713..468386f 100644 --- a/frontend/src/components/ViewerBoard.vue +++ b/frontend/src/components/ViewerBoard.vue @@ -1,6 +1,9 @@