BhargavMN commited on
Commit
9115da0
Β·
1 Parent(s): 8ef5e98

feat(lobby): enhance start logic with "Start anyway" option and player count checks

Browse files
Files changed (2) hide show
  1. app.py +105 -26
  2. 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
- # Keep Start enabled only once every team has a player, and keep the
1062
- # instruction line in sync with who's waiting on whom.
1063
- ready, total = _teams_ready(session_id)
1064
- all_ready = total > 0 and ready == total
1065
- lobby_start = gr.update(interactive=all_ready)
 
1066
  if is_host:
1067
- lobby_hint = HINT_HOST_READY if all_ready else HINT_HOST_WAIT
1068
- else:
1069
- lobby_hint = HINT_GUEST
 
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
- if rooms.all_finished(session_id):
1081
- new_screen = "win"
1082
- nav = list(vis("win"))
1083
- win_score = scoreboard_md(session_id)
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, lobby_hint,
1102
- play_timer, play_status, wait_status, win_score, timer_update, new_state)
 
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
- HINT_HOST_WAIT = "⏳ Waiting for every team to have at least one player…"
 
 
 
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), HINT_HOST_WAIT, *tb)
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
- first_team = (s.get("teams") or [{"id": "team-a"}])[0]["id"]
 
 
 
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, first_team),
1240
- status_strip_html(session_id, first_team), "")
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, lobby_hint,
1854
- play_timer, play_status, wait_status, win_scoreboard,
 
 
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
- if all_finished(session_id):
 
 
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