Surn commited on
Commit
8c718b2
·
1 Parent(s): 1dcc163

Enhance gameplay and UI; add Playwright tests

Browse files

Updated README.md to document version 0.2.9 changes, including a fix for sonar grid alignment and a new feature to auto-mark words as found when all letters are revealed. Updated __init__.py to version 0.2.9. Added `auto_mark_completed_words` function in logic.py and integrated it into ui.py. Refactored UI functions for improved layout stability and style consistency. Modified scope_blue.png (details not specified).

.gitignore CHANGED
@@ -488,3 +488,5 @@ secrets.*
488
  /.vs
489
  /battlewords/__pycache__/ui.cpython-311.pyc
490
  /battlewords/__pycache__/__init__.cpython-311.pyc
 
 
 
488
  /.vs
489
  /battlewords/__pycache__/ui.cpython-311.pyc
490
  /battlewords/__pycache__/__init__.cpython-311.pyc
491
+ /package.json
492
+ /package-lock.json
README.md CHANGED
@@ -106,6 +106,10 @@ docker run -p 8501:8501 battlewords
106
 
107
  ## Changelog
108
 
 
 
 
 
109
  -0.2.8
110
  - Add 10 incorrect guess limit per game
111
 
 
106
 
107
  ## Changelog
108
 
109
+ -0.2.9
110
+ - fix sonar grid alignment issue on some browsers
111
+ - When all letters of a word are revealed, it is automatically marked as found.
112
+
113
  -0.2.8
114
  - Add 10 incorrect guess limit per game
115
 
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.8"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.2.9"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/assets/scope_blue.png CHANGED
battlewords/logic.py CHANGED
@@ -141,4 +141,28 @@ def compute_tier(score: int) -> str:
141
  return "Great"
142
  if 34 <= score <= 37:
143
  return "Good"
144
- return "Keep practicing"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  return "Great"
142
  if 34 <= score <= 37:
143
  return "Good"
144
+ return "Keep practicing"
145
+
146
+
147
+ def auto_mark_completed_words(state: GameState) -> bool:
148
+ """Automatically mark words as found when all their letters are revealed.
149
+
150
+ Returns True if any word state changed (e.g., guessed/score/points).
151
+ Scoring in this case is base length only (no unrevealed bonus).
152
+ """
153
+ changed = False
154
+ for w in state.puzzle.words:
155
+ if w.text in state.guessed:
156
+ continue
157
+ if all(c in state.revealed for c in w.cells):
158
+ # Award base points if not already assigned
159
+ if w.text not in state.points_by_word:
160
+ base_points = w.length
161
+ state.points_by_word[w.text] = base_points
162
+ state.score += base_points
163
+ state.guessed.add(w.text)
164
+ changed = True
165
+ if changed:
166
+ # Do not alter can_guess; just note the auto-complete
167
+ state.last_action = (state.last_action or "") + "\nAuto-complete: revealed word(s) marked as found."
168
+ return changed
battlewords/ui.py CHANGED
@@ -14,7 +14,7 @@ import time
14
  from datetime import datetime
15
 
16
  from .generator import generate_puzzle, sort_word_file
17
- from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
18
  from .models import Coord, GameState, Puzzle
19
  from .word_loader import get_wordlist_files, load_word_list # use loader directly
20
  from .version_info import versions_html # version info footer
@@ -491,7 +491,7 @@ def get_scope_image(uid: str, size=4, bgcolor="none", scope_color="green", img_n
491
  return imgscope
492
 
493
  def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
494
- fig, ax = plt.subplots(figsize=(size, size), dpi=96)
495
  ax.set_facecolor(bgcolor)
496
  fig.patch.set_alpha(0.5)
497
  ax.set_zorder(0)
@@ -523,10 +523,10 @@ def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
523
  ax.plot([0, x], [0, y], color=scope_color, alpha=0.5, zorder=1)
524
 
525
  # Set limits and remove axes
526
- #ax.set_xlim(-0.5, 0.5)
527
- #ax.set_ylim(-0.5, 0.5)
528
  ax.set_aspect('equal', adjustable='box')
529
- #ax.axis('off')
530
 
531
  return fig, ax
532
 
@@ -548,7 +548,7 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
548
  max_x = max(xs - 0.5) + r_max
549
  min_y = min(ys - 0.5) - r_max # Note: ys are inverted in plot
550
  max_y = max(ys - 0.5) + r_max
551
- ring_linewidth = 4
552
 
553
  rgba_labels = mcolors.to_rgba("#FFFFFF", 0.7)
554
  rgba_ticks = mcolors.to_rgba("#FFFFFF", 0.66)
@@ -565,23 +565,13 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
565
 
566
  # Use per-puzzle scope image keyed by puzzle.uid
567
  imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color)
568
- fig, ax = plt.subplots(figsize=(scope_size, scope_size))
569
- fig.subplots_adjust(left=0, right=0.9, top=0.9, bottom=0)
570
- fig.patch.set_alpha(0.0)
571
- ax.set_position([0.02, 0.02, 0.98, 0.98])
572
- ax.set_xlim(min_x, max_x)
573
- ax.set_ylim(max_y, min_y) # Inverted for grid coordinates
574
 
575
- if show_ticks:
576
- ax.set_xticks(range(1, size + 1))
577
- ax.set_yticks(range(1, size + 1))
578
- ax.tick_params(axis="both", which="both", labelcolor=rgba_labels)
579
- ax.tick_params(axis="both", which="both", colors=rgba_ticks)
580
- else:
581
- ax.set_xticks([])
582
- ax.set_yticks([])
583
-
584
- ax.set_aspect('equal', adjustable='box')
585
 
586
  def _make_linear_gradient(width: int, height: int, angle_deg: float,
587
  colors_hex: list[str], stops: list[float]) -> np.ndarray:
@@ -605,7 +595,6 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
605
  img = (1.0 - w) * c0 + w * c1
606
  return img
607
 
608
- fig_w, fig_h = [int(v) for v in fig.canvas.get_width_height()]
609
  grad_img = _make_linear_gradient(
610
  width=fig_w,
611
  height=fig_h,
@@ -613,23 +602,37 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
613
  colors_hex=['#a1a1a1', '#ffffff', '#a1a1a1', '#666666'],
614
  stops=[0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0],
615
  )
616
- bg_ax = fig.add_axes([0, 0, 1, 1], zorder=0)
617
  bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear')
618
  bg_ax.axis('off')
619
 
620
- scope_ax = fig.add_axes([-0.1, -0.095, 1.18, 1.195], zorder=1)
621
- # scope_ax.set_position([0, 0, 1, 1])
622
  scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos')
623
  scope_ax.axis('off')
624
 
 
 
625
  ax.set_facecolor('none')
626
- ax.set_zorder(2)
 
 
 
 
 
 
 
 
 
 
 
 
627
  for spine in ax.spines.values():
628
  spine.set_visible(False)
629
 
 
630
  rings: list[Circle] = []
631
  for x, y in zip(xs, ys):
632
- ring = Circle((x - 0.5, y - 0.5), radius=r_min, fill=False, edgecolor='#9ceffe', linewidth=ring_linewidth, alpha=1.0, zorder=3)
633
  ax.add_patch(ring)
634
  rings.append(ring)
635
 
@@ -664,7 +667,7 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
664
  if cached_path and os.path.exists(cached_path) and cached_sig == gif_signature:
665
  with open(cached_path, "rb") as f:
666
  gif_bytes = f.read()
667
- st.image(gif_bytes, width='content', output_format="auto")
668
  plt.close(fig)
669
  return
670
 
@@ -677,7 +680,7 @@ def _render_radar(puzzle: Puzzle, size: int, r_max: float = 0.8, max_frames: int
677
  gif_bytes = tmpfile.read()
678
  st.session_state.radar_gif_path = tmpfile.name # Save path for reuse
679
  st.session_state.radar_gif_signature = gif_signature # Save signature to detect changes
680
- st.image(gif_bytes, width='content', output_format="auto")
681
 
682
  def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
683
  size = state.grid_size
@@ -798,6 +801,11 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
798
 
799
  if clicked is not None:
800
  reveal_cell(state, letter_map, clicked)
 
 
 
 
 
801
  st.session_state.letter_map = build_letter_map(st.session_state.puzzle)
802
  _sync_back(state)
803
 
@@ -838,20 +846,7 @@ def _render_hit_miss(state: GameState):
838
  # Render as a circular radio group, side-by-side
839
  st.markdown(
840
  f"""
841
- <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Hit or Miss\">
842
- <div class=\"bw-radio-item\">
843
- <div class=\"bw-radio-circle {'active hit' if is_hit else ''}\" role=\"radio\" aria-checked=\"{'true' if is_hit else 'false'}\" aria-label=\"Hit\">
844
- <span class=\"dot\"></span>
845
- </div>
846
- <div class=\"bw-radio-caption\">HIT</div>
847
- </div>
848
- <div class=\"bw-radio-item\">
849
- <div class=\"bw-radio-circle {'active miss' if is_miss else ''}\" role=\"radio\" aria-checked=\"{'true' if is_miss else 'false'}\" aria-label=\"Miss\">
850
- <span class=\"dot\"></span>
851
- </div>
852
- <div class=\"bw-radio-caption\">MISS</div>
853
- </div>
854
- </div>
855
  """,
856
  unsafe_allow_html=True,
857
  )
@@ -874,20 +869,7 @@ def _render_correct_try_again(state: GameState):
874
 
875
  st.markdown(
876
  f"""
877
- <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Correct or Try Again\">
878
- <div class=\"bw-radio-item\">
879
- <div class=\"bw-radio-circle {'active hit' if is_correct else ''}\" role=\"radio\" aria-checked=\"{'true' if is_correct else 'false'}\" aria-label=\"Correct\">
880
- <span class=\"dot\"></span>
881
- </div>
882
- <div class=\"bw-radio-caption{' inactive' if not is_correct else ''}\">CORRECT!</div>
883
- </div>
884
- <div class=\"bw-radio-item\">
885
- <div class=\"bw-radio-circle {'active miss' if is_try_again else ''}\" role=\"radio\" aria-checked=\"{'true' if is_try_again else 'false'}\" aria-label=\"Try Again\">
886
- <span class=\"dot\"></span>
887
- </div>
888
- <div class=\"bw-radio-caption{' inactive' if not is_try_again else ''}\">TRY AGAIN</div>
889
- </div>
890
- </div>
891
  """,
892
  unsafe_allow_html=True,
893
  )
@@ -1116,7 +1098,7 @@ def _game_over_content(state: GameState) -> None:
1116
  padding: 16px;
1117
  }
1118
  .bw-dialog-header { display:flex; justify-content: space-between; align-items:center; }
1119
- .bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
1120
  .text-success { color: #20d46c;font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003); }
1121
  .st-key-new_game_btn_dialog, .st-key-close_game_over { width: 50% !important;
1122
  height: auto;
@@ -1132,7 +1114,7 @@ def _game_over_content(state: GameState) -> None:
1132
  /*filter:drop-shadow(1px 1px 2px #003);*/
1133
  /*filter: invert(1);*/
1134
  }
1135
- .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1136
  </style>
1137
  """,
1138
  unsafe_allow_html=True,
@@ -1140,8 +1122,7 @@ def _game_over_content(state: GameState) -> None:
1140
 
1141
  st.markdown(
1142
  f"""
1143
- <div class=\"bw-dialog-container shiny-border\">
1144
- <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
1145
  <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
1146
  <div class=\"mb-2\">Time: <strong>{timer_str}</strong></div>
1147
  <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
@@ -1243,7 +1224,7 @@ def run_app():
1243
  _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
1244
  st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
1245
 
1246
- # End condition (only show overlay if not dismissed)
1247
  state = _to_state()
1248
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1249
  _render_game_over(state)
 
14
  from datetime import datetime
15
 
16
  from .generator import generate_puzzle, sort_word_file
17
+ from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words
18
  from .models import Coord, GameState, Puzzle
19
  from .word_loader import get_wordlist_files, load_word_list # use loader directly
20
  from .version_info import versions_html # version info footer
 
491
  return imgscope
492
 
493
  def _create_radar_scope(size=4, bgcolor="none", scope_color="green"):
494
+ fig, ax = plt.subplots(figsize=(size, size), dpi=144)
495
  ax.set_facecolor(bgcolor)
496
  fig.patch.set_alpha(0.5)
497
  ax.set_zorder(0)
 
523
  ax.plot([0, x], [0, y], color=scope_color, alpha=0.5, zorder=1)
524
 
525
  # Set limits and remove axes
526
+ ax.set_xlim(-0.5, 0.5)
527
+ ax.set_ylim(-0.5, 0.5)
528
  ax.set_aspect('equal', adjustable='box')
529
+ ax.axis('off')
530
 
531
  return fig, ax
532
 
 
548
  max_x = max(xs - 0.5) + r_max
549
  min_y = min(ys - 0.5) - r_max # Note: ys are inverted in plot
550
  max_y = max(ys - 0.5) + r_max
551
+ ring_linewidth = 3
552
 
553
  rgba_labels = mcolors.to_rgba("#FFFFFF", 0.7)
554
  rgba_ticks = mcolors.to_rgba("#FFFFFF", 0.66)
 
565
 
566
  # Use per-puzzle scope image keyed by puzzle.uid
567
  imgscope = get_scope_image(uid=puzzle.uid, size=scope_size, bgcolor=bgcolor, scope_color=scope_color)
 
 
 
 
 
 
568
 
569
+ # Build figure with explicit axes occupying full canvas to avoid browser-specific padding
570
+ fig = plt.figure(figsize=(scope_size, scope_size), dpi=144)
571
+ # Background gradient covering full figure
572
+ bg_ax = fig.add_axes([0, 0, 1, 1], zorder=0)
573
+ fig.canvas.draw() # ensure size
574
+ fig_w, fig_h = [int(v) for v in fig.canvas.get_width_height()]
 
 
 
 
575
 
576
  def _make_linear_gradient(width: int, height: int, angle_deg: float,
577
  colors_hex: list[str], stops: list[float]) -> np.ndarray:
 
595
  img = (1.0 - w) * c0 + w * c1
596
  return img
597
 
 
598
  grad_img = _make_linear_gradient(
599
  width=fig_w,
600
  height=fig_h,
 
602
  colors_hex=['#a1a1a1', '#ffffff', '#a1a1a1', '#666666'],
603
  stops=[0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0],
604
  )
 
605
  bg_ax.imshow(grad_img, aspect='auto', interpolation='bilinear')
606
  bg_ax.axis('off')
607
 
608
+ # Decorative scope image as overlay (does not affect coordinates)
609
+ scope_ax = fig.add_axes([0, 0, 1, 1], zorder=1)
610
  scope_ax.imshow(imgscope, aspect='auto', interpolation='lanczos')
611
  scope_ax.axis('off')
612
 
613
+ # Main axes for rings and ticks with fixed limits to stabilize layout across browsers
614
+ ax = fig.add_axes([0, 0, 1, 1], zorder=2)
615
  ax.set_facecolor('none')
616
+ ax.set_xlim(0.5, size + 0.5)
617
+ ax.set_ylim(size + 0.5, 0.5) # Inverted for grid coordinates
618
+
619
+ if show_ticks:
620
+ ax.set_xticks(range(1, size + 1))
621
+ ax.set_yticks(range(1, size + 1))
622
+ ax.tick_params(axis="both", which="both", labelcolor=rgba_labels)
623
+ ax.tick_params(axis="both", which="both", colors=rgba_ticks)
624
+ else:
625
+ ax.set_xticks([])
626
+ ax.set_yticks([])
627
+
628
+ ax.set_aspect('equal', adjustable='box')
629
  for spine in ax.spines.values():
630
  spine.set_visible(False)
631
 
632
+ # Build rings centered on exact cell centers (integer coords)
633
  rings: list[Circle] = []
634
  for x, y in zip(xs, ys):
635
+ ring = Circle((x, y), radius=r_min, fill=False, edgecolor='#9ceffe', linewidth=ring_linewidth, alpha=1.0, zorder=3)
636
  ax.add_patch(ring)
637
  rings.append(ring)
638
 
 
667
  if cached_path and os.path.exists(cached_path) and cached_sig == gif_signature:
668
  with open(cached_path, "rb") as f:
669
  gif_bytes = f.read()
670
+ st.image(gif_bytes, use_container_width=True)
671
  plt.close(fig)
672
  return
673
 
 
680
  gif_bytes = tmpfile.read()
681
  st.session_state.radar_gif_path = tmpfile.name # Save path for reuse
682
  st.session_state.radar_gif_signature = gif_signature # Save signature to detect changes
683
+ st.image(gif_bytes, use_container_width=True)
684
 
685
  def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
686
  size = state.grid_size
 
801
 
802
  if clicked is not None:
803
  reveal_cell(state, letter_map, clicked)
804
+ # Auto-mark and award base points for any fully revealed words
805
+ if auto_mark_completed_words(state):
806
+ # Invalidate radar GIF cache to hide completed rings
807
+ st.session_state.radar_gif_path = None
808
+ st.session_state.radar_gif_signature = None
809
  st.session_state.letter_map = build_letter_map(st.session_state.puzzle)
810
  _sync_back(state)
811
 
 
846
  # Render as a circular radio group, side-by-side
847
  st.markdown(
848
  f"""
849
+ <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Hit or Miss\">\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active hit' if is_hit else ''}\" role=\"radio\" aria-checked=\"{'true' if is_hit else 'false'}\" aria-label=\"Hit\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption\">HIT</div>\n </div>\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active miss' if is_miss else ''}\" role=\"radio\" aria-checked=\"{'true' if is_miss else 'false'}\" aria-label=\"Miss\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption\">MISS</div>\n </div>\n </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  """,
851
  unsafe_allow_html=True,
852
  )
 
869
 
870
  st.markdown(
871
  f"""
872
+ <div class=\"bw-radio-group\" role=\"radiogroup\" aria-label=\"Correct or Try Again\">\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active hit' if is_correct else ''}\" role=\"radio\" aria-checked=\"{'true' if is_correct else 'false'}\" aria-label=\"Correct\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption{' inactive' if not is_correct else ''}\">CORRECT!</div>\n </div>\n <div class=\"bw-radio-item\">\n <div class=\"bw-radio-circle {'active miss' if is_try_again else ''}\" role=\"radio\" aria-checked=\"{'true' if is_try_again else 'false'}\" aria-label=\"Try Again\">\n <span class=\"dot\"></span>\n </div>\n <div class=\"bw-radio-caption{' inactive' if not is_try_again else ''}\">TRY AGAIN</div>\n </div>\n </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  """,
874
  unsafe_allow_html=True,
875
  )
 
1098
  padding: 16px;
1099
  }
1100
  .bw-dialog-header { display:flex; justify-content: space-between; align-items:center; }
1101
+ .bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
1102
  .text-success { color: #20d46c;font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003); }
1103
  .st-key-new_game_btn_dialog, .st-key-close_game_over { width: 50% !important;
1104
  height: auto;
 
1114
  /*filter:drop-shadow(1px 1px 2px #003);*/
1115
  /*filter: invert(1);*/
1116
  }
1117
+ .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1118
  </style>
1119
  """,
1120
  unsafe_allow_html=True,
 
1122
 
1123
  st.markdown(
1124
  f"""
1125
+ <div class=\"bw-dialog-container shiny-border\">\n <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
 
1126
  <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
1127
  <div class=\"mb-2\">Time: <strong>{timer_str}</strong></div>
1128
  <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
 
1224
  _render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
1225
  st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
1226
 
1227
+ # End condition (only show overlay if dismissed)
1228
  state = _to_state()
1229
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1230
  _render_game_over(state)