qiantong 0.1.0__tar.gz

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.
qiantong-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mediapig
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.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: qiantong
3
+ Version: 0.1.0
4
+ Summary: Guanyin fortune sticks (观音灵签) in the terminal: jiaobei tossing, lot drawing, quantum randomness
5
+ Author-email: mediapig <ky8contact9@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mediapig/qiantong
8
+ Project-URL: Source, https://github.com/mediapig/qiantong
9
+ Project-URL: Issues, https://github.com/mediapig/qiantong/issues
10
+ Keywords: 观音灵签,抽签,fortune,omikuji,divination,cli,quantum random
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Natural Language :: Chinese (Simplified)
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Games/Entertainment
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # qiantong · Guanyin fortune sticks in your terminal
25
+
26
+ Draw a Guanyin fortune stick (观音灵签) in the terminal: first ask for permission by tossing jiaobei blocks (掷筊), and once you get three holy tosses (圣杯) in a row, shake the lot tube and read your fortune poem and its interpretation.
27
+
28
+ The interface and the fortune texts are in Chinese.
29
+
30
+ ## Installation
31
+
32
+ Requires Python 3.9+. No third-party dependencies.
33
+
34
+ ```bash
35
+ # Recommended: install as a standalone command
36
+ uv tool install qiantong
37
+ # or
38
+ pipx install qiantong
39
+ # or
40
+ pip install qiantong
41
+
42
+ # Latest code from GitHub
43
+ uv tool install git+https://github.com/mediapig/qiantong.git
44
+ ```
45
+
46
+ After installing, run `qiantong` or `chouqian`.
47
+
48
+ ## How to play
49
+
50
+ | Key | Action |
51
+ | --- | --- |
52
+ | Space | Start / stop (press once to start, again to stop) |
53
+ | q / Esc / Ctrl-C | Quit |
54
+
55
+ 1. **Ask with jiaobei (掷筊请示).** For each toss, press space to throw the two crescent-shaped blocks and press space again to land them. Each block lands yin or yang:
56
+ - one yin, one yang: holy toss (圣杯) ✓
57
+ - two yang: laughing toss (笑杯) ✗
58
+ - two yin: yin toss (阴杯) ✗
59
+
60
+ You need three holy tosses in a row before you may draw a lot. Any other result starts over from the first toss.
61
+ 2. **Shake the tube (摇签).** Press space to start shaking and space again to stop. A stick pops out. Its number stays hidden until the result is shown.
62
+ 3. **Read the lot (解签).** Shows the lot number, its rank, the story behind it, the poem and the interpretation.
63
+
64
+ ## In Japan: Sensō-ji fortunes
65
+
66
+ If either of the following is detected, the lots switch to the 100 Kannon fortunes of Sensō-ji in Asakusa, Tokyo (ranks 大吉 / 吉 / 半吉 / 小吉 / 末小吉 / 末吉 / 凶), and the jiaobei step is skipped:
67
+
68
+ - the preferred system language is Japanese (macOS reads the system preferences, Windows reads the UI language, other systems read `LANG` and related variables);
69
+ - the time zone is Japan (`Asia/Tokyo`, `Japan`, JST).
70
+
71
+ You can also choose explicitly:
72
+
73
+ ```bash
74
+ qiantong --temple sensoji # Sensō-ji
75
+ qiantong --temple guanyin # Guanyin
76
+ TZ=Asia/Tokyo qiantong # pretend to be in Japan
77
+ ```
78
+
79
+ A command-line tool has no access to GPS, so location is not used.
80
+
81
+ ## Randomness
82
+
83
+ Every result is decided at the moment you press space to stop. The animation is only for show and has no effect on the outcome.
84
+
85
+ **Quantum random numbers by default.** At startup the program fetches 1024 bytes from the [quantum random number service of the OTH Regensburg Laboratory for Digitalisation](https://lfdr.de/QRNG/) into a pool, and fetches more when the pool runs out. The bytes are produced by ID Quantique's Quantis quantum random number hardware (a PCIe card), whose randomness comes from quantum physical processes.
86
+
87
+ **Local fallback.** If the service is unreachable, times out (3 seconds) or misbehaves, the program falls back to Python's standard `secrets` module, i.e. the operating system's cryptographic random number generator. After one failure it stays offline for the rest of the run so that draws never stall. The startup line tells you which source is in use.
88
+
89
+ Each draw takes 32 random bytes, appends the nanosecond timestamp of your key press, hashes them with SHA-256, and maps the result onto the target range by rejection sampling. Every lot therefore has a probability of exactly 1/100, and each block is yin or yang with probability exactly 1/2.
90
+
91
+ ## Data sources
92
+
93
+ - Guanyin lots: from [yanxinyu777-beep/guanyin-lingqian-100](https://github.com/yanxinyu777-beep/guanyin-lingqian-100).
94
+ - Sensō-ji Kannon lots (Chinese translation): from [Tamshen/senso-ji-stick-data](https://github.com/Tamshen/senso-ji-stick-data); the fortune entries of lots 6–9 were reformatted.
95
+
96
+ Guanyin lot texts vary slightly between regions. For entertainment and cultural interest only.
97
+
98
+ ## License
99
+
100
+ The code is released under the [MIT License](https://github.com/mediapig/qiantong/blob/main/LICENSE): anyone may use, modify and distribute it freely.
101
+
102
+ The fortune data in `src/qiantong/data/` comes from the third-party repositories listed above. It is not covered by the MIT License, and its copyright remains with the original authors.
@@ -0,0 +1,79 @@
1
+ # qiantong · Guanyin fortune sticks in your terminal
2
+
3
+ Draw a Guanyin fortune stick (观音灵签) in the terminal: first ask for permission by tossing jiaobei blocks (掷筊), and once you get three holy tosses (圣杯) in a row, shake the lot tube and read your fortune poem and its interpretation.
4
+
5
+ The interface and the fortune texts are in Chinese.
6
+
7
+ ## Installation
8
+
9
+ Requires Python 3.9+. No third-party dependencies.
10
+
11
+ ```bash
12
+ # Recommended: install as a standalone command
13
+ uv tool install qiantong
14
+ # or
15
+ pipx install qiantong
16
+ # or
17
+ pip install qiantong
18
+
19
+ # Latest code from GitHub
20
+ uv tool install git+https://github.com/mediapig/qiantong.git
21
+ ```
22
+
23
+ After installing, run `qiantong` or `chouqian`.
24
+
25
+ ## How to play
26
+
27
+ | Key | Action |
28
+ | --- | --- |
29
+ | Space | Start / stop (press once to start, again to stop) |
30
+ | q / Esc / Ctrl-C | Quit |
31
+
32
+ 1. **Ask with jiaobei (掷筊请示).** For each toss, press space to throw the two crescent-shaped blocks and press space again to land them. Each block lands yin or yang:
33
+ - one yin, one yang: holy toss (圣杯) ✓
34
+ - two yang: laughing toss (笑杯) ✗
35
+ - two yin: yin toss (阴杯) ✗
36
+
37
+ You need three holy tosses in a row before you may draw a lot. Any other result starts over from the first toss.
38
+ 2. **Shake the tube (摇签).** Press space to start shaking and space again to stop. A stick pops out. Its number stays hidden until the result is shown.
39
+ 3. **Read the lot (解签).** Shows the lot number, its rank, the story behind it, the poem and the interpretation.
40
+
41
+ ## In Japan: Sensō-ji fortunes
42
+
43
+ If either of the following is detected, the lots switch to the 100 Kannon fortunes of Sensō-ji in Asakusa, Tokyo (ranks 大吉 / 吉 / 半吉 / 小吉 / 末小吉 / 末吉 / 凶), and the jiaobei step is skipped:
44
+
45
+ - the preferred system language is Japanese (macOS reads the system preferences, Windows reads the UI language, other systems read `LANG` and related variables);
46
+ - the time zone is Japan (`Asia/Tokyo`, `Japan`, JST).
47
+
48
+ You can also choose explicitly:
49
+
50
+ ```bash
51
+ qiantong --temple sensoji # Sensō-ji
52
+ qiantong --temple guanyin # Guanyin
53
+ TZ=Asia/Tokyo qiantong # pretend to be in Japan
54
+ ```
55
+
56
+ A command-line tool has no access to GPS, so location is not used.
57
+
58
+ ## Randomness
59
+
60
+ Every result is decided at the moment you press space to stop. The animation is only for show and has no effect on the outcome.
61
+
62
+ **Quantum random numbers by default.** At startup the program fetches 1024 bytes from the [quantum random number service of the OTH Regensburg Laboratory for Digitalisation](https://lfdr.de/QRNG/) into a pool, and fetches more when the pool runs out. The bytes are produced by ID Quantique's Quantis quantum random number hardware (a PCIe card), whose randomness comes from quantum physical processes.
63
+
64
+ **Local fallback.** If the service is unreachable, times out (3 seconds) or misbehaves, the program falls back to Python's standard `secrets` module, i.e. the operating system's cryptographic random number generator. After one failure it stays offline for the rest of the run so that draws never stall. The startup line tells you which source is in use.
65
+
66
+ Each draw takes 32 random bytes, appends the nanosecond timestamp of your key press, hashes them with SHA-256, and maps the result onto the target range by rejection sampling. Every lot therefore has a probability of exactly 1/100, and each block is yin or yang with probability exactly 1/2.
67
+
68
+ ## Data sources
69
+
70
+ - Guanyin lots: from [yanxinyu777-beep/guanyin-lingqian-100](https://github.com/yanxinyu777-beep/guanyin-lingqian-100).
71
+ - Sensō-ji Kannon lots (Chinese translation): from [Tamshen/senso-ji-stick-data](https://github.com/Tamshen/senso-ji-stick-data); the fortune entries of lots 6–9 were reformatted.
72
+
73
+ Guanyin lot texts vary slightly between regions. For entertainment and cultural interest only.
74
+
75
+ ## License
76
+
77
+ The code is released under the [MIT License](https://github.com/mediapig/qiantong/blob/main/LICENSE): anyone may use, modify and distribute it freely.
78
+
79
+ The fortune data in `src/qiantong/data/` comes from the third-party repositories listed above. It is not covered by the MIT License, and its copyright remains with the original authors.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qiantong"
7
+ version = "0.1.0"
8
+ description = "Guanyin fortune sticks (观音灵签) in the terminal: jiaobei tossing, lot drawing, quantum randomness"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "mediapig", email = "ky8contact9@gmail.com" }]
14
+ keywords = ["观音灵签", "抽签", "fortune", "omikuji", "divination", "cli", "quantum random"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Natural Language :: Chinese (Simplified)",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Topic :: Games/Entertainment",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/mediapig/qiantong"
29
+ Source = "https://github.com/mediapig/qiantong"
30
+ Issues = "https://github.com/mediapig/qiantong/issues"
31
+
32
+ [project.scripts]
33
+ qiantong = "qiantong.cli:main"
34
+ chouqian = "qiantong.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.setuptools.package-data]
40
+ qiantong = ["data/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Guanyin fortune sticks (观音灵签) in the terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from qiantong.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,425 @@
1
+ """Command-line entry: three holy jiaobei tosses → shake the lot tube → read the lot."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ import textwrap
7
+ import time
8
+ import unicodedata
9
+
10
+ from qiantong import __version__, japan, rng
11
+ from qiantong.keys import KeyReader
12
+ from qiantong.lots import GUANYIN, SENSOJI, load_lots
13
+
14
+ FRAME = 0.08 # seconds per animation frame
15
+ THROWS = 3 # consecutive holy tosses required
16
+
17
+ ORDINALS = "一二三四五六七八九十"
18
+
19
+
20
+ class Quit(Exception):
21
+ pass
22
+
23
+
24
+ # ---------- terminal output ----------
25
+
26
+ COLOR = sys.stdout.isatty() and "NO_COLOR" not in os.environ
27
+
28
+
29
+ def paint(text, code):
30
+ return f"\x1b[{code}m{text}\x1b[0m" if COLOR else text
31
+
32
+
33
+ def red(t):
34
+ return paint(t, "1;31")
35
+
36
+
37
+ def gold(t):
38
+ return paint(t, "1;33")
39
+
40
+
41
+ def dim(t):
42
+ return paint(t, "2")
43
+
44
+
45
+ def width(text):
46
+ """Display width in the terminal: wide CJK characters take two cells; ANSI codes take none."""
47
+ total, i = 0, 0
48
+ while i < len(text):
49
+ if text[i] == "\x1b":
50
+ i = text.index("m", i) + 1
51
+ continue
52
+ total += 2 if unicodedata.east_asian_width(text[i]) in "WF" else 1
53
+ i += 1
54
+ return total
55
+
56
+
57
+ NO_LINE_START = ",。、;:!?)」』”’"
58
+
59
+
60
+ def wrap_cjk(text, cells):
61
+ """Wrap text by display width."""
62
+ lines, line = [], ""
63
+ for ch in text:
64
+ if width(line + ch) > cells:
65
+ if ch in NO_LINE_START and len(line) > 1:
66
+ lines.append(line[:-1]) # never start a line with punctuation: carry the previous character along
67
+ line = line[-1]
68
+ else:
69
+ lines.append(line)
70
+ line = ""
71
+ line += ch
72
+ if line:
73
+ lines.append(line)
74
+ return lines
75
+
76
+
77
+ class Canvas:
78
+ """Redraw a block of lines in place, for animation."""
79
+
80
+ def __init__(self):
81
+ self.height = 0
82
+
83
+ def draw(self, lines):
84
+ out = []
85
+ if self.height:
86
+ out.append(f"\x1b[{self.height}F") # move back to the top of the previous drawing
87
+ for line in lines:
88
+ out.append("\x1b[2K" + line + "\n")
89
+ extra = self.height - len(lines)
90
+ if extra > 0: # new frame is shorter: clear the leftover lines and come back
91
+ out.append("\x1b[2K\n" * extra + f"\x1b[{extra}F")
92
+ sys.stdout.write("".join(out))
93
+ sys.stdout.flush()
94
+ self.height = len(lines)
95
+
96
+
97
+ def say(text=""):
98
+ sys.stdout.write(text + "\n")
99
+ sys.stdout.flush()
100
+
101
+
102
+ # ---------- key control ----------
103
+
104
+
105
+ def wait_space(keys, prompt):
106
+ """Show a prompt and wait for space; q or Esc quits."""
107
+ keys.flush()
108
+ say(dim(prompt))
109
+ while True:
110
+ ch = keys.read()
111
+ if ch == " ":
112
+ return
113
+ if ch in ("q", "Q", "\x1b"):
114
+ raise Quit
115
+
116
+
117
+ def animate(keys, frame_fn):
118
+ """Draw frame_fn(i) repeatedly until space is pressed; return the press time in nanoseconds."""
119
+ canvas = Canvas()
120
+ i = 0
121
+ keys.flush()
122
+ while True:
123
+ canvas.draw(frame_fn(i))
124
+ ch = keys.read(FRAME)
125
+ if ch == " ":
126
+ return time.perf_counter_ns(), canvas
127
+ if ch in ("q", "Q", "\x1b"):
128
+ raise Quit
129
+ i += 1
130
+
131
+
132
+ # ---------- jiaobei tossing ----------
133
+
134
+
135
+ def verdict(a, b):
136
+ if a != b:
137
+ return red("一阴一阳 · 圣杯 ✓")
138
+ if a:
139
+ return dim("两阳 · 笑杯 ✗")
140
+ return dim("两阴 · 阴杯 ✗")
141
+
142
+
143
+ # Crescent-shaped jiaobei blocks, each pose two rows of 7 cells.
144
+ # Flat side up (horns up) is yang; round side up (horns down) is yin.
145
+ MOON_UP = ("█▖ ▗█", " ▀███▀ ")
146
+ MOON_RIGHT = (" ▄██▀▀ ", " ▀██▄▄ ")
147
+ MOON_DOWN = (" ▄███▄ ", "█▘ ▝█")
148
+ MOON_LEFT = (" ▀▀██▄ ", " ▄▄██▀ ")
149
+ SPIN = [MOON_UP, MOON_RIGHT, MOON_DOWN, MOON_LEFT] # one full flip
150
+ BLANK = (" " * 7, " " * 7)
151
+ AIR = 5 # rows above the ground
152
+ FLIGHT = [1, 2, 3, 3, 2, 1] # looping flight heights (row of the crescent's lower edge)
153
+
154
+
155
+ def moon(is_yang):
156
+ """A landed block: yang in red, yin in gold."""
157
+ art = MOON_UP if is_yang else MOON_DOWN
158
+ return tuple((red if is_yang else gold)(row) for row in art)
159
+
160
+
161
+ def spinning(step):
162
+ return tuple(gold(row) for row in SPIN[step % len(SPIN)])
163
+
164
+
165
+ def toss_scene(label, cells, status):
166
+ """Draw one frame of the toss. `cells` holds (height, pose) for both blocks; height 0 is on the ground."""
167
+ rows = []
168
+ for row in range(AIR - 1, -1, -1):
169
+ parts = []
170
+ for height, art in cells:
171
+ if row == height + 1:
172
+ parts.append(art[0])
173
+ elif row == height:
174
+ parts.append(art[1])
175
+ else:
176
+ parts.append(BLANK[0])
177
+ rows.append(" " + " ".join(parts))
178
+ rows.append(" " + dim("─" * 25))
179
+ rows.append(f" {label} {status}")
180
+ return rows
181
+
182
+
183
+ def throw_record(label, a, b):
184
+ """Two-line record left behind once the blocks have landed."""
185
+ ma, mb = moon(a), moon(b)
186
+ names = " ".join("⚊阳" if x else "⚋阴" for x in (a, b))
187
+ return [
188
+ f" {label} {ma[0]} {mb[0]} {verdict(a, b)}",
189
+ f" {ma[1]} {mb[1]} {dim(names)}",
190
+ ]
191
+
192
+
193
+ def throw_once(keys, n):
194
+ """One toss: space throws the blocks, space again lands them. Return whether it is holy."""
195
+ label = f"第{ORDINALS[n]}掷"
196
+ wait_space(keys, f"按空格掷出第{ORDINALS[n]}次筊杯")
197
+
198
+ def flying(i):
199
+ return [(FLIGHT[(i + k * 3) % len(FLIGHT)], spinning(i + k * 2)) for k in range(2)]
200
+
201
+ state = {}
202
+
203
+ def frame(i):
204
+ state["i"] = i
205
+ return toss_scene(label, flying(i), dim("翻滚中…按空格落地"))
206
+
207
+ moment, canvas = animate(keys, frame)
208
+ a, b = rng.coin(moment), rng.coin(moment + 1)
209
+
210
+ # landing: fall one row at a time while spinning, bounce once, then settle
211
+ i = state["i"]
212
+ heights = [h for h, _ in flying(i)]
213
+ spin = i
214
+ while any(heights):
215
+ spin += 1
216
+ heights = [max(h - 1, 0) for h in heights]
217
+ canvas.draw(toss_scene(label, [(h, spinning(spin + k * 2)) for k, h in enumerate(heights)], ""))
218
+ time.sleep(FRAME)
219
+ for bounce in (1, 0):
220
+ spin += 1
221
+ canvas.draw(toss_scene(label, [(bounce, spinning(spin + k * 2)) for k in range(2)], ""))
222
+ time.sleep(FRAME * 1.5)
223
+
224
+ canvas.draw(toss_scene(label, [(0, moon(a)), (0, moon(b))], verdict(a, b)))
225
+ time.sleep(0.6)
226
+ canvas.draw(throw_record(label, a, b))
227
+ return a != b
228
+
229
+
230
+ def ask_permission(keys):
231
+ """Toss until three holy results in a row; only then may a lot be drawn."""
232
+ while True:
233
+ say()
234
+ say(gold("【掷筊请示】") + " 需连得三次圣杯(一阴一阳),方可求签")
235
+ say(dim(" 月牙两角朝上为阳,两角朝下为阴"))
236
+ if all(throw_once(keys, n) for n in range(THROWS)):
237
+ say()
238
+ say(red(" 三圣杯,菩萨应允。"))
239
+ return
240
+ say()
241
+ say(dim(" 未得三圣杯,菩萨未允。请静心默念所求之事,再来一次。"))
242
+
243
+
244
+ # ---------- shaking the lot tube ----------
245
+
246
+
247
+ def tube_frame(i, label):
248
+ shift = " " * (i % 4 if i % 4 < 3 else 1) # sway left and right
249
+ sticks = "".join("╿" if (i + k) % 3 else "│" for k in range(7))
250
+ return [
251
+ f" {shift} {sticks}",
252
+ f" {shift}┃{sticks}┃",
253
+ f" {shift}┃{label}┃",
254
+ f" {shift}┗━━━━━━━┛",
255
+ f" {dim('摇签中…按空格停止')}",
256
+ ]
257
+
258
+
259
+ def pop_frames(label):
260
+ """A stick pops out of the tube."""
261
+ frames = []
262
+ for rise in range(1, 4):
263
+ top = ["" for _ in range(3 - rise)] + [f" {red('┃')}" for _ in range(rise)]
264
+ frames.append(
265
+ top
266
+ + [
267
+ " ╿╿╿╿╿╿╿",
268
+ " ┃╿╿╿╿╿╿╿┃",
269
+ f" ┃{label}┃",
270
+ " ┗━━━━━━━┛",
271
+ "",
272
+ ]
273
+ )
274
+ frames[-1][-1] = " " + red("签已落出")
275
+ return frames
276
+
277
+
278
+ def draw_lot(keys, lots, label, prompt):
279
+ wait_space(keys, prompt)
280
+ moment, canvas = animate(keys, lambda i: [""] * 3 + tube_frame(i, label))
281
+ lot = lots[rng.randbelow(len(lots), moment)]
282
+ for frame in pop_frames(label):
283
+ canvas.draw(frame)
284
+ time.sleep(0.25)
285
+ return lot
286
+
287
+
288
+ # ---------- lot text ----------
289
+
290
+ LEVEL_COLOR = {
291
+ "上签": red, "中签": gold, "下签": dim,
292
+ "大吉": red, "吉": red, "半吉": gold, "小吉": gold, "末小吉": gold, "末吉": gold, "凶": dim,
293
+ }
294
+ INNER = 36 # inner width of the lot box
295
+
296
+
297
+ def boxed(body, inner=INNER):
298
+ out = ["╭" + "─" * (inner + 2) + "╮"]
299
+ for line in body:
300
+ out.append("│ " + line + " " * (inner - width(line)) + " │")
301
+ out.append("╰" + "─" * (inner + 2) + "╯")
302
+ return out
303
+
304
+
305
+ def render_lot(lot, inner=INNER):
306
+ level = LEVEL_COLOR.get(lot["level"], str)(lot["level"])
307
+ body = [
308
+ red(f"观音灵签 · 第{lot['number']}签") + " " + level,
309
+ "典故:" + lot["story"],
310
+ "",
311
+ *[gold(" " + line) for line in lot["poem"]],
312
+ "",
313
+ "解曰:",
314
+ *[" " + line for line in wrap_cjk(lot["interpretation"], inner - 2)],
315
+ ]
316
+ if lot.get("category"):
317
+ body += ["", dim("宜问:" + lot["category"])]
318
+ return boxed(body, inner)
319
+
320
+
321
+ def render_sensoji(lot, inner=INNER):
322
+ level = LEVEL_COLOR.get(lot["level"], str)(lot["level"])
323
+ poem = set(lot["poem"])
324
+ body = [
325
+ red(f"浅草寺 观音百签 · 第{lot['number']}番") + " " + level,
326
+ "",
327
+ *[gold(" " + line) for line in lot["poem"]],
328
+ "",
329
+ "解说:",
330
+ ]
331
+ for line in lot["explanation"]:
332
+ if line in poem or (len(line) == 5 and "," not in line and "。" not in line):
333
+ body.append(gold(" " + line)) # poem line heading its explanation
334
+ else:
335
+ body += [" " + part for part in wrap_cjk(line, inner - 4)]
336
+ body += ["", "运势:"]
337
+ for line in lot["fortune"]:
338
+ body += [" " + part for part in wrap_cjk(line, inner - 2)]
339
+ if lot["note"]:
340
+ body += [""] + [" " + part for part in wrap_cjk(lot["note"], inner - 2)]
341
+ if lot["level"] == "凶":
342
+ body += ["", *[dim(part) for part in wrap_cjk("抽到凶签莫担心:把签纸系在寺内的架子上,厄运就留在寺里了。", inner)]]
343
+ return boxed(body, inner)
344
+
345
+
346
+ # ---------- main flow ----------
347
+
348
+
349
+ def connect_entropy():
350
+ """Prefetch quantum random bytes at startup and tell the user which source is in use."""
351
+ sys.stdout.write(dim(" 正在连接量子随机数服务…"))
352
+ sys.stdout.flush()
353
+ label = rng.warm_up()
354
+ sys.stdout.write("\r\x1b[2K")
355
+ if label == rng.QUANTUM:
356
+ say(dim(f" 随机源:{label}"))
357
+ else:
358
+ say(dim(f" 量子随机数服务连不上,改用{label}"))
359
+
360
+
361
+ def run(keys, temple, reason):
362
+ lots = load_lots(temple)
363
+ say()
364
+ if temple == SENSOJI:
365
+ say(red(" ⛩ 浅草寺 观音百签 · おみくじ ⛩"))
366
+ if reason:
367
+ say(dim(f" {reason},今日改求浅草寺的签。"))
368
+ say(dim(" 双手捧起签筒,心中默念所求之事。空格:开始 / 停止;q:退出"))
369
+ else:
370
+ say(red(" ☸ 观音灵签 ☸"))
371
+ say(dim(" 心中默念姓名、住址与所求之事。空格:开始 / 停止;q:退出"))
372
+ connect_entropy()
373
+ while True:
374
+ if temple == SENSOJI:
375
+ # Sensō-ji has no jiaobei: go straight to the hexagonal tube
376
+ lot = draw_lot(keys, lots, " みくじ", "按空格摇动签筒")
377
+ box = render_sensoji(lot)
378
+ else:
379
+ ask_permission(keys)
380
+ lot = draw_lot(keys, lots, " 签 筒 ", "按空格开始摇签筒")
381
+ box = render_lot(lot)
382
+ say()
383
+ for line in box:
384
+ say(" " + line)
385
+ say()
386
+ wait_space(keys, "按空格再求一签,q 退出")
387
+
388
+
389
+ def main(argv=None):
390
+ parser = argparse.ArgumentParser(prog="qiantong", description="命令行观音灵签:掷筊三圣杯,空格摇签。")
391
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
392
+ parser.add_argument(
393
+ "--temple",
394
+ choices=("auto", GUANYIN, SENSOJI),
395
+ default="auto",
396
+ help="auto:在日本(系统语言或时区)用浅草寺签,否则用观音灵签(默认)",
397
+ )
398
+ args = parser.parse_args(argv)
399
+
400
+ if not sys.stdin.isatty():
401
+ print("qiantong 需要在交互式终端中运行。", file=sys.stderr)
402
+ return 1
403
+
404
+ reason = None
405
+ if args.temple == "auto":
406
+ reason = japan.detect()
407
+ temple = SENSOJI if reason else GUANYIN
408
+ else:
409
+ temple = args.temple
410
+
411
+ sys.stdout.write("\x1b[?25l") # hide cursor
412
+ try:
413
+ with KeyReader() as keys:
414
+ run(keys, temple, reason)
415
+ except (Quit, KeyboardInterrupt):
416
+ say()
417
+ say(dim(" 阿弥陀佛,善哉。"))
418
+ finally:
419
+ sys.stdout.write("\x1b[?25h") # show cursor
420
+ sys.stdout.flush()
421
+ return 0
422
+
423
+
424
+ if __name__ == "__main__":
425
+ raise SystemExit(main())