File size: 48,775 Bytes
39c3669
 
372c910
6bbf552
 
b483ca7
39c3669
6bbf552
b483ca7
6bbf552
 
a8afc36
 
6bbf552
 
 
 
 
b939433
6bbf552
 
 
 
 
 
39c3669
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
6bbf552
 
 
 
a8afc36
6bbf552
a8afc36
39c3669
6bbf552
 
 
 
 
 
 
 
 
 
a8afc36
 
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
6bbf552
a8afc36
6bbf552
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39c3669
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39c3669
 
 
 
 
b939433
a8afc36
 
c395e6f
 
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
a8afc36
6bbf552
 
 
 
 
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
a8afc36
6bbf552
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b939433
a8afc36
 
6bbf552
 
 
a8afc36
 
 
 
 
 
 
6bbf552
 
b939433
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
 
 
 
 
 
 
 
b939433
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b483ca7
a8afc36
 
 
b483ca7
a8afc36
 
 
 
 
 
 
b483ca7
 
 
39c3669
 
 
b483ca7
39c3669
b483ca7
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
c395e6f
 
 
a8afc36
372c910
 
 
a8afc36
6bbf552
 
 
a8afc36
 
 
 
 
 
 
 
b939433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
 
 
 
7e0483e
 
 
a8afc36
7e0483e
6bbf552
 
 
 
 
b939433
6bbf552
 
a8afc36
 
 
 
 
b939433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
 
 
7e0483e
 
a8afc36
7e0483e
a8afc36
6bbf552
 
 
a8afc36
 
 
 
 
 
 
6bbf552
 
372c910
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
 
 
a8afc36
6bbf552
 
 
 
 
a8afc36
372c910
6bbf552
 
 
372c910
a8afc36
b483ca7
 
a8afc36
372c910
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
a8afc36
 
 
 
 
 
 
 
6bbf552
 
 
 
a8afc36
 
 
 
 
 
6bbf552
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
a8afc36
6bbf552
 
a8afc36
 
6bbf552
 
 
 
 
a8afc36
6bbf552
 
 
 
 
 
 
 
 
a8afc36
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
6bbf552
 
 
 
 
 
 
a8afc36
6bbf552
 
 
 
 
 
a8afc36
 
 
 
 
 
 
6bbf552
 
a8afc36
6bbf552
a8afc36
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a8afc36
 
6bbf552
 
39c3669
6bbf552
 
 
 
 
 
 
 
 
 
 
 
39c3669
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39c3669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6bbf552
39c3669
6bbf552
 
 
 
39c3669
 
 
 
 
6bbf552
 
39c3669
6bbf552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
import base64
import time
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable, Iterator
from dataclasses import dataclass, replace
import os
from pathlib import Path
from random import Random
import sys
from typing import Sequence

import forge
from art import ArtClient, illustrate_card
from boss import boss_chooser
from budget import Card, CardSpec, EffectPlan, cost_card
from clients import boss_client_from_env
from draft import backbone_deck, draft_anchor_indexes, draft_order
from game import MAX_ENERGY, DuelState, PlayerState, create_player, draw_cards, play_card_from_hand, start_round, winner
from generator import CardPackClient, distinct_name, generate_pack
from play import OPENING_HAND_SIZE, SYNERGY_COSTS, best_draft_index, choose_enemy_card, playable_indexes, shuffled_deck
from primitives import School

CARD_PANEL_COUNT = 3
HAND_PANEL_COUNT = 10
THEME = "dark fantasy"
# Minimum draft loading-screen window: every pick shows the same brief "forging"
# beat, which also absorbs any pack that is not prefetched yet. Tunable via env.
try:
    MIN_DRAFT_LOADING_SECONDS = float(os.environ.get("TABRAS_MIN_DRAFT_LOADING", "2.0"))
except ValueError:
    MIN_DRAFT_LOADING_SECONDS = 2.0


@dataclass(frozen=True)
class RunState:
    player_name: str
    world: str
    school: School
    enemy_school: School
    player_deck: tuple[Card, ...]
    enemy_deck: tuple[Card, ...]
    draft_step: int
    draft_order: tuple[int, ...]
    anchor_indexes: frozenset[int]
    draft_anchors: tuple[Card, ...]
    current_pack: tuple[Card, ...]
    duel: DuelState | None
    turn_order: tuple[str, ...]
    turn_position: int
    log: tuple[str, ...]
    rng: Random
    enemy_seed: int = 0
    showcase: tuple[tuple[str, Card], ...] = ()
    hp_flash: tuple[int, int] = (0, 0)
    round_flash: int = 0
    boss_thinking: bool = False
    boss_thought: str = ""
    pack_fading: int = -1
    loading: str = ""
    loading_since: float = 0.0


Steps = Iterator[RunState]


# Return the next school used by the enemy.
def enemy_school_for(school: School) -> School:
    return {"fire": "earth", "earth": "ice", "ice": "fire"}[school]


# Start a new run shell with the starter deck visible immediately.
def new_run_shell(
    player_name: str,
    world: str,
    school: School,
    seed: int = 7,
) -> RunState:
    rng = Random(seed)
    player_deck = backbone_deck(school, world or THEME)
    enemy_school = enemy_school_for(school)
    order = draft_order(len(SYNERGY_COSTS), draft_anchor_indexes(len(SYNERGY_COSTS), rng))
    state = RunState(
        player_name=player_name or "You",
        world=world or THEME,
        school=school,
        enemy_school=enemy_school,
        player_deck=player_deck,
        enemy_deck=(),
        draft_step=0,
        draft_order=order,
        anchor_indexes=frozenset(order[:2]),
        draft_anchors=(),
        current_pack=(),
        duel=None,
        turn_order=(),
        turn_position=0,
        log=(f"{player_name or 'You'} enters {world or THEME}.",),
        rng=rng,
        enemy_seed=rng.getrandbits(32),
    )
    return state


# Start a new run, generate the first draft pack, and warm the background forge.
def new_run(
    player_name: str,
    world: str,
    school: School,
    client: CardPackClient | None = None,
    seed: int = 7,
    art_client: ArtClient | None = None,
) -> RunState:
    state = new_run_shell(player_name, world, school, seed)
    warm_enemy_deck(state, client, art_client)
    state = deal_next_pack(state, client, art_client)
    prefetch_next_packs(state, client, art_client)
    return state


# Finish the first generated draft pack for a visible starter-deck shell.
def finish_opening_draft(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState:
    warm_enemy_deck(state, client, art_client)
    state = deal_next_pack(state, client, art_client)
    prefetch_next_packs(state, client, art_client)
    return state


# Queue the next draft pack and render a loading state instead of blocking.
def queue_next_pack(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState:
    warm_enemy_deck(state, client, art_client)
    if state.draft_step >= len(state.draft_order):
        return state
    cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
    forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
    return replace(state, current_pack=(), loading=loading_message(state), loading_since=time.monotonic())


# Attach a queued draft pack once its background job finishes.
def collect_ready_pack(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState:
    if state.duel is not None or state.current_pack or state.draft_step >= len(state.draft_order):
        return state
    cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
    pack = forge.take_ready(pack_key(state))
    if pack is None:
        forge.submit(pack_key(state), pack_maker(client, art_client, state, cost))
        return state
    # Hold the loading beat for a minimum window so every draft transition looks
    # the same (a deliberate "forging" pause) instead of some snapping in instantly
    # and some lagging; the prefetched pack is already done well within it.
    if state.loading_since and time.monotonic() - state.loading_since < MIN_DRAFT_LOADING_SECONDS:
        return state
    pack = dedupe_pack_against_deck(pack, state.player_deck)
    warm_card_art(art_client, pack)
    pack = collect_ready_cards(pack)
    # Show the pack the moment it is ready; prefetch the next packs in the
    # background (never block the visible pack on speculative work).
    state = replace(state, current_pack=pack, loading="")
    prefetch_next_packs(state, client, art_client)
    return state


# Return whether all one-pick-ahead packs are already forged.
def next_packs_ready(state: RunState) -> bool:
    for index in range(len(state.current_pack)):
        nxt = apply_pick(state, index)
        if nxt.draft_step >= len(nxt.draft_order):
            continue
        if forge.take_ready(pack_key(nxt)) is None:
            return False
    return True


# Return copy for the visible draft loading state.
def loading_message(state: RunState) -> str:
    if state.draft_step == 0:
        return "Forging your first draft pack"
    return "Forging the next draft pack"


# Apply one draft pick to the run state.
def apply_pick(state: RunState, index: int) -> RunState:
    selected = state.current_pack[index]
    cost_index = state.draft_order[state.draft_step]
    anchors = state.draft_anchors + ((selected,) if cost_index in state.anchor_indexes else ())
    return replace(
        state,
        player_deck=state.player_deck + (selected,),
        draft_anchors=anchors,
        draft_step=state.draft_step + 1,
        log=state.log + (f"Drafted {selected.name}.",),
    )


# Choose a draft card, yielding paced battle states once drafting completes.
def choose_draft_card_steps(
    state: RunState,
    index: int,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> Steps:
    state = refresh_art(state)
    if index < 0 or index >= len(state.current_pack):
        yield add_log(state, "That draft card is not available.")
        return
    state = apply_pick(state, index)
    yield replace(state, pack_fading=index)
    if state.draft_step >= len(state.draft_order):
        yield from start_battle_steps(state, client, art_client)
        return
    state = deal_next_pack(state, client, art_client)
    prefetch_next_packs(state, client, art_client)
    yield replace(state, pack_fading=-1)


# Choose a draft card and queue the next pack without blocking UI rendering.
def choose_draft_card_loading_steps(
    state: RunState,
    index: int,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> Steps:
    state = refresh_art(state)
    if index < 0 or index >= len(state.current_pack):
        yield add_log(state, "That draft card is not available.")
        return
    state = apply_pick(state, index)
    yield replace(state, pack_fading=index)
    if state.draft_step >= len(state.draft_order):
        yield from begin_battle_or_wait_steps(state, client, art_client)
        return
    yield queue_next_pack(replace(state, pack_fading=-1), client, art_client)


# Choose a draft card and advance to the next pack or battle.
def choose_draft_card(
    state: RunState,
    index: int,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState:
    return last_state(choose_draft_card_steps(state, index, client, art_client))


# Rename pack cards that collide with the drafted deck (or each other), drawing
# fresh names from the school pool so the run never shows a repeat or a number.
def dedupe_pack_against_deck(pack: Sequence[Card], deck: Sequence[Card]) -> tuple[Card, ...]:
    taken = {card.name for card in deck}
    result = []
    for card in pack:
        name = distinct_name(card.name, taken, card.school)
        taken.add(name)
        result.append(card if name == card.name else replace(card, name=name))
    return tuple(result)


# Deal the next draft pack, preferring a forge-prefetched one.
def deal_next_pack(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState:
    cost = SYNERGY_COSTS[state.draft_order[state.draft_step]]
    maker = pack_maker(client, art_client, state, cost)
    pack = forge.take(pack_key(state), maker) if client is not None else maker()
    pack = dedupe_pack_against_deck(pack, state.player_deck)
    warm_card_art(art_client, pack)
    return replace(state, current_pack=collect_ready_cards(pack))


# Return a deck-content signature for forge keys.
def deck_sig(cards: Sequence[Card]) -> tuple[str, ...]:
    return tuple(f"{card.cost} {card.name}" for card in cards)


# Return the forge key for the next pack of one draft state.
def pack_key(state: RunState) -> tuple:
    return ("pack", state.enemy_seed, state.draft_step, deck_sig(state.player_deck), deck_sig(state.draft_anchors))


# Build a pack generator bound to one draft state.
def pack_maker(
    client: CardPackClient | None,
    art_client: ArtClient | None,
    state: RunState,
    cost: int,
) -> Callable[[], tuple[Card, ...]]:
    return lambda: make_pack(client, None, state.school, state.world, state.player_deck, cost, state.draft_anchors)


# Pre-generate a bounded number of next-pack branches while the player reads.
def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
    if client is None or state.draft_step >= len(state.draft_order):
        return
    for index in range(min(len(state.current_pack), prefetch_pack_limit())):
        nxt = apply_pick(state, index)
        if nxt.draft_step >= len(nxt.draft_order):
            continue
        cost = SYNERGY_COSTS[nxt.draft_order[nxt.draft_step]]
        forge.submit(pack_key(nxt), pack_maker(client, art_client, nxt, cost))


# Return how many possible next draft branches to forge speculatively.
def prefetch_pack_limit() -> int:
    try:
        # Prefetch every branch (one per visible card) so whichever card the
        # player picks, its next pack is already forging in the background.
        return max(0, int(os.environ.get("TABRAS_PREFETCH_PACKS", str(CARD_PANEL_COUNT))))
    except ValueError:
        return CARD_PANEL_COUNT


# Return the forge key for the boss deck of one run.
def enemy_deck_key(state: RunState) -> tuple:
    return ("enemy", state.enemy_school, state.world, state.enemy_seed)


# Build a boss-deck generator with its own deterministic rng.
def enemy_deck_maker(
    client: CardPackClient | None,
    art_client: ArtClient | None,
    state: RunState,
) -> Callable[[], tuple[Card, ...]]:
    return lambda: draft_enemy_deck_for_ui(client, art_client, state.enemy_school, state.world, Random(state.enemy_seed))


# Start forging the boss deck on the slow lane, but only after the first couple of
# picks so its ~9 card calls do not compete with the first visible draft pack.
# The non-blocking battle handoff tolerates the deck arriving late.
def warm_enemy_deck(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None:
    # Start forging the boss deck immediately on the slow lane: the reveal + rules
    # dwell and the player's own draft give it ample runway to finish before battle.
    if client is not None:
        forge.submit(enemy_deck_key(state), enemy_deck_maker(client, art_client, state), lane="slow")


# Start combat after drafting, yielding paced states for the opening round.
def start_battle_steps(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> Steps:
    state = refresh_art(state)
    maker = enemy_deck_maker(client, art_client, state)
    enemy_deck = forge.take(enemy_deck_key(state), maker) if client is not None else maker()
    yield from battle_opening_steps(state, enemy_deck, art_client)


# Return the forged boss deck, building it inline only when no model is configured.
# With a model it never blocks: it returns None while the deck forges in the background.
def ready_enemy_deck(
    state: RunState,
    client: CardPackClient | None,
    art_client: ArtClient | None,
) -> tuple[Card, ...] | None:
    if client is None:
        return enemy_deck_maker(client, art_client, state)()
    deck = forge.take_ready(enemy_deck_key(state))
    if deck is None:
        forge.submit(enemy_deck_key(state), enemy_deck_maker(client, art_client, state), lane="slow")
    return deck


# Open the battle from a ready boss deck, yielding paced states for the first round.
def battle_opening_steps(
    state: RunState,
    enemy_deck: tuple[Card, ...],
    art_client: ArtClient | None = None,
) -> Steps:
    state = refresh_art(state)
    duel = DuelState(
        create_player(state.player_name, shuffled_deck(state.player_deck, state.rng)),
        create_player("Boss", shuffled_deck(enemy_deck, state.rng)),
    )
    draw_cards(duel.player, OPENING_HAND_SIZE)
    draw_cards(duel.enemy, OPENING_HAND_SIZE)
    # Warm the player's hand first, then the boss's: the draft is over, so the GPU
    # is free to illustrate the boss's cards ahead of time — they show art the
    # moment the boss plays them instead of shimmering in.
    warm_card_art(art_client, duel.player.hand)
    warm_card_art(art_client, duel.enemy.hand)
    state = replace(
        state,
        enemy_deck=enemy_deck,
        current_pack=(),
        duel=duel,
        loading="",
        log=state.log + ("The boss takes the far side of the table.",),
    )
    for step in paced_action(state, advance_to_player_steps):
        warm_battle_art(art_client, step)
        yield step


# Start the battle if the boss deck is forged, else show a non-blocking shuffle state.
def begin_battle_or_wait_steps(
    state: RunState,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> Steps:
    state = refresh_art(state)
    enemy_deck = ready_enemy_deck(state, client, art_client)
    if enemy_deck is None:
        yield replace(state, current_pack=(), duel=None, pack_fading=-1, loading="The boss shuffles its deck")
        return
    yield from battle_opening_steps(replace(state, pack_fading=-1), enemy_deck, art_client)


# Start the battle on a timer tick once the background boss deck finishes forging.
def collect_ready_battle(
    state: RunState | None,
    client: CardPackClient | None = None,
    art_client: ArtClient | None = None,
) -> RunState | None:
    if state is None or state.duel is not None or state.current_pack:
        return state
    if state.draft_step < len(state.draft_order):
        return state
    enemy_deck = ready_enemy_deck(state, client, art_client)
    if enemy_deck is None:
        return state
    return last_state(battle_opening_steps(state, enemy_deck, art_client))


# Queue art for currently visible combat cards.
def warm_battle_art(art_client: ArtClient | None, state: RunState) -> None:
    if state.duel is None:
        return
    # Post-draft the GPU is free: warm the player's hand, the boss's hand (so its
    # plays show art instantly), and the played-card showcase.
    warm_card_art(art_client, state.duel.player.hand)
    warm_card_art(art_client, state.duel.enemy.hand)
    warm_card_art(art_client, tuple(card for _, card in state.showcase))


# Draft a boss deck for the UI.
def draft_enemy_deck_for_ui(
    client: CardPackClient | None,
    art_client: ArtClient | None,
    school: School,
    world: str,
    rng: Random,
) -> tuple[Card, ...]:
    deck = list(backbone_deck(school, world))
    anchor_indexes = draft_anchor_indexes(len(SYNERGY_COSTS), rng)
    order = draft_order(len(SYNERGY_COSTS), anchor_indexes)

    # One card per boss pick, generated concurrently. The boss deck does not need
    # to be deck-aware (it is the opponent), so the 9 picks run in parallel instead
    # of ~22s sequentially, ensuring it is ready well before battle.
    def boss_pick(cost_index: int) -> Card:
        pack = make_pack(client, None, school, world, tuple(deck), SYNERGY_COSTS[cost_index], (), quick=True, pack_size=1)
        return pack[0]

    with ThreadPoolExecutor(max_workers=4) as pool:
        picks = list(pool.map(boss_pick, order))
    return tuple(deck + picks)


# Generate a model pack or deterministic fallback.
def make_pack(
    client: CardPackClient | None,
    art_client: ArtClient | None,
    school: School,
    world: str,
    current_deck: Sequence[Card],
    cost: int,
    anchors: Sequence[Card] = (),
    quick: bool = False,
    pack_size: int = CARD_PANEL_COUNT,
) -> tuple[Card, ...]:
    if client is not None:
        try:
            pack = generate_pack(client, school, world, current_deck, cost, pack_size=pack_size, draft_anchors=anchors, quick=quick)
            return collect_ready_cards(pack)
        except Exception as exc:
            print(f"Tabras model pack failed; using fallback: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
    pack = fallback_pack(school, world, cost, anchors, current_deck)
    return collect_ready_cards(pack[:pack_size])


# Return the stable background-art key for one card face.
def card_art_key(card: Card) -> tuple:
    return ("art", card.school, card.theme, card.cost, card.name, card.rules_text(), card.flavor, card.art_prompt)


# Start card illustration jobs on the dedicated art lane so images never queue
# behind the boss-deck draft (slow lane) or the player's packs (fast lane).
def warm_card_art(art_client: ArtClient | None, cards: Sequence[Card]) -> None:
    if art_client is None:
        return
    for card in cards:
        if not card.art_uri:
            forge.submit(card_art_key(card), lambda card=card: illustrate_card(art_client, card), lane="art")


# Attach a finished generated image to one card if the forge has it.
def collect_ready_card(card: Card) -> Card:
    if card.art_uri:
        return card
    illustrated = forge.take_ready(card_art_key(card), card)
    if isinstance(illustrated, Card) and illustrated.art_uri:
        return illustrated
    return card


# Attach any finished generated images to a card sequence.
def collect_ready_cards(cards: Sequence[Card]) -> tuple[Card, ...]:
    return tuple(collect_ready_card(card) for card in cards)


# Return cards plus whether all queued art jobs have settled.
def collect_pack_art_status(cards: Sequence[Card], art_client: ArtClient | None) -> tuple[tuple[Card, ...], bool]:
    if art_client is None:
        return collect_ready_cards(cards), True
    collected = tuple(collect_ready_art_status(card) for card in cards)
    return tuple(card for card, _ in collected), all(ready for _, ready in collected)


# Return one card plus whether its art job has settled.
def collect_ready_art_status(card: Card) -> tuple[Card, bool]:
    if card.art_uri:
        return card, True
    illustrated = forge.take_ready(card_art_key(card))
    if illustrated is None:
        return card, False
    if isinstance(illustrated, Card):
        return illustrated, True
    return card, True


# Refresh generated art across the visible run state.
def refresh_art(state: RunState | None) -> RunState | None:
    if state is None:
        return None
    return replace(
        state,
        player_deck=collect_ready_cards(state.player_deck),
        enemy_deck=collect_ready_cards(state.enemy_deck),
        current_pack=collect_ready_cards(state.current_pack),
        duel=refresh_duel_art(state.duel),
    )


# Attach finished generated images inside an active duel.
def refresh_duel_art(duel: DuelState | None) -> DuelState | None:
    if duel is None:
        return None
    return replace(duel, player=refresh_player_art(duel.player), enemy=refresh_player_art(duel.enemy))


# Attach finished generated images inside one player zone.
def refresh_player_art(player: PlayerState) -> PlayerState:
    return replace(
        player,
        deck=list(collect_ready_cards(player.deck)),
        hand=list(collect_ready_cards(player.hand)),
        discard=list(collect_ready_cards(player.discard)),
    )


# Return a deterministic draft pack when no model is configured.
def fallback_pack(
    school: School,
    world: str,
    cost: int,
    anchors: Sequence[Card] = (),
    current_deck: Sequence[Card] = (),
) -> tuple[Card, ...]:
    plans = fallback_plans(school, anchors, cost, current_deck)
    return tuple(cost_card(CardSpec(name, cost, school, world, effects, flavor, art)) for name, effects, flavor, art in plans)


# Return fallback effect plans for a school.
def fallback_plans(
    school: School,
    anchors: Sequence[Card],
    cost: int = 1,
    current_deck: Sequence[Card] = (),
) -> tuple[tuple[str, tuple[EffectPlan, ...], str, str], ...]:
    if school == "fire":
        variants = (
            (
                ("Cinder Rush", (EffectPlan("deal"), EffectPlan("burn")), "Heat hits first, then keeps biting.", "a rushing ribbon of cinders across black stone"),
                ("Fuse Prayer", (EffectPlan("bomb"),), "A delayed blast tucked under the enemy's next breath.", "black powder sigil glowing under ash"),
                ("Kindling Draw", (EffectPlan("draw"), EffectPlan("deal")), "The next spark finds your hand.", "embers rising into a bright spiral"),
            ),
            (
                ("Ember Warrant", (EffectPlan("deal"),), "The sentence arrives before the smoke.", "a red-hot seal stamped into dark basalt"),
                ("Blackpowder Hymn", (EffectPlan("bomb"), EffectPlan("burn")), "Every note waits for the fuse.", "sparks crawling through a ritual fuse circle"),
                ("Coalglass Vow", (EffectPlan("draw"), EffectPlan("burn")), "The oath glows brighter when broken.", "molten glass reflecting a small flame"),
            ),
            (
                ("Ashen Oath", (EffectPlan("burn"), EffectPlan("scaling")), "A promise written in soot remembers every spark.", "ash lifting from a cracked ember sigil"),
                ("Red Sigil", (EffectPlan("deal"), EffectPlan("draw")), "One mark, one opening, one breath of heat.", "a crimson rune flaring in smoke"),
                ("Charcoal Saint", (EffectPlan("block"), EffectPlan("burn")), "The saint shields with one hand and burns with the other.", "a soot-black statue lit from within"),
            ),
        )
        return variants[fallback_variant_index(cost, current_deck, len(variants))]
    if school == "ice":
        return (
            ("Glass Tempo", (EffectPlan("initiative"), EffectPlan("vulnerable")), "The air freezes one heartbeat ahead.", "frosted hourglass above a frozen floor"),
            ("Needle Flurry", (EffectPlan("multi_hit"),), "Small cuts find the opening.", "ice needles crossing a moonlit hall"),
            ("Crack in Winter", (EffectPlan("vulnerable"), EffectPlan("conditional")), "A weakness appears where the frost thins.", "blue crack in a frozen shield"),
        )
    if any(effect.primitive_id == "scaling" for card in anchors for effect in card.effects):
        return (
            ("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"),
            ("Table Ward", (EffectPlan("block"), EffectPlan("ward")), "A quiet wall between you and ruin.", "earthen ward carved into dark stone"),
            ("Buried Leverage", (EffectPlan("draw"), EffectPlan("block")), "Patience becomes pressure.", "roots lifting cards from soil"),
        )
    return (
        ("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"),
        ("Gravemoss Guard", (EffectPlan("block"), EffectPlan("weak")), "The ground takes their strength first.", "mossy shield under candlelight"),
        ("Cairn Echo", (EffectPlan("scaling"),), "Stored force returns with interest.", "runes glowing in cracked stone"),
    )


# Return the rotating fallback pack index.
def fallback_variant_index(cost: int, current_deck: Sequence[Card], count: int) -> int:
    return (cost + len(current_deck)) % count


# Play one hand card, yielding paced states for the boss response.
def play_hand_card_steps(state: RunState, index: int) -> Steps:
    if state.duel is None or index < 0 or index >= len(state.duel.player.hand):
        yield state
        return
    card = state.duel.player.hand[index]
    if card.cost > state.duel.player.energy:
        yield add_log(state, f"{card.name} needs {card.cost} energy.")
        return
    yield from paced_action(state, resolve_card_steps, index)


# Play one player hand card by index.
def play_hand_card(state: RunState, index: int) -> RunState:
    return last_state(play_hand_card_steps(state, index))


# Pass the turn, yielding paced states for the boss response.
def pass_turn_steps(state: RunState) -> Steps:
    if state.duel is None or winner(state.duel):
        yield state
        return
    yield from paced_action(state, resolve_pass_steps)


# Pass the current player turn.
def pass_turn(state: RunState) -> RunState:
    return last_state(pass_turn_steps(state))


# Yield action steps stamped with per-step damage and round-turnover flashes.
def paced_action(state: RunState, steps_fn: Callable[..., Steps], *args: object) -> Steps:
    assert state.duel is not None
    duel = state.duel
    player_hp, enemy_hp, round_seen = duel.player.hp, duel.enemy.hp, duel.round_number
    fresh = replace(state, showcase=(), hp_flash=(0, 0), round_flash=0, boss_thinking=False, boss_thought="")
    for step in steps_fn(fresh, *args):
        assert step.duel is not None
        flash = (max(0, player_hp - step.duel.player.hp), max(0, enemy_hp - step.duel.enemy.hp))
        turned = step.duel.round_number if step.duel.round_number != round_seen else 0
        player_hp, enemy_hp, round_seen = step.duel.player.hp, step.duel.enemy.hp, step.duel.round_number
        yield replace(step, hp_flash=flash, round_flash=turned)


# Resolve one affordable hand card; the turn ends only when the player ends it.
def resolve_card_steps(state: RunState, index: int) -> Steps:
    assert state.duel is not None
    player = state.duel.player
    played = play_card_from_hand(state.duel, player, index)
    state = show_play(state, player.name, played)
    state = add_log(state, f"{player.name} plays {played.name}: {played.rules_text()}")
    if winner(state.duel):
        yield add_log(state, f"Winner: {winner(state.duel)}")
        return
    yield state


# Resolve a pass by spending remaining energy, then advance.
def resolve_pass_steps(state: RunState) -> Steps:
    assert state.duel is not None
    state.duel.player.energy = 0
    state = add_log(state, f"{state.duel.player.name} passes.")
    yield state
    yield from advance_after_player_steps(state)


# Yield combat steps after the player has ended their action.
def advance_after_player_steps(state: RunState) -> Steps:
    assert state.duel is not None
    if winner(state.duel):
        yield state
        return
    position = state.turn_position + 1
    if position < len(state.turn_order) and state.turn_order[position] == state.duel.enemy.name:
        state = yield from boss_turn_steps(replace(state, turn_position=position))
    if winner(state.duel):
        yield add_log(state, f"Winner: {winner(state.duel)}")
        return
    yield from advance_to_player_steps(state)


# Yield round turnovers until the player can act.
def advance_to_player_steps(state: RunState) -> Steps:
    assert state.duel is not None
    if winner(state.duel):
        yield state
        return
    while not winner(state.duel):
        order = start_round(state.duel, state.rng)
        header = (
            f"Round {state.duel.round_number}: {' then '.join(order)} · "
            f"{state.duel.player.name} {state.duel.player.hp} HP · Boss {state.duel.enemy.hp} HP"
        )
        state = replace(state, turn_order=order, turn_position=0, log=state.log + (header,))
        yield state
        if order[0] == state.duel.player.name:
            return
        state = yield from boss_turn_steps(state)
        if len(order) > 1 and order[1] == state.duel.player.name and not winner(state.duel):
            state = replace(state, turn_position=1)
            yield state
            return
    yield add_log(state, f"Winner: {winner(state.duel)}")


_boss_chooser_cache = None


# Return the boss chooser, wiring the env-configured boss model once.
def ui_boss_chooser():
    global _boss_chooser_cache
    if _boss_chooser_cache is None:
        _boss_chooser_cache = boss_chooser(boss_client_from_env(), choose_enemy_card)
    return _boss_chooser_cache


# Yield boss plays one card at a time with thinking pauses; return the final state.
def boss_turn_steps(state: RunState) -> Steps:
    assert state.duel is not None
    chooser = ui_boss_chooser()
    played_any = False
    while playable_indexes(state.duel.enemy):
        for thought in boss_thought_lines(state):
            yield replace(state, boss_thinking=True, boss_thought=thought)
        choice = chooser(state.duel, state.duel.enemy)
        if choice is None:
            state.duel.enemy.energy = 0
            state = add_log(state, "Boss passes.")
            yield state
            return state
        card = play_card_from_hand(state.duel, state.duel.enemy, choice)
        played_any = True
        state = show_play(state, state.duel.enemy.name, card)
        state = add_log(state, f"Boss plays {card.name}: {card.rules_text()}")
        yield state
        if winner(state.duel):
            return state
    if not played_any:
        state = add_log(state, "Boss has no playable cards.")
    state.duel.enemy.energy = 0
    return state


# Return short visible boss reasoning beats from public board state.
def boss_thought_lines(state: RunState) -> tuple[str, ...]:
    assert state.duel is not None
    return (
        boss_pending_thought(state.duel),
        boss_health_thought(state.duel.enemy),
        boss_hand_thought(state.duel.enemy),
    )


# Return the boss thought about incoming delayed effects.
def boss_pending_thought(duel: DuelState) -> str:
    threats = [effect for effect in duel.pending if effect.target == duel.enemy.name]
    bombs = [effect for effect in threats if effect.primitive_id == "bomb"]
    burns = [effect for effect in threats if effect.primitive_id == "burn"]
    if bombs:
        soonest = min(effect.delay + 1 for effect in bombs)
        total = sum(effect.amount for effect in bombs)
        label = "turn" if soonest == 1 else "turns"
        return f"The boss realizes a {total}-damage bomb lands in {soonest} {label}."
    if burns:
        total = sum(effect.amount for effect in burns)
        return f"The boss notices {total} burn damage still ticking."
    return "The boss scans the table for delayed threats."


# Return the boss thought about its own health and defenses.
def boss_health_thought(enemy: PlayerState) -> str:
    defenses = enemy.block + enemy.ward
    if enemy.hp <= 8:
        return f"The boss looks at its health: {enemy.hp} HP, with {defenses} defense."
    return f"The boss checks its health: {enemy.hp} HP, with {defenses} defense."


# Return the boss thought about playable hand options.
def boss_hand_thought(enemy: PlayerState) -> str:
    playable = playable_indexes(enemy)
    if not playable:
        return "The boss considers its hand and finds no playable card."
    count = len(playable)
    label = "line" if count == 1 else "lines"
    return f"The boss considers {count} playable {label} without revealing them."


# Record one played card for the battlefield showcase.
def show_play(state: RunState, owner: str, card: Card) -> RunState:
    return replace(state, showcase=state.showcase + ((owner, card),))


# Return the last state from an action generator.
def last_state(steps: Steps) -> RunState:
    state = None
    for state in steps:
        pass
    assert state is not None
    return state


# Add one line to the turn log.
def add_log(state: RunState, line: str) -> RunState:
    return replace(state, log=(state.log + (line,))[-80:])


# Return HTML for one card face.
def card_html(card: Card | None, classes: str = "", style: str = "", onclick: str = "") -> str:
    if card is None:
        return "<div class='tabras-card empty'></div>"
    return (
        f'<div class="tabras-card school-{card.school} {classes}" style="{style}" onclick="{onclick}">'
        f"<div class='card-cost'>{card.cost}</div>"
        f"<div class='card-name'>{escape_html(card.name)}</div>"
        f"{card_art_html(card)}"
        f"<div class='card-rules'>{escape_html(card.rules_text())}</div>"
        f"<div class='card-flavor'>{escape_html(card.flavor)}</div>"
        "</div>"
    )


# Return a card art panel, using generated art when present.
def card_art_html(card: Card) -> str:
    if not card.art_uri:
        return f"<div class='card-art pending-art school-art-{card.school}'></div>"
    return f'<div class="card-art generated" style="background-image:url(&quot;{escape_html(card.art_uri)}&quot;)"></div>'


# Return HTML for the full draft screen.
def draft_screen_html(state: RunState | None) -> str:
    if state is None or state.duel is not None:
        return ""
    if not state.current_pack:
        loading = loading_html(state.loading or "Forging draft pack")
        return f"<div class='draft-board loading-board'>{loading}{deck_strip_html(state)}</div>"
    if state.draft_step >= len(state.draft_order):
        banner = "<div class='draft-banner'><h2>Deck complete</h2><p>The boss shuffles its deck&hellip;</p></div>"
    else:
        cost_index = state.draft_order[state.draft_step]
        kind = "Anchor pick" if cost_index in state.anchor_indexes else "Pick"
        banner = (
            f"<div class='draft-banner'><h2>{kind} {state.draft_step + 1} of {len(state.draft_order)}</h2>"
            f"<p>{escape_html(state.player_name)} of {escape_html(state.world)} &mdash; {school_mark(state.school)}</p></div>"
        )
    cards = "".join(
        draft_card_html(state, index, card) for index, card in enumerate(state.current_pack)
    )
    return f"<div class='draft-board'>{banner}<div class='draft-pack'>{cards}</div>{deck_strip_html(state)}</div>"


# Return loading HTML for asynchronous draft generation.
def loading_html(message: str) -> str:
    return (
        "<div class='draft-loading'>"
        f"<div class='loading-title'>{escape_html(message)}</div>"
        "<div class='loading-bar'><span></span></div>"
        "<div class='loading-subtitle'>Your first draft pack is being prepared.</div>"
        "</div>"
    )


# Return one starter-deck card without draft click behavior.
def starter_card_html(card: Card) -> str:
    return card_html(card, "starter-card")


# Return one draft card, fading the old pack out after a pick.
def draft_card_html(state: RunState, index: int, card: Card) -> str:
    if state.pack_fading < 0:
        return card_html(card, "draft-card", "", f"tabrasClick('draft-btn-{index}')")
    chosen = index == state.pack_fading
    return card_html(card, "draft-card picked" if chosen else "draft-card fading")


# Return a mini-chip strip of the deck drafted so far.
def deck_strip_html(state: RunState) -> str:
    chips = "".join(
        f"<span class='deck-chip{' anchor' if card in state.draft_anchors else ''}'>"
        f"<b>{card.cost}</b> {escape_html(card.name)}</span>"
        for card in state.player_deck
    )
    return f"<div class='deck-strip'><span class='deck-strip-label'>Deck {len(state.player_deck)}/15</span>{chips}</div>"


# Return HTML for the full Hearthstone-style battle board.
def board_html(state: RunState | None) -> str:
    if state is None or state.duel is None:
        return ""
    duel = state.duel
    over = " game-over" if winner(duel) else ""
    quake = " quake" if state.hp_flash[0] >= 4 else ""
    thinking = " thinking" if state.boss_thinking else ""
    return (
        f"<div class='board{over}{quake}{thinking}'>"
        f"{enemy_zone_html(state)}"
        f"{battlefield_html(state)}"
        f"{player_zone_html(state)}"
        f"{round_splash_html(state)}"
        f"{winner_banner_html(state)}"
        "</div>"
    )


# Return the enemy side of the board.
def enemy_zone_html(state: RunState) -> str:
    assert state.duel is not None
    enemy = state.duel.enemy
    thought = state.boss_thought or "The boss studies the board."
    thinking = f"<div class='boss-thinking'>{escape_html(thought)}</div>" if state.boss_thinking else ""
    return (
        "<div class='zone enemy-zone'>"
        f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1], boss_portrait_uri(state.world))}{thinking}</div>"
        f"{piles_html(enemy)}"
        "</div>"
    )


# Return the player side of the board.
def player_zone_html(state: RunState) -> str:
    assert state.duel is not None
    player = state.duel.player
    return (
        "<div class='zone player-zone'>"
        "<div class='zone-center'>"
        f"{hero_html(player, state.school, state.player_name, False, state.hp_flash[0], player_portrait_uri(state.world, state.school))}"
        f"{mana_html(player, state.duel.round_number)}"
        f"{hand_fan_html(player)}"
        "</div>"
        f"{piles_html(player)}"
        "</div>"
    )


# Return the middle strip with pending effects, played cards, round banner, and end turn.
def battlefield_html(state: RunState) -> str:
    assert state.duel is not None
    duel = state.duel
    over = winner(duel) is not None
    pulse = " pulse" if not over and not playable_indexes(duel.player) else ""
    waiting = "the boss responds" if state.boss_thinking else "your turn"
    banner = "duel over" if over else waiting
    end_turn = "" if over else f"<button class='end-turn{pulse}' onclick=\"tabrasClick('end-turn-btn')\">END TURN</button>"
    return (
        "<div class='battlefield'>"
        f"<div class='pending-row'>{pending_tokens_html(duel, duel.enemy.name)}</div>"
        f"{showcase_html(state)}"
        f"<div class='round-banner'>Round {duel.round_number} &mdash; {banner}</div>"
        f"{end_turn}"
        f"<div class='pending-row'>{pending_tokens_html(duel, duel.player.name)}</div>"
        "</div>"
    )


# Return the cards played since the last player action, labeled, newest animated in.
def showcase_html(state: RunState) -> str:
    assert state.duel is not None
    enemy = state.duel.enemy.name
    last = len(state.showcase) - 1
    slots = []
    for index, (owner, card) in enumerate(state.showcase):
        boss = owner == enemy
        fresh = " fresh" if index == last else ""
        slots.append(
            f"<div class='play-slot{fresh}'>"
            f"<span class='play-label {'boss' if boss else 'you'}'>{'Boss played' if boss else 'You played'}</span>"
            f"{card_html(card, 'played-card ' + ('enemy-play' if boss else 'player-play'))}"
            "</div>"
        )
    return f"<div class='showcase'>{''.join(slots)}</div>"


# Return the darkened round-turnover splash announcing initiative.
def round_splash_html(state: RunState) -> str:
    if not state.round_flash or state.duel is None:
        return ""
    first = state.turn_order[0] if state.turn_order else state.duel.player.name
    you_first = first == state.duel.player.name
    line = "YOU GO FIRST" if you_first else "BOSS GOES FIRST"
    return (
        "<div class='round-splash'>"
        f"<div class='splash-round'>ROUND {state.round_flash}</div>"
        f"<div class='splash-initiative {'you' if you_first else 'boss'}'>{line}</div>"
        "</div>"
    )


# Cached portrait thumbnails (small JPEGs) keyed by asset stem, embedded as data
# URIs so the board can re-render each timer tick without streaming the big PNGs.
_THUMBS = Path(__file__).parent / "assets" / "thumbs"
_portrait_cache: dict[str, str] = {}
_WORLD_PREFIX = {"dark fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"}


# Return a thumbnail data URI for an asset stem, or "" if it is missing.
def thumb_uri(stem: str) -> str:
    if stem not in _portrait_cache:
        path = _THUMBS / f"{stem}.jpg"
        _portrait_cache[stem] = (
            "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") if path.exists() else ""
        )
    return _portrait_cache[stem]


# Return the asset file prefix for a world (handles the display-name casings).
def world_prefix(world: str) -> str:
    return _WORLD_PREFIX.get(world.strip().lower(), "darkFantasy")


# Return the player's portrait for their world + school (tries casing variants).
def player_portrait_uri(world: str, school: str) -> str:
    prefix = world_prefix(world)
    for variant in (school.title(), school.lower(), school.capitalize()):
        uri = thumb_uri(f"{prefix}{variant}")
        if uri:
            return uri
    return ""


# Return the boss portrait (circle thumbnail) for a world.
def boss_portrait_uri(world: str) -> str:
    return thumb_uri(f"{world_prefix(world)}Boss")


# Return the full boss splash image (medium JPEG) for the reveal screen.
def boss_splash_uri(world: str) -> str:
    return thumb_uri(f"{world_prefix(world)}Boss_splash")


# Return a hero portrait with HP gem, defenses, status chips, and damage pop.
def hero_html(player: PlayerState, school: School, title: str, enemy: bool, damage: int = 0, portrait: str = "") -> str:
    block = f"<div class='block-gem'>{player.block}</div>" if player.block else ""
    ward = f"<div class='ward-gem'>{player.ward}</div>" if player.ward else ""
    pop = f"<div class='dmg-pop'>-{damage}</div>" if damage else ""
    hit = " hit" if damage else ""
    face = (
        f"<div class='hero-face portrait' style=\"background-image:url('{portrait}')\"></div>"
        if portrait
        else f"<div class='hero-face'>{school_mark(school)}</div>"
    )
    return (
        f"<div class='hero {'enemy' if enemy else 'you'}'>"
        f"<div class='hero-frame{hit}'>{face}"
        f"<div class='hp-gem'>{player.hp}</div>{block}{ward}{pop}</div>"
        f"<div class='hero-name'>{escape_html(title)}</div>"
        f"{status_chips_html(player)}"
        "</div>"
    )


# Return status chips for charge, weak, and vulnerable.
def status_chips_html(player: PlayerState) -> str:
    chips = []
    if player.shield_charge:
        chips.append(f"<span class='chip charge'>Charge {player.shield_charge}</span>")
    if player.weak:
        chips.append(f"<span class='chip weak'>Weak {player.weak} ({player.weak_turns}t)</span>")
    if player.vulnerable:
        chips.append(f"<span class='chip vuln'>Vulnerable +{player.vulnerable} ({player.vulnerable_turns}t)</span>")
    return f"<div class='chips'>{''.join(chips)}</div>"


# Return the mana crystal bar for the player.
def mana_html(player: PlayerState, round_number: int) -> str:
    cap = min(MAX_ENERGY, round_number)
    total = max(cap, player.energy)
    gems = "".join(f"<span class='mana{' filled' if index < player.energy else ''}'></span>" for index in range(total))
    return f"<div class='mana-bar'>{gems}<span class='mana-count'>{player.energy}/{cap}</span></div>"


# Return deck and discard piles with a fatigue warning when decked out.
def piles_html(player: PlayerState) -> str:
    fatigue = f"<div class='fatigue-warn'>Fatigue {player.fatigue}</div>" if not player.deck else ""
    return (
        "<div class='piles'>"
        f"<div class='pile deck'><span>{len(player.deck)}</span><label>Deck</label>{fatigue}</div>"
        f"<div class='pile discard'><span>{len(player.discard)}</span><label>Discard</label></div>"
        "</div>"
    )


# Return the face-down enemy hand fan.
def enemy_hand_html(enemy: PlayerState) -> str:
    backs = "".join("<div class='enemy-card-back'></div>" for _ in enemy.hand[:HAND_PANEL_COUNT])
    return f"<div class='enemy-hand'>{backs}</div>"


# Return the fanned, clickable player hand.
def hand_fan_html(player: PlayerState) -> str:
    cards = player.hand[:HAND_PANEL_COUNT]
    middle = (len(cards) - 1) / 2
    pieces = []
    for index, card in enumerate(cards):
        offset = index - middle
        style = f"--rot:{offset * 3.5:.1f}deg;--ty:{abs(offset) * 8:.0f}px;z-index:{index + 1};"
        playable = card.cost <= player.energy
        classes = "hand-card" if playable else "hand-card unplayable"
        onclick = f"tabrasPlay('hand-btn-{index}', this)" if playable else ""
        pieces.append(card_html(card, classes, style, onclick))
    return f"<div class='hand-fan'>{''.join(pieces)}</div>"


# Return telegraphed pending-effect tokens aimed at one player.
def pending_tokens_html(duel: DuelState, target: str) -> str:
    tokens = []
    for effect in duel.pending:
        if effect.target != target:
            continue
        if effect.primitive_id == "bomb":
            tokens.append(f"<div class='token bomb'><b>{effect.amount}</b><span>bomb in {effect.delay + 1}</span></div>")
        else:
            tokens.append(f"<div class='token burn'><b>{effect.amount}</b><span>burn &times;{max(effect.duration, 1)}</span></div>")
    return "".join(tokens)


# Return the end-of-duel banner with a new-run control.
def winner_banner_html(state: RunState) -> str:
    assert state.duel is not None
    win = winner(state.duel)
    if win is None:
        return ""
    text = "VICTORY" if win == state.duel.player.name else ("DRAW" if win == "draw" else "DEFEAT")
    return (
        f"<div class='winner-banner {text.lower()}'><h1>{text}</h1>"
        "<button class='new-run' onclick=\"tabrasClick('restart-btn')\">New Run</button></div>"
    )


# Return the battle log as styled entries, newest at the bottom.
def log_html(state: RunState | None) -> str:
    if state is None:
        return ""
    entries = "".join(log_entry_html(line) for line in reversed(state.log[-24:]))
    return f"<div class='log-scroll'>{entries}</div>"


# Return one styled log entry, breaking plays into owner, card, and effect.
def log_entry_html(line: str) -> str:
    kind = log_kind(line)
    if " plays " in line and ": " in line:
        head, rules = line.split(": ", 1)
        owner, card_name = head.split(" plays ", 1)
        return (
            f"<div class='log-line {kind}'><span class='log-owner'>{escape_html(owner)} played</span>"
            f"<b>{escape_html(card_name)}</b><span class='log-rules'>{escape_html(rules)}</span></div>"
        )
    return f"<div class='log-line {kind}'>{escape_html(line)}</div>"


# Classify one log line for styling.
def log_kind(line: str) -> str:
    if line.startswith("Round "):
        return "log-round"
    if line.startswith("Winner:"):
        return "log-winner"
    if " plays " in line:
        return "log-play log-boss" if line.startswith("Boss") else "log-play log-you"
    if "Drafted" in line:
        return "log-draft"
    if "passes" in line or "no playable" in line:
        return "log-muted"
    return "log-info"


# Return the school display mark.
def school_mark(school: str) -> str:
    return {"fire": "Fire", "ice": "Ice", "earth": "Earth"}.get(school, school.title())


# Escape HTML for generated card text.
def escape_html(text: str) -> str:
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&#x27;")
    )