BhargavMN commited on
Commit Β·
9115da0
1
Parent(s): 8ef5e98
feat(lobby): enhance start logic with "Start anyway" option and player count checks
Browse files- app.py +105 -26
- app/services/rooms.py +43 -1
app.py
CHANGED
|
@@ -1051,22 +1051,27 @@ def poll_tick(session_id, screen, team_id, player_name, is_host, poll_state):
|
|
| 1051 |
nav = [gr.update()] * 7
|
| 1052 |
new_screen = screen
|
| 1053 |
lobby_code = lobby_roster = play_timer = play_status = wait_status = win_score = gr.update()
|
| 1054 |
-
lobby_start = lobby_hint = gr.update()
|
|
|
|
| 1055 |
|
| 1056 |
if session_id:
|
| 1057 |
status = rooms.session_status(session_id)
|
|
|
|
|
|
|
| 1058 |
if screen == "lobby":
|
| 1059 |
lobby_code = code_html(session_id)
|
| 1060 |
lobby_roster = roster_html(session_id, player_name)
|
| 1061 |
-
#
|
| 1062 |
-
#
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
|
|
|
| 1066 |
if is_host:
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
|
|
|
| 1070 |
if status == "playing":
|
| 1071 |
new_screen = "play"
|
| 1072 |
nav = list(vis("play"))
|
|
@@ -1076,17 +1081,22 @@ def poll_tick(session_id, screen, team_id, player_name, is_host, poll_state):
|
|
| 1076 |
play_timer = timer_html(session_id, team_id)
|
| 1077 |
play_status = status_strip_html(session_id, team_id)
|
| 1078 |
elif screen == "wait":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1079 |
wait_status = status_strip_html(session_id, team_id)
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
|
| 1085 |
# ββ Decide next poll interval from whether anything changed ββ
|
| 1086 |
def _s(x):
|
| 1087 |
return x if isinstance(x, str) else ""
|
| 1088 |
sig = "|".join([new_screen, _s(lobby_code), _s(lobby_roster), _s(play_timer),
|
| 1089 |
-
_s(play_status), _s(wait_status), _s(win_score)])
|
| 1090 |
last_sig, idle, cur_iv = poll_state or ("", 0, POLL_FAST)
|
| 1091 |
if sig != last_sig:
|
| 1092 |
idle, target_iv = 0, POLL_FAST
|
|
@@ -1098,8 +1108,9 @@ def poll_tick(session_id, screen, team_id, player_name, is_host, poll_state):
|
|
| 1098 |
timer_update = gr.Timer(target_iv) if target_iv != cur_iv else gr.update()
|
| 1099 |
new_state = (sig, idle, target_iv)
|
| 1100 |
|
| 1101 |
-
return (*nav, new_screen, lobby_code, lobby_roster, lobby_start,
|
| 1102 |
-
play_timer, play_status, wait_status,
|
|
|
|
| 1103 |
|
| 1104 |
|
| 1105 |
def play_tick(session_id, screen, team_id, taskids, cur_task):
|
|
@@ -1122,12 +1133,39 @@ def play_tick(session_id, screen, team_id, taskids, cur_task):
|
|
| 1122 |
|
| 1123 |
|
| 1124 |
# ββ Screen-action handlers ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1125 |
-
# Lobby instruction copy β differs for the host vs. everyone else.
|
| 1126 |
-
|
|
|
|
|
|
|
|
|
|
| 1127 |
HINT_HOST_READY = "β
Everyone's in β start the adventure whenever you're ready."
|
| 1128 |
HINT_GUEST = "β³ Waiting for the host to start the adventureβ¦"
|
| 1129 |
|
| 1130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1131 |
def _team_btn_updates(session_id, selected_id):
|
| 1132 |
"""Button updates for the 4 team-pick slots: label + visibility, with the
|
| 1133 |
player's current team flagged 'selected' so it reads as chosen (green)."""
|
|
@@ -1164,10 +1202,13 @@ def do_create(name, gtype, city, area, duration, teams_n, head_start,
|
|
| 1164 |
rooms.create_room(name, config, session_id)
|
| 1165 |
teams = rooms.list_teams(session_id)
|
| 1166 |
tb = _team_btn_updates(session_id, teams[0]["id"])
|
|
|
|
|
|
|
|
|
|
| 1167 |
return (*vis("lobby"), "lobby", session_id, name, teams[0]["id"], True,
|
| 1168 |
code_html(session_id), roster_html(session_id, name),
|
| 1169 |
gr.update(visible=True, interactive=False),
|
| 1170 |
-
copy_btn_html(session_id),
|
| 1171 |
|
| 1172 |
|
| 1173 |
def do_join(code, name):
|
|
@@ -1227,17 +1268,20 @@ def prepare_game(session_id):
|
|
| 1227 |
yield "<div class='cq-loading'>Couldn't pre-generate β it'll generate when you start.</div>"
|
| 1228 |
|
| 1229 |
|
| 1230 |
-
def do_start(session_id):
|
| 1231 |
s = SESSION_STORE.get(session_id) or {}
|
| 1232 |
-
|
|
|
|
|
|
|
|
|
|
| 1233 |
if not s.get("game"):
|
| 1234 |
# Fallback: lobby pre-generation didn't finish β show a loader.
|
| 1235 |
yield (*vis("lobby"), "lobby", gr.update(), gr.update(),
|
| 1236 |
"<div class='cq-loading'>Summoning your questβ¦</div>")
|
| 1237 |
generate_for_session(session_id)
|
| 1238 |
rooms.begin_play(session_id, SESSION_STORE[session_id].get("game"))
|
| 1239 |
-
yield (*vis("play"), "play", timer_html(session_id,
|
| 1240 |
-
status_strip_html(session_id,
|
| 1241 |
|
| 1242 |
|
| 1243 |
def do_leave():
|
|
@@ -1253,6 +1297,10 @@ def _open_task(session_id, team_id, tid):
|
|
| 1253 |
"""Render the full task-detail view for one task. Shared by the play-screen
|
| 1254 |
entry, the horizontal task tabs, and the prev/next arrows so every entry
|
| 1255 |
point produces an identical, consistent detail screen."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1256 |
can = rooms.can_complete(session_id, team_id, tid)
|
| 1257 |
return (
|
| 1258 |
tid,
|
|
@@ -1412,6 +1460,12 @@ def do_finish(session_id, team_id):
|
|
| 1412 |
return (*vis("wait"), "wait", status_strip_html(session_id, team_id))
|
| 1413 |
|
| 1414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1415 |
def _gather_moments(session_id, team_id):
|
| 1416 |
bits = [j.get("transcript", "") for j in rooms.team_journals(session_id, team_id)]
|
| 1417 |
bits += [p.get("caption", "") for p in rooms.team_photos(session_id, team_id)]
|
|
@@ -1719,6 +1773,12 @@ with gr.Blocks(title="CityQuest Β· AI") as demo:
|
|
| 1719 |
lobby_hint = gr.Markdown(HINT_GUEST, elem_classes=["lobby-hint"])
|
| 1720 |
with gr.Row():
|
| 1721 |
lobby_leave = gr.Button("Leave", variant="secondary", elem_id="lobby-leave")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1722 |
lobby_start = gr.Button("Start the adventure β", variant="primary",
|
| 1723 |
visible=False, interactive=False,
|
| 1724 |
elem_id="lobby-start", elem_classes=["lobby-start-btn"])
|
|
@@ -1822,6 +1882,14 @@ with gr.Blocks(title="CityQuest Β· AI") as demo:
|
|
| 1822 |
gr.Markdown("## πΏ You're done β nicely paced!")
|
| 1823 |
gr.Markdown("*Waiting for the other teams to wrap up. Meanwhile, make something silly:*")
|
| 1824 |
wait_status = gr.HTML("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1825 |
with gr.Row():
|
| 1826 |
wait_funny_recap = gr.Button("π Funny recap", variant="secondary", elem_id="wait-funny-recap")
|
| 1827 |
wait_funny_post = gr.Button("π¨ Funny poster", variant="secondary", elem_id="wait-funny-post")
|
|
@@ -1850,8 +1918,10 @@ with gr.Blocks(title="CityQuest Β· AI") as demo:
|
|
| 1850 |
poll = gr.Timer(1.5)
|
| 1851 |
nav_groups = [g_home, g_create, g_join, g_lobby, g_play, g_wait, g_win]
|
| 1852 |
poll.tick(poll_tick, inputs=[s_session, s_screen, s_team, s_player, s_host, s_poll_state],
|
| 1853 |
-
outputs=nav_groups + [s_screen, lobby_code, lobby_roster, lobby_start,
|
| 1854 |
-
|
|
|
|
|
|
|
| 1855 |
poll, s_poll_state])
|
| 1856 |
|
| 1857 |
# ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1901,10 +1971,16 @@ with gr.Blocks(title="CityQuest Β· AI") as demo:
|
|
| 1901 |
outputs=[s_team, lobby_roster] + team_btn)
|
| 1902 |
|
| 1903 |
lobby_leave.click(do_leave, outputs=nav_groups + [s_screen, s_session, s_player, s_team, s_host])
|
|
|
|
|
|
|
| 1904 |
lobby_start.click(_btn_off, None, [lobby_start]).then(
|
| 1905 |
-
do_start, inputs=[s_session],
|
| 1906 |
outputs=nav_groups + [s_screen, play_timer, play_status, lobby_prep]
|
| 1907 |
).then(_btn_on, None, [lobby_start])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1908 |
|
| 1909 |
# ββ Task tabs β detail ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1910 |
# Order must match _open_task's return tuple (19 fixed + 12 tab buttons).
|
|
@@ -1962,6 +2038,9 @@ with gr.Blocks(title="CityQuest Β· AI") as demo:
|
|
| 1962 |
outputs=detail_open_outputs)
|
| 1963 |
|
| 1964 |
# ββ Wait-lobby actions ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 1965 |
wait_funny_recap.click(_btn_off, None, [wait_funny_recap]).then(
|
| 1966 |
do_funny_recap, inputs=[s_session, s_team], outputs=[wait_recap_md]
|
| 1967 |
).then(_btn_on, None, [wait_funny_recap])
|
|
|
|
| 1051 |
nav = [gr.update()] * 7
|
| 1052 |
new_screen = screen
|
| 1053 |
lobby_code = lobby_roster = play_timer = play_status = wait_status = win_score = gr.update()
|
| 1054 |
+
lobby_start = lobby_start_anyway = lobby_hint = gr.update()
|
| 1055 |
+
wait_line = wait_results_btn = gr.update()
|
| 1056 |
|
| 1057 |
if session_id:
|
| 1058 |
status = rooms.session_status(session_id)
|
| 1059 |
+
# Canonical team id β never "" β so same-team members converge on one id.
|
| 1060 |
+
team_id = rooms.resolve_team_id(session_id, player_name) or team_id
|
| 1061 |
if screen == "lobby":
|
| 1062 |
lobby_code = code_html(session_id)
|
| 1063 |
lobby_roster = roster_html(session_id, player_name)
|
| 1064 |
+
# Start gate (Model B): the primary Start enables only when the whole
|
| 1065 |
+
# party is here (full AND min_ok). The host-only "Start anyway"
|
| 1066 |
+
# override covers the short-handed-but-legal case (min_ok, not full).
|
| 1067 |
+
pcs = rooms.player_count_status(session_id)
|
| 1068 |
+
full, min_ok = pcs["full"], pcs["min_ok"]
|
| 1069 |
+
lobby_start = gr.update(interactive=(full and min_ok))
|
| 1070 |
if is_host:
|
| 1071 |
+
lobby_start_anyway = gr.update(
|
| 1072 |
+
visible=(not full), interactive=(min_ok and not full),
|
| 1073 |
+
value=f"βΆ Start anyway ({pcs['joined']}/{pcs['target']} here)")
|
| 1074 |
+
lobby_hint = lobby_hint_text(session_id, is_host)
|
| 1075 |
if status == "playing":
|
| 1076 |
new_screen = "play"
|
| 1077 |
nav = list(vis("play"))
|
|
|
|
| 1081 |
play_timer = timer_html(session_id, team_id)
|
| 1082 |
play_status = status_strip_html(session_id, team_id)
|
| 1083 |
elif screen == "wait":
|
| 1084 |
+
# The wait screen NEVER navigates on its own β for any player, under
|
| 1085 |
+
# any condition. Each tick only UPDATES: the team strip, the
|
| 1086 |
+
# "who's still playing" line, and the results button's enabled state.
|
| 1087 |
+
# The wait->win transition happens ONLY when a player clicks the
|
| 1088 |
+
# "See final results" button (see do_see_results).
|
| 1089 |
wait_status = status_strip_html(session_id, team_id)
|
| 1090 |
+
wait_line = wait_playing_md(session_id)
|
| 1091 |
+
pcs = rooms.player_count_status(session_id)
|
| 1092 |
+
can_results = rooms.all_finished(session_id) and pcs["min_ok"]
|
| 1093 |
+
wait_results_btn = gr.update(interactive=can_results)
|
| 1094 |
|
| 1095 |
# ββ Decide next poll interval from whether anything changed ββ
|
| 1096 |
def _s(x):
|
| 1097 |
return x if isinstance(x, str) else ""
|
| 1098 |
sig = "|".join([new_screen, _s(lobby_code), _s(lobby_roster), _s(play_timer),
|
| 1099 |
+
_s(play_status), _s(wait_status), _s(wait_line), _s(win_score)])
|
| 1100 |
last_sig, idle, cur_iv = poll_state or ("", 0, POLL_FAST)
|
| 1101 |
if sig != last_sig:
|
| 1102 |
idle, target_iv = 0, POLL_FAST
|
|
|
|
| 1108 |
timer_update = gr.Timer(target_iv) if target_iv != cur_iv else gr.update()
|
| 1109 |
new_state = (sig, idle, target_iv)
|
| 1110 |
|
| 1111 |
+
return (*nav, new_screen, lobby_code, lobby_roster, lobby_start, lobby_start_anyway,
|
| 1112 |
+
lobby_hint, play_timer, play_status, wait_status, wait_line,
|
| 1113 |
+
wait_results_btn, win_score, timer_update, new_state)
|
| 1114 |
|
| 1115 |
|
| 1116 |
def play_tick(session_id, screen, team_id, taskids, cur_task):
|
|
|
|
| 1133 |
|
| 1134 |
|
| 1135 |
# ββ Screen-action handlers ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1136 |
+
# Lobby instruction copy β differs for the host vs. everyone else. The host
|
| 1137 |
+
# copy shows progress (joined/target); HINT_HOST_WAIT/HINT_HOST_SHORT are
|
| 1138 |
+
# format strings filled by lobby_hint_text().
|
| 1139 |
+
HINT_HOST_WAIT = "ⳠWaiting for players⦠{joined}/{target} joined"
|
| 1140 |
+
HINT_HOST_SHORT = "βΆ {joined}/{target} here β start now, or wait for the rest."
|
| 1141 |
HINT_HOST_READY = "β
Everyone's in β start the adventure whenever you're ready."
|
| 1142 |
HINT_GUEST = "β³ Waiting for the host to start the adventureβ¦"
|
| 1143 |
|
| 1144 |
|
| 1145 |
+
def lobby_hint_text(session_id: str, is_host: bool) -> str:
|
| 1146 |
+
"""Lobby instruction line. Guests always wait on the host; the host sees
|
| 1147 |
+
progress and which gate (full vs. structural minimum) they're at."""
|
| 1148 |
+
if not is_host:
|
| 1149 |
+
return HINT_GUEST
|
| 1150 |
+
pcs = rooms.player_count_status(session_id)
|
| 1151 |
+
if pcs["full"] and pcs["min_ok"]:
|
| 1152 |
+
return HINT_HOST_READY
|
| 1153 |
+
if pcs["min_ok"]:
|
| 1154 |
+
return HINT_HOST_SHORT.format(joined=pcs["joined"], target=pcs["target"])
|
| 1155 |
+
return HINT_HOST_WAIT.format(joined=pcs["joined"], target=pcs["target"])
|
| 1156 |
+
|
| 1157 |
+
|
| 1158 |
+
def wait_playing_md(session_id: str) -> str:
|
| 1159 |
+
"""Live 'who's still playing' line for the wait screen β active teams
|
| 1160 |
+
(>=1 player) that haven't finished yet."""
|
| 1161 |
+
active = rooms.active_team_ids(session_id)
|
| 1162 |
+
still = [tid for tid in active if not rooms.team_finished(session_id, tid)]
|
| 1163 |
+
if not still:
|
| 1164 |
+
return "Everyone's finished β see results whenever you're ready."
|
| 1165 |
+
names = ", ".join(rooms.team_meta(session_id, tid)["name"] for tid in still)
|
| 1166 |
+
return f"Waiting on: {names}"
|
| 1167 |
+
|
| 1168 |
+
|
| 1169 |
def _team_btn_updates(session_id, selected_id):
|
| 1170 |
"""Button updates for the 4 team-pick slots: label + visibility, with the
|
| 1171 |
player's current team flagged 'selected' so it reads as chosen (green)."""
|
|
|
|
| 1202 |
rooms.create_room(name, config, session_id)
|
| 1203 |
teams = rooms.list_teams(session_id)
|
| 1204 |
tb = _team_btn_updates(session_id, teams[0]["id"])
|
| 1205 |
+
# Host's s_team is their real team id (teams[0] β create_room files the host
|
| 1206 |
+
# under it too), NOT "", so host proof/journal/finish state and final
|
| 1207 |
+
# standings all key off the same id as every other same-team member.
|
| 1208 |
return (*vis("lobby"), "lobby", session_id, name, teams[0]["id"], True,
|
| 1209 |
code_html(session_id), roster_html(session_id, name),
|
| 1210 |
gr.update(visible=True, interactive=False),
|
| 1211 |
+
copy_btn_html(session_id), lobby_hint_text(session_id, True), *tb)
|
| 1212 |
|
| 1213 |
|
| 1214 |
def do_join(code, name):
|
|
|
|
| 1268 |
yield "<div class='cq-loading'>Couldn't pre-generate β it'll generate when you start.</div>"
|
| 1269 |
|
| 1270 |
|
| 1271 |
+
def do_start(session_id, team_id=""):
|
| 1272 |
s = SESSION_STORE.get(session_id) or {}
|
| 1273 |
+
# Render the starter's OWN team strip/timer, not always team A β a host who
|
| 1274 |
+
# picked a later team shouldn't see Team A's strip. Never empty: fall back
|
| 1275 |
+
# to the first team (mirrors rooms.resolve_team_id's fallback).
|
| 1276 |
+
team_id = team_id or (s.get("teams") or [{"id": "team-a"}])[0]["id"]
|
| 1277 |
if not s.get("game"):
|
| 1278 |
# Fallback: lobby pre-generation didn't finish β show a loader.
|
| 1279 |
yield (*vis("lobby"), "lobby", gr.update(), gr.update(),
|
| 1280 |
"<div class='cq-loading'>Summoning your questβ¦</div>")
|
| 1281 |
generate_for_session(session_id)
|
| 1282 |
rooms.begin_play(session_id, SESSION_STORE[session_id].get("game"))
|
| 1283 |
+
yield (*vis("play"), "play", timer_html(session_id, team_id),
|
| 1284 |
+
status_strip_html(session_id, team_id), "")
|
| 1285 |
|
| 1286 |
|
| 1287 |
def do_leave():
|
|
|
|
| 1297 |
"""Render the full task-detail view for one task. Shared by the play-screen
|
| 1298 |
entry, the horizontal task tabs, and the prev/next arrows so every entry
|
| 1299 |
point produces an identical, consistent detail screen."""
|
| 1300 |
+
# NOTE: tasks are session-global (session["game"]["tasks"]) and are the SAME
|
| 1301 |
+
# for everyone β never slice/filter them per player. team_id here only keys
|
| 1302 |
+
# the per-team completion/proof/hint OVERLAY, so same-team members (sharing
|
| 1303 |
+
# one resolved team_id) see one shared task/proof view. Do not regress this.
|
| 1304 |
can = rooms.can_complete(session_id, team_id, tid)
|
| 1305 |
return (
|
| 1306 |
tid,
|
|
|
|
| 1460 |
return (*vis("wait"), "wait", status_strip_html(session_id, team_id))
|
| 1461 |
|
| 1462 |
|
| 1463 |
+
def do_see_results(session_id):
|
| 1464 |
+
"""Manual wait->win advance β the ONLY path to the win screen. Fired by the
|
| 1465 |
+
'See final results' button (gated/enabled by poll_tick), never automatic."""
|
| 1466 |
+
return (*vis("win"), "win", scoreboard_md(session_id))
|
| 1467 |
+
|
| 1468 |
+
|
| 1469 |
def _gather_moments(session_id, team_id):
|
| 1470 |
bits = [j.get("transcript", "") for j in rooms.team_journals(session_id, team_id)]
|
| 1471 |
bits += [p.get("caption", "") for p in rooms.team_photos(session_id, team_id)]
|
|
|
|
| 1773 |
lobby_hint = gr.Markdown(HINT_GUEST, elem_classes=["lobby-hint"])
|
| 1774 |
with gr.Row():
|
| 1775 |
lobby_leave = gr.Button("Leave", variant="secondary", elem_id="lobby-leave")
|
| 1776 |
+
# Host-only short-handed override. Hidden until poll_tick reveals
|
| 1777 |
+
# it (min_ok and not full); the normal Start covers the full case.
|
| 1778 |
+
lobby_start_anyway = gr.Button("βΆ Start anyway", variant="secondary",
|
| 1779 |
+
visible=False, interactive=False,
|
| 1780 |
+
elem_id="lobby-start-anyway",
|
| 1781 |
+
elem_classes=["lobby-start-btn"])
|
| 1782 |
lobby_start = gr.Button("Start the adventure β", variant="primary",
|
| 1783 |
visible=False, interactive=False,
|
| 1784 |
elem_id="lobby-start", elem_classes=["lobby-start-btn"])
|
|
|
|
| 1882 |
gr.Markdown("## πΏ You're done β nicely paced!")
|
| 1883 |
gr.Markdown("*Waiting for the other teams to wrap up. Meanwhile, make something silly:*")
|
| 1884 |
wait_status = gr.HTML("")
|
| 1885 |
+
# Live "who's still playing" line + manual advance to results. The
|
| 1886 |
+
# button stays DISABLED until the results gate passes (all active
|
| 1887 |
+
# teams finished AND min_ok); poll_tick flips its enabled state. The
|
| 1888 |
+
# wait screen NEVER auto-navigates β this click is the only path.
|
| 1889 |
+
wait_status_line = gr.Markdown("", elem_classes=["lobby-hint"])
|
| 1890 |
+
wait_see_results = gr.Button("π See final results", variant="primary",
|
| 1891 |
+
interactive=False, elem_id="wait-see-results",
|
| 1892 |
+
elem_classes=["lobby-start-btn"])
|
| 1893 |
with gr.Row():
|
| 1894 |
wait_funny_recap = gr.Button("π Funny recap", variant="secondary", elem_id="wait-funny-recap")
|
| 1895 |
wait_funny_post = gr.Button("π¨ Funny poster", variant="secondary", elem_id="wait-funny-post")
|
|
|
|
| 1918 |
poll = gr.Timer(1.5)
|
| 1919 |
nav_groups = [g_home, g_create, g_join, g_lobby, g_play, g_wait, g_win]
|
| 1920 |
poll.tick(poll_tick, inputs=[s_session, s_screen, s_team, s_player, s_host, s_poll_state],
|
| 1921 |
+
outputs=nav_groups + [s_screen, lobby_code, lobby_roster, lobby_start,
|
| 1922 |
+
lobby_start_anyway, lobby_hint,
|
| 1923 |
+
play_timer, play_status, wait_status, wait_status_line,
|
| 1924 |
+
wait_see_results, win_scoreboard,
|
| 1925 |
poll, s_poll_state])
|
| 1926 |
|
| 1927 |
# ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1971 |
outputs=[s_team, lobby_roster] + team_btn)
|
| 1972 |
|
| 1973 |
lobby_leave.click(do_leave, outputs=nav_groups + [s_screen, s_session, s_player, s_team, s_host])
|
| 1974 |
+
# Both Start and Start-anyway funnel through the SAME do_start path β no
|
| 1975 |
+
# duplicated start logic; only the gating (in poll_tick) differs.
|
| 1976 |
lobby_start.click(_btn_off, None, [lobby_start]).then(
|
| 1977 |
+
do_start, inputs=[s_session, s_team],
|
| 1978 |
outputs=nav_groups + [s_screen, play_timer, play_status, lobby_prep]
|
| 1979 |
).then(_btn_on, None, [lobby_start])
|
| 1980 |
+
lobby_start_anyway.click(_btn_off, None, [lobby_start_anyway]).then(
|
| 1981 |
+
do_start, inputs=[s_session, s_team],
|
| 1982 |
+
outputs=nav_groups + [s_screen, play_timer, play_status, lobby_prep]
|
| 1983 |
+
).then(_btn_on, None, [lobby_start_anyway])
|
| 1984 |
|
| 1985 |
# ββ Task tabs β detail ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1986 |
# Order must match _open_task's return tuple (19 fixed + 12 tab buttons).
|
|
|
|
| 2038 |
outputs=detail_open_outputs)
|
| 2039 |
|
| 2040 |
# ββ Wait-lobby actions ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2041 |
+
# Manual, click-only advance to the win screen (gate/enabled by poll_tick).
|
| 2042 |
+
wait_see_results.click(do_see_results, inputs=[s_session],
|
| 2043 |
+
outputs=nav_groups + [s_screen, win_scoreboard])
|
| 2044 |
wait_funny_recap.click(_btn_off, None, [wait_funny_recap]).then(
|
| 2045 |
do_funny_recap, inputs=[s_session, s_team], outputs=[wait_recap_md]
|
| 2046 |
).then(_btn_on, None, [wait_funny_recap])
|
app/services/rooms.py
CHANGED
|
@@ -175,6 +175,24 @@ def team_meta(session_id: str, team_id: str) -> dict:
|
|
| 175 |
return {"id": team_id, "name": team_id, "role": ""}
|
| 176 |
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# ββ Start / staggered timing βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 179 |
def begin_play(session_id: str, game: dict) -> None:
|
| 180 |
"""Host starts the game: store game, stamp start, compute per-team offsets."""
|
|
@@ -336,12 +354,36 @@ def active_team_ids(session_id: str) -> list[str]:
|
|
| 336 |
return ids or [t["id"] for t in session.get("teams", [])][:1]
|
| 337 |
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
def finish_team(session_id: str, team_id: str) -> None:
|
| 340 |
session = SESSION_STORE.get(session_id)
|
| 341 |
if not session:
|
| 342 |
return
|
| 343 |
session.setdefault("team_finished", {})[team_id] = _now_iso()
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
session["status"] = "finished"
|
| 346 |
save_state()
|
| 347 |
|
|
|
|
| 175 |
return {"id": team_id, "name": team_id, "role": ""}
|
| 176 |
|
| 177 |
|
| 178 |
+
def resolve_team_id(session_id: str, player_name: str) -> str:
|
| 179 |
+
"""Canonical team_id for a player.
|
| 180 |
+
|
| 181 |
+
Returns the id recorded for that player in ``session["players"]``, falling
|
| 182 |
+
back to ``teams[0]["id"]`` if missing/empty. NEVER returns "" β same-team
|
| 183 |
+
members must converge on a single id, since task/proof/completion state all
|
| 184 |
+
key off team_id (an empty id would diverge from a guest's real team id).
|
| 185 |
+
"""
|
| 186 |
+
session = SESSION_STORE.get(session_id) or {}
|
| 187 |
+
teams = session.get("teams", [])
|
| 188 |
+
fallback = teams[0]["id"] if teams else ""
|
| 189 |
+
name = (player_name or "").lower()
|
| 190 |
+
for p in session.get("players", []):
|
| 191 |
+
if p["name"].lower() == name:
|
| 192 |
+
return p.get("team_id") or fallback
|
| 193 |
+
return fallback
|
| 194 |
+
|
| 195 |
+
|
| 196 |
# ββ Start / staggered timing βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
def begin_play(session_id: str, game: dict) -> None:
|
| 198 |
"""Host starts the game: store game, stamp start, compute per-team offsets."""
|
|
|
|
| 354 |
return ids or [t["id"] for t in session.get("teams", [])][:1]
|
| 355 |
|
| 356 |
|
| 357 |
+
def player_count_status(session_id: str) -> dict:
|
| 358 |
+
"""Player-count gating snapshot for the lobby/start logic.
|
| 359 |
+
|
| 360 |
+
Returns ``{joined, target, full, min_ok}`` where:
|
| 361 |
+
* ``joined`` β total bodies across all teams (``len(players)``).
|
| 362 |
+
* ``target`` β the host's ``num_players`` slider (a SOFT ceiling).
|
| 363 |
+
* ``full`` β ``joined >= target`` (the whole party is here).
|
| 364 |
+
* ``min_ok`` β structural minimum, NEVER loosened: ``joined >= 2`` AND
|
| 365 |
+
every team has at least one player (no empty team = broken match).
|
| 366 |
+
"""
|
| 367 |
+
session = SESSION_STORE.get(session_id) or {}
|
| 368 |
+
players = session.get("players", [])
|
| 369 |
+
teams = session.get("teams", [])
|
| 370 |
+
joined = len(players)
|
| 371 |
+
target = int(session.get("config", {}).get("num_players", 0) or 0)
|
| 372 |
+
have = {p["team_id"] for p in players}
|
| 373 |
+
every_team_has_player = bool(teams) and all(t["id"] in have for t in teams)
|
| 374 |
+
min_ok = joined >= 2 and every_team_has_player
|
| 375 |
+
full = joined >= target
|
| 376 |
+
return {"joined": joined, "target": target, "full": full, "min_ok": min_ok}
|
| 377 |
+
|
| 378 |
+
|
| 379 |
def finish_team(session_id: str, team_id: str) -> None:
|
| 380 |
session = SESSION_STORE.get(session_id)
|
| 381 |
if not session:
|
| 382 |
return
|
| 383 |
session.setdefault("team_finished", {})[team_id] = _now_iso()
|
| 384 |
+
# Flip to "finished" once every ACTIVE team has finished AND the structural
|
| 385 |
+
# minimum holds β never require `full` (a no-show must not strand the rest).
|
| 386 |
+
if all_finished(session_id) and player_count_status(session_id)["min_ok"]:
|
| 387 |
session["status"] = "finished"
|
| 388 |
save_state()
|
| 389 |
|