-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhell.py
More file actions
1722 lines (1536 loc) · 63.4 KB
/
Copy pathhell.py
File metadata and controls
1722 lines (1536 loc) · 63.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
A Short Stay in Hell
Based on the novella by Steven L. Peck.
A library containing every possible book of 410 pages, 40 lines, 80 characters.
Find the book that contains the story of your life.
"""
import os
import sys
import json
import random
import hashlib
import textwrap
import time
import math
from pathlib import Path
# Allow conversion of astronomically large integers (positions have ~2.6M digits)
sys.set_int_max_str_digits(0) # 0 = unlimited
# -- Dependencies -------------------------------------------------------------
# Required (hard fail if missing)
_missing_required = []
try:
from spellchecker import SpellChecker
except ImportError:
_missing_required.append("pyspellchecker")
try:
import textstat
except ImportError:
_missing_required.append("textstat")
try:
import nltk
except ImportError:
_missing_required.append("nltk")
if _missing_required:
print(f"Missing required packages: {', '.join(_missing_required)}")
print(f"Run: pip install {' '.join(_missing_required)}")
sys.exit(1)
import logging
logging.getLogger("nltk").setLevel(logging.ERROR)
nltk.download("words", quiet=True)
nltk.download("punkt", quiet=True)
from nltk.corpus import words as nltk_words
# Optional -- graceful fallback if unavailable
try:
import anthropic
_ANTHROPIC_AVAILABLE = True
except ImportError:
_ANTHROPIC_AVAILABLE = False
try:
import language_tool_python
_GRAMMAR_AVAILABLE = True
except ImportError:
_GRAMMAR_AVAILABLE = False
# Inform user of optional missing packages (once, at startup)
_optional_missing = []
if not _ANTHROPIC_AVAILABLE:
_optional_missing.append("anthropic (pip install anthropic) -- enables AI interview")
if not _GRAMMAR_AVAILABLE:
_optional_missing.append("language_tool_python + Java -- enables grammar checking")
if _optional_missing:
print()
print(" Optional packages not available:")
for m in _optional_missing:
print(f" - {m}")
print()
# -- Constants ----------------------------------------------------------------
PAGES_PER_BOOK = 410
LINES_PER_PAGE = 40
CHARS_PER_LINE = 80
CHARS_PER_PAGE = LINES_PER_PAGE * CHARS_PER_LINE # 3,200
CHARS_PER_BOOK = PAGES_PER_BOOK * CHARS_PER_PAGE # 1,312,000
CHARSET = ''.join(chr(i) for i in range(32, 127)) # 95 printable ASCII
CHARSET_SIZE = len(CHARSET) # 95
ROWS_PER_UNIT = 8
BOOKS_PER_ROW = 35
BOOKS_PER_UNIT = ROWS_PER_UNIT * BOOKS_PER_ROW # 280
SIDES_PER_FLOOR = 2
# Total books in the Library = 95^1,312,000
print(" Calibrating the Library's geometry...", end="", flush=True)
TOTAL_BOOKS = CHARSET_SIZE ** CHARS_PER_BOOK
# The Library is square: floors == shelf units per side. Each floor is an
# "O" shape -- two long sides of UNITS_PER_SIDE shelf units each, connected
# at both ends. Walking forward traverses one full side before crossing
# to the other at the end, then moving up a floor after completing the loop.
#
# floors^2 * SIDES_PER_FLOOR * BOOKS_PER_UNIT = TOTAL_BOOKS
# UNITS_PER_SIDE = floors = isqrt(TOTAL_BOOKS / (SIDES_PER_FLOOR * BOOKS_PER_UNIT))
#
# math.isqrt is used instead of floating point log/sqrt because the result
# has over a million digits -- far beyond float64 precision.
_target_for_sqrt = TOTAL_BOOKS // (SIDES_PER_FLOOR * BOOKS_PER_UNIT)
UNITS_PER_SIDE = math.isqrt(_target_for_sqrt)
UNITS_PER_FLOOR = SIDES_PER_FLOOR * UNITS_PER_SIDE # full "O" loop, both sides
print(" done.")
# -- Fall physics (sea-level Earth standard) ----------------------------------
GRAVITY_MS2 = 9.81 # m/s^2
TERMINAL_VELOCITY_MS = 53.0 # m/s, average human belly-to-earth position
FLOOR_HEIGHT_M = 2.9 # meters per floor (matches library design)
DEHYDRATION_DAYS = 3.0 # days to die of dehydration without water
DEHYDRATION_SECONDS = DEHYDRATION_DAYS * 86400
# Total books = 95^1,312,000 (already computed above during geometry calibration)
STATE_FILE = Path.home() / ".hell_state.json"
SHARES_FILE = Path.home() / ".hell_shares" # directory for .hell share files
# -- Planted text (optional easter egg) ---------------------------------------
# If a text file is present alongside hell.py, one specific book on every
# floor -- determined by floor number salted with PLANTED_TEXT_SALT -- will
# contain that text instead of generated noise. The position is derived,
# not hardcoded, so it cannot be found by reading the source alone; you
# would need to know the salt and recompute it per floor.
#
# Absent the file, the game behaves exactly as it always has -- this is a
# fully optional dependency with a silent fallback.
PLANTED_TEXT_FILENAME = "zend_avesta.txt"
PLANTED_TEXT_SALT = "Ahura Mazda"
_planted_text_cache: dict = {"pages": None, "loaded": False}
def _load_planted_text() -> list[str] | None:
"""
Load and reflow the planted text file into a list of pages, each
exactly CHARS_PER_PAGE characters (padded with spaces if needed),
cached after first load. Returns None if the file is absent.
"""
if _planted_text_cache["loaded"]:
return _planted_text_cache["pages"]
_planted_text_cache["loaded"] = True # only attempt this once
text_path = Path(__file__).resolve().parent / PLANTED_TEXT_FILENAME
if not text_path.exists():
return None
try:
raw = text_path.read_text(encoding="utf-8", errors="replace")
except Exception:
return None
# Collapse whitespace to single spaces, keep it within the printable
# ASCII charset the library uses elsewhere
flat = " ".join(raw.split())
flat = "".join(c if c in CHARSET else " " for c in flat)
# Reflow into fixed-width pages of CHARS_PER_PAGE characters each,
# breaking on word boundaries where possible
pages: list[str] = []
words = flat.split(" ")
current_page_chars: list[str] = []
current_len = 0
def flush_page():
nonlocal current_page_chars, current_len
page_text = " ".join(current_page_chars)
page_text = page_text[:CHARS_PER_PAGE].ljust(CHARS_PER_PAGE)
# Re-wrap into fixed 80-char lines for display consistency
lines = [page_text[i:i + CHARS_PER_LINE]
for i in range(0, CHARS_PER_PAGE, CHARS_PER_LINE)]
pages.append("\n".join(lines))
current_page_chars = []
current_len = 0
for word in words:
if not word:
continue
addition = (1 if current_page_chars else 0) + len(word)
if current_len + addition > CHARS_PER_PAGE:
flush_page()
if len(pages) >= PAGES_PER_BOOK:
break
current_page_chars.append(word)
current_len += addition
if current_page_chars and len(pages) < PAGES_PER_BOOK:
flush_page()
_planted_text_cache["pages"] = pages
return pages
def _planted_text_position(floor_number: int) -> int:
"""
Derive the single book position on a given floor that holds the
planted text, salted so it is not predictable from source alone.
"""
units_per_floor_books = UNITS_PER_FLOOR * BOOKS_PER_UNIT
seed_str = f"{floor_number}:{PLANTED_TEXT_SALT}"
digest = hashlib.sha256(seed_str.encode()).hexdigest()
offset = int(digest, 16) % units_per_floor_books
return floor_number * units_per_floor_books + offset
def _check_planted_text(pos: int) -> str | None:
"""
If `pos` is the planted-text position for its own floor, and the
text file is available, return the page list for that book.
Otherwise return None (caller falls back to generated noise).
"""
pages = _load_planted_text()
if not pages:
return None
units_per_floor_books = UNITS_PER_FLOOR * BOOKS_PER_UNIT
floor_number = pos // units_per_floor_books
if pos == _planted_text_position(floor_number):
return pages
return None
# -- ANSI colors --------------------------------------------------------------
YELLOW = "\033[93m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
# -- Failure taunts -----------------------------------------------------------
def get_taunts(player: dict) -> list:
name = player.get("name", "soul")
pet = player.get("pet", "your pet")
first = player.get("first_love", "your first love")
last = player.get("last_love", "your last love")
birthplace = player.get("birthplace", "wherever you came from")
return [
"The Library mocks you with its silence. This is not your book.",
"Eleven thousand souls have thought the same thing. They were wrong too.",
f"The pages know nothing of you, {name}. Move on.",
"Not yours. Not even close. The shelf awaits.",
f"{pet} would not recognize a single word on these pages.",
f"You think of {first}. The book does not.",
f"Somewhere between {first} and {last}, your story was written. It is not here.",
f"The shelves of {birthplace} produced more coherent text than this.",
"This book has never heard your name. It never will.",
"You have an eternity to keep looking. Do not waste it here.",
f"The numbers do not lie, {name}. This is not the one.",
f"Move along. {last} would want you to keep searching.",
]
# -- Fall damage messages -----------------------------------------------------
FALL_MESSAGES = [
(1, 5, ("You stumble down several levels, catching a railing at the last moment.\n"
"You are bruised but upright. The books watch impassively.")),
(6, 20, ("You fall hard, bouncing off railings, landing in a heap on a lower floor.\n"
"Something aches that did not ache before. You lie still for a moment.")),
(21, 50, ("The fall is long enough that you have time to regret it.\n"
"You hit the floor with a sound that echoes through the stacks.\n"
"Something may be broken. You are not sure. You get up anyway.")),
(51, 99, ("You fall for what feels like minutes.\n"
"The impact is enormous. You lie on the floor a long time,\n"
"staring up at the shelves receding into darkness above you.\n"
"Eventually you rise. There is nothing else to do.")),
(100, None,("The fall takes long enough that you lose consciousness.\n"
"When you open your eyes the ceiling is unfamiliar.\n"
"A distant voice -- bureaucratic, immense, faintly bored -- says:\n"
" \'Rule Four. Death within the Library is temporary.\n"
" You have been restored to the nearest floor.\n"
" Please resume your search.\'\n"
"You are exactly where you started, minus the dignity.")),
]
def fall_message(floors: int) -> str:
for min_f, max_f, msg in FALL_MESSAGES:
if max_f is None or floors <= max_f:
return msg
return FALL_MESSAGES[-1][2]
# -- Anthropic client (optional) ---------------------------------------------
def get_anthropic_client(api_key: str | None) -> object | None:
"""Return an Anthropic client if a key is available, else None."""
if not api_key or not _ANTHROPIC_AVAILABLE:
return None
try:
return anthropic.Anthropic(api_key=api_key)
except Exception:
return None
# -- Book generation ----------------------------------------------------------
def generate_page(book_position: int, page_number: int) -> str:
"""
Generate a deterministic page of text from book position and page number.
On exactly one specially-derived position per floor, if a planted text
file is present, real text is served instead of generated noise for
however many pages that text occupies; pages beyond the text's length
(within that same book) fall back to ordinary generated noise, keeping
the book's remaining pages indistinguishable from any other.
"""
planted_pages = _check_planted_text(book_position)
if planted_pages is not None and page_number < len(planted_pages):
return planted_pages[page_number]
seed = book_position * PAGES_PER_BOOK + page_number
rng = random.Random(seed)
chars = [rng.choice(CHARSET) for _ in range(CHARS_PER_PAGE)]
lines = [''.join(chars[i * CHARS_PER_LINE:(i + 1) * CHARS_PER_LINE])
for i in range(LINES_PER_PAGE)]
return '\n'.join(lines)
# -- Position <-> physical location -------------------------------------------
# Caches the last computed location since the floor/side divmod is expensive
# (division between numbers with ~1.3M and ~2.6M digits respectively).
_location_cache: dict = {"pos": None, "loc": None}
def position_to_location(pos: int) -> dict:
"""
Map a raw book position to physical coordinates within the Library's
real "O" shaped geometry: each floor is a loop with two long sides
(Right and Left) of UNITS_PER_SIDE shelf units each, joined at both
ends across the central abyss. Walking forward traverses the full
length of one side before crossing over to the other.
"""
if _location_cache["pos"] == pos:
return _location_cache["loc"]
book_in_unit = pos % BOOKS_PER_UNIT
row = book_in_unit // BOOKS_PER_ROW
col = book_in_unit % BOOKS_PER_ROW
unit_index = pos // BOOKS_PER_UNIT # which shelf unit, overall
# divmod computes quotient and remainder together -- roughly 15x faster
# than calling % and // separately when dividing million-digit integers
floor_number, unit_in_floor = divmod(unit_index, UNITS_PER_FLOOR)
if unit_in_floor < UNITS_PER_SIDE:
side = "Right"
unit_on_side = unit_in_floor
else:
side = "Left"
unit_on_side = unit_in_floor - UNITS_PER_SIDE
loc = {
"floor": floor_number,
"side": side,
"unit": unit_on_side, # position along the current side
"unit_overall": unit_index, # raw shelf unit index, for reference
"row": row + 1,
"col": col + 1,
}
_location_cache["pos"] = pos
_location_cache["loc"] = loc
return loc
_format_big_cache: dict = {}
_FORMAT_BIG_CACHE_MAX = 8 # small LRU-ish cache; header uses ~3 distinct values
def format_big(n: int, digits: int = 6) -> str:
"""
Format a huge integer as 'first...last (N digits)'. Cached, since
str() on a million-digit integer is itself expensive (~1-2 seconds)
-- the header re-renders the same few values (floor, unit, position)
repeatedly between navigation actions.
"""
key = (n, digits)
if key in _format_big_cache:
return _format_big_cache[key]
s = str(n)
if len(s) <= digits * 2 + 3:
result = f"{n:,}"
else:
result = f"{s[:digits]}...{s[-digits:]} ({len(s)} digits)"
# Simple bound: clear the cache if it grows past the small limit.
# We only ever need to hold the current floor/unit/position triple.
if len(_format_big_cache) >= _FORMAT_BIG_CACHE_MAX:
_format_big_cache.clear()
_format_big_cache[key] = result
return result
# -- Life book position -------------------------------------------------------
def derive_life_book_position(player: dict) -> int:
combined = "|".join([
player.get("name", ""),
player.get("birthdate", ""),
player.get("birthplace", ""),
player.get("pet", ""),
player.get("first_love", ""),
player.get("last_love", ""),
]).encode("utf-8")
digest = hashlib.sha512(combined).hexdigest()
raw = int(digest, 16)
return raw % TOTAL_BOOKS
# -- Coherence scoring --------------------------------------------------------
_spell = None
_lt_tool = None
def _get_spell():
global _spell
if _spell is None:
_spell = SpellChecker()
return _spell
def _get_lt():
global _lt_tool
if not _GRAMMAR_AVAILABLE:
return None
if _lt_tool is None:
_lt_tool = language_tool_python.LanguageTool("en-US")
return _lt_tool
def score_page(page_text: str, player: dict,
book_position: int, life_position: int) -> dict:
# Extract actual words (3+ letters) from the noise using regex
# Raw split() gives full 80-char lines as tokens which never match dictionary
import re as _re
_words = _re.findall(r'[a-zA-Z]{3,}', page_text)
total_tokens = max(len(_words), 1)
# Spelling -- what fraction of extracted words are real English words
spell = _get_spell()
_wf = spell.word_frequency
known_count = sum(1 for w in _words if w.lower() in _wf)
spelling_score = known_count / total_tokens
# Readability
try:
flesch = textstat.flesch_reading_ease(page_text)
readability_score = max(0.0, min(flesch / 100.0, 1.0))
except Exception:
readability_score = 0.0
# Grammar (optional -- requires language_tool_python + Java)
grammar_score = None # None = unavailable
grammar_available = _GRAMMAR_AVAILABLE
try:
lt = _get_lt()
if lt is not None:
matches = lt.check(page_text)
error_rate = len(matches) / total_tokens
grammar_score = max(0.0, 1.0 - error_rate)
except Exception:
grammar_score = None
# Personal resonance
personal_fields = [
player.get("name", ""),
player.get("birthplace", ""),
player.get("pet", ""),
player.get("first_love", ""),
player.get("last_love", ""),
player.get("birthdate", ""),
]
page_lower = page_text.lower()
hits = sum(1 for f in personal_fields if f and f.lower() in page_lower)
personal_score = hits / len(personal_fields)
# Reweight if grammar unavailable -- redistribute its 20% to others
if grammar_score is None:
overall = (
spelling_score * 0.267 +
readability_score * 0.267 +
personal_score * 0.466
)
else:
overall = (
spelling_score * 0.20 +
readability_score * 0.20 +
grammar_score * 0.20 +
personal_score * 0.40
)
is_life_book = (book_position == life_position)
return {
"spelling": spelling_score,
"readability": readability_score,
"grammar": grammar_score, # None if unavailable
"grammar_available": grammar_score is not None,
"personal": personal_score,
"overall": overall,
"is_life_book": is_life_book,
"page_text": page_text,
}
# -- Display ------------------------------------------------------------------
def clear():
os.system("clear")
def highlight_personal(page_text: str, player: dict) -> str:
"""Return page text with personal details highlighted in yellow."""
fields = [f for f in [
player.get("name", ""),
player.get("birthplace", ""),
player.get("pet", ""),
player.get("first_love", ""),
player.get("last_love", ""),
player.get("birthdate", ""),
] if f]
result = page_text
for field in fields:
# Case-insensitive replacement preserving original case
import re
pattern = re.compile(re.escape(field), re.IGNORECASE)
result = pattern.sub(lambda m: f"{YELLOW}{BOLD}{m.group()}{RESET}", result)
return result
# -- Great Clock --------------------------------------------------------------
# Time hierarchy (in seconds):
_SPY = 365 * 24 * 3600 # seconds per year
_SDEC = 10 * _SPY # per decade
_SCEN = 100 * _SPY # per century
_SMIL = 1_000 * _SPY # per millennium
_SEON = 1_000_000_000 * _SPY # per eon (1 billion years)
_SAGE = 1_000_000_000_000 * _SPY # per Age (1 trillion years)
# 1 Reckoning = 95^1,312,000 seconds -- shown symbolically, always 0
def format_elapsed(seconds: float) -> str:
"""Format elapsed seconds into the full cosmological clock display."""
s = int(seconds)
# Break out all units
ages, rem = divmod(s, _SAGE)
eons, rem = divmod(rem, _SEON)
millennia, rem = divmod(rem, _SMIL)
centuries, rem = divmod(rem, _SCEN)
decades, rem = divmod(rem, _SDEC)
years, rem = divmod(rem, _SPY)
days, rem = divmod(rem, 86400)
hours, rem = divmod(rem, 3600)
minutes, secs = divmod(rem, 60)
def u(n, singular, plural=None):
if plural is None:
plural = singular + "s"
return f"{n:,} {singular if n == 1 else plural}"
# Always show Reckonings → Ages → Eons → Millennia → Centuries with
# leading zeros so the player can see what awaits them
lines = []
lines.append(f" Reckonings : 0")
lines.append(f" Ages : {ages:,}")
lines.append(f" Eons : {eons:,}")
lines.append(f" Millennia : {millennia:,}")
lines.append(f" Centuries : {centuries:,}")
lines.append(f" Decades : {decades:,}")
lines.append(f" Years : {years:,}")
lines.append(f" Days : {days:,}")
lines.append(f" Hours : {hours:,}")
lines.append(f" Minutes : {minutes:,}")
lines.append(f" Seconds : {secs:,}")
return "\n".join(lines)
def clock_comment(seconds: float) -> str:
"""A Xandern-flavored comment based on elapsed time."""
s = int(seconds)
m = s // 60
h = s // 3600
d = h // 24
y = d // 365
# First hour: comments every ~10 minutes
if s < 600: return "You have only just arrived."
if s < 1200: return "The doors are behind you. There are no doors."
if s < 1800: return "The first hour is the longest. Or so they say."
if s < 2400: return "You are beginning to notice the smell of old paper."
if s < 3000: return "Your eyes are adjusting to the light. It does not change."
if s < 3600: return "You are beginning to understand."
# Hours 1-6: comments every ~hour
if h < 2: return "The books are indifferent to your haste."
if h < 3: return "Others passed this shelf before you. None found what they sought."
if h < 4: return "The silence here is not empty. It is full of failed searches."
if h < 6: return "You have walked further than you know."
if h < 12: return "Half a day. The Library has not noticed."
if h < 18: return "The vending machines are further than you remember."
if h < 24: return "You have been here nearly a full day. It feels longer."
# Days 1-7: comments every day or two
if d < 2: return "The shelves looked the same yesterday."
if d < 3: return "You are developing a sense of the architecture. It does not help."
if d < 5: return "Three days. Four. The numbers blur."
if d < 7: return "A week in the Library. Your life above had weeks like this too."
# Weeks 1-4
if d < 10: return "The shelves do not miss you when you are gone."
if d < 14: return "Nearly two weeks. The shelves have not changed. You have."
if d < 21: return "Others arrived after you. They are still looking too."
if d < 30: return "The Library is the same in every direction. You know this now."
# Months 1-12
if d < 45: return "A month and a half. You are becoming efficient, for all the good it does."
if d < 60: return "Two months. You have stopped counting pages."
if d < 90: return "Others arrived after you. They are not catching up."
if d < 120: return "You have worn a groove in the floor no one else will notice."
if d < 180: return "Half a year. The world above has moved on without you."
if d < 270: return "Nine months. Something was born above ground while you searched here."
if d < 365: return "A season has passed above ground. Here, nothing changes."
# Years 1-10: comments every year or two
if y < 2: return "A full year. Others have been here longer."
if y < 3: return "Two years. You have read more books than most libraries contain."
if y < 4: return "You are learning the patience of the Library."
if y < 5: return "Four years. The vending machine still works."
if y < 7: return "You have begun to dream of shelves."
if y < 10: return "A decade. The Library has barely registered your presence."
# Decades
if y < 15: return "The Library has begun to feel familiar. That is not good."
if y < 20: return "Two decades. Some souls have given up searching. Not you. Not yet."
if y < 25: return "You are becoming part of the Library."
if y < 30: return "Thirty years. You remember the world above in pieces now."
if y < 40: return "You have outlasted several small civilisations in here."
if y < 50: return "Half a century. Your name sounds strange when you say it aloud."
# Generations
if y < 75: return "Few remember the world above as clearly as you once did."
if y < 100: return "A century. The clock is unsurprised."
if y < 150: return "The Library is all there has ever been."
if y < 200: return "The Library is all there is. The Library has always been."
if y < 300: return "Other souls have arrived and despaired while you searched."
if y < 500: return "Your arrival is a distant rumour, even to yourself."
# Centuries → millennia
if y < 750: return "The world you knew has crumbled into archaeology."
if y <1000: return "Millennia are the Library's native currency."
if y <2000: return "Over a thousand years. The clock does not shrug. It has no shoulders."
if y <5000: return "You have outlasted languages, empires, and certainties."
if y <10000: return "Ten millennia. The Library remains unimpressed."
# Deep time
if y < 1_000_000:
return "The stars have shifted since you arrived. Slightly."
if y < 1_000_000_000:
return "The clock notes your persistence without admiration."
if y < 1_000_000_000_000:
return "The Eons turn. The Library does not."
return "The Reckoning has not yet begun. It will."
def format_clock(state: dict) -> tuple[str, str]:
"""Return (elapsed_str, comment_str) for the Great Clock."""
arrival = state.get("arrival_time", None)
if arrival is None:
return " (arrival unrecorded)", "The clock did not record your arrival."
import time as _time
elapsed = _time.time() - arrival
return format_elapsed(elapsed), clock_comment(elapsed)
def display_header(state: dict):
loc = position_to_location(state["position"])
taken = state["position"] in state["taken_books"]
elapsed, comment = format_clock(state)
# -- Title & location block
print("=" * 70)
print(" A SHORT STAY IN HELL")
print(f" Floor: {format_big(loc['floor'])} Side: {loc['side']} "
f"Unit: {format_big(loc['unit'])}")
print(f" Shelf: {loc['row']}/8 Position: {loc['col']}/35 "
f"Book: {format_big(state['position'])}")
books_read = state.get("books_read", 0)
mode_label = f"{DIM}[AI]{RESET}" if state.get("api_key") else f"{DIM}[local]{RESET}"
print(f" Page: {state['page'] + 1} / {PAGES_PER_BOOK} Books Read: {books_read:,} {mode_label}")
if taken:
print(" *** SLOT EMPTY -- book has been taken ***")
# -- Clock block
print("-" * 70)
print(" THE GREAT CLOCK")
# Render clock units on two rows of 6
units = elapsed.split("\n") # 11 lines: Reckonings..Seconds
# Parse into label:value pairs
pairs = []
for line in units:
line = line.strip()
if ":" in line:
label, val = line.split(":", 1)
pairs.append(f"{label.strip()}: {val.strip()}")
# Print in two rows
row1 = " ".join(pairs[:6])
row2 = " ".join(pairs[6:])
print(f" {row1}")
print(f" {row2}")
print(f" {DIM}{comment}{RESET}")
print("-" * 70)
def display_page(state: dict):
clear()
display_header(state)
if state["position"] in state["taken_books"]:
print()
print(" [ missing book ]")
print()
else:
page_text = generate_page(state["position"], state["page"])
print()
for line in page_text.split("\n"):
print(f" {line}")
print()
display_controls()
# -- Book sharing -------------------------------------------------------------
import zlib as _zlib
import hashlib as _hashlib
def book_share_code(position: int) -> str:
"""8-char hex code uniquely identifying a book position."""
return _hashlib.sha256(str(position).encode()).hexdigest()[:8]
def export_share(position: int, label: str) -> Path:
"""Write a .hell share file and return its path."""
SHARES_FILE.mkdir(exist_ok=True)
code = book_share_code(position)
pos_bytes = position.to_bytes((position.bit_length() + 7) // 8, "big")
compressed = _zlib.compress(pos_bytes, level=9)
data = (code.encode("ascii") + b"\x00" +
label.encode("utf-8") + b"\x00" +
compressed)
out_path = SHARES_FILE / f"{code}.hell"
out_path.write_bytes(data)
return out_path
def import_share(path: str) -> dict | None:
"""Read a .hell share file. Returns {code, label, position} or None."""
try:
data = Path(path).read_bytes()
code = data[:8].decode("ascii")
rest = data[9:]
lbl_end = rest.index(b"\x00")
label = rest[:lbl_end].decode("utf-8")
comp = rest[lbl_end + 1:]
pos_bytes = _zlib.decompress(comp)
position = int.from_bytes(pos_bytes, "big")
return {"code": code, "label": label, "position": position}
except Exception as e:
return None
def list_shares() -> list[dict]:
"""Return all shares in the shares directory."""
if not SHARES_FILE.exists():
return []
results = []
for f in sorted(SHARES_FILE.glob("*.hell")):
entry = import_share(str(f))
if entry:
results.append(entry)
return results
def mark_book(state: dict) -> None:
"""Mark the current book with a label and export a share file."""
pos = state["position"]
if pos in state["taken_books"]:
print(" This slot is empty -- nothing to mark.")
time.sleep(1)
return
code = book_share_code(pos)
print()
print(f" Book code: {BOLD}{code}{RESET}")
label = input(" Enter a label for this book (or blank to cancel): ").strip()
if not label:
print(" Cancelled.")
time.sleep(1)
return
out = export_share(pos, label)
print()
print(f" Marked. Share file saved to:")
print(f" {out}")
print()
print(f" Send that file to another soul. They can import it with the")
print(f" \'X\' command and jump directly to this book.")
input("\n Press Enter to continue...")
def jump_to_share(state: dict) -> None:
"""Import a .hell file or jump to a known share code."""
print()
print(" OPTIONS:")
print(" [1] Import a .hell share file")
print(" [2] Jump to a share code already in your collection")
print(" [q] Cancel")
print()
choice = input(" Choice: ").strip().lower()
if choice == "1":
path = input(" Path to .hell file: ").strip()
entry = import_share(path)
if entry is None:
print(" Could not read that file.")
time.sleep(1.5)
return
# Copy into shares dir
export_share(entry["position"], entry["label"])
print()
print(f" Imported: [{entry['code']}] \"{entry['label']}\"")
go = input(" Jump to it now? [y/n]: ").strip().lower()
if go == "y":
state["position"] = entry["position"]
state["page"] = 0
state["books_read"] = state.get("books_read", 0) + 1
save_state(state)
elif choice == "2":
shares = list_shares()
if not shares:
print(" No shares in your collection yet.")
time.sleep(1.5)
return
print()
print(" YOUR COLLECTION:")
print("-" * 60)
for i, s in enumerate(shares, 1):
print(f" [{i:2}] {s['code']} \"{s['label']}\"")
print("-" * 60)
raw = input(" Jump to number (or blank to cancel): ").strip()
if not raw:
return
try:
idx = int(raw) - 1
if 0 <= idx < len(shares):
state["position"] = shares[idx]["position"]
state["page"] = 0
state["books_read"] = state.get("books_read", 0) + 1
save_state(state)
print(f" Jumping to [{shares[idx]['code']}]...")
time.sleep(0.8)
except ValueError:
pass
def display_controls():
print("-" * 70)
print(" NAVIGATE: n/p = next/prev page f/b = next/prev book")
print(" F/B = next/prev shelf unit J = jump N shelf units")
print(" U/D = up/down one floor u/d = up/down one row")
print(" ACTIONS: t = take book i = inventory W = fall floors")
print(" m = mark/share book X = jump to share")
print(" ? = Validate my Life's Book q = quit")
print("=" * 70)
print()
def display_score(scores: dict, player: dict, taunt_index: int):
"""Show verdict first; offer breakdown on request."""
taunts = get_taunts(player)
taunt = taunts[taunt_index % len(taunts)]
print()
print("-" * 60)
print(" This is not your book.")
print(f" {DIM}{taunt}{RESET}")
print("-" * 60)
print()
cmd = input(" [w] Why not? [Enter] Continue: ").strip().lower()
if cmd == "w":
print()
print("-" * 60)
print(f" Spelling: {scores['spelling'] * 100:6.2f}%")
print(f" Readability: {scores['readability'] * 100:6.2f}%")
if scores.get("grammar_available"):
print(f" Grammar: {scores['grammar'] * 100:6.2f}%")
else:
print(f" Grammar: {DIM}n/a (install language_tool_python + Java){RESET}")
print(f" Personal: {scores['personal'] * 100:6.2f}%")
print(f" {chr(8212) * 25}")
print(f" Overall: {scores['overall'] * 100:6.2f}%")
print("-" * 60)
input("\n Press Enter to continue...")
# -- Win condition ------------------------------------------------------------
XANDERN_RELEASE_SYSTEM = """You are Xandern, the ancient demon who processed this soul at intake.
You are speaking to them for the last time -- they have found their book.
You know everything about them from their intake file.
Speak a brief, formal dismissal (4-6 sentences). Acknowledge the specific details of their life:
their name, birthplace, the names of their loves, their pet.
Note how long the search felt, and that it is now over.
Be neither warm nor cruel -- ancient, measured, final.
Do not use JSON. Just speak plainly."""
def _last_page_of_life(state: dict) -> str:
"""Generate page 410 of the player's life book -- the last lines."""
return generate_page(state["life_book_position"], PAGES_PER_BOOK - 1)
def dramatic_win_reveal(state: dict, client, scores: dict):
"""The dramatic reveal sequence when the player finds their book."""
player = state["player"]
page_text = scores["page_text"]
clear()
print()
print("=" * 70)
print()
print(" Analysing...")
print()
time.sleep(1.5)
# Animate the stats climbing
labels = ["Spelling", "Readability", "Grammar", "Personal"]
keys = ["spelling", "readability", "grammar", "personal"]
for label, key in zip(labels, keys):
if key == "grammar" and not scores.get("grammar_available"):
print(f" {label:<14} {DIM}n/a{RESET}")
else:
val = scores[key] * 100
print(f" {label:<14} {val:6.2f}%")
time.sleep(0.4)
print(f" {'─' * 25}")
time.sleep(0.6)
print(f" {'Overall':<14} {scores['overall'] * 100:6.2f}%")
time.sleep(1.5)
clear()
print()
print("=" * 70)
print()
print(f" {YELLOW}{BOLD} . . . something is different . . .{RESET}")
print()
time.sleep(2)
# Show the page with personal details highlighted
highlighted = highlight_personal(page_text, player)
print("-" * 70)
for line in highlighted.split("\n"):
print(f" {line}")
time.sleep(0.05)
print("-" * 70)
print()
time.sleep(2)
input(f" {YELLOW}Press Enter to continue...{RESET}\n")
# Xandern's final dismissal via API
clear()
print()
print("=" * 70)
print()
print(" A presence you have not felt since the beginning makes itself known.")
print()
time.sleep(2)
# Xandern's dismissal -- API if available, local pool otherwise
elapsed_secs = time.time() - state.get("arrival_time", time.time())
try:
if client is not None:
summary = (
f"Soul: {player.get('name')}, born {player.get('birthdate')} "
f"in {player.get('birthplace')}. "
f"Pet: {player.get('pet')}. "
f"First love: {player.get('first_love')}. "
f"Last love: {player.get('last_love')}. "
f"They have found their book after searching the Library."
)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=400,
system=XANDERN_RELEASE_SYSTEM,
messages=[{"role": "user", "content": summary}],
)
speech = response.content[0].text.strip()
else:
speech = local_win_dismissal(player, elapsed_secs)
except Exception:
speech = local_win_dismissal(player, elapsed_secs)
# Show the last page of the life book
last_page = _last_page_of_life(state)
highlighted_last = highlight_personal(last_page, player)
print()
for para in speech.split("\n"):
if para.strip():
for line in textwrap.wrap(para.strip(), width=66):
print(f" {line}")
time.sleep(0.04)
else:
print()
print()
time.sleep(2)
# Reveal the last page of the life book
input(f" {YELLOW}Press Enter to read the last page of your book...{RESET}\n")
clear()
print()
print("=" * 70)
print(f" {YELLOW}{BOLD}THE LAST PAGE OF YOUR LIFE'S BOOK{RESET}")
print(f" {DIM}Page 410 of 410{RESET}")
print("-" * 70)
print()
for line in highlighted_last.split("\n"):
print(f" {line}")
time.sleep(0.04)
print()
print("-" * 70)
time.sleep(2)
print()
print(f" {YELLOW}{BOLD}You are free.{RESET}")
print()
time.sleep(3)
# Delete save file -- the soul is released
if STATE_FILE.exists():
STATE_FILE.unlink()
input(" Press Enter to leave the Library...\n")
clear()
sys.exit(0)
# -- State persistence --------------------------------------------------------
INVENTORY_MAX = 12