Repository navigation
Expand file tree
/
Copy pathboxes.py
More file actions
1500 lines (1376 loc) · 60.1 KB
/
Copy pathboxes.py
File metadata and controls
1500 lines (1376 loc) · 60.1 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
"""Train Locksmithing on the boxes the hunt brought home: ;boxes
;boxes every box in the loot container, then in any other container holding one
;boxes source=<bag> that container alone instead of the profile's `loot_container`
;boxes careful every DISARM and PICK careful, whatever the reading
;boxes safe put back every box reading "longshot" or harder, no exceptions
;boxes stand stay standing (the script sits, which helps)
;boxes limit=<n> stop after n boxes opened
;boxes until=<n> treat that mindstate as the lock instead of 34
;boxes once exit at the lock instead of holding for the drain
;boxes tries=<n> tries at a trap or a lock before the box goes back (5)
;boxes return (typed while it runs) finish the box in hand and end
;stop boxes quit at once
What it does
- Sits, takes off the `hindering_gear`, and per box: DISARM, PICK, OPEN, empty, DISMANTLE.
- Puts back a trap reading "longshot" or worse unless it is a nuisance trap;
a deadly or unknown one always. A sprung toad or mime is waited out (10–15 min).
- Picks with the `lockpick` or the worn `lockpick_ring`, refilled once a run at
Ragge's (`lockpick_refill` of `lockpick_kind`; 0 never buys).
- Coins to the purse, gems to `gem_pouch`, the rest to the loot container.
- Sits for the work, but stays standing when the load reads Overburdened:
a sitter that heavy cannot stand again.
- What `loot_ignore` names (the common metals by default) and a
`loot_subtractions` noun (never picked up) go in the room's trash;
with no trash in the room they are kept.
- At the lock it holds until Locksmithing drains, then goes on.
- Logs each opened box to history.db (`box_contents`): trap, lock, coins, items,
and the creature and ground it came from, by the id ;hunt logged at pickup;
and every IDENTIFY, DISARM, PICK and OPEN sent (`box_attempts`): reading, outcome, rank at the time.
Never a drop
- An undismantled box goes in the room's bucket only if settings.json's
`droppable` lists it, else back into the container.
- A box with no room anywhere is lowered to the feet, and LIFTed again before
any flight, walk or end, ;stop included.
A room whose guards forbid box work ("Do that somewhere else.", the bank):
it walks to the profile's `home` once and works the boxes there.
When it stops
- every box tried, the `limit`, or the lock with `once`
- guards forbid box work here and at `home`, or there is no `home`
- below `health_floor` or a wound at `wound_floor`, so ;heal can run
- no lockpick left, death, or hostiles (it flees)
- ;boxes return; the gear goes back on however it ends
client/game/boxes.py is the model, after dr-scripts' pick.lic and Elanthipedia;
docs/training.md has the profile keys. Report any "boxes: <command> answered ..." line.
"""
import time
from client.engine.scripting import ScriptStopped
from client.game import (
boxlog,
discard,
encumbrance,
flight,
gems,
hands,
items,
money,
probe,
shop,
stores,
trainer,
travel,
walker,
)
from client.game import boxes as boxes_model
from client.game.act import ask, missing, said
from client.game.boxes import (
DISARM_OUTCOMES,
DISARMED_LOOK,
already_disarmed,
LOCK_CAUTION,
LOCK_READINGS,
OPEN_OUTCOMES,
LOCKPICK_CATALOG,
LOCKPICK_SHOP,
PICK_OUTCOMES,
RING_EMPTY,
RING_REFUSED,
DISMANTLED,
TOAD,
MIME_OVER,
SKILL,
TAKE_OUTCOMES,
TOO_HARD,
TRAP_CAUTION,
TRAP_READINGS,
box_containers,
boxes_in,
caution,
containers,
held_boxes,
listed,
parse_args,
reading,
)
from client.game.creatures import noun_of, phrase
from client.game.loop import danger, ensure_mindstate, mindstate, wants_stop
from client.game.loot import GEM_NOUNS, ignored, short_name
from client.game.probe import classify
from client.game.wounds import level, parse_health
# The design notes the manual above leaves out: what each rule came
# from, with its issue — read by people, never served as ;help.
_NOTES = """Train Locksmithing on the boxes the hunt brought home: ;boxes
;boxes every box in the loot container, then any other container INV LIST shows one in: disarmed, picked, opened, emptied
;boxes source=backpack that container alone instead of the profile's loot_container
;boxes careful every DISARM and PICK careful, whatever the reading says
;boxes stand stay standing (the script sits, which helps)
;boxes limit=3 stop after that many boxes opened
;boxes until=30 stop at that mindstate instead of 34
;boxes once exit at mind-lock instead of holding for the drain
;boxes safe put back every box reading "longshot" or harder, nuisance traps and locks included
;boxes tries=15 attempts at a trap or a lock before the box goes back (5 by default)
;boxes return (typed while it runs) finish the box in hand and end
Every DISARM and PICK teaches Locksmithing (Elanthipedia: Locksmithing
skill, Disarm command, Pick command; client/game/boxes.py is the model,
after dr-scripts' pick.lic, which pops boxes the same way). A box
already in hand at the start — ;hunt's, refused by a full loot
container — is PUT into the loot container or any other container
INV LIST shows first, so PICK has its free hand, else LOWERed to the
feet (LOWER <box> TO GROUND, Elanthipedia: Lower command) and worked
first, LIFTed one at a time; a box kept past the reading with no room
in the container goes to the feet too. The at-feet slot is the
character's, not the room's — the janitor never takes it — but no
move is possible while anything lies there (Elanthipedia: Inventory,
At Feet Slot; the operator, 2026-09-26), so before a flight, a walk
to Ragge's and at every end, a `;stop` included, each box is LIFTed
and put into a container with room, else held. Nothing is dropped. A
box at a
time out of the container (GET <box> FROM MY <container>, the next of
its noun by ordinal when one was put back): DISARM MY <box> IDENTIFY
reads the trap's difficulty, and a reading of "longshot" or worse is a
box for a better locksmith, back into the container at once — an
identify of a trap already read comes back with no roundtime and
teaches nothing (2026-09-23: eighty of them in forty seconds left
Locksmithing at 0/34; the 0/34 to 2/34 of the first run was the one
careful DISARM), so the boxes a low rank trains on are boxes it can
read as its own, from lower creatures — unless the trap's look is a
nuisance trap (Elanthipedia: Box traps: frog, laughing gas, mime,
shadowling, sleeper, mana sucker, bouncing box — a toad or a joke,
never a wound), which gets a careful try whatever it reads: at rank 3
every box from the Crossing's grendels read 11 and up (2026-09-25),
so the risk is the only way in. A deadly trap or a look not recognized
goes back; the laughing gas catches the whole room, so it waits for a
room with no other player in it; `safe` puts back every box at or past the
threshold. Else DISARM MY <box> <caution> —
quick, plain or careful by the reading, pick.lic's thresholds — until
the trap is down, up to five tries; then PICK MY <box> IDENTIFY and
PICK MY <box> <caution> the same way — a lock at or past the threshold
tried careful anyway, since a failed pick risks the pick, not the
locksmith — with the profile's `lockpick` in
the free hand (GOT from wherever it is kept, STOWed after) or the
worn `lockpick_ring`, whose top pick the game takes by itself (an
empty ring — its last pick broken, or PICK wanting "a more
appropriate tool" — falls back to the loose `lockpick`, and before
the next box the ring is refilled once a run: the profile's
`lockpick_refill` picks of `lockpick_kind` bought at Ragge's
Locksmithing — ORDER <kind> LOCKPICK, OFFER the quoted price, through
client/game/shop.py (#407) — the teller visited first for a short
purse (shop.afford), each PUT on the ring; `lockpick_refill` 0 never buys); OPEN,
LOOK IN, and every item out — coins to the purse, a gem into the
profile's `gem_pouch`, the rest into the loot container — and the empty
box DISMANTLEd in hand ("...dismantling the oaken crate and tossing the
pieces aside.", 2026-09-26; Elanthipedia: Dismantle command), or, when
that answers otherwise, into the room's bucket through
client/game/discard.py, which only takes a noun settings.json's
`droppable` lists (box, coffer, chest...); a box not on the list goes
back into the container and is said. A frog trap's toad (every command
answering "You're just a simple toad now.") is waited out, ten minutes
at most, before the box is picked up or the gear worn back. It sits
first (the wiki: kneeling or sitting helps) and stands at the end.
Some worn gear hinders every attempt ("Your armor hinders your
attempt." / "Your brass knuckles hinders your attempt.", captured
2026-09-23): the hands, the head and a shield — gauntlets, knuckles, a
leather cowl, a target shield — and not body plate or leg wear, one
piece at a time on 2026-09-26 (docs/training.md). Each noun in the
profile's `hindering_gear` (knuckles, gauntlets, cowl, shield) is REMOVEd and stowed
before the first box — judged by the piece landing in a hand, whatever
the game says — and GOT and worn back when the run ends, however it
ends; a piece that would not come off or go back on is said. Whatever
still hinders after that is said once.
A sprung trap is said with its line; the stun is waited out (up to
five minutes; "You are still stunned." to any step meanwhile is a
wait, not a try — the laughing gas outlasted the old thirty seconds
and the run skipped every box, 2026-09-25), a box knocked to the floor
is picked back up and a prone character sits up again, and a
health below the profile's `health_floor` or a wound at its
`wound_floor` (HEALTH, the hunt's floor) ends the run so ;heal can
run. "You're in no shape to be disarming anything" ends it too. A
broken lockpick is said; with a ring the next pick is the game's to
take, without one the script GETs another and stops when there is none.
The mindstate is the exp window's; at mind-lock the script holds until
Locksmithing drains below 28, then goes on (`once` exits at the lock);
an empty container ends the run. ;train runs it as a task (skills:
["Locksmithing"], script "boxes", return_word "return") after the hunt
and the skins. It stops on death and on hostiles in the room.
The wordings are pick.lic's and the wiki's until captured (2026-09-23):
the first answer of each kind in a run is echoed as
"boxes: <command> answered ..." so they become fixtures — report them.
Stop with: ;stop boxes, or ;boxes return.
Each opened box is a history.db `box_contents` row (#423, the operator
asked what a ground's boxes are worth, 2026-10-02): its item id off the
hand that holds it, the hardest trap and lock readings, the coins in
copper ("You pick up 5 silver Kronars.", 2026-09-28) and the items,
told to the creature and ground of the `box_drops` row ;hunt wrote at
pickup — by the id, which holds within a login; else the batch of
boxes found since the last run (client/game/boxlog.py).
"""
# A session started before client/game/boxes.py joined RELOADABLE_MODULES
# keeps the copy it first imported (2026-09-23: the loop's first boxes
# task failed on "cannot import name 'HINDERED'" until the relaunch);
# the constants added since are read with a fallback.
HINDERED = getattr(boxes_model, "HINDERED", ("hinders your attempt",))
WORN = getattr(boxes_model, "WORN", ("onto your hands", "you slip", "you slide"))
NUISANCE_TRAPS = getattr(boxes_model, "NUISANCE_TRAPS", ())
def nuisance_trap(answer):
reader = getattr(boxes_model, "nuisance_trap", None)
return reader(answer) if reader is not None else None
def hindering_gear(profile):
reader = getattr(boxes_model, "hindering_gear", None)
if reader is not None:
return reader(profile)
return [str(item).strip().lower() for item in profile.get("hindering_gear") or []]
MIND_LOCK = 34
IDENTIFY_TRIES = 3
WORK_TRIES = 5
MAX_BOXES = 200 # the fuse under the loop
# No SIT at this burden or past it: "You are overburdened and cannot
# manage to stand." (#411, 2026-10-02 — the walk home failed seated).
LOAD_LIMIT = "Overburdened"
def _wordings(*tables):
"""Every wording in the tables given — an outcomes table's rows
(name, wordings) or a plain tuple of wordings — flattened."""
found = []
for table in tables:
for entry in table:
if (
isinstance(entry, tuple)
and len(entry) == 2
and isinstance(entry[1], tuple)
):
found.extend(entry[1])
else:
found.append(entry)
return tuple(found)
_NOT_FOUND = ("what were you referring", "could not find")
# The wordings each answer report's kind expects of the game (#410):
# act.said quotes the line holding one, never a bystander's line or
# another script's output that landed first in the window — "look in
# my sack answered 'Name: Lanival ...'" and "get coffer answered
# '<a passer-by> goes out.'" (2026-10-02). The "(unrecognized)" kinds
# have no known wording by definition and keep the first line.
REPORT_NEEDLES = {
"look in": ("you see", "nothing in", "is empty", "there is nothing", *_NOT_FOUND),
"look in container": ("you see", "nothing in", "is empty", "there is nothing"),
"get": ("you get", "you remove", "you pick up", "need a free hand", *_NOT_FOUND),
"take": _wordings(TAKE_OUTCOMES),
"open": _wordings(OPEN_OUTCOMES),
"disarm identify": _wordings(TRAP_READINGS, DISARM_OUTCOMES, DISARMED_LOOK),
"disarm": _wordings(DISARM_OUTCOMES),
"pick identify": _wordings(LOCK_READINGS, PICK_OUTCOMES),
"pick": _wordings(PICK_OUTCOMES),
"ring": ("you put", "you slide", "onto", "you add", *RING_EMPTY, *RING_REFUSED),
"wear": WORN,
"lower": ("you lower", "you set", "lower what", *_NOT_FOUND),
"lift": ("you lift", "you pick up", "lift what", *_NOT_FOUND),
"dismantle": (*DISMANTLED, *TOAD, "dismantle what", *_NOT_FOUND),
}
DEFAULT_WOUND_FLOOR = "harmful"
class Run:
"""One run's state: the profile, the container, the boxes put back,
the answers echoed once."""
def __init__(self, s, profile, options):
self.s = s
self.profile = profile
self.options = options
self.container = options["source"] or profile.get("loot_container") or ""
self.kept = {} # noun -> boxes put back into the container
self.moved = False # walked home once, the guards having forbidden it
self.kept_elsewhere = 0 # put back into the containers worked before it
self.kept_from = {} # creature -> boxes kept this run, by their drop rows (#495)
self.reported = set()
self.opened = 0
self.pick_in_hand = False
# The worn ring ran out of picks this run: the loose lockpick
# from here (2026-09-26).
self.ring_empty = False
self.refilled = False # one trip to Ragge's a run at most
self.doffed = [] # the hindering gear taken off, in order
self.at_feet = [] # box nouns LOWERed to the feet, not yet lifted
self.targets = [self.container] # where a box may go, loot container first
# The box in hand, for its box_contents row (#423): its item id,
# the trap and lock readings, the coins and the items out of it.
self.box = None
self.started = boxlog.now() # the run's start: the batch attribution's mark
def say(self, text):
self.s.echo(f"boxes: {text}")
def report(self, kind, command, answer):
"""Echo a run's first answer of each kind, for the fixtures: the
game's own line, found by the wordings the kind expects
(REPORT_NEEDLES), never a bystander's that landed first (#410)."""
if kind in self.reported:
return
self.reported.add(kind)
self.say(f"{command} answered {said(answer, REPORT_NEEDLES.get(kind, ()))!r}")
def hindrance(run, answer):
"""The gear hindering the attempt, said once a run: "Your armor
hinders your attempt." / "Your brass knuckles hinders your attempt."
(captured 2026-09-23) — the operator can take it off."""
if "hindered" in run.reported:
return
lines = [
line.strip()
for line in answer.splitlines()
if any(word in line.lower() for word in HINDERED)
]
if lines:
run.reported.add("hindered")
run.say(f"{' '.join(lines)} — remove it for better odds")
def doff(run):
"""The profile's hindering gear off and stowed before the first box:
REMOVE MY <noun>, judged by the piece landing in a hand (REMOVE's
wordings are uncaptured, 2026-09-23), then STOW; a piece that stays
on is said with the game's line and left."""
s = run.s
for noun in hindering_gear(run.profile):
free_other_hand(run, "")
answer = ask(s, f"remove my {noun}")
s.waitrt()
if not hands.holding(s, noun):
run.say(f"the {noun} did not come off — {said(answer)!r}")
continue
ask(s, f"stow my {noun}")
run.doffed.append(noun)
if run.doffed:
run.say(f"off and stowed: {', '.join(run.doffed)} — worn back at the end")
def don(run):
"""The gear taken off worn back, last off first: GET MY <noun>, WEAR
MY <noun>; a piece that will not go on is stowed and said. After a
`;stop boxes` every read raises, so the rest goes out blind as
cleanup puts — the gear is never left in the sack; under a trap's
stun the session holds them until it passes (#318)."""
s = run.s
run.donning = None
try:
don_reading(run)
except ScriptStopped:
# The piece in flight too (2026-09-23: the gauntlets stayed in
# the backpack after a `;stop` caught the read on their GET).
pending = ([run.donning] if run.donning else []) + list(reversed(run.doffed))
for noun in pending:
s.put(f"get my {noun}", cleanup=True)
s.put(f"wear my {noun}", cleanup=True)
run.doffed = []
raise
def don_reading(run):
s = run.s
worn = []
while run.doffed:
noun = run.doffed.pop()
run.donning = noun
free_other_hand(run, "")
answer = ask(s, f"get my {noun}")
if is_toad(answer) and wait_toad(run):
answer = ask(s, f"get my {noun}")
if not hands.holding(s, noun):
run.say(f"the {noun} is nowhere to be worn back — please look for it")
continue
answer = ask(s, f"wear my {noun}")
s.waitrt()
if hands.holding(s, noun):
run.say(
f"the {noun} would not go back on — {said(answer)!r} — stowed instead"
)
ask(s, f"stow my {noun}")
continue
if not any(word in answer.lower() for word in WORN):
run.report("wear", f"wear my {noun}", answer) # a new wording
worn.append(noun)
run.donning = None
if worn:
run.say(f"worn back: {', '.join(worn)}")
def wound_floor(profile):
floor = str(profile.get("wound_floor") or "").strip().lower()
if floor == "off":
return ""
return floor or DEFAULT_WOUND_FLOOR
def hurt(run):
"""Why the character should stop after a sprung trap, or None: the
health below the profile's floor, or HEALTH showing a wound at the
profile's wound floor."""
s = run.s
floor = int(run.profile.get("health_floor") or 0)
health = getattr(getattr(s, "status", None), "health", None)
if floor and health is not None and health < floor:
return f"health {health}% below the floor of {floor}%"
name = wound_floor(run.profile)
if not name:
return None
try:
wanted = level(name)
except ValueError:
return None
panel = getattr(s.state, "injuries", None)
if isinstance(panel, dict) and not panel:
return None
health_answer = parse_health(ask(s, "health"))
hits = health_answer.at_least(wanted)
if hits:
area, kind, severity = max(hits, key=lambda hit: hit[2])
return f"{kind} {area} wound at {severity} — the wound floor is {name}"
return None
STUN_WAIT = 300 # seconds a sprung trap's stun is waited out at most
def wait_stun(run):
"""A sprung trap's stun waited out, a second at a time — up to five
minutes: the laughing gas held Lanival past the thirty seconds this
once waited, and every DISARM after answered "You are still
stunned." until the boxes were all skipped (2026-09-25)."""
s = run.s
for _ in range(STUN_WAIT):
if not getattr(getattr(s, "status", None), "stunned", False):
return
s.sleep(1)
def recover(run, noun):
"""After a sprung trap: the box back in hand if it left it — the
laughing gas put the skippet on the floor (2026-09-25: "You also see
a cracked ironwood skippet", and the put-back that followed left it
there) — and the character back off the floor."""
s = run.s
if noun and not hands.holding(s, noun):
objs = str(getattr(s.state, "room_objs", "") or "").lower()
if noun.lower() in objs:
ask(s, f"get {noun}")
s.waitrt()
run.say(f"the {noun} was knocked to the floor — picked back up")
posture = getattr(getattr(s, "status", None), "posture", None)
if posture == "prone":
ask(s, "stand" if run.options.get("stand") or too_heavy_to_sit(run) else "sit")
s.waitrt()
TOAD_WAIT = 600 # seconds the frog trap's toad is waited out at most
TOAD_POLL = 15
def is_toad(answer):
return any(line in (answer or "").lower() for line in TOAD)
def wait_toad(run):
"""The frog trap's toad waited out: GLANCE (no roundtime) every
fifteen seconds until it answers as a character again, ten minutes
at most. On 2026-09-26 the run went on as a toad — the coffer stayed
on the floor and all four pieces of gear read "nowhere to be worn
back", every GET answering "You're just a simple toad now." True
when the toad has worn off."""
s = run.s
run.say("turned into a toad by the trap — waiting it out")
for _ in range(TOAD_WAIT // TOAD_POLL):
s.sleep(TOAD_POLL)
if not is_toad(ask(s, "get my nothing-at-all")):
run.say("a character again")
return True
return False
MIME_WAIT = 900 # seconds the mime trap's invisible box is waited out at most
MIME_POLL = 15
def wait_mime(run):
"""The mime trap's invisible box waited out (#497): the stream read
for the line that ends it ("doing performance art"), MIME_WAIT at
most — every attempt meanwhile costs a 10 s roundtime and answers
nothing else (sixteen of them, 2026-10-09). True when it ended."""
s = run.s
run.say("caught in the mime trap's invisible box — waiting it out")
# The cap by the clock: counting only quiet polls let a busy room
# stretch the wait past it (#497).
deadline = probe.clock() + MIME_WAIT
while probe.clock() < deadline:
line = s.get(timeout=MIME_POLL)
if line is None:
continue
if any(word in line.lower() for word in MIME_OVER):
run.say("out of the invisible box")
return True
run.say(
f"still in the invisible box after {MIME_WAIT // 60} minutes — trying anyway"
)
return False
def sprung(run, answer, noun=""):
"""A trap went off: said, the stun waited out, the roundtime too,
a toad waited out, the box picked back up; the reason to stop, or
None."""
run.say(f"a trap sprung — {said(answer)!r}")
run.s.waitrt()
wait_stun(run)
if is_toad(answer) and not wait_toad(run):
return "still a toad after ten minutes"
recover(run, noun)
return hurt(run)
def too_heavy_to_sit(run):
"""ENCUMBRANCE read: True at LOAD_LIMIT or past it, said once a run
— a sitter that heavy cannot stand again (#411)."""
level = encumbrance.parse_level(ask(run.s, "encumbrance"))
heavy = bool(level) and encumbrance.level_index(level) >= encumbrance.level_index(
LOAD_LIMIT
)
if heavy and "load" not in run.reported:
run.reported.add("load")
run.say(f"the load reads {level} — working standing, a sit could not be undone")
return heavy
def sit(run):
"""SIT for the box work, unless `stand` was asked or the load is too
heavy to stand up from again."""
if run.options["stand"]:
return
if too_heavy_to_sit(run):
return
ask(run.s, "sit")
def stand(run):
ask(run.s, "stand")
def ready_pick(run):
"""A lockpick in hand when the profile keeps no ring; True when the
game has one to pick with."""
profile = run.profile
if profile.get("lockpick_ring") and not run.ring_empty:
return True
if run.pick_in_hand:
return True
noun = profile.get("lockpick") or "lockpick"
answer = ask(run.s, f"get my {noun}")
lowered = answer.lower()
if missing(answer) or "get what" in lowered:
run.say(
f"no {noun} to pick with — Ragge's Locksmithing in the Crossing sells them"
)
return False
run.pick_in_hand = True
return True
def ring_ran_out(run):
"""The worn ring is empty — its last pick broke, or PICK found "a
more appropriate tool" wanting while the ring was the tool (captured
2026-09-26: the ring's last pick went on a 12/17 crate, and the next
run read the empty ring as a lock wanting another kind of pick and
put the crate back). True, said once, the first time; False when
the ring was already out or none is worn — then the answer is the
lock's."""
if not run.profile.get("lockpick_ring") or run.ring_empty:
return False
run.ring_empty = True
run.say(
"the lockpick ring is empty — the loose lockpick from here "
"(Ragge's Locksmithing in the Crossing sells picks for the ring)"
)
return True
def refill_ring(run):
"""The empty ring refilled (the operator, 2026-09-26): the profile's
`lockpick_refill` picks of `lockpick_kind` bought at the nearest
`locksmith` room (Ragge's in the Crossing) — the purse topped up
at the teller first (shop.afford), each ORDERed and paid through
shop.buy at up to twice the catalog price — and PUT on the worn
ring one by one. Once a run. True when the ring holds picks again;
False, said, otherwise."""
s, profile = run.s, run.profile
count = int(profile.get("lockpick_refill") or 0)
ring = profile.get("lockpick_ring") or ""
if not ring or count <= 0 or run.refilled:
return False
run.refilled = True
kind = str(profile.get("lockpick_kind") or "ordinary").lower()
if kind not in LOCKPICK_CATALOG:
run.say(f"lockpick_kind {kind!r} is not on Ragge's catalog — no refill")
return False
price = LOCKPICK_CATALOG[kind]
mapdb = travel.mapdb()
if not shop.afford(
s, ask, "boxes", price * count, "Kronars", db=mapdb, walk=walker.walk
):
run.say("no coins for lockpicks — no refill")
return False
store = set(mapdb.rooms_tagged(LOCKPICK_SHOP))
if not travel.go(s, store, "Ragge's Locksmithing", db=mapdb):
run.say("could not reach a locksmith — no refill")
return False
stacked = 0
for _ in range(count):
paid = shop.buy(s, ask, "boxes", f"order {kind} lockpick", max_price=price * 2)
if paid is None:
break # no quote, over twice the price or refused: shop said why
answer = ask(s, f"put my lockpick on my {ring}")
if stacked == 0:
run.report("ring", "put on ring", answer)
if any(word in answer.lower() for word in RING_REFUSED):
run.say(
f"the {ring} refused the pick: {said(answer)!r} — stowed, no more bought"
)
ask(s, "stow my lockpick")
break
stacked += 1
if stacked:
run.ring_empty = False
run.say(
f"the {ring} refilled — {stacked} {kind} lockpick(s), "
f"{money.phrase(stacked * price, 'Kronars')}"
)
return bool(stacked)
def put_pick_away(run):
if run.pick_in_hand:
ask(run.s, f"stow my {run.profile.get('lockpick') or 'lockpick'}")
run.pick_in_hand = False
def free_other_hand(run, noun):
"""PICK wants the box in one hand and the other empty (or the pick
in it): whatever else a hand holds, as the parser knows it, is
STOWed by its noun (hands.free), the box and the pick kept."""
pick_noun = run.profile.get("lockpick") or "lockpick"
keep = tuple(kept for kept in (noun, pick_noun) if kept)
hands.free(run.s, keep=keep, ask=ask)
def take_box(run, noun):
"""GET the next box of a noun out of the container — past the ones
put back this run — True when it landed in a hand."""
which = phrase(noun, run.kept.get(noun, 0) + 1)
answer = ask(run.s, f"get {which} from my {run.container}")
run.report("get", f"get {which}", answer)
lowered = answer.lower()
return not (
missing(answer) or any(word in lowered for word in ("get what", "can't"))
)
FEET = "feet"
def lower_to_feet(run, noun):
"""The box in hand into the at-feet slot — LOWER <item> TO GROUND,
"You lower the <item> and place it on the ground at your feet."
(Elanthipedia: Lower command). Not a DROP: the slot stays with the
character, the janitor never clears it, and it holds fifteen items;
but no move is possible while anything lies there (Inventory, At
Feet Slot), so every box lowered is lifted again before the run
ends or flees (the operator, 2026-09-26). True when it went down."""
answer = ask(run.s, f"lower {noun} to ground")
if "at your feet" not in answer.lower():
run.report("lower", f"lower {noun} to ground", answer)
return False
run.at_feet.append(noun)
return True
def lift(run, noun):
"""LIFT <item>: the box at the feet back into a hand (Elanthipedia:
Lift command; the answer uncaptured, judged like a GET)."""
answer = ask(run.s, f"lift {noun}")
run.report("lift", f"lift {noun}", answer)
lowered = answer.lower()
if missing(answer) or any(
word in lowered for word in ("lift what", "can't", "nothing")
):
return False
if noun in run.at_feet:
run.at_feet.remove(noun)
return True
def into_a_container(run, noun):
"""The box in hand PUT into the first container with room — the loot
container, then the others INV LIST shows; the container, or None."""
for container in run.targets:
answer = ask(run.s, f"put my {noun} in my {container}")
if "you put" in answer.lower():
return container
return None
def clear_feet(run):
"""Nothing left at the feet, before a flight and at every end: each
box LIFTed and put into a container with room, else held. After a
`;stop` every read raises, so the LIFTs go out blind as cleanup
puts — a box left at the feet would hold the character in the room."""
try:
for noun in list(run.at_feet):
if not lift(run, noun):
run.say(
f"the {noun} at your feet would not LIFT — LIFT it before moving"
)
continue
container = into_a_container(run, noun)
if container:
run.say(f"the {noun} from your feet goes into the {container}")
else:
run.say(
f"the {noun} from your feet stays in hand — no container has room"
)
except ScriptStopped:
for noun in run.at_feet:
run.s.put(f"lift {noun}", cleanup=True)
run.at_feet = []
raise
def put_back(run, noun, why):
"""The box in hand back into the container, remembered so the next
GET passes it; at the feet when the container has no room. False when
neither took it: the box is still in hand."""
answer = ask(run.s, f"put my {noun} in my {run.container}")
if "you put" in answer.lower():
run.kept[noun] = run.kept.get(noun, 0) + 1
run.say(f"the {noun} goes back into the {run.container} — {why}")
return True
if lower_to_feet(run, noun):
run.kept_elsewhere += 1
run.say(
f"no room in the {run.container}: the {noun} goes down at your feet — {why}"
)
return True
run.say(f"the {noun} does not fit back into the {run.container} — it stays in hand")
return False
def stash_held(run):
"""Boxes already in hand at the start — ;hunt's, refused by a full
loot container (2026-09-26: a chest and a casket) — put into the loot
container, else any container INV LIST shows, else at the feet, so
both hands are free to pick. Returns ({noun: container or FEET},
[nouns still in hand]). Nothing is dropped."""
placed, stuck = {}, []
for noun in held_boxes(*hands.tags(run.s).values()):
container = into_a_container(run, noun)
if container:
placed[noun] = container
run.say(f"the {noun} in hand goes into the {container}")
elif lower_to_feet(run, noun):
placed[noun] = FEET
run.say(f"no container has room: the {noun} in hand goes down at your feet")
else:
stuck.append(noun)
return placed, stuck
def risk_trap(run, noun, rank, answer):
"""The caution for a trap past TOO_HARD: "careful" when its look is
a nuisance trap (a toad, jokes, a nap) and the run takes the risk,
else None, said — a deadly or unknown trap, `safe`, or a trap that
hits the whole room while another player stands in it."""
trap = None if run.options.get("safe") else nuisance_trap(answer)
if trap is None:
run.say(f"the {noun}'s trap reads {rank}/17 — {TOO_HARD} or harder, too hard")
return None
name, area = trap
players = list(getattr(run.s.state, "room_players", None) or [])
if area and players:
run.say(
f"the {noun}'s {name} trap would catch {', '.join(players)} too — "
"left for an empty room"
)
return None
run.say(
f"the {noun}'s trap reads {rank}/17 — a {name} trap, a nuisance at worst: "
"trying careful"
)
return "careful"
def log_attempt(run, verb, noun, reading, outcome, since):
"""The attempt as a history.db `box_attempts` row (#493): the verb,
the reading it was made at, the class its answer got, the lockpick
kind, Locksmithing's rank and mindstate now, and its seconds. Never
raises."""
box = run.box or {}
skills = getattr(run.s.state, "experience", None) or {}
skill = skills.get(SKILL) if isinstance(skills, dict) else None
skill = skill if isinstance(skill, dict) else {}
boxlog.log_attempt(
run.s,
run_started=run.started,
box_id=box.get("box_id"),
noun=noun,
verb=verb,
reading=reading,
outcome=outcome or "unknown",
lockpick=str(run.profile.get("lockpick_kind") or "ordinary").lower(),
rank=skill.get("rank"),
mindstate=skill.get("mindstate"),
seconds=round(time.monotonic() - since, 1),
)
def disarm(run, noun):
"""The traps off a box: "clear", "too hard", "stop:<why>" or
"lost"."""
s = run.s
for _round in range(run.options.get("tries") or WORK_TRIES):
rank = None
for _ in range(IDENTIFY_TRIES):
since = time.monotonic()
answer = ask(s, f"disarm my {noun} identify")
s.waitrt()
run.report("disarm identify", "disarm identify", answer)
hindrance(run, answer)
outcome = classify(answer, DISARM_OUTCOMES)
seen = reading(answer, TRAP_READINGS)
log_attempt(
run,
"identify",
noun,
seen,
outcome
or ("read" if seen is not None else None)
or ("already disarmed" if already_disarmed(answer) else None),
since,
)
if outcome == "forbidden":
return "stop:forbidden"
if outcome == "sprung":
why = sprung(run, answer, noun)
if why:
return f"stop:{why}"
continue
if outcome == "stunned":
wait_stun(run) # nothing was tried: wait, take the step again
recover(run, noun)
continue
if outcome == "boxed":
wait_mime(run)
continue
if outcome == "injured":
return "stop:too hurt to disarm anything"
if outcome == "lost":
return "lost"
if outcome == "no trap":
note_reading(run, "trap", 0)
return "clear"
if already_disarmed(answer):
# The look with a roundtime and no reading (#418): the
# trap is down already — thirty boxes were "would not
# identify", re-asked and disarmed again for nothing.
run.say(f"the {noun}'s trap is already down — on to the lock")
return "clear"
rank = reading(answer, TRAP_READINGS)
if rank is not None:
note_reading(run, "trap", rank)
break
if outcome != "identify failed":
run.report("disarm identify?", "disarm identify (unrecognized)", answer)
if rank is None:
run.say(f"the {noun}'s trap would not identify — treating it as careful")
word = "careful"
else:
word = caution(rank, TRAP_CAUTION)
if word is None:
word = risk_trap(run, noun, rank, answer)
if word is None:
return "too hard"
if run.options["careful"]:
word = "careful"
command = f"disarm my {noun} {word}".strip()
since = time.monotonic()
answer = ask(s, command)
s.waitrt()
run.report("disarm", "disarm", answer)
hindrance(run, answer)
outcome = classify(answer, DISARM_OUTCOMES)
log_attempt(run, "disarm", noun, rank, outcome, since)
if outcome == "forbidden":
return "stop:forbidden"
if outcome == "sprung":
why = sprung(run, answer, noun)
if why:
return f"stop:{why}"
continue
if outcome == "stunned":
wait_stun(run) # nothing was tried: wait, take the step again
recover(run, noun)
continue
if outcome == "boxed":
wait_mime(run)
continue
if outcome == "injured":
return "stop:too hurt to disarm anything"
if outcome == "lost":
return "lost"
if outcome in ("disarmed", "no trap"):
run.say(f"the {noun}'s trap is down ({word or 'plain'}, read {rank}/17)")
continue # IDENTIFY again: another trap, or none
if outcome in ("retry", "identify failed"):
# "identify failed" here is the shift line after a failed
# attempt ("your manipulation caused something to shift",
# 2026-09-23) — the trap moved, and the next IDENTIFY reads
# it again, harder (10/17 to 11/17 that night).
continue
run.report("disarm?", "disarm (unrecognized)", answer)
return "too hard"
def pick(run, noun):
"""The locks off a box: "open", "too hard", "stop:<why>" or
"lost"."""
s = run.s
for _round in range(run.options.get("tries") or WORK_TRIES):
if not ready_pick(run):
return "stop:no lockpick"
rank = None
for _ in range(IDENTIFY_TRIES):
since = time.monotonic()
answer = ask(s, f"pick my {noun} identify")
s.waitrt()
run.report("pick identify", "pick identify", answer)
hindrance(run, answer)
outcome = classify(answer, PICK_OUTCOMES)
seen = reading(answer, LOCK_READINGS)
log_attempt(
run,
"identify",
noun,
seen,
outcome or ("read" if seen is not None else None),
since,
)
if outcome == "forbidden":
return "stop:forbidden"
if outcome == "sprung":
why = sprung(run, answer, noun)
if why:
return f"stop:{why}"
continue
if outcome == "stunned":
wait_stun(run) # nothing was tried: wait, take the step again
recover(run, noun)
continue
if outcome == "boxed":
wait_mime(run)
continue
if outcome == "injured":