Surn commited on
Commit
d747bc4
·
1 Parent(s): 5e94b78

Initial Scaffold

Browse files
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
app.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+ from battlewords.ui import run_app
4
+ def main():
5
+ st.set_page_config(page_title="Battlewords (POC)", layout="wide")
6
+ run_app()
7
+
8
+
9
+ if __name__ == "__main__":
10
+ main()
battlewords/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __all__ = ["models", "generator", "logic", "ui"]
battlewords/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (207 Bytes). View file
 
battlewords/__pycache__/ui.cpython-311.pyc ADDED
Binary file (11.5 kB). View file
 
battlewords/generator.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Tuple
6
+
7
+ import streamlit as st
8
+
9
+ from .models import Coord, Word, Puzzle
10
+
11
+
12
+ # Fallback minimal word pools if file missing or too small
13
+ _FALLBACK: Dict[int, List[str]] = {
14
+ 4: ["TREE", "BOAT", "WIND", "FROG", "LION", "MOON", "FORK", "GLOW", "GAME", "CODE"],
15
+ 5: ["APPLE", "RIVER", "STONE", "PLANT", "MOUSE", "BOARD", "CHAIR", "SCALE", "SMILE", "CLOUD"],
16
+ 6: ["ORANGE", "PYTHON", "STREAM", "MARKET", "FOREST", "THRIVE", "LOGGER", "BREATH", "DOMAIN", "GALAXY"],
17
+ }
18
+
19
+
20
+ @st.cache_data(show_spinner=False)
21
+ def load_word_list() -> Dict[int, List[str]]:
22
+ """
23
+ Loads and filters a word list for lengths 4, 5, 6.
24
+ Returns a dict length->words (uppercase, A–Z only).
25
+ """
26
+ base = Path(__file__).parent / "words" / "wordlist.txt"
27
+ words_by_len: Dict[int, List[str]] = {4: [], 5: [], 6: []}
28
+ if base.exists():
29
+ try:
30
+ for line in base.read_text(encoding="utf-8").splitlines():
31
+ w = line.strip().upper()
32
+ if w.isalpha() and len(w) in (4, 5, 6):
33
+ words_by_len[len(w)].append(w)
34
+ except Exception:
35
+ pass
36
+
37
+ # Ensure minimum pools; otherwise fallback
38
+ for L in (4, 5, 6):
39
+ if len(words_by_len[L]) < 500:
40
+ words_by_len[L] = _FALLBACK[L].copy()
41
+
42
+ return words_by_len
43
+
44
+
45
+ def _fits_and_free(cells: List[Coord], used: set[Coord], size: int) -> bool:
46
+ for c in cells:
47
+ if not c.in_bounds(size) or c in used:
48
+ return False
49
+ return True
50
+
51
+
52
+ def _build_cells(start: Coord, length: int, direction: str) -> List[Coord]:
53
+ if direction == "H":
54
+ return [Coord(start.x, start.y + i) for i in range(length)]
55
+ else:
56
+ return [Coord(start.x + i, start.y) for i in range(length)]
57
+
58
+
59
+ def generate_puzzle(
60
+ grid_size: int = 12,
61
+ words_by_len: Optional[Dict[int, List[str]]] = None,
62
+ seed: Optional[int] = None,
63
+ max_attempts: int = 5000,
64
+ ) -> Puzzle:
65
+ """
66
+ Place exactly six words: 2x4, 2x5, 2x6, horizontal or vertical,
67
+ no cell overlaps. Radar pulses are last-letter cells.
68
+ """
69
+ rng = random.Random(seed)
70
+ words_by_len = words_by_len or load_word_list()
71
+ target_lengths = [4, 4, 5, 5, 6, 6]
72
+
73
+ used: set[Coord] = set()
74
+ placed: List[Word] = []
75
+
76
+ # Pre-shuffle the word pools for variety but deterministic with seed
77
+ pools: Dict[int, List[str]] = {L: words_by_len[L][:] for L in (4, 5, 6)}
78
+ for L in pools:
79
+ rng.shuffle(pools[L])
80
+
81
+ attempts = 0
82
+ for L in target_lengths:
83
+ placed_ok = False
84
+ pool = pools[L]
85
+ if not pool:
86
+ raise RuntimeError(f"No words available for length {L}")
87
+
88
+ # Try different source words and positions
89
+ word_try_order = pool[:] # copy
90
+ rng.shuffle(word_try_order)
91
+
92
+ for cand_text in word_try_order:
93
+ if attempts >= max_attempts:
94
+ break
95
+ attempts += 1
96
+
97
+ # Try a variety of starts/orientations for this word
98
+ for _ in range(50):
99
+ direction = rng.choice(["H", "V"])
100
+ if direction == "H":
101
+ row = rng.randrange(0, grid_size)
102
+ col = rng.randrange(0, grid_size - L + 1)
103
+ else:
104
+ row = rng.randrange(0, grid_size - L + 1)
105
+ col = rng.randrange(0, grid_size)
106
+
107
+ cells = _build_cells(Coord(row, col), L, direction)
108
+ if _fits_and_free(cells, used, grid_size):
109
+ w = Word(cand_text, Coord(row, col), direction)
110
+ placed.append(w)
111
+ used.update(cells)
112
+ placed_ok = True
113
+ break
114
+
115
+ if placed_ok:
116
+ break
117
+
118
+ if not placed_ok:
119
+ # Hard reset and retry whole generation if we hit a wall
120
+ if attempts >= max_attempts:
121
+ raise RuntimeError("Puzzle generation failed: max attempts reached")
122
+ return generate_puzzle(grid_size=grid_size, words_by_len=words_by_len, seed=rng.randrange(1 << 30))
123
+
124
+ puzzle = Puzzle(words=placed)
125
+ validate_puzzle(puzzle, grid_size=grid_size)
126
+ return puzzle
127
+
128
+
129
+ def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
130
+ # Bounds and overlap checks
131
+ seen: set[Coord] = set()
132
+ counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
133
+ for w in puzzle.words:
134
+ if len(w.text) not in (4, 5, 6):
135
+ raise AssertionError("Word length invalid")
136
+ counts[len(w.text)] += 1
137
+ for c in w.cells:
138
+ if not c.in_bounds(grid_size):
139
+ raise AssertionError("Cell out of bounds")
140
+ if c in seen:
141
+ raise AssertionError("Overlapping words detected")
142
+ seen.add(c)
143
+ # Last cell must match radar pulse for that word
144
+ if w.last_cell not in puzzle.radar:
145
+ raise AssertionError("Radar pulse missing for last cell")
146
+
147
+ if counts[4] != 2 or counts[5] != 2 or counts[6] != 2:
148
+ raise AssertionError("Incorrect counts of word lengths")
battlewords/logic.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Tuple
4
+
5
+ from .models import Coord, Puzzle, GameState, Word
6
+
7
+
8
+ def build_letter_map(puzzle: Puzzle) -> Dict[Coord, str]:
9
+ mapping: Dict[Coord, str] = {}
10
+ for w in puzzle.words:
11
+ for idx, c in enumerate(w.cells):
12
+ mapping[c] = w.text[idx]
13
+ return mapping
14
+
15
+
16
+ def reveal_cell(state: GameState, letter_map: Dict[Coord, str], coord: Coord) -> None:
17
+ if coord in state.revealed:
18
+ state.last_action = "Already revealed."
19
+ return
20
+ state.revealed.add(coord)
21
+ state.can_guess = True
22
+ ch = letter_map.get(coord, "�")
23
+ if ch == "�":
24
+ state.last_action = f"Revealed empty at ({coord.x+1},{coord.y+1})."
25
+ else:
26
+ state.last_action = f"Revealed '{ch}' at ({coord.x+1},{coord.y+1})."
27
+
28
+
29
+ def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]:
30
+ if not state.can_guess:
31
+ state.last_action = "You must reveal a cell before guessing."
32
+ return False, 0
33
+ guess = (guess_text or "").strip().upper()
34
+ if not (len(guess) in (4, 5, 6) and guess.isalpha()):
35
+ state.last_action = "Guess must be A�Z and length 4, 5, or 6."
36
+ state.can_guess = False
37
+ return False, 0
38
+ if guess in state.guessed:
39
+ state.last_action = f"Already guessed {guess}."
40
+ state.can_guess = False
41
+ return False, 0
42
+
43
+ # Find matching unguessed word
44
+ target: Word | None = None
45
+ for w in state.puzzle.words:
46
+ if w.text == guess and w.text not in state.guessed:
47
+ target = w
48
+ break
49
+
50
+ if target is None:
51
+ state.last_action = f"'{guess}' is not in the puzzle."
52
+ state.can_guess = False
53
+ return False, 0
54
+
55
+ # Scoring: base = length, bonus = unrevealed cells in that word
56
+ unrevealed = sum(1 for c in target.cells if c not in state.revealed)
57
+ points = target.length + unrevealed
58
+ state.score += points
59
+ state.points_by_word[target.text] = points
60
+
61
+ # Reveal all cells of the word
62
+ for c in target.cells:
63
+ state.revealed.add(c)
64
+ state.guessed.add(target.text)
65
+
66
+ state.last_action = f"Correct! +{points} points for {target.text}."
67
+ state.can_guess = False
68
+ return True, points
69
+
70
+
71
+ def is_game_over(state: GameState) -> bool:
72
+ return len(state.guessed) == 6
73
+
74
+
75
+ def compute_tier(score: int) -> str:
76
+ if score >= 42:
77
+ return "Fantastic"
78
+ if 38 <= score <= 41:
79
+ return "Great"
80
+ if 34 <= score <= 37:
81
+ return "Good"
82
+ return "Keep practicing"
battlewords/models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, List, Set, Dict
5
+
6
+
7
+ Direction = Literal["H", "V"]
8
+
9
+
10
+ @dataclass(frozen=True, order=True)
11
+ class Coord:
12
+ x: int # row, 0-based
13
+ y: int # col, 0-based
14
+
15
+ def in_bounds(self, size: int) -> bool:
16
+ return 0 <= self.x < size and 0 <= self.y < size
17
+
18
+
19
+ @dataclass
20
+ class Word:
21
+ text: str
22
+ start: Coord
23
+ direction: Direction
24
+ cells: List[Coord] = field(default_factory=list)
25
+
26
+ def __post_init__(self):
27
+ self.text = self.text.upper()
28
+ if self.direction not in ("H", "V"):
29
+ raise ValueError("direction must be 'H' or 'V'")
30
+ if not self.text.isalpha():
31
+ raise ValueError("word must be alphabetic A�Z only")
32
+ # compute cells based on start and direction
33
+ length = len(self.text)
34
+ cells: List[Coord] = []
35
+ for i in range(length):
36
+ if self.direction == "H":
37
+ cells.append(Coord(self.start.x, self.start.y + i))
38
+ else:
39
+ cells.append(Coord(self.start.x + i, self.start.y))
40
+ object.__setattr__(self, "cells", cells)
41
+
42
+ @property
43
+ def length(self) -> int:
44
+ return len(self.text)
45
+
46
+ @property
47
+ def last_cell(self) -> Coord:
48
+ return self.cells[-1]
49
+
50
+
51
+ @dataclass
52
+ class Puzzle:
53
+ words: List[Word]
54
+ radar: List[Coord] = field(default_factory=list)
55
+
56
+ def __post_init__(self):
57
+ pulses = [w.last_cell for w in self.words]
58
+ self.radar = pulses
59
+
60
+
61
+ @dataclass
62
+ class GameState:
63
+ grid_size: int
64
+ puzzle: Puzzle
65
+ revealed: Set[Coord]
66
+ guessed: Set[str]
67
+ score: int
68
+ last_action: str
69
+ can_guess: bool
70
+ points_by_word: Dict[str, int] = field(default_factory=dict)
battlewords/ui.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import matplotlib.pyplot as plt
6
+ import streamlit as st
7
+
8
+ from .generator import generate_puzzle, load_word_list
9
+ from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier
10
+ from .models import Coord, GameState, Puzzle
11
+
12
+
13
+ def _init_session() -> None:
14
+ if "initialized" in st.session_state and st.session_state.initialized:
15
+ return
16
+
17
+ words = load_word_list()
18
+ puzzle = generate_puzzle(grid_size=12, words_by_len=words)
19
+
20
+ st.session_state.puzzle = puzzle
21
+ st.session_state.grid_size = 12
22
+ st.session_state.revealed = set()
23
+ st.session_state.guessed = set()
24
+ st.session_state.score = 0
25
+ st.session_state.last_action = "Welcome to Battlewords! Reveal a cell to begin."
26
+ st.session_state.can_guess = False
27
+ st.session_state.points_by_word = {}
28
+ st.session_state.letter_map = build_letter_map(puzzle)
29
+ st.session_state.initialized = True
30
+
31
+
32
+ def _new_game() -> None:
33
+ st.session_state.clear()
34
+ _init_session()
35
+ st.rerun()
36
+
37
+
38
+ def _to_state() -> GameState:
39
+ return GameState(
40
+ grid_size=st.session_state.grid_size,
41
+ puzzle=st.session_state.puzzle,
42
+ revealed=st.session_state.revealed,
43
+ guessed=st.session_state.guessed,
44
+ score=st.session_state.score,
45
+ last_action=st.session_state.last_action,
46
+ can_guess=st.session_state.can_guess,
47
+ points_by_word=st.session_state.points_by_word,
48
+ )
49
+
50
+
51
+ def _sync_back(state: GameState) -> None:
52
+ st.session_state.revealed = state.revealed
53
+ st.session_state.guessed = state.guessed
54
+ st.session_state.score = state.score
55
+ st.session_state.last_action = state.last_action
56
+ st.session_state.can_guess = state.can_guess
57
+ st.session_state.points_by_word = state.points_by_word
58
+
59
+
60
+ def _render_header():
61
+ st.title("Battlewords (POC)")
62
+ st.subheader("Reveal cells, then guess the hidden words.")
63
+ st.markdown(
64
+ "- Grid is 12×12 with 6 words (two 4-letter, two 5-letter, two 6-letter).\n"
65
+ "- After each reveal, you may submit one guess.\n"
66
+ "- Scoring: length + unrevealed letters of that word at guess time."
67
+ )
68
+
69
+ def _render_sidebar():
70
+ with st.sidebar:
71
+ st.header("Controls")
72
+ st.button("New Game", use_container_width=True, on_click=_new_game)
73
+ st.markdown("Radar pulses show the last letter position of each hidden word.")
74
+
75
+
76
+ def _render_radar(puzzle: Puzzle, size: int):
77
+ fig, ax = plt.subplots(figsize=(4, 4))
78
+ xs = [c.y + 1 for c in puzzle.radar] # columns on x-axis
79
+ ys = [c.x + 1 for c in puzzle.radar] # rows on y-axis
80
+ ax.scatter(xs, ys, c="red", s=60, marker="o")
81
+ ax.set_xlim(0.5, size + 0.5)
82
+ ax.set_ylim(0.5, size + 0.5)
83
+ ax.set_xticks(range(1, size + 1))
84
+ ax.set_yticks(range(1, size + 1))
85
+ ax.grid(True, which="both", linestyle="--", alpha=0.3)
86
+ ax.set_title("Radar")
87
+ st.pyplot(fig, use_container_width=True)
88
+ plt.close(fig)
89
+
90
+
91
+ def _render_grid(state: GameState, letter_map):
92
+ size = state.grid_size
93
+ clicked: Optional[Coord] = None
94
+
95
+ grid_container = st.container()
96
+ with grid_container:
97
+ for r in range(size):
98
+ cols = st.columns(size, gap="small")
99
+ for c in range(size):
100
+ coord = Coord(r, c)
101
+ revealed = coord in state.revealed
102
+ label = letter_map.get(coord, " ") if revealed else " "
103
+ key = f"cell_{r}_{c}"
104
+ # Make revealed cells look disabled via help text; but still keep them clickable for UX feedback
105
+ if cols[c].button(label, key=key, help=f"({r+1},{c+1})"):
106
+ clicked = coord
107
+
108
+ if clicked is not None:
109
+ reveal_cell(state, letter_map, clicked)
110
+ _sync_back(state)
111
+ # No need to st.rerun(); Streamlit will rerun after button click
112
+
113
+
114
+ def _render_guess_form(state: GameState):
115
+ with st.form("guess_form"):
116
+ guess_text = st.text_input("Your guess", value="", max_chars=12)
117
+ submitted = st.form_submit_button("OK", disabled=not state.can_guess, use_container_width=True)
118
+ if submitted:
119
+ correct, _ = guess_word(state, guess_text)
120
+ _sync_back(state)
121
+
122
+
123
+ def _render_score_panel(state: GameState):
124
+ col1, col2 = st.columns([1, 3])
125
+ with col1:
126
+ st.metric("Score", state.score)
127
+ with col2:
128
+ st.markdown(f"Last action: {state.last_action}")
129
+
130
+
131
+ def _render_game_over(state: GameState):
132
+ st.subheader("Game Over")
133
+ tier = compute_tier(state.score)
134
+ st.markdown(f"Final score: {state.score} — Tier: **{tier}**")
135
+
136
+ with st.expander("Game summary", expanded=True):
137
+ for w in state.puzzle.words:
138
+ pts = state.points_by_word.get(w.text, 0)
139
+ st.markdown(f"- {w.text} ({len(w.text)}): +{pts} points")
140
+ st.markdown(f"**Total**: {state.score}")
141
+
142
+ st.stop()
143
+
144
+
145
+ def run_app():
146
+ _init_session()
147
+ _render_header()
148
+ _render_sidebar()
149
+
150
+ state = _to_state()
151
+
152
+ left, right = st.columns([3, 1], gap="large")
153
+ with left:
154
+ _render_grid(state, st.session_state.letter_map)
155
+ with right:
156
+ _render_radar(state.puzzle, size=state.grid_size)
157
+ _render_score_panel(state)
158
+
159
+ st.divider()
160
+ _render_guess_form(state)
161
+
162
+ # End condition
163
+ state = _to_state()
164
+ if is_game_over(state):
165
+ _render_game_over(state)
battlewords/words/wordlist.txt ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optional: place a large A�Z word list here (one word per line).
2
+ # The app falls back to built-in pools if fewer than 500 words per length are found.
3
+ TREE
4
+ BOAT
5
+ WIND
6
+ FROG
7
+ LION
8
+ MOON
9
+ FORK
10
+ GLOW
11
+ GAME
12
+ CODE
13
+ APPLE
14
+ RIVER
15
+ STONE
16
+ PLANT
17
+ MOUSE
18
+ BOARD
19
+ CHAIR
20
+ SCALE
21
+ SMILE
22
+ CLOUD
23
+ ORANGE
24
+ PYTHON
25
+ STREAM
26
+ MARKET
27
+ FOREST
28
+ THRIVE
29
+ LOGGER
30
+ BREATH
31
+ DOMAIN
32
+ GALAXY
33
+ ABLE
34
+ ACID
35
+ AGED
36
+ ALSO
37
+ AREA
38
+ ARMY
39
+ AWAY
40
+ BABY
41
+ BACK
42
+ BALL
43
+ BAND
44
+ BANK
45
+ BASE
46
+ BATH
47
+ BEAR
48
+ BEAT
49
+ BEEF
50
+ BELL
51
+ BELT
52
+ BEST
53
+ BILL
54
+ BIRD
55
+ BLOW
56
+ BLUE
57
+ BOAT
58
+ BODY
59
+ BOMB
60
+ BOND
61
+ BONE
62
+ BOOK
63
+ BOOM
64
+ BOOT
65
+ BORN
66
+ BOSS
67
+ BOTH
68
+ BOWL
69
+ BULK
70
+ BURN
71
+ BUSH
72
+ BUSY
73
+ CALL
74
+ CALM
75
+ CAME
76
+ CAMP
77
+ CARD
78
+ CARE
79
+ CASE
80
+ CASH
81
+ CAST
82
+ CELL
83
+ CHAT
84
+ CHEF
85
+ CHEW
86
+ CHIN
87
+ CITY
88
+ CLAY
89
+ CLUB
90
+ COAL
91
+ COAT
92
+ CODE
93
+ COIN
94
+ COLD
95
+ COME
96
+ COOK
97
+ COOL
98
+ COPE
99
+ COPY
100
+ CORE
101
+ COST
102
+ CREW
103
+ CROP
104
+ DARK
105
+ DATA
106
+ DATE
107
+ DAWN
108
+ DEAL
109
+ DEAN
110
+ DEAR
111
+ DEBT
112
+ DECK
113
+ DEEP
114
+ DEER
115
+ DIAL
116
+ DICK
117
+ DIET
118
+ DISC
119
+ DISK
120
+ DOCK
121
+ DOES
122
+ DONE
123
+ DOOR
124
+ DOWN
125
+ DRAW
126
+ DREW
127
+ DROP
128
+ DRUG
129
+ DUAL
130
+ DUCK
131
+ DULL
132
+ DUST
133
+ DUTY
134
+ EACH
135
+ EARN
136
+ EASE
137
+ EAST
138
+ EDGE
139
+ ELSE
140
+ EVEN
141
+ EVER
142
+ EVIL
143
+ EXIT
144
+ FACE
145
+ FACT
146
+ FAIR
147
+ FALL
148
+ FARM
149
+ FAST
150
+ FEAR
151
+ FEED
152
+ FEEL
153
+ FEET
154
+ FELL
155
+ FELT
156
+ FILE
157
+ FILL
158
+ FILM
159
+ FIND
160
+ FINE
161
+ FIRE
162
+ FIRM
163
+ FISH
164
+ FIVE
165
+ FLAT
166
+ FLOW
167
+ FOAM
168
+ FOOD
169
+ FOOT
170
+ FORD
171
+ FORM
172
+ FORT
173
+ FOUR
174
+ FREE
175
+ FROM
176
+ FUEL
177
+ FULL
178
+ FUND
179
+ GAIN
180
+ GAME
181
+ GATE
182
+ GEAR
183
+ GENE
184
+ GIFT
185
+ GIRL
186
+ GIVE
187
+ GLAD
188
+ GOAL
189
+ GOAT
190
+ GOLD
191
+ GOLF
192
+ GONE
193
+ GOOD
194
+ GRAY
195
+ GREAT
196
+ GRID
197
+ GRIP
198
+ GROW
199
+ GULF
200
+ HAIR
201
+ HALF
202
+ HALL
203
+ HAND
204
+ HANG
205
+ HARD
206
+ HARM
207
+ HATE
208
+ HAVE
209
+ HEAD
210
+ HEAR
211
+ HEAT
212
+ HELD
213
+ HELL
214
+ HELP
215
+ HERB
216
+ HERE
217
+ HERO
218
+ HIGH
219
+ HILL
220
+ HIRE
221
+ HOLD
222
+ HOLE
223
+ HOME
224
+ HOPE
225
+ HOST
226
+ HOUR
227
+ HUGE
228
+ HUNT
229
+ HURT
230
+ IDEA
231
+ INCH
232
+ INTO
233
+ IRON
234
+ ITEM
235
+ JACK
236
+ JANE
237
+ JEAN
238
+ JOHN
239
+ JOIN
240
+ JUMP
241
+ JURY
242
+ JUST
243
+ KEEP
244
+ KENT
245
+ KEPT
246
+ KICK
247
+ KILL
248
+ KIND
249
+ KING
250
+ KISS
251
+ KNEE
252
+ KNOW
253
+ LACK
254
+ LADY
255
+ LAKE
256
+ LAND
257
+ LAST
258
+ LATE
259
+ LEAD
260
+ LEFT
261
+ LESS
262
+ LIFE
263
+ LIFT
264
+ LIKE
265
+ LINE
266
+ LINK
267
+ LIST
268
+ LIVE
269
+ LOAD
270
+ LOAN
271
+ LOCK
272
+ LOGO
273
+ LONG
274
+ LOOK
275
+ LOST
276
+ LOVE
277
+ LUCK
278
+ LUNG
279
+ MAIL
280
+ MAIN
281
+ MAKE
282
+ MALE
283
+ MANY
284
+ MARK
285
+ MASS
286
+ MATE
287
+ MATH
288
+ MEAL
289
+ MEAN
290
+ MEAT
291
+ MEET
292
+ MENU
293
+ MERE
294
+ MILE
295
+ MILK
296
+ MIND
297
+ MINE
298
+ MISS
299
+ MODE
300
+ MOOD
301
+ MOON
302
+ MORE
303
+ MOST
304
+ MOVE
305
+ MUCH
306
+ MUST
307
+ NAME
308
+ NAVY
309
+ NEAR
310
+ NECK
311
+ NEED
312
+ NEWS
313
+ NEXT
314
+ NICE
315
+ NICK
316
+ NINE
317
+ NOSE
318
+ NOTE
319
+ OBEY
320
+ ODDS
321
+ OILY
322
+ ONCE
323
+ ONLY
324
+ ONTO
325
+ OPEN
326
+ ORAL
327
+ OVER
328
+ PACE
329
+ PACK
330
+ PAGE
331
+ PAID
332
+ PAIN
333
+ PAIR
334
+ PALM
335
+ PARK
336
+ PART
337
+ PASS
338
+ PAST
339
+ PATH
340
+ PEAK
341
+ PICK
342
+ PINK
343
+ PIPE
344
+ PLAN
345
+ PLAY
346
+ PLOT
347
+ PLUG
348
+ PLUS
349
+ POEM
350
+ POLE
351
+ POOL
352
+ POOR
353
+ PORT
354
+ POST
355
+ PULL
356
+ PURE
357
+ PUSH
358
+ RACE
359
+ RAIL
360
+ RAIN
361
+ RANK
362
+ RATE
363
+ READ
364
+ REAL
365
+ REAR
366
+ RELY
367
+ RENT
368
+ REST
369
+ RICE
370
+ RICH
371
+ RIDE
372
+ RING
373
+ RISE
374
+ RISK
375
+ ROAD
376
+ ROCK
377
+ ROLE
378
+ ROLL
379
+ ROOF
380
+ ROOM
381
+ ROOT
382
+ ROSE
383
+ RULE
384
+ RUSH
385
+ SAFE
386
+ SAID
387
+ SAKE
388
+ SALE
389
+ SALT
390
+ SAME
391
+ SAND
392
+ SAVE
393
+ SEAL
394
+ SEAT
395
+ SEED
396
+ SEEK
397
+ SEEM
398
+ SEEN
399
+ SELF
400
+ SELL
401
+ SEND
402
+ SENT
403
+ SERV
404
+ SETT
405
+ SEXY
406
+ SHED
407
+ SHIP
408
+ SHOP
409
+ SHOT
410
+ SHOW
411
+ SHUT
412
+ SICK
413
+ SIDE
414
+ SIGN
415
+ SILK
416
+ SING
417
+ SINK
418
+ SITE
419
+ SIZE
420
+ SKIN
421
+ SLIP
422
+ SLOW
423
+ SNOW
424
+ SOFT
425
+ SOIL
426
+ SOLD
427
+ SOLE
428
+ SOME
429
+ SONG
430
+ SOON
431
+ SORT
432
+ SOUL
433
+ SPIN
434
+ SPOT
435
+ STAR
436
+ STAY
437
+ STEM
438
+ STEP
439
+ STOP
440
+ SUCH
441
+ SUIT
442
+ SURE
443
+ SWIM
444
+ TAKE
445
+ TALE
446
+ TALK
447
+ TALL
448
+ TANK
449
+ TASK
450
+ TEAM
451
+ TECH
452
+ TELL
453
+ TEND
454
+ TERM
455
+ TEST
456
+ TEXT
457
+ THAN
458
+ THAT
459
+ THEM
460
+ THEN
461
+ THEY
462
+ THIN
463
+ THIS
464
+ TIME
465
+ TINY
466
+ TOLD
467
+ TOLL
468
+ TONE
469
+ TONY
470
+ TOOL
471
+ TOUR
472
+ TOWN
473
+ TREE
474
+ TRIP
475
+ TRUE
476
+ TUNE
477
+ TURN
478
+ TWIN
479
+ TYPE
480
+ UNIT
481
+ UPON
482
+ USED
483
+ USER
484
+ VARY
485
+ VAST
486
+ VERY
487
+ VIEW
488
+ VOTE
489
+ WAGE
490
+ WAIT
491
+ WAKE
492
+ WALK
493
+ WALL
494
+ WANT
495
+ WARD
496
+ WARM
497
+ WASH
498
+ WAVE
499
+ WAYS
500
+ WEAK
501
+ WEAR
502
+ WEEK
503
+ WELL
504
+ WENT
505
+ WEST
506
+ WHAT
507
+ WHEN
508
+ WHOM
509
+ WIDE
510
+ WIFE
511
+ WILD
512
+ WILL
513
+ WIND
514
+ WINE
515
+ WING
516
+ WIRE
517
+ WISE
518
+ WISH
519
+ WITH
520
+ WOOD
521
+ WORD
522
+ WORK
523
+ YARD
524
+ YARN
525
+ YEAR
526
+ YELL
527
+ YOGA
528
+ YOUNG
529
+ YOUR
530
+ ZERO
531
+ ZONE
532
+ APPLE
533
+ RIVER
534
+ STONE
535
+ PLANT
536
+ MOUSE
537
+ BOARD
538
+ CHAIR
539
+ SCALE
540
+ SMILE
541
+ CLOUD
542
+ ORANGE
543
+ PYTHON
544
+ STREAM
545
+ MARKET
546
+ FOREST
547
+ THRIVE
548
+ LOGGER
549
+ BREATH
550
+ DOMAIN
551
+ GALAXY
pyproject.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "battlewords"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "streamlit>=1.49.1",
9
+ ]
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  altair
2
  pandas
3
- streamlit
 
 
1
  altair
2
  pandas
3
+ streamlit
4
+ # Entry point is now app.py, not main.py
specs/history.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Battlewords: History
2
+
3
+ ## Foundation
4
+ - Invented in the early 1980s as "Word Battle," a paper-and-pencil grid game inspired by Battleship.
5
+ - Used in reading classes for international students at San Diego State University.
6
+ - In 1992, developed for submission to game agents; accepted by Technical Game Services (TGS).
7
+
8
+ ## Rename & Presentation
9
+ - Renamed "Battlewords" in 1993.
10
+ - Presented to Milton-Bradley at the New York trade show, but only as a videotaped paper prototype.
11
+ - MB declined due to another European word game in development.
12
+
13
+ ## Sabotage & Similar Games
14
+ - Turkish students reported a similar game, "The Admiral Sank," in Turkey.
15
+ - Suspicions arose that students adapted and sold the idea, which became "Battle Words" (two words) in Europe, licensed to Hasbro International.
16
+ - MB ultimately rejected the original Battlewords after seeing the European version.
17
+
18
+ ## Reboot & Digital Version
19
+ - The European "Battle Words" faded into obscurity.
20
+ - The original creator retained prototypes and correspondence.
21
+ - Later collaborated with a Turkish programmer to create a Flash version of Battlewords for the website.
22
+ - Plans to further develop the game.
23
+
24
+ ## Copyright
25
+ BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
specs/requirements.md ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Battlewords: Implementation Requirements
2
+
3
+ This document breaks down the tasks to build Battlewords using the game rules described in `specs.md`. It is organized in phases: a minimal Proof of Concept (POC) and a Full Version with all features and polish.
4
+
5
+ Assumptions
6
+ - Tech stack: Python 3.10+, Streamlit for UI, standard library only for POC.
7
+ - Single-player, local state stored in Streamlit session state for POC.
8
+ - Grid is always 12x12 with exactly six words: two 4-letter, two 5-letter, two 6-letter words; horizontal/vertical only; no shared letters or overlaps.
9
+
10
+ Streamlit key components (API usage plan)
11
+ - State & caching
12
+ - `st.session_state` for `puzzle`, `revealed`, `guessed`, `score`, `can_guess`, `last_action`.
13
+ - `st.cache_data` to load and filter the word list.
14
+ - Layout & structure
15
+ - `st.title`, `st.subheader`, `st.markdown` for headers/instructions.
16
+ - `st.columns(12)` to render the 12×12 grid; `st.container` for grouping; `st.sidebar` for secondary controls/help.
17
+ - `st.expander` for inline help/intel tips.
18
+ - Widgets (interaction)
19
+ - `st.button` for each grid cell (144 total) with unique `key` to handle reveals.
20
+ - `st.form` + `st.text_input` + `st.form_submit_button("OK")` for controlled word guessing.
21
+ - `st.button("New Game")` or `st.link_button` to reset state.
22
+ - `st.metric` to show score; `st.checkbox`/`st.toggle` for optional settings (e.g., show radar).
23
+ - Visualization
24
+ - `st.pyplot` for the radar mini-grid (scatter on a 12×12 axes) or `st.plotly_chart` if interactive.
25
+ - Control flow
26
+ - App reruns on interaction; optionally use `st.rerun()` after state resets; `st.stop()` after game over summary to freeze UI.
27
+
28
+ Folder Structure
29
+ - `app.py` – Streamlit entry point
30
+ - `battlewords/` – Python package
31
+ - `__init__.py`
32
+ - `models.py` – data models and types
33
+ - `generator.py` – word placement and puzzle generation
34
+ - `logic.py` – game mechanics (reveal, guess, scoring)
35
+ - `ui.py` – Streamlit UI composition
36
+ - `words/wordlist.txt` – candidate words
37
+ - `specs/` – documentation (existing)
38
+ - `tests/` – unit tests
39
+
40
+ Phase 1: Proof of Concept Version
41
+ Goal: A playable, single-session game demonstrating core rules, scoring, and radar without persistence or advanced UX.
42
+
43
+ 1) Data Models
44
+ - Define `Coord(x:int, y:int)`.
45
+ - Define `Word(text:str, start:Coord, direction:str{"H","V"}, cells:list[Coord])`.
46
+ - Define `Puzzle(words:list[Word], radar:list[Coord])` – radar holds last-letter coordinates.
47
+ - Define `GameState(grid_size:int=12, puzzle:Puzzle, revealed:set[Coord], guessed:set[str], score:int, last_action:str, can_guess:bool)`.
48
+
49
+ Acceptance: Types exist and are consumed by generator/logic; simple constructors and validators.
50
+
51
+ 2) Word List
52
+ - Add an English word list filtered to alphabetic uppercase, lengths in {4,5,6}.
53
+ - Ensure words contain no special characters; maintain reasonable difficulty.
54
+ - Streamlit: `st.cache_data` to memoize loading/filtering.
55
+
56
+ Acceptance: Loading function returns lists by length with >= 500 words per length or fallback minimal lists.
57
+
58
+ 3) Puzzle Generation (Placement)
59
+ - Randomly place 2×4, 2×5, 2×6 letter words on a 12×12 grid.
60
+ - Constraints:
61
+ - Horizontal (left→right) or Vertical (top→down) only.
62
+ - No overlapping letters.
63
+ - No shared letters between different words (cells must be unique; letters adjacent orthogonally are allowed).
64
+ - Compute radar pulses as the last cell of each word.
65
+ - Retry strategy with max attempts; raise a controlled error if generation fails.
66
+
67
+ Acceptance: Generator returns a valid `Puzzle` passing validation checks (no collisions, in-bounds, correct counts).
68
+
69
+ 4) Game Mechanics
70
+ - Reveal:
71
+ - Click a covered cell to reveal; if the cell is part of a word, show the letter; else mark empty.
72
+ - After a reveal action, set `can_guess=True`.
73
+ - Streamlit: 12×12 `st.columns` + `st.button(label, key=f"cell_{r}_{c}")` per cell; on click, update `st.session_state` and optionally `st.rerun()`.
74
+ - Guess:
75
+ - Accept a guess only if `can_guess` is True and input length ∈ {4,5,6}.
76
+ - Match guess case-insensitively against unguessed words in puzzle.
77
+ - If correct: add base points = word length; bonus points = count of unrevealed cells in that word at guess time; mark all cells of the word as revealed; add to `guessed`.
78
+ - If incorrect: no points awarded.
79
+ - After any guess, set `can_guess=False` and require another reveal before next guess.
80
+ - Streamlit: `with st.form("guess"):` + `st.text_input("Your guess", key="guess_text")` + `st.form_submit_button("OK", disabled=not st.session_state.can_guess)`.
81
+ - End of game when all 6 words are guessed; display summary and tier, then `st.stop()`.
82
+
83
+ Acceptance: Unit tests cover scoring, guess gating, and reveal behavior.
84
+
85
+ 5) UI (Streamlit)
86
+ - Layout:
87
+ - Title and brief instructions via `st.title`, `st.subheader`, `st.markdown`.
88
+ - Left: 12×12 grid using `st.columns(12)` of `st.button`s.
89
+ - Right: Radar mini-grid via `st.pyplot` (matplotlib scatter) or `st.plotly_chart`.
90
+ - Bottom/right: Guess form using `st.form`, `st.text_input`, `st.form_submit_button`.
91
+ - Score panel showing current score using `st.metric` and `st.markdown` for last action.
92
+ - Optional `st.sidebar` to host reset/new game and settings.
93
+ - Visuals:
94
+ - Covered cell vs revealed styles: use button labels/emojis and background color hints; if needed, small custom `st.html`/CSS for color.
95
+
96
+ Acceptance: Users can play end-to-end in one session; UI updates consistently; radar shows exactly 6 pulses.
97
+
98
+ 6) Scoring Tiers
99
+ - After game ends, compute tier:
100
+ - Good: 34–37
101
+ - Great: 38–41
102
+ - Fantastic: 42+
103
+ - Display final summary with found words, per-word points, total.
104
+ - Streamlit: show results in a `st.container` or `st.expander("Game summary")`.
105
+
106
+ Acceptance: Tier text shown at game end; manual test with mocked states.
107
+
108
+ 7) Basic Tests
109
+ - Unit tests for:
110
+ - Placement validity (bounds, overlap, counts).
111
+ - Scoring logic and bonus calculation.
112
+ - Guess gating (must reveal before next guess).
113
+
114
+ Acceptance: Tests run and pass locally.
115
+
116
+ Phase 2: Full Version (All Features and Polish)
117
+ Goal: Robust app with polish, persistence, test coverage, and optional advanced features.
118
+
119
+ A) UX and Visual Polish
120
+ - Cell rendering with consistent sizing and responsive layout (desktop/mobile).
121
+ - Keyboard support for navigation and guessing (custom JS via `st.html` or a component if needed).
122
+ - Animations for reveals and correct guesses (CSS/JS via `st.html` or component).
123
+ - Color-blind friendly palette and accessible contrast.
124
+ - Configurable themes (light/dark) via Streamlit theme config.
125
+ - Streamlit: `st.tabs` for modes/help; `st.popover`/`st.expander` for tips; `st.toast`/`st.warning` for feedback.
126
+
127
+ B) Game Content and Generation
128
+ - Curated word lists by difficulty; exclude obscure/abusive words.
129
+ - Deterministic seed support to reproduce puzzles (e.g., daily seed based on date).
130
+ - Validation pass to ensure no unintended partial words formed adjacently (content curation rule, optional).
131
+ - Optional generator diagnostics panel for QA using `st.expander` and `st.dataframe`.
132
+ - Streamlit: `st.cache_data` for word lists; `st.cache_resource` if needed for heavier resources.
133
+
134
+ C) Game Modes and Settings
135
+ - Daily Puzzle mode (same seed for all players per day).
136
+ - Practice mode (random puzzles).
137
+ - Difficulty presets that tweak word selection (common vs. rare) but still keep 2×4, 2×5, 2×6.
138
+ - Optional hint system with limited uses (e.g., reveal a random letter in an unguessed word) with score penalty.
139
+ - Streamlit: mode selection via `st.radio`/`st.segmented_control`; settings via `st.sidebar` with `st.toggle`/`st.slider`.
140
+
141
+ D) Persistence and Profiles
142
+ - Save/Load local game state (browser cookie or Streamlit session + query params).
143
+ - Cloud persistence via lightweight backend API (FastAPI) or Streamlit secrets + hosted storage for:
144
+ - User profiles (username only), completed puzzles, scores.
145
+ - Leaderboards for Daily mode.
146
+ - Privacy notice and opt-in for storing data.
147
+ - Streamlit: `st.text_input` for username; `st.button` to save; call backend via `requests`.
148
+
149
+ E) Leaderboards and Sharing
150
+ - Global and friends leaderboards (score and completion time if captured; note: game is strategy-first, time is optional).
151
+ - Share result string with spoiler-free grid (similar to popular word games).
152
+ - Streamlit: `st.table`/`st.dataframe` for leaderboards; `st.download_button` or copy-to-clipboard via `st.text_area` + `st.button`.
153
+
154
+ F) Observability and Quality
155
+ - Logging for generator failures and gameplay events (anonymized).
156
+ - Error boundary UI with recover/retry.
157
+ - Test suite:
158
+ - Unit: generator, logic, scoring, gating, radar.
159
+ - Property-based tests for generator (e.g., Hypothesis) to stress placement constraints.
160
+ - Integration tests that simulate a full game and validate scoring.
161
+ - Visual regression snapshots of grid/radar (optional).
162
+ - CI/CD with linting (flake8/ruff), type checks (mypy/pyright), tests, and build.
163
+ - Streamlit: developer toggles in an `st.expander` to simulate states; optional `st.fragment` to limit rerenders in hotspots.
164
+
165
+ G) Performance
166
+ - Optimize generator to avoid excessive retries (precompute candidate slots, shuffle deterministically).
167
+ - Memoize derived UI state.
168
+ - Efficient grid rendering (batch updates or delta rendering where possible in Streamlit).
169
+ - Streamlit: consider `st.fragment` for the grid/radar to avoid full-page rerenders.
170
+
171
+ H) Internationalization (Optional)
172
+ - i18n-ready strings; language toggle.
173
+ - Locale-specific word lists.
174
+ - Streamlit: language toggle via `st.selectbox`.
175
+
176
+ I) Security and Abuse Prevention
177
+ - Validate all inputs (guess strings A–Z only).
178
+ - Rate-limit backend endpoints (if any) and sanitize stored data.
179
+ - Streamlit: enforce validation in the submit handler and sanitize displayed content with strict formatting.
180
+
181
+ J) Deployment
182
+ - Streamlit Community Cloud or containerized deployment (Dockerfile) to any cloud.
183
+ - Environment configuration via `.env` or Streamlit secrets.
184
+ - Streamlit: use `st.secrets` for API keys.
185
+
186
+ Milestones and Estimates (High-level)
187
+ - Phase 1 (POC): 2–4 days
188
+ - Models + generator + logic: 1–2 days
189
+ - UI + scoring + radar: 1 day
190
+ - Tests and polish: 0.5–1 day
191
+ - Phase 2 (Full): 1–2 weeks depending on features selected
192
+
193
+ Definitions of Done (per task)
194
+ - Code merged with tests and docs updated.
195
+ - No regressions in existing tests; coverage maintained or improved for core logic.
196
+ - Manual playthrough validates rules: reveal/guess gating, scoring, radar pulses, end state and tiers.
specs/specs.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Battlewords Game Requirements (specs.md)
2
+
3
+ ## Overview
4
+ Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
5
+
6
+ ## Game Board
7
+ - 12 x 12 grid.
8
+ - Six hidden words:
9
+ - Two four-letter words
10
+ - Two five-letter words
11
+ - Two six-letter words
12
+ - Words are placed horizontally (left-right) or vertically (top-down), not diagonally.
13
+ - Words do not overlap or share letters.
14
+ - Radar screen indicates the location of the last letter of each word.
15
+
16
+ ## Gameplay
17
+ - Players click grid squares to reveal letters or empty spaces.
18
+ - Blue squares turn black if empty; otherwise, a letter is revealed.
19
+ - Use radar pulses to locate word boundaries (first and last letters).
20
+ - After revealing a letter, players may guess a word by entering it in a text box.
21
+ - Only one guess per letter reveal; must uncover another letter before guessing again.
22
+
23
+ ## Scoring
24
+ - Each correct word guess awards points:
25
+ - 1 point per letter in the word
26
+ - Bonus points for each hidden letter at the time of guessing
27
+ - Score tiers:
28
+ - Good: 34-37
29
+ - Great: 38-41
30
+ - Fantastic: 42+
31
+
32
+ ## Strategy
33
+ - Focus on finding word boundaries using radar.
34
+ - Guess words with hidden letters for higher scores.
35
+ - The game rewards strategy over speed.
36
+
37
+ ## UI Elements
38
+ - 12x12 grid
39
+ - Radar screen (shows last letter locations)
40
+ - Text box for word guesses
41
+ - Score display (shows word, base points, bonus points, total score)
42
+
43
+ ## Copyright
44
+ BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
tests/test_generator.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from battlewords.generator import generate_puzzle, validate_puzzle
4
+ from battlewords.models import Coord
5
+
6
+
7
+ class TestGenerator(unittest.TestCase):
8
+ def test_generate_valid_puzzle(self):
9
+ p = generate_puzzle(grid_size=12, seed=1234)
10
+ validate_puzzle(p, grid_size=12)
11
+ # Ensure 6 words and 6 radar pulses
12
+ self.assertEqual(len(p.words), 6)
13
+ self.assertEqual(len(p.radar), 6)
14
+ # Ensure no overlaps
15
+ seen = set()
16
+ for w in p.words:
17
+ for c in w.cells:
18
+ self.assertNotIn(c, seen)
19
+ seen.add(c)
20
+ self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
21
+
22
+
23
+ if __name__ == "__main__":
24
+ unittest.main()
tests/test_logic.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from battlewords.logic import build_letter_map, reveal_cell, guess_word, is_game_over
4
+ from battlewords.models import Coord, Word, Puzzle, GameState
5
+
6
+
7
+ class TestLogic(unittest.TestCase):
8
+ def make_state(self):
9
+ w1 = Word("TREE", Coord(0, 0), "H")
10
+ w2 = Word("APPLE", Coord(2, 0), "H")
11
+ w3 = Word("ORANGE", Coord(4, 0), "H")
12
+ w4 = Word("WIND", Coord(0, 6), "V")
13
+ w5 = Word("MOUSE", Coord(0, 8), "V")
14
+ w6 = Word("PYTHON", Coord(0, 10), "V")
15
+ p = Puzzle([w1, w2, w3, w4, w5, w6])
16
+ state = GameState(
17
+ grid_size=12,
18
+ puzzle=p,
19
+ revealed=set(),
20
+ guessed=set(),
21
+ score=0,
22
+ last_action="",
23
+ can_guess=False,
24
+ )
25
+ return state, p
26
+
27
+ def test_reveal_and_guess_gating(self):
28
+ state, puzzle = self.make_state()
29
+ letter_map = build_letter_map(puzzle)
30
+ # Can't guess before reveal
31
+ ok, pts = guess_word(state, "TREE")
32
+ self.assertFalse(ok)
33
+ self.assertEqual(pts, 0)
34
+ # Reveal one cell then guess
35
+ reveal_cell(state, letter_map, Coord(0, 0))
36
+ self.assertTrue(state.can_guess)
37
+ ok, pts = guess_word(state, "TREE")
38
+ self.assertTrue(ok)
39
+ self.assertGreater(pts, 0)
40
+ self.assertIn("TREE", state.guessed)
41
+ self.assertFalse(state.can_guess)
42
+
43
+ def test_game_over(self):
44
+ state, puzzle = self.make_state()
45
+ letter_map = build_letter_map(puzzle)
46
+ # Guess all words after a reveal each time
47
+ for w in puzzle.words:
48
+ reveal_cell(state, letter_map, w.start)
49
+ ok, _ = guess_word(state, w.text)
50
+ self.assertTrue(ok)
51
+ self.assertTrue(is_game_over(state))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
uv.lock ADDED
The diff for this file is too large to render. See raw diff