gabraken commited on
Commit
8c8d636
ยท
1 Parent(s): d530c6f

The expand

Browse files
backend/game/engine.py CHANGED
@@ -998,19 +998,29 @@ class GameEngine:
998
  return False
999
 
1000
  def _find_build_position(
1001
- self, player: PlayerState, bt: BuildingType, near_scv: Unit
 
 
 
 
 
1002
  ) -> Optional[tuple[float, float]]:
1003
- """Return CENTER coordinates for a new building, or None if no valid spot found."""
 
 
 
 
1004
  defn = BUILDING_DEFS[bt]
1005
- cx, cy = near_scv.x, near_scv.y
 
1006
 
1007
- for radius in range(1, int(self._SCV_BUILD_RANGE) + 2):
1008
  for dx in range(-radius, radius + 1):
1009
  for dy in range(-radius, radius + 1):
1010
  tl_x, tl_y = int(cx) + dx, int(cy) + dy
1011
  tile_cx = tl_x + defn.width / 2.0
1012
  tile_cy = tl_y + defn.height / 2.0
1013
- if (tile_cx - cx) ** 2 + (tile_cy - cy) ** 2 > self._SCV_BUILD_RANGE ** 2:
1014
  continue
1015
  if not self._can_place(tl_x, tl_y, defn):
1016
  continue
@@ -1019,6 +1029,91 @@ class GameEngine:
1019
  return (tile_cx, tile_cy)
1020
  return None
1021
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
  def _eject_units_from_building(self, building: "Building") -> None:
1023
  """Push any ground unit whose centre falls inside building's collision box to the nearest walkable tile."""
1024
  from .pathfinding import snap_to_walkable
@@ -1147,15 +1242,26 @@ class GameEngine:
1147
  if count == 0:
1148
  return ActionResult(action_type="build", success=False, data={"error": "build_no_scv"})
1149
 
1150
- cc = player.command_center()
1151
- cx, cy = (float(cc.x), float(cc.y)) if cc else (0.0, 0.0)
1152
-
1153
  built = 0
1154
  for i in range(count):
1155
  scv = available_scvs[i]
1156
 
 
 
 
 
1157
  # Find center position for this building
1158
- if bt == BuildingType.REFINERY:
 
 
 
 
 
 
 
 
 
 
1159
  geyser = self.state.game_map.nearest_geyser_without_refinery(cx, cy)
1160
  if not geyser:
1161
  break
@@ -1166,7 +1272,12 @@ class GameEngine:
1166
  break
1167
  geyser.has_refinery = True
1168
  else:
1169
- pos_opt = self._find_build_position(player, bt, scv)
 
 
 
 
 
1170
  if not pos_opt:
1171
  break
1172
  pos_cx, pos_cy = pos_opt
 
998
  return False
999
 
1000
  def _find_build_position(
1001
+ self,
1002
+ player: PlayerState,
1003
+ bt: BuildingType,
1004
+ near_scv: Unit,
1005
+ search_center: Optional[tuple[float, float]] = None,
1006
+ search_radius: Optional[float] = None,
1007
  ) -> Optional[tuple[float, float]]:
1008
+ """Return CENTER coordinates for a new building, or None if no valid spot found.
1009
+
1010
+ If search_center is given, the spiral search is anchored there instead of the SCV.
1011
+ If search_radius is given, it overrides _SCV_BUILD_RANGE as the maximum search distance.
1012
+ """
1013
  defn = BUILDING_DEFS[bt]
1014
+ cx, cy = search_center if search_center else (near_scv.x, near_scv.y)
1015
+ radius_limit = search_radius if search_radius is not None else self._SCV_BUILD_RANGE
1016
 
1017
+ for radius in range(1, int(radius_limit) + 2):
1018
  for dx in range(-radius, radius + 1):
1019
  for dy in range(-radius, radius + 1):
1020
  tl_x, tl_y = int(cx) + dx, int(cy) + dy
1021
  tile_cx = tl_x + defn.width / 2.0
1022
  tile_cy = tl_y + defn.height / 2.0
1023
+ if (tile_cx - cx) ** 2 + (tile_cy - cy) ** 2 > radius_limit ** 2:
1024
  continue
1025
  if not self._can_place(tl_x, tl_y, defn):
1026
  continue
 
1029
  return (tile_cx, tile_cy)
1030
  return None
1031
 
1032
+ def _find_expansion_position(self, player: PlayerState) -> Optional[tuple[float, float]]:
1033
+ """Find a valid position for a new command center near unclaimed resource clusters.
1034
+
1035
+ An expansion position is a resource cluster (group of minerals) that has no
1036
+ existing command center (from any player) within CC_CLAIM_RADIUS tiles.
1037
+ The closest such cluster to the player's current base is preferred.
1038
+ """
1039
+ from .map import ResourceType
1040
+ CC_CLAIM_RADIUS = 15.0
1041
+ CLUSTER_MERGE_DIST = 10.0
1042
+ CC_SEARCH_RADIUS = 14.0
1043
+
1044
+ all_ccs = [
1045
+ b
1046
+ for p in self.state.players.values()
1047
+ for b in p.buildings.values()
1048
+ if b.building_type == BuildingType.COMMAND_CENTER
1049
+ and b.status != BuildingStatus.DESTROYED
1050
+ ]
1051
+
1052
+ minerals = [
1053
+ r for r in self.state.game_map.resources
1054
+ if r.resource_type == ResourceType.MINERAL and not r.is_depleted
1055
+ ]
1056
+ if not minerals:
1057
+ return None
1058
+
1059
+ free_minerals = [
1060
+ m for m in minerals
1061
+ if not any(
1062
+ (m.x - cc.x) ** 2 + (m.y - cc.y) ** 2 <= CC_CLAIM_RADIUS ** 2
1063
+ for cc in all_ccs
1064
+ )
1065
+ ]
1066
+ if not free_minerals:
1067
+ return None
1068
+
1069
+ # Group free minerals into clusters
1070
+ clusters: list[list] = []
1071
+ for m in free_minerals:
1072
+ placed = False
1073
+ for cluster in clusters:
1074
+ ccx = sum(r.x for r in cluster) / len(cluster)
1075
+ ccy = sum(r.y for r in cluster) / len(cluster)
1076
+ if (m.x - ccx) ** 2 + (m.y - ccy) ** 2 <= CLUSTER_MERGE_DIST ** 2:
1077
+ cluster.append(m)
1078
+ placed = True
1079
+ break
1080
+ if not placed:
1081
+ clusters.append([m])
1082
+
1083
+ if not clusters:
1084
+ return None
1085
+
1086
+ cc = player.command_center()
1087
+ ref_x = cc.x if cc else float(MAP_WIDTH) / 2
1088
+ ref_y = cc.y if cc else float(MAP_HEIGHT) / 2
1089
+
1090
+ def cluster_center(cluster: list) -> tuple[float, float]:
1091
+ return (
1092
+ sum(r.x for r in cluster) / len(cluster),
1093
+ sum(r.y for r in cluster) / len(cluster),
1094
+ )
1095
+
1096
+ clusters_by_dist = sorted(
1097
+ clusters,
1098
+ key=lambda c: (cluster_center(c)[0] - ref_x) ** 2 + (cluster_center(c)[1] - ref_y) ** 2,
1099
+ )
1100
+
1101
+ cc_defn = BUILDING_DEFS[BuildingType.COMMAND_CENTER]
1102
+ for cluster in clusters_by_dist:
1103
+ ecx, ecy = cluster_center(cluster)
1104
+ for radius in range(0, int(CC_SEARCH_RADIUS) + 2):
1105
+ for dx in range(-radius, radius + 1):
1106
+ for dy in range(-radius, radius + 1):
1107
+ tl_x, tl_y = int(ecx) + dx, int(ecy) + dy
1108
+ tile_cx = tl_x + cc_defn.width / 2.0
1109
+ tile_cy = tl_y + cc_defn.height / 2.0
1110
+ if (tile_cx - ecx) ** 2 + (tile_cy - ecy) ** 2 > CC_SEARCH_RADIUS ** 2:
1111
+ continue
1112
+ if not self._can_place(tl_x, tl_y, cc_defn):
1113
+ continue
1114
+ return (tile_cx, tile_cy)
1115
+ return None
1116
+
1117
  def _eject_units_from_building(self, building: "Building") -> None:
1118
  """Push any ground unit whose centre falls inside building's collision box to the nearest walkable tile."""
1119
  from .pathfinding import snap_to_walkable
 
1242
  if count == 0:
1243
  return ActionResult(action_type="build", success=False, data={"error": "build_no_scv"})
1244
 
 
 
 
1245
  built = 0
1246
  for i in range(count):
1247
  scv = available_scvs[i]
1248
 
1249
+ # Determine the command center that should anchor this build
1250
+ nearest_cc = player.nearest_command_center(scv.x, scv.y)
1251
+ cc_anchor = (float(nearest_cc.x), float(nearest_cc.y)) if nearest_cc else None
1252
+
1253
  # Find center position for this building
1254
+ if bt == BuildingType.COMMAND_CENTER:
1255
+ # New CC must go on an expansion (near unclaimed resources)
1256
+ pos_opt = self._find_expansion_position(player)
1257
+ if not pos_opt:
1258
+ # Fallback: build anywhere the SCV can reach
1259
+ pos_opt = self._find_build_position(player, bt, scv)
1260
+ if not pos_opt:
1261
+ break
1262
+ pos_cx, pos_cy = pos_opt
1263
+ elif bt == BuildingType.REFINERY:
1264
+ cx, cy = cc_anchor if cc_anchor else (scv.x, scv.y)
1265
  geyser = self.state.game_map.nearest_geyser_without_refinery(cx, cy)
1266
  if not geyser:
1267
  break
 
1272
  break
1273
  geyser.has_refinery = True
1274
  else:
1275
+ # Build around the nearest command center with a wider search radius
1276
+ pos_opt = self._find_build_position(
1277
+ player, bt, scv,
1278
+ search_center=cc_anchor,
1279
+ search_radius=12.0,
1280
+ )
1281
  if not pos_opt:
1282
  break
1283
  pos_cx, pos_cy = pos_opt
backend/game/state.py CHANGED
@@ -69,6 +69,21 @@ class PlayerState(BaseModel):
69
  None,
70
  )
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  def summary(self, lang: str = "fr") -> str:
73
  active = [
74
  b.building_type.value for b in self.buildings.values()
 
69
  None,
70
  )
71
 
72
+ def command_centers(self) -> list[Building]:
73
+ """Return all non-destroyed command centers."""
74
+ return [
75
+ b for b in self.buildings.values()
76
+ if b.building_type == BuildingType.COMMAND_CENTER
77
+ and b.status != BuildingStatus.DESTROYED
78
+ ]
79
+
80
+ def nearest_command_center(self, x: float, y: float) -> Optional[Building]:
81
+ """Return the non-destroyed command center closest to (x, y)."""
82
+ ccs = self.command_centers()
83
+ if not ccs:
84
+ return None
85
+ return min(ccs, key=lambda b: (b.x - x) ** 2 + (b.y - y) ** 2)
86
+
87
  def summary(self, lang: str = "fr") -> str:
88
  active = [
89
  b.building_type.value for b in self.buildings.values()
frontend/src/lib/components/TutorialOverlay.svelte CHANGED
@@ -131,6 +131,7 @@
131
  // Hint state
132
  let activeHintId: string | null = null;
133
  let hintTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
 
134
 
135
  // Tutorial completion โ€” driven by lockedCount so Svelte tracks it correctly
136
  $: allDone = lockedCount === OBJECTIVE_IDS.length;
@@ -160,11 +161,14 @@
160
  }
161
 
162
  function scheduleHintIfNeeded(id: string) {
 
163
  if (hintTimers.has(id)) return;
 
 
 
164
  const t = setTimeout(() => {
165
  hintTimers.delete(id);
166
- const obj = objectives.find((o) => o.id === id);
167
- if (obj && !obj.done) {
168
  activeHintId = id;
169
  }
170
  }, HINT_DELAY_MS);
@@ -182,9 +186,8 @@
182
  function dismissHint() {
183
  const dismissedId = activeHintId;
184
  activeHintId = null;
185
- // Restart timer for same objective so hint can reappear later
186
  if (dismissedId !== null) {
187
- scheduleHintIfNeeded(dismissedId);
188
  }
189
  }
190
 
 
131
  // Hint state
132
  let activeHintId: string | null = null;
133
  let hintTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
134
+ let shownHints: Set<string> = new Set();
135
 
136
  // Tutorial completion โ€” driven by lockedCount so Svelte tracks it correctly
137
  $: allDone = lockedCount === OBJECTIVE_IDS.length;
 
161
  }
162
 
163
  function scheduleHintIfNeeded(id: string) {
164
+ if (shownHints.has(id)) return;
165
  if (hintTimers.has(id)) return;
166
+ // Only show hint if all previous objectives are completed
167
+ const idx = (OBJECTIVE_IDS as readonly string[]).indexOf(id);
168
+ if (idx > 0 && (OBJECTIVE_IDS as readonly string[]).slice(0, idx).some((prevId) => !doneMap[prevId])) return;
169
  const t = setTimeout(() => {
170
  hintTimers.delete(id);
171
+ if (!doneMap[id] && !shownHints.has(id)) {
 
172
  activeHintId = id;
173
  }
174
  }, HINT_DELAY_MS);
 
186
  function dismissHint() {
187
  const dismissedId = activeHintId;
188
  activeHintId = null;
 
189
  if (dismissedId !== null) {
190
+ shownHints.add(dismissedId);
191
  }
192
  }
193
 
frontend/src/routes/+page.svelte CHANGED
@@ -4,13 +4,13 @@
4
  import { backendUrl, getSocket } from "$lib/socket";
5
  import {
6
  gameState,
 
7
  mapTextureUrl,
8
  myPlayerId,
9
  playerName,
10
  roomId,
11
  roomState,
12
  winnerId,
13
- isTutorial,
14
  } from "$lib/stores/game";
15
  import type { GameState, Room } from "$lib/types";
16
  import { onDestroy, onMount } from "svelte";
@@ -100,8 +100,13 @@
100
  }, 500);
101
  }
102
 
103
- const _savedName = typeof localStorage !== 'undefined' ? localStorage.getItem('sc_player_name') : null;
104
- const _nameLocked = typeof localStorage !== 'undefined' && localStorage.getItem('sc_name_locked') === 'true';
 
 
 
 
 
105
  if (_savedName) playerName.set(_savedName);
106
 
107
  let name = _savedName || $playerName || "";
@@ -307,14 +312,14 @@
307
  // โ”€โ”€ Activity toast notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
308
  type StatEvent = {
309
  player_name: string;
310
- event_type: 'tutorial_complete' | 'game_won';
311
  duration_s: number | null;
312
  opponent_name: string | null;
313
  recorded_at: number;
314
  };
315
 
316
  let toastVisible = false;
317
- let toastText = '';
318
  let toastTimeout: ReturnType<typeof setTimeout> | null = null;
319
  let toastPollInterval: ReturnType<typeof setInterval> | null = null;
320
  let toastRotateInterval: ReturnType<typeof setInterval> | null = null;
@@ -322,18 +327,20 @@
322
  let toastIndex = 0;
323
 
324
  function fmtDurationShort(s: number | null): string {
325
- if (s == null) return '';
326
  const m = Math.floor(s / 60);
327
  const sec = s % 60;
328
  if (m === 0) return ` in ${sec}s`;
329
- return ` in ${m}m ${sec.toString().padStart(2, '0')}s`;
330
  }
331
 
332
  function buildToastText(ev: StatEvent): string {
333
- if (ev.event_type === 'tutorial_complete') {
334
  return `${ev.player_name} completed the tutorial${fmtDurationShort(ev.duration_s)}`;
335
  }
336
- const opp = ev.opponent_name ? ` defeated ${ev.opponent_name}` : ' won a match';
 
 
337
  return `${ev.player_name}${opp}${fmtDurationShort(ev.duration_s)}`;
338
  }
339
 
@@ -341,7 +348,9 @@
341
  toastText = text;
342
  toastVisible = true;
343
  if (toastTimeout) clearTimeout(toastTimeout);
344
- toastTimeout = setTimeout(() => { toastVisible = false; }, 5000);
 
 
345
  }
346
 
347
  function rotateToast() {
@@ -364,7 +373,9 @@
364
  toastIndex = 0;
365
  showToast(buildToastText(events[0]));
366
  }
367
- } catch { /* silent */ }
 
 
368
  }
369
 
370
  function startToastSystem() {
@@ -390,22 +401,32 @@
390
  }
391
 
392
  // Observe modal
393
- type PlayingRoom = { room_id: string; player1: string; player2: string; elapsed: number };
 
 
 
 
 
394
  let observeModalOpen = false;
395
  let playingRooms: PlayingRoom[] = [];
396
  let observeElapsedInterval: ReturnType<typeof setInterval> | null = null;
397
 
398
  function formatElapsed(seconds: number): string {
399
- const m = Math.floor(seconds / 60).toString().padStart(2, '0');
400
- const s = (seconds % 60).toString().padStart(2, '0');
 
 
401
  return `${m}:${s}`;
402
  }
403
 
404
  function openObserveModal() {
405
- socket.emit('get_playing_rooms', {});
406
  observeModalOpen = true;
407
  observeElapsedInterval = setInterval(() => {
408
- playingRooms = playingRooms.map((r) => ({ ...r, elapsed: r.elapsed + 1 }));
 
 
 
409
  }, 1000);
410
  }
411
 
@@ -420,7 +441,7 @@
420
 
421
  function observeRoom(room_id: string) {
422
  closeObserveModal();
423
- socket.emit('observe', { room_id });
424
  }
425
  </script>
426
 
@@ -645,10 +666,17 @@
645
 
646
  {#if toastVisible}
647
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
648
- <div class="activity-toast" on:click={() => (toastVisible = false)} role="status" aria-live="polite">
 
 
 
 
 
649
  <span class="activity-toast-icon">๐ŸŽฎ</span>
650
  <span class="activity-toast-text">{toastText}</span>
651
- <a href="/leaderboard" class="activity-toast-link" on:click|stopPropagation>View โ†’</a>
 
 
652
  </div>
653
  {/if}
654
 
@@ -656,8 +684,16 @@
656
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
657
  <div class="obs-backdrop" on:click={closeObserveModal}>
658
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
659
- <div class="obs-modal" on:click|stopPropagation role="dialog" aria-modal="true" aria-label="Select a match to observe">
660
- <button class="obs-close" on:click={closeObserveModal} aria-label="Close">โœ•</button>
 
 
 
 
 
 
 
 
661
  <h2 class="obs-title">Live Matches</h2>
662
  {#if playingRooms.length === 0}
663
  <p class="obs-empty">No matches available right now.</p>
@@ -672,7 +708,10 @@
672
  </div>
673
  <div class="obs-meta">
674
  <span class="obs-timer">{formatElapsed(room.elapsed)}</span>
675
- <button class="btn btn-observe obs-watch-btn" on:click={() => observeRoom(room.room_id)}>Watch</button>
 
 
 
676
  </div>
677
  </li>
678
  {/each}
@@ -1471,7 +1510,6 @@
1471
  font-size: 0.82rem;
1472
  }
1473
 
1474
-
1475
  /* Activity toast notification */
1476
  .activity-toast {
1477
  position: fixed;
@@ -1493,8 +1531,14 @@
1493
  animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
1494
  }
1495
  @keyframes toastIn {
1496
- from { opacity: 0; transform: translateY(-16px); }
1497
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
1498
  }
1499
  .activity-toast-icon {
1500
  font-size: 1rem;
@@ -1515,5 +1559,7 @@
1515
  white-space: nowrap;
1516
  transition: color 0.15s;
1517
  }
1518
- .activity-toast-link:hover { color: #c4b5fd; }
 
 
1519
  </style>
 
4
  import { backendUrl, getSocket } from "$lib/socket";
5
  import {
6
  gameState,
7
+ isTutorial,
8
  mapTextureUrl,
9
  myPlayerId,
10
  playerName,
11
  roomId,
12
  roomState,
13
  winnerId,
 
14
  } from "$lib/stores/game";
15
  import type { GameState, Room } from "$lib/types";
16
  import { onDestroy, onMount } from "svelte";
 
100
  }, 500);
101
  }
102
 
103
+ const _savedName =
104
+ typeof localStorage !== "undefined"
105
+ ? localStorage.getItem("sc_player_name")
106
+ : null;
107
+ const _nameLocked =
108
+ typeof localStorage !== "undefined" &&
109
+ localStorage.getItem("sc_name_locked") === "true";
110
  if (_savedName) playerName.set(_savedName);
111
 
112
  let name = _savedName || $playerName || "";
 
312
  // โ”€โ”€ Activity toast notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
313
  type StatEvent = {
314
  player_name: string;
315
+ event_type: "tutorial_complete" | "game_won";
316
  duration_s: number | null;
317
  opponent_name: string | null;
318
  recorded_at: number;
319
  };
320
 
321
  let toastVisible = false;
322
+ let toastText = "";
323
  let toastTimeout: ReturnType<typeof setTimeout> | null = null;
324
  let toastPollInterval: ReturnType<typeof setInterval> | null = null;
325
  let toastRotateInterval: ReturnType<typeof setInterval> | null = null;
 
327
  let toastIndex = 0;
328
 
329
  function fmtDurationShort(s: number | null): string {
330
+ if (s == null) return "";
331
  const m = Math.floor(s / 60);
332
  const sec = s % 60;
333
  if (m === 0) return ` in ${sec}s`;
334
+ return ` in ${m}m ${sec.toString().padStart(2, "0")}s`;
335
  }
336
 
337
  function buildToastText(ev: StatEvent): string {
338
+ if (ev.event_type === "tutorial_complete") {
339
  return `${ev.player_name} completed the tutorial${fmtDurationShort(ev.duration_s)}`;
340
  }
341
+ const opp = ev.opponent_name
342
+ ? ` defeated ${ev.opponent_name}`
343
+ : " won a match";
344
  return `${ev.player_name}${opp}${fmtDurationShort(ev.duration_s)}`;
345
  }
346
 
 
348
  toastText = text;
349
  toastVisible = true;
350
  if (toastTimeout) clearTimeout(toastTimeout);
351
+ toastTimeout = setTimeout(() => {
352
+ toastVisible = false;
353
+ }, 5000);
354
  }
355
 
356
  function rotateToast() {
 
373
  toastIndex = 0;
374
  showToast(buildToastText(events[0]));
375
  }
376
+ } catch {
377
+ /* silent */
378
+ }
379
  }
380
 
381
  function startToastSystem() {
 
401
  }
402
 
403
  // Observe modal
404
+ type PlayingRoom = {
405
+ room_id: string;
406
+ player1: string;
407
+ player2: string;
408
+ elapsed: number;
409
+ };
410
  let observeModalOpen = false;
411
  let playingRooms: PlayingRoom[] = [];
412
  let observeElapsedInterval: ReturnType<typeof setInterval> | null = null;
413
 
414
  function formatElapsed(seconds: number): string {
415
+ const m = Math.floor(seconds / 60)
416
+ .toString()
417
+ .padStart(2, "0");
418
+ const s = (seconds % 60).toString().padStart(2, "0");
419
  return `${m}:${s}`;
420
  }
421
 
422
  function openObserveModal() {
423
+ socket.emit("get_playing_rooms", {});
424
  observeModalOpen = true;
425
  observeElapsedInterval = setInterval(() => {
426
+ playingRooms = playingRooms.map((r) => ({
427
+ ...r,
428
+ elapsed: r.elapsed + 1,
429
+ }));
430
  }, 1000);
431
  }
432
 
 
441
 
442
  function observeRoom(room_id: string) {
443
  closeObserveModal();
444
+ socket.emit("observe", { room_id });
445
  }
446
  </script>
447
 
 
666
 
667
  {#if toastVisible}
668
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
669
+ <div
670
+ class="activity-toast"
671
+ on:click={() => (toastVisible = false)}
672
+ role="status"
673
+ aria-live="polite"
674
+ >
675
  <span class="activity-toast-icon">๐ŸŽฎ</span>
676
  <span class="activity-toast-text">{toastText}</span>
677
+ <a href="/leaderboard" class="activity-toast-link" on:click|stopPropagation
678
+ >View โ†’</a
679
+ >
680
  </div>
681
  {/if}
682
 
 
684
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
685
  <div class="obs-backdrop" on:click={closeObserveModal}>
686
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
687
+ <div
688
+ class="obs-modal"
689
+ on:click|stopPropagation
690
+ role="dialog"
691
+ aria-modal="true"
692
+ aria-label="Select a match to observe"
693
+ >
694
+ <button class="obs-close" on:click={closeObserveModal} aria-label="Close"
695
+ >โœ•</button
696
+ >
697
  <h2 class="obs-title">Live Matches</h2>
698
  {#if playingRooms.length === 0}
699
  <p class="obs-empty">No matches available right now.</p>
 
708
  </div>
709
  <div class="obs-meta">
710
  <span class="obs-timer">{formatElapsed(room.elapsed)}</span>
711
+ <button
712
+ class="btn btn-observe obs-watch-btn"
713
+ on:click={() => observeRoom(room.room_id)}>Watch</button
714
+ >
715
  </div>
716
  </li>
717
  {/each}
 
1510
  font-size: 0.82rem;
1511
  }
1512
 
 
1513
  /* Activity toast notification */
1514
  .activity-toast {
1515
  position: fixed;
 
1531
  animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
1532
  }
1533
  @keyframes toastIn {
1534
+ from {
1535
+ opacity: 0;
1536
+ transform: translateY(-16px);
1537
+ }
1538
+ to {
1539
+ opacity: 1;
1540
+ transform: translateY(0);
1541
+ }
1542
  }
1543
  .activity-toast-icon {
1544
  font-size: 1rem;
 
1559
  white-space: nowrap;
1560
  transition: color 0.15s;
1561
  }
1562
+ .activity-toast-link:hover {
1563
+ color: #c4b5fd;
1564
+ }
1565
  </style>