borse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
borse/progress.py ADDED
@@ -0,0 +1,167 @@
1
+ """Progress tracking for Borse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class DailyProgress:
13
+ """Progress for a single day.
14
+
15
+ Attributes:
16
+ morse_words: Number of Morse code words answered.
17
+ braille_words: Number of Braille words answered.
18
+ semaphore_words: Number of semaphore words answered.
19
+ a1z26_words: Number of A1Z26 words answered.
20
+ """
21
+
22
+ morse_words: int = 0
23
+ braille_words: int = 0
24
+ semaphore_words: int = 0
25
+ a1z26_words: int = 0
26
+
27
+ @property
28
+ def total_words(self) -> int:
29
+ """Get total words answered today.
30
+
31
+ Returns:
32
+ Sum of all words across all modes.
33
+ """
34
+ return (
35
+ self.morse_words
36
+ + self.braille_words
37
+ + self.semaphore_words
38
+ + self.a1z26_words
39
+ )
40
+
41
+ def to_dict(self) -> dict[str, int]:
42
+ """Convert to dictionary.
43
+
44
+ Returns:
45
+ Dictionary representation.
46
+ """
47
+ return {
48
+ "morse_words": self.morse_words,
49
+ "braille_words": self.braille_words,
50
+ "semaphore_words": self.semaphore_words,
51
+ "a1z26_words": self.a1z26_words,
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict[str, int]) -> DailyProgress:
56
+ """Create from dictionary.
57
+
58
+ Args:
59
+ data: Dictionary with progress values.
60
+
61
+ Returns:
62
+ DailyProgress instance.
63
+ """
64
+ return cls(
65
+ morse_words=data.get("morse_words", 0),
66
+ braille_words=data.get("braille_words", 0),
67
+ semaphore_words=data.get("semaphore_words", 0),
68
+ a1z26_words=data.get("a1z26_words", 0),
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class Progress:
74
+ """Overall progress tracking.
75
+
76
+ Attributes:
77
+ daily: Dictionary mapping date strings to daily progress.
78
+ """
79
+
80
+ daily: dict[str, DailyProgress] = field(default_factory=dict)
81
+
82
+ def get_today(self) -> DailyProgress:
83
+ """Get today's progress.
84
+
85
+ Returns:
86
+ DailyProgress for today, creating if needed.
87
+ """
88
+ today = date.today().isoformat()
89
+ if today not in self.daily:
90
+ self.daily[today] = DailyProgress()
91
+ return self.daily[today]
92
+
93
+ def add_word(self, mode: str) -> None:
94
+ """Add a completed word for today.
95
+
96
+ Args:
97
+ mode: The game mode ('morse', 'braille', 'semaphore', or 'a1z26').
98
+ """
99
+ today = self.get_today()
100
+ if mode == "morse":
101
+ today.morse_words += 1
102
+ elif mode == "braille":
103
+ today.braille_words += 1
104
+ elif mode == "semaphore":
105
+ today.semaphore_words += 1
106
+ elif mode == "a1z26":
107
+ today.a1z26_words += 1
108
+
109
+ def to_dict(self) -> dict[str, dict[str, dict[str, int]]]:
110
+ """Convert to dictionary.
111
+
112
+ Returns:
113
+ Dictionary representation.
114
+ """
115
+ return {"daily": {k: v.to_dict() for k, v in self.daily.items()}}
116
+
117
+ @classmethod
118
+ def from_dict(cls, data: dict[str, dict[str, dict[str, int]]]) -> Progress:
119
+ """Create from dictionary.
120
+
121
+ Args:
122
+ data: Dictionary with progress values.
123
+
124
+ Returns:
125
+ Progress instance.
126
+ """
127
+ daily_data = data.get("daily", {})
128
+ daily = {k: DailyProgress.from_dict(v) for k, v in daily_data.items()}
129
+ return cls(daily=daily)
130
+
131
+
132
+ def load_progress(progress_path: Path | str) -> Progress:
133
+ """Load progress from file.
134
+
135
+ Args:
136
+ progress_path: Path to progress file.
137
+
138
+ Returns:
139
+ Progress instance with loaded or default values.
140
+ """
141
+ path = Path(progress_path)
142
+
143
+ if not path.exists():
144
+ return Progress()
145
+
146
+ try:
147
+ with open(path) as f:
148
+ data = json.load(f)
149
+ return Progress.from_dict(data)
150
+ except (json.JSONDecodeError, OSError):
151
+ return Progress()
152
+
153
+
154
+ def save_progress(progress: Progress, progress_path: Path | str) -> None:
155
+ """Save progress to file.
156
+
157
+ Args:
158
+ progress: Progress instance to save.
159
+ progress_path: Path to progress file.
160
+ """
161
+ path = Path(progress_path)
162
+
163
+ # Ensure directory exists
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+
166
+ with open(path, "w") as f:
167
+ json.dump(progress.to_dict(), f, indent=2)
borse/semaphore.py ADDED
@@ -0,0 +1,154 @@
1
+ """Flag semaphore encoding module with ASCII art display."""
2
+
3
+ # Flag positions (like clock positions):
4
+ # 0 = down (6 o'clock)
5
+ # 1 = down-left (about 7:30)
6
+ # 2 = out-left (9 o'clock)
7
+ # 3 = up-left (about 10:30)
8
+ # 4 = up (12 o'clock)
9
+ # 5 = up-right (about 1:30)
10
+ # 6 = out-right (3 o'clock)
11
+ # 7 = down-right (about 4:30)
12
+
13
+ # Semaphore positions for each letter: (left_flag, right_flag)
14
+ # Positions are numbered 0-7 going clockwise from down
15
+ SEMAPHORE_POSITIONS: dict[str, tuple[int, int]] = {
16
+ "A": (0, 1),
17
+ "B": (0, 2),
18
+ "C": (0, 3),
19
+ "D": (0, 4),
20
+ "E": (0, 5),
21
+ "F": (0, 6),
22
+ "G": (0, 7),
23
+ "H": (1, 2),
24
+ "I": (1, 3),
25
+ "J": (4, 6),
26
+ "K": (1, 4),
27
+ "L": (1, 5),
28
+ "M": (1, 6),
29
+ "N": (1, 7),
30
+ "O": (2, 3),
31
+ "P": (2, 4),
32
+ "Q": (2, 5),
33
+ "R": (2, 6),
34
+ "S": (2, 7),
35
+ "T": (3, 4),
36
+ "U": (3, 5),
37
+ "V": (4, 7),
38
+ "W": (5, 6),
39
+ "X": (5, 7),
40
+ "Y": (3, 6),
41
+ "Z": (6, 7),
42
+ }
43
+
44
+ # Grid is 7x5 (wider for horizontal arms):
45
+ # 0 1 2 3 4 5 6
46
+ # 7 8 9 10 11 12 13
47
+ # 14 15 16 17 18 19 20
48
+ # 21 22 23 24 25 26 27
49
+ # 28 29 30 31 32 33 34
50
+ # Position 17 is center (the person)
51
+
52
+ # Grid positions for each flag position
53
+ # Most positions have 2 cells (inner, outer)
54
+ # Horizontal positions (2, 6) have 3 cells for longer arms
55
+ POSITION_TO_GRID: dict[int, tuple[int, ...]] = {
56
+ 0: (24, 31), # down
57
+ 1: (23, 29), # down-left
58
+ 2: (16, 15, 14), # out-left (3 hyphens)
59
+ 3: (9, 1), # up-left
60
+ 4: (10, 3), # up
61
+ 5: (11, 5), # up-right
62
+ 6: (18, 19, 20), # out-right (3 hyphens)
63
+ 7: (25, 33), # down-right
64
+ }
65
+
66
+ # Characters to show for each flag position
67
+ # Based on the direction from center
68
+ POSITION_CHARS: dict[int, str] = {
69
+ 0: "|", # down
70
+ 1: "/", # down-left
71
+ 2: "-", # out-left
72
+ 3: "\\", # up-left
73
+ 4: "|", # up
74
+ 5: "/", # up-right
75
+ 6: "-", # out-right
76
+ 7: "\\", # down-right
77
+ }
78
+
79
+
80
+ def encode_char(char: str) -> list[str]:
81
+ """Encode a single character to semaphore ASCII art.
82
+
83
+ Args:
84
+ char: A single character to encode.
85
+
86
+ Returns:
87
+ A list of 5 strings representing the 5 rows of the semaphore display.
88
+ """
89
+ upper = char.upper()
90
+ if upper not in SEMAPHORE_POSITIONS:
91
+ return [" ", " ", " ", " ", " "]
92
+
93
+ left_pos, right_pos = SEMAPHORE_POSITIONS[upper]
94
+
95
+ # Build the 7x5 grid
96
+ grid = [" "] * 35
97
+ grid[17] = "O" # Person in center
98
+
99
+ # Place the flags (variable length based on position)
100
+ left_cells = POSITION_TO_GRID[left_pos]
101
+ right_cells = POSITION_TO_GRID[right_pos]
102
+
103
+ left_char = POSITION_CHARS[left_pos]
104
+ right_char = POSITION_CHARS[right_pos]
105
+
106
+ for cell in left_cells:
107
+ grid[cell] = left_char
108
+ for cell in right_cells:
109
+ grid[cell] = right_char
110
+
111
+ # Convert to 5 rows (7 columns each)
112
+ rows = [
113
+ "".join(grid[0:7]),
114
+ "".join(grid[7:14]),
115
+ "".join(grid[14:21]),
116
+ "".join(grid[21:28]),
117
+ "".join(grid[28:35]),
118
+ ]
119
+
120
+ return rows
121
+
122
+
123
+ def encode_word(word: str) -> list[list[str]]:
124
+ """Encode a word to semaphore ASCII art.
125
+
126
+ Args:
127
+ word: The word to encode.
128
+
129
+ Returns:
130
+ A list of character encodings, each being a list of 5 row strings.
131
+ """
132
+ return [encode_char(c) for c in word if c.upper() in SEMAPHORE_POSITIONS]
133
+
134
+
135
+ def get_display_lines(word: str) -> list[str]:
136
+ """Get the display lines for a word in semaphore.
137
+
138
+ Args:
139
+ word: The word to encode.
140
+
141
+ Returns:
142
+ A list of 5 strings, one for each row, with characters separated by spaces.
143
+ """
144
+ chars = encode_word(word)
145
+ if not chars:
146
+ return ["", "", "", "", ""]
147
+
148
+ # Combine all characters horizontally with space between
149
+ lines = []
150
+ for row in range(5):
151
+ line = " ".join(char[row] for char in chars)
152
+ lines.append(line)
153
+
154
+ return lines
borse/words.py ADDED
@@ -0,0 +1,44 @@
1
+ """Word list for the game."""
2
+
3
+ import random
4
+ import string
5
+ from pathlib import Path
6
+
7
+ LETTERS = string.ascii_lowercase
8
+ PATH_TO_WORDS = Path(__file__).with_name("WORDS.txt")
9
+
10
+
11
+ with open(PATH_TO_WORDS) as f:
12
+ COMMON_WORDS = [line.strip() for line in f]
13
+
14
+
15
+ def get_random_word() -> str:
16
+ """Get a random word from the word list.
17
+
18
+ Returns:
19
+ A random common English word.
20
+ """
21
+ return random.choice(COMMON_WORDS)
22
+
23
+
24
+ def get_random_letter() -> str:
25
+ """Get a random single letter from A-Z.
26
+
27
+ Returns:
28
+ A random lowercase letter.
29
+ """
30
+ return random.choice(LETTERS)
31
+
32
+
33
+ def get_random_word_or_letter(single_letter_probability: float = 0.3) -> str:
34
+ """Get either a random word or a single letter based on probability.
35
+
36
+ Args:
37
+ single_letter_probability: Probability (0-1) of returning a single letter.
38
+
39
+ Returns:
40
+ A random word or single letter.
41
+ """
42
+ if random.random() < single_letter_probability:
43
+ return get_random_letter()
44
+ return get_random_word()
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: borse
3
+ Version: 0.1.0
4
+ Summary: A terminal game for practicing Morse code, Braille, and semaphore.
5
+ Project-URL: repository, https://github.com/vEnhance/borse
6
+ Author-email: Evan Chen <evan@evanchen.cc>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: <4.0,>=3.10
10
+ Requires-Dist: tomli-w>=1.0.0
11
+ Requires-Dist: tomli>=2.0.0; python_version < '3.11'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # borse
15
+
16
+ **borse** is a terminal program meant to practice
17
+ reading braille, Morse code, and semaphore,
18
+ which are common encodings for
19
+ [puzzle hunts](https://web.evanchen.cc/upload/EvanPuzzleCodings.pdf).
20
+ Also supports A1Z26 practice.
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ uvx borse
26
+ ```
27
+
28
+ Or you can install from PyPI by using `uv`, `pip`, etc.
29
+
30
+ ## Configuration
31
+
32
+ Configuration is stored in `~/.config/borse/config.json` by default:
33
+
34
+ ```json
35
+ {
36
+ "progress_file": "~/.config/borse/progress.json",
37
+ "words_per_game": 10
38
+ }
39
+ ```
40
+
41
+ Your daily progress is also automatically saved and displayed on the main menu.
42
+
43
+ ## Hints for memorizing the encodings
44
+
45
+ Actually this is a note-to-self.
46
+
47
+ ### Remembering braille
48
+
49
+ For Grade 1 (just the letters `A-Z`),
50
+ the chart on [Wikipedia](https://en.wikipedia.org/wiki/English_Braille)
51
+ is helpful!
52
+ The trick is to memorize just the first 10 symbols for `A-J`,
53
+ which only use the upper four dots.
54
+ That's because `K-T` are the same as `A-J` with one extra dot,
55
+ while `UVXYZ` are `A-E` with one extra dot.
56
+
57
+ In real life, Grade 2 braille has some additional contractions.
58
+ It might be nice to add these into borse at some point.
59
+
60
+ ### Remembering Morse code
61
+
62
+ In Morse code, the most frequent letters are shorter.
63
+ So I think it's a lot easier to remember Morse code as a binary tree,
64
+ since the common letters will all be towards the top.
65
+ I found [this picture on the Internet](https://slidetodoc.com/binary-trees-binary-tree-structure-root-node-stores/):
66
+
67
+ ![A Morse code binary tree](morse-tree.jpg)
68
+
69
+ I think the dot looks like 0 and a dash looks like a (rotated) 1,
70
+ so it makes sense to me that dots are in the left of the tree.
71
+
72
+ Then you can just memorize the letters in each row in order.
73
+ Here are some terrible mnemonics I made up that worked for me
74
+ for the first three rows (you're on your own for the last one):
75
+
76
+ - `ET`: Eastern Time, or a [1982 movie][et]
77
+ - `IANM`: I Am Not Mad
78
+ - `SURWDKGO`: [SuperUser][su] [ReWrote][rw] [DynamicKernel][dk] in [GO][go]
79
+
80
+ (Also, `surdwkgo` is also the name of a
81
+ [Taiwanese CodeForces grandmaster](https://codeforces.com/profile/surwdkgo).)
82
+
83
+ [et]: https://en.wikipedia.org/wiki/E.T._the_Extra-Terrestrial
84
+ [su]: https://en.wikipedia.org/wiki/Su_(Unix)
85
+ [rw]: https://lean-lang.org/doc/reference/latest/Tactic-Proofs/Tactic-Reference/#rw
86
+ [dk]: https://en.wikipedia.org/wiki/Dynamic_Kernel_Module_Support
87
+ [go]: https://en.wikipedia.org/wiki/Go_(programming_language)
88
+
89
+ ### Remembering semaphore
90
+
91
+ If you look at a semaphore chart,
92
+ what you'll find is that there are some groups of adjacent letters
93
+ that just differ in one hand rotating clockwise.
94
+ For example, the letters from `A-G` are obtained
95
+ by fixing one arm at 6 o'clock and rotating the other arm
96
+ all the way from 7:30 to 4:30.
97
+
98
+ So in my head, I organize the letters in "blocks",
99
+ where each block starts with two arms at a 45-degree angle,
100
+ and then having the other arm rotate clockwise.
101
+ The resulting blocks are then easier to remember:
102
+
103
+ - **A block**: `ABCDEFG`
104
+ - **H block**: `HIKLMN` (note `J` is missing)
105
+ - **O block**: `OPQRS`
106
+ - **T block**: `TUY` (note the additional `Y`)
107
+ - **# block**: `#JV` (this is the weird exceptions one)
108
+ - **W block**: `WX`
109
+ - **Z block**: `Z`
110
+
111
+ I don't know if `A HOT #WZ` means anything to you.
112
+
113
+ ## Development
114
+
115
+ Set up by cloning the repository and running
116
+
117
+ ```bash
118
+ uv sync
119
+ uv run prek install
120
+ ```
121
+
122
+ To manually run the linter and tests
123
+
124
+ ```bash
125
+ uv run prek --all-files # run linter
126
+ uv run prek --all-files --hook-stage pre-push
127
+ ```
128
+
129
+ ## FAQ
130
+
131
+ - _Where does the name come from?_
132
+
133
+ From `Braille mORse SEmaphore`.
134
+
135
+ - _Should "braille" be capitalized?_
136
+
137
+ [No](https://www.brailleauthority.org/capitalization/capitalization.html).
138
+
139
+ - _Why would you spend time learning this?_
140
+
141
+ It could be a great conversation starter for a first date…
@@ -0,0 +1,17 @@
1
+ borse/WORDS.txt,sha256=eEjDg7tmTgtFzhQMQrSNx58UdPpPmnax7JZIHjRGA5g,40575
2
+ borse/__about__.py,sha256=0zET0Hr7ZlMR2QzPnTxlIVERltHMYywiz05ap-993o8,67
3
+ borse/__init__.py,sha256=M5ARPgePzj8zEaqyk11jzt0RtDD5nbhA2KPS31vGJRk,109
4
+ borse/a1z26.py,sha256=BmkTTxeCu4fvjFRMDSZktJWu0E169H32uga1khkl7gY,964
5
+ borse/braille.py,sha256=h1q_r_4Am3_RswL3gqsa5Mm076RU0vSY7TSsQ72ExZQ,2642
6
+ borse/config.py,sha256=eUDNRkxTIQRRpCnm9P3tQgFKjS4f2MF5-lNZFtt78Jg,3695
7
+ borse/game.py,sha256=h_vLIRk-AX0FeHiL9y5UCj-1QyJW2iSOCFFPWjUvOnA,17278
8
+ borse/main.py,sha256=peNdARRdBjtCP0rg2_hjq1GRkESWQhfxxAuGMYxyCkw,535
9
+ borse/morse.py,sha256=nh7ptvXaoIHoUN_eval0-nZRd4oWyDNpDdnxsclx9F0,1818
10
+ borse/progress.py,sha256=CPsv5SMF4ulHKbZu95_qK2_kpR1_BX1OFWChSoiKl44,4390
11
+ borse/semaphore.py,sha256=mdU1siRkW4eAAgtCuSdc7aMCr2-DbZOwgmzpGnism8I,3855
12
+ borse/words.py,sha256=3YifBii9OuMI2eHXAtvENxVM5n0T4eyZTU-NJ7EPmtQ,1023
13
+ borse-0.1.0.dist-info/METADATA,sha256=LRsX8A7DmCJIa6pFCisk_MGg7KDhS3ybCPHea6ASnOg,4175
14
+ borse-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
15
+ borse-0.1.0.dist-info/entry_points.txt,sha256=e3JYLL_H22Xgqe3_TyecetryyyE2kt07nBOGz4T41Ic,42
16
+ borse-0.1.0.dist-info/licenses/LICENSE,sha256=gq-dD45uKs1sNrFCbrHXC8PpsWoSauIPzU-NEQHmTEc,1066
17
+ borse-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ borse = borse.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evan Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.