-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrewind.patch
More file actions
2347 lines (2299 loc) · 104 KB
/
Copy pathrewind.patch
File metadata and controls
2347 lines (2299 loc) · 104 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
diff --git a/forge-game/src/main/java/forge/game/Game.java b/forge-game/src/main/java/forge/game/Game.java
index 3456a071..124936e8 100644
--- a/forge-game/src/main/java/forge/game/Game.java
+++ b/forge-game/src/main/java/forge/game/Game.java
@@ -55,6 +55,7 @@ import org.tinylog.Logger;
import org.tinylog.TaggedLogger;
import java.util.*;
+import java.util.function.BiConsumer;
import java.util.function.Predicate;
/**
@@ -103,6 +104,60 @@ public class Game {
// If this merges with LKI In the future, it will need to change forms
private GameSnapshot previousGameState = null;
+
+ /**
+ * Rewind points, newest first. One is taken at the start of each human player's own
+ * turn — see stashTurnRewindPoint for why that particular moment.
+ */
+ private final Deque<RewindPoint> turnRewindPoints = new ArrayDeque<>();
+
+ /** How many of their own turns a player may rewind. Set by the Match from the preferences. */
+ public int REWIND_STEPS = 3;
+
+ /**
+ * Called with (turn, state text) whenever a rewind point is taken, so the position can
+ * also be put on disk. Set from the GUI side, which is what knows about folders; null
+ * in tests and on servers. A rewind point already exists in memory at that moment, so
+ * this costs nothing beyond the write itself.
+ */
+ private BiConsumer<Integer, List<String>> rewindAutosave = null;
+
+ public void setRewindAutosave(final BiConsumer<Integer, List<String>> hook) {
+ rewindAutosave = hook;
+ }
+
+ /**
+ * A rewind point is the game written out in Forge's own save format, the same one
+ * puzzles and saved games use. Stored as text rather than as a parsed GameState so it
+ * cannot hold on to live objects from the game it was taken in.
+ */
+ private static final class RewindPoint {
+ private final List<String> stateText;
+ /** Turn and priority bookkeeping that the save format itself does not carry. */
+ private final PhaseHandler.PriorityState priorityState;
+ /**
+ * Emblems and the hidden helper cards behind lasting effects ("for the rest of the
+ * game", "you have no maximum hand size"). The save format can only name printed
+ * cards, so these would be dropped; the point holds on to the objects instead.
+ */
+ private final List<Pair<Card, Player>> commandEffects;
+ /** Whose turn this point is the start of; only that player is offered it. */
+ private final Player player;
+ private final int turn;
+
+ private RewindPoint(List<String> stateText, PhaseHandler.PriorityState priorityState,
+ List<Pair<Card, Player>> commandEffects, Player player, int turn) {
+ this.stateText = stateText;
+ this.priorityState = priorityState;
+ this.commandEffects = commandEffects;
+ this.player = player;
+ this.turn = turn;
+ }
+
+ private String describe() {
+ return String.valueOf(turn);
+ }
+ }
private CardCollection lastStateBattlefield = new CardCollection();
private CardCollection lastStateGraveyard = new CardCollection();
@@ -215,6 +270,190 @@ public class Game {
return true;
}
+ /**
+ * Remember where this turn started, if this is the right moment for it.
+ *
+ * The save format carries no "until end of turn" effects and no running combat, so a
+ * point taken mid-turn would always be missing something. The start of a player's own
+ * turn is the one moment where that does not matter: the previous turn's temporary
+ * effects have already worn off in its cleanup step, no combat is running, and by the
+ * first main phase the upkeep and draw triggers have finished resolving. Hence the
+ * conditions below — own turn, first main phase, empty stack, and only once per turn.
+ */
+ public void stashTurnRewindPoint(Player p) {
+ if (REWIND_STEPS < 1 || p == null) {
+ return;
+ }
+ if (p != phaseHandler.getPlayerTurn() || phaseHandler.getPhase() != PhaseType.MAIN1) {
+ return;
+ }
+ if (!stack.isEmpty() || stack.isFrozen()) {
+ return;
+ }
+ final RewindPoint newest = turnRewindPoints.peekFirst();
+ if (newest != null && newest.player == p && newest.turn == phaseHandler.getTurn()) {
+ return; // already have this turn, including right after rewinding back into it
+ }
+
+ final GameState state = new GameState();
+ try {
+ state.initFromGame(this);
+ } catch (Exception e) {
+ Logger.warn(e, "Could not record a rewind point for turn {}", phaseHandler.getTurn());
+ return;
+ }
+ final List<String> stateText = Arrays.asList(state.toString().split("\n"));
+ turnRewindPoints.addFirst(new RewindPoint(stateText,
+ phaseHandler.capturePriorityState(), collectCommandEffects(), p, phaseHandler.getTurn()));
+
+ if (rewindAutosave != null) {
+ try {
+ rewindAutosave.accept(phaseHandler.getTurn(), stateText);
+ } catch (Exception e) {
+ // A full disk must never cost anyone their turn.
+ Logger.warn(e, "Could not autosave turn {}", phaseHandler.getTurn());
+ }
+ }
+
+ // Trim per player, so a second human at the table cannot push someone else's
+ // points out of the list before they have used up their own allowance.
+ int mine = 0;
+ for (Iterator<RewindPoint> it = turnRewindPoints.iterator(); it.hasNext();) {
+ if (it.next().player == p && ++mine > REWIND_STEPS) {
+ it.remove();
+ }
+ }
+ }
+
+ /**
+ * The cards in the command zone that the save format cannot describe, because it can
+ * only write down printed cards and tokens. Emblems and the helper cards that carry
+ * lasting effects are neither, so applying a state silently drops them — see the
+ * paper card check in GameState#addCard. Keeping the objects themselves lets a rewind
+ * put back exactly the ones that existed at that moment.
+ */
+ private List<Pair<Card, Player>> collectCommandEffects() {
+ final List<Pair<Card, Player>> out = Lists.newArrayList();
+ for (final Player p : getPlayers()) {
+ for (final Card c : p.getZone(ZoneType.Command).getCards()) {
+ if (c.getPaperCard() == null && !c.isToken()) {
+ out.add(Pair.of(c, p));
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Short description of each rewind point available to this player, newest first, so a
+ * menu can say where a step leads instead of just how far.
+ */
+ public List<String> describeRewindPoints(Player p) {
+ List<String> out = Lists.newArrayList();
+ for (RewindPoint point : turnRewindPoints) {
+ if (point.player == p) {
+ out.add(point.describe());
+ }
+ }
+ return out;
+ }
+
+ /** Drops the rewind history, e.g. after loading a save — it belongs to another game. */
+ public void clearRewindPoints() {
+ turnRewindPoints.clear();
+ }
+
+ /**
+ * How many of this player's own turns can still be rewound to. Never more than
+ * REWIND_STEPS, and less than that in the opening turns of a game.
+ */
+ public int getAvailableRewindSteps(Player p) {
+ int found = 0;
+ for (RewindPoint point : turnRewindPoints) {
+ if (point.player == p) {
+ found++;
+ }
+ }
+ return found;
+ }
+
+ /**
+ * Rewind the whole game to the start of this player's n-th most recent own turn,
+ * discarding everything that happened since — including other players' and the AI's
+ * moves, which get played out again from there.
+ *
+ * @param steps 1 = back to the start of the current or last own turn, 2 = the one before it, ...
+ * @return true if a matching rewind point was found and restored
+ */
+ public boolean rewindToActionOf(Player p, int steps) {
+ if (steps < 1 || steps > REWIND_STEPS) {
+ return false;
+ }
+
+ int found = 0;
+ for (RewindPoint point : turnRewindPoints) { // newest first
+ if (point.player != p || ++found < steps) {
+ continue;
+ }
+ final GameState state = new GameState();
+ try {
+ state.parse(point.stateText);
+ // Applying a state only adds the spells it recorded, it never clears what
+ // is there — so anything in flight when the rewind was asked for would
+ // survive it. A turn is starting over: nothing may be waiting to resolve.
+ stack.clearSimultaneousStack();
+ stack.clearFrozen();
+ stack.clearUndoStack();
+ stack.clear();
+ state.applyToGame(this);
+ } catch (Exception e) {
+ Logger.error(e, "Rewind to turn {} failed", point.turn);
+ return false;
+ }
+ phaseHandler.restorePriorityState(point.priorityState);
+ restoreCommandEffects(point);
+ // Everything newer than the target is gone for good — you can't redo a rewind.
+ while (turnRewindPoints.peekFirst() != point) {
+ turnRewindPoints.removeFirst();
+ }
+ fireEvent(new GameEventAddLog(GameLogEntryType.INFORMATION,
+ p + " rewound the game to turn " + point.turn + "."));
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Puts back the emblems and lasting-effect helper cards the state could not carry.
+ * Applying a state empties the command zone, so anything created after the point is
+ * already gone and only what belongs there is added back.
+ */
+ private void restoreCommandEffects(RewindPoint point) {
+ for (final Pair<Card, Player> entry : point.commandEffects) {
+ final Zone zone = entry.getRight().getZone(ZoneType.Command);
+ final Card effect = entry.getLeft();
+ // Applying the state rebuilds a few of these itself — the commander, speed and
+ // adventure effects among them — and the rebuilt one is the better copy,
+ // because it points at the cards the state just created. An Adventure reminder
+ // is named after the adventure half, so it will not match by name.
+ if (zone.contains(effect) || hasEffectNamed(zone, effect.getName())
+ || effect.getName().endsWith("'s Adventure")) {
+ continue;
+ }
+ zone.add(effect);
+ }
+ action.checkStateEffects(true);
+ }
+
+ private static boolean hasEffectNamed(Zone zone, String name) {
+ for (final Card c : zone.getCards()) {
+ if (c.getPaperCard() == null && c.getName().equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
public void copyLastState() {
lastStateBattlefield.clear();
lastStateGraveyard.clear();
diff --git a/forge-game/src/main/java/forge/game/GameActionUtil.java b/forge-game/src/main/java/forge/game/GameActionUtil.java
index 432477be..a666743a 100644
--- a/forge-game/src/main/java/forge/game/GameActionUtil.java
+++ b/forge-game/src/main/java/forge/game/GameActionUtil.java
@@ -944,6 +944,20 @@ public final class GameActionUtil {
// If we're able to restore the whole game state when rolling back an ability don't try to manually roll back
System.out.println("Restored state from snapshot! Rolled back: " + ability.getHostCard().getName() + " - " + ability.getActivatingPlayer());
+ // The snapshot covers the cards, their zones and the mana pool, so the manual
+ // rollback below and its refund would double up on it. The ability is not part of
+ // the snapshot though, and neither is the frozen stack: without the cleanup an
+ // announced value or a chosen target survives into the next attempt to cast the
+ // card, and the stack stays frozen.
+ Card restored = game.getCardState(oldCard, null);
+ if (restored != null) {
+ restored.setCastSA(null);
+ restored.setCastFrom(null);
+ }
+ resetAnnouncedCosts(ability);
+ resetChosenTargets(ability);
+ game.getStack().clearFrozen();
+ game.getTriggerHandler().clearWaitingTriggers();
return;
}
@@ -964,18 +978,7 @@ public final class GameActionUtil {
Integer newPosition = zonePosition >= 0 ? Math.min(zonePosition, fromZone.size()) : null;
fromZone.add(oldCard, newPosition, null, true);
ability.setHostCard(oldCard);
- ability.setXManaCostPaid(null);
- ability.setSpendPhyrexianMana(false);
- ability.clearPipsToReduce();
- ability.setPaidLife(0);
- if (ability.hasParam("Announce")) {
- for (final String aVar : ability.getParam("Announce").split(",")) {
- final String varName = aVar.trim();
- if (!varName.equals("X")) {
- ability.setSVar(varName, "0");
- }
- }
- }
+ resetAnnouncedCosts(ability);
// better safe than sorry approach in case rolled back ability was copy (from addExtraKeywordCost)
for (SpellAbility sa : oldCard.getSpells()) {
sa.setHostCard(oldCard);
@@ -996,6 +999,30 @@ public final class GameActionUtil {
}
}
+ resetChosenTargets(ability);
+ payment.refundPayment();
+ game.getStack().clearFrozen();
+ game.getTriggerHandler().clearWaitingTriggers();
+ }
+
+ /** Values the player announced or committed to while casting, which a snapshot does not hold. */
+ private static void resetAnnouncedCosts(SpellAbility ability) {
+ ability.setXManaCostPaid(null);
+ ability.setSpendPhyrexianMana(false);
+ ability.clearPipsToReduce();
+ ability.setPaidLife(0);
+ if (ability.hasParam("Announce")) {
+ for (final String aVar : ability.getParam("Announce").split(",")) {
+ final String varName = aVar.trim();
+ if (!varName.equals("X")) {
+ ability.setSVar(varName, "0");
+ }
+ }
+ }
+ }
+
+ /** Modes and targets picked while casting, likewise not covered by a snapshot. */
+ private static void resetChosenTargets(SpellAbility ability) {
if (ability.getApi() == ApiType.Charm) {
// reset chain
ability.setSubAbility(null);
@@ -1005,9 +1032,6 @@ public final class GameActionUtil {
ability.clearTargets();
ability.resetOnceResolved();
- payment.refundPayment();
- game.getStack().clearFrozen();
- game.getTriggerHandler().clearWaitingTriggers();
}
}
diff --git a/forge-game/src/main/java/forge/game/GameSnapshot.java b/forge-game/src/main/java/forge/game/GameSnapshot.java
index d74b5ca1..b3193c76 100644
--- a/forge-game/src/main/java/forge/game/GameSnapshot.java
+++ b/forge-game/src/main/java/forge/game/GameSnapshot.java
@@ -14,6 +14,7 @@ import forge.game.spellability.SpellAbility;
import forge.game.spellability.SpellAbilityStackInstance;
import forge.game.trigger.TriggerType;
import forge.game.zone.PlayerZoneBattlefield;
+import forge.game.zone.Zone;
import forge.game.zone.ZoneType;
import java.util.Collections;
@@ -298,7 +299,12 @@ public class GameSnapshot {
for(Card fromCard : fromGame.getCardsInGame()) {
Card newCard = toGame.findById(fromCard.getId());
- Player toPlayer = findBy(toGame, fromCard.getController());
+ // Zones belong to a player: the controller's battlefield, but the owner's
+ // graveyard, hand and library. Going by the controller alone files a card whose
+ // control has changed under the wrong player, so a stolen creature that died
+ // reappears in the thief's graveyard when the snapshot is used.
+ Player fromZonePlayer = fromCard.getZone().getPlayer();
+ Player toPlayer = findBy(toGame, fromZonePlayer != null ? fromZonePlayer : fromCard.getController());
ZoneType fromType = fromCard.getZone().getZoneType();
int zonePosition = 0;
if (ZoneType.ORDERED_ZONES.contains(fromType)) {
@@ -308,16 +314,19 @@ public class GameSnapshot {
}
if (newCard == null) {
- // Storing a game uses this path...
- newCard = createCardCopy(toGame, toPlayer, fromCard);
+ // Storing a game uses this path... the copy keeps the original owner, which
+ // is not the same player as the one whose zone it currently sits in.
+ newCard = createCardCopy(toGame, findBy(toGame, fromCard.getOwner()), fromCard);
} else {
- ZoneType type = newCard.getZone().getZoneType();
- if (type != fromType) {
- if (type.equals(ZoneType.Stack)) {
- toGame.getStackZone().remove(newCard);
- } else {
- toPlayer.getZone(type).remove(newCard);
- }
+ // Take it out of wherever it currently is, unless that is already the zone
+ // it is going into. Comparing zone types alone misses a move between two
+ // players' battlefields — a creature stolen after the snapshot was taken
+ // then ends up in both of them.
+ Zone currentZone = newCard.getZone();
+ Zone targetZone = fromType.equals(ZoneType.Stack)
+ ? toGame.getStackZone() : toPlayer.getZone(fromType);
+ if (currentZone != null && currentZone != targetZone) {
+ currentZone.remove(newCard);
}
}
@@ -334,6 +343,19 @@ public class GameSnapshot {
setCardInCopiedGame(toGame, ue.toPlayer, ue.fromCard, ue.newCard, ue.fromType, ue.zonePosition);
}
+ // Cards the current game has but the snapshot does not: tokens, copies and effect
+ // cards that came into being after it was taken. There is no earlier state to put
+ // them back into, so they leave the game — and the loops below would otherwise look
+ // them up in the snapshot and find nothing.
+ for (Card extraCard : toGame.getCardsInGame()) {
+ if (fromGame.findById(extraCard.getId()) == null) {
+ Zone zone = extraCard.getZone();
+ if (zone != null) {
+ zone.remove(extraCard);
+ }
+ }
+ }
+
// This loop happens later to make sure all cards are in the correct zone first
for (Card newCard : toGame.getCardsIn(ZoneType.Battlefield)) {
Card fromCard = fromGame.findById(newCard.getId());
@@ -346,6 +368,10 @@ public class GameSnapshot {
newAttachedTo.addAttachedCard(newCard);
}
}
+ // Melded or not, the front half has to point at what the snapshot had — the two
+ // halves are one permanent, and a stale link outlives the meld otherwise.
+ newCard.setMeldedWith(fromCard.getMeldedWith() == null ? null
+ : toGame.findById(fromCard.getMeldedWith().getId()));
if (fromCard.getCloneOrigin() != null) {
newCard.setCloneOrigin(toGame.findById(fromCard.getCloneOrigin().getId()));
}
@@ -377,7 +403,20 @@ public class GameSnapshot {
if (fromType.equals(ZoneType.Stack)) {
toGame.getStackZone().add(newCard);
newCard.setZone(toGame.getStackZone());
+ } else if (isMelded(fromCard)) {
+ // The back half of a meld lives in the battlefield zone but in its own
+ // collection rather than the card list. Putting it in the list instead would
+ // leave it on the battlefield as a permanent of its own.
+ PlayerZoneBattlefield battlefield = (PlayerZoneBattlefield) toPlayer.getZone(ZoneType.Battlefield);
+ if (newCard.getZone() == null) {
+ newCard.setZone(battlefield);
+ }
+ battlefield.addToMelded(newCard);
} else {
+ // It may have been melded in the state we are leaving behind.
+ if (toPlayer.getZone(ZoneType.Battlefield) instanceof PlayerZoneBattlefield battlefield) {
+ battlefield.removeFromMelded(newCard);
+ }
toPlayer.getZone(fromType).add(newCard);
newCard.setZone(toPlayer.getZone(fromType));
}
@@ -389,11 +428,25 @@ public class GameSnapshot {
newCard.setFaceDown(fromCard.isFaceDown());
newCard.setManifested(fromCard.getManifestedSA());
newCard.setSickness(fromCard.hasSickness());
- //newCard.setForetold(fromCard.isForetold());
- //newCard.setForetoldCostByEffect(fromCard.isForetoldCostByEffect());
+ // Who controls a card is state of its own. A creature stolen after the snapshot was
+ // taken stays with the thief otherwise: its zone is corrected, but the card still
+ // names the thief as controller, so it keeps playing for them.
+ Player fromController = findBy(toGame, fromCard.getController());
+ if (fromController != null && fromController != newCard.getController()) {
+ newCard.setController(fromController, toGame.getNextTimestamp());
+ }
+ newCard.setForetold(fromCard.isForetold());
+ newCard.setForetoldCostByEffect(fromCard.isForetoldCostByEffect());
+ newCard.setBackSide(fromCard.isBackSide());
newCard.setState(fromCard.getCurrentStateName(), false);
}
+ /** The back half of a meld: on the battlefield, but held apart from its card list. */
+ private static boolean isMelded(Card c) {
+ return c.getZone() instanceof PlayerZoneBattlefield battlefield
+ && battlefield.getMeldedCards().contains(c);
+ }
+
private static SpellAbility findSAInCard(SpellAbility sa, Card c) {
String saDesc = sa.getDescription();
for (SpellAbility cardSa : c.getAllSpellAbilities()) {
diff --git a/forge-game/src/main/java/forge/game/GameState.java b/forge-game/src/main/java/forge/game/GameState.java
index 38039ad1..97915de6 100644
--- a/forge-game/src/main/java/forge/game/GameState.java
+++ b/forge-game/src/main/java/forge/game/GameState.java
@@ -70,6 +70,12 @@ public class GameState {
private final Map<Integer, Card> idToCard = new HashMap<>();
private final Map<Card, Integer> cardToAttachId = new HashMap<>();
+ /**
+ * Cards on an Adventure. Their permission effect goes into the command zone, which is
+ * set up after exile, so creating it while reading the exile zone would only see it
+ * wiped again — it has to wait until every zone is in place.
+ */
+ private final List<Card> cardsOnAdventure = new ArrayList<>();
private final Map<Card, Player> cardToEnchantPlayerId = new HashMap<>();
private final Map<Card, Integer> markedDamage = new HashMap<>();
private final Map<Card, List<String>> cardToChosenClrs = new HashMap<>();
@@ -588,6 +594,7 @@ public class GameState {
}
idToCard.clear();
+ cardsOnAdventure.clear();
cardToAttachId.clear();
cardToEnchantPlayerId.clear();
cardToRememberedId.clear();
@@ -617,6 +624,7 @@ public class GameState {
for (int i = 0; i < playerStates.size(); i++) {
setupPlayerState(game.getPlayers().get(i), playerStates.get(i));
}
+ handleAdventures();
handleCardAttachments();
handleChosenEntities();
handleRememberedEntities();
@@ -1054,6 +1062,18 @@ public class GameState {
}
}
+ /**
+ * Gives back the permission that keeps a card on an Adventure castable while it waits
+ * in exile, built from the same definition the game itself uses.
+ */
+ private void handleAdventures() {
+ for (final Card c : cardsOnAdventure) {
+ final SpellAbility sa = CardFactoryUtil.makeAdventureEffect(c.getState(CardStateName.Secondary));
+ sa.setActivatingPlayer(c.getOwner());
+ sa.resolve();
+ }
+ }
+
private void handleCardAttachments() {
// Unattach all permanents first
for (Entry<Card, Integer> entry : cardToAttachId.entrySet()) {
@@ -1334,14 +1354,7 @@ public class GameState {
c.setBackSide(true);
}
else if (info.startsWith("OnAdventure")) {
- String abAdventure = "DB$ Effect | RememberObjects$ Self | StaticAbilities$ Play | ForgetOnMoved$ Exile | Duration$ Permanent | ConditionDefined$ Self | ConditionPresent$ Card.!copiedSpell";
- SpellAbility saAdventure = AbilityFactory.getAbility(abAdventure, c);
- StringBuilder sbPlay = new StringBuilder();
- sbPlay.append("Mode$ Continuous | MayPlay$ True | EffectZone$ Command | Affected$ Card.IsRemembered+nonAdventure");
- sbPlay.append(" | AffectedZone$ Exile | Description$ You may cast the card.");
- saAdventure.setSVar("Play", sbPlay.toString());
- saAdventure.setActivatingPlayer(c.getOwner());
- saAdventure.resolve();
+ cardsOnAdventure.add(c);
c.setExiledWith(c); // This seems to be the way it's set up internally. Potentially not needed here?
c.setExiledBy(c.getController());
} else if (info.startsWith("IsCommander")) {
diff --git a/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java b/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java
index 545242b9..a42a922b 100644
--- a/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java
+++ b/forge-game/src/main/java/forge/game/card/CardFactoryUtil.java
@@ -4062,6 +4062,22 @@ public class CardFactoryUtil {
SpellAbility saExile = AbilityFactory.getAbility(abExile, card);
+ saExile.setSubAbility(makeAdventureEffect(card));
+
+ ReplacementEffect re = ReplacementHandler.parseReplacement(repeffstr, card.getCard(), true);
+
+ re.setOverridingAbility(saExile);
+ return re;
+ }
+
+ /**
+ * The effect that keeps a card on an Adventure castable while it sits in exile.
+ *
+ * Also needed when a game state is loaded, because the card comes back in exile with
+ * nothing to grant that permission. Shared so the two cannot drift apart — the copy in
+ * GameState had done exactly that.
+ */
+ public static AbilitySub makeAdventureEffect(CardState card) {
String abEffect = "DB$ Effect | RememberObjects$ Self | StaticAbilities$ Play | ForgetOnMoved$ Exile | Duration$ Permanent | ConditionDefined$ Self | ConditionPresent$ Card.!copiedSpell+!token | Adventure$ True";
AbilitySub saEffect = (AbilitySub)AbilityFactory.getAbility(abEffect, card);
@@ -4070,12 +4086,7 @@ public class CardFactoryUtil {
sbPlay.append(" | AffectedZone$ Exile | Description$ You may cast EFFECTSOURCE.");
saEffect.setSVar("Play", sbPlay.toString());
- saExile.setSubAbility(saEffect);
-
- ReplacementEffect re = ReplacementHandler.parseReplacement(repeffstr, card.getCard(), true);
-
- re.setOverridingAbility(saExile);
- return re;
+ return saEffect;
}
public static ReplacementEffect setupOmenAbility(CardState card) {
diff --git a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java
index c95b9061..6c186922 100644
--- a/forge-game/src/main/java/forge/game/phase/PhaseHandler.java
+++ b/forge-game/src/main/java/forge/game/phase/PhaseHandler.java
@@ -1059,9 +1059,34 @@ public class PhaseHandler implements java.io.Serializable, IHasForgeLog {
return;
}
game.stashGameState();
+ // Record where this turn began, if this is that moment. Only for players
+ // who can actually ask for a rewind. Reading the game out does not change
+ // it, so the call is safe here; the timing conditions are checked inside.
+ if (!pPlayerPriority.getController().isAI()) {
+ game.stashTurnRewindPoint(pPlayerPriority);
+ }
chosenSa = pPlayerPriority.getController().chooseSpellAbilityToPlay();
+ // The player asked to rewind instead of acting. Do it here, where the state
+ // is between actions, and let the main game loop restart from the restored
+ // position on its next step.
+ final int rewindSteps = pPlayerPriority.getController().consumeRewindRequest();
+ if (rewindSteps > 0 && game.rewindToActionOf(pPlayerPriority, rewindSteps)) {
+ pPlayerPriority.getController().afterRewind();
+ return;
+ }
+
+ // A saved game the player asked to load. Same reasoning as the rewind: the
+ // state is between actions here, so it is safe to replace it wholesale.
+ final GameState pendingState = pPlayerPriority.getController().consumePendingGameState();
+ if (pendingState != null) {
+ pendingState.applyToGame(game);
+ game.clearRewindPoints();
+ pPlayerPriority.getController().afterRewind();
+ return;
+ }
+
// this needs to come after chosenSa so it sees you conceding on own turn
if (playerTurn.hasLost() && pPlayerPriority.equals(playerTurn) && pFirstPriority.equals(playerTurn)) {
// If the active player has lost, and they have priority, set the next player to have priority
@@ -1211,6 +1236,74 @@ public class PhaseHandler implements java.io.Serializable, IHasForgeLog {
// this is a hack for the setup game state mode, do not use outside of devSetupGameState code
// as it avoids calling any of the phase effects that may be necessary in a less enforced context
+ /**
+ * The parts of the turn structure that GameSnapshot does not carry: who holds priority,
+ * who held it first this round, and the per-turn counters. Without these a restored
+ * snapshot would resume the main loop at the wrong player.
+ */
+ public static final class PriorityState implements java.io.Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private final Player pPlayerPriority, pFirstPriority, playerPreviousTurn;
+ private final boolean givePriorityToPlayer, skipDamageSteps, bRepeatCleanup;
+ private final int nUpkeepsThisTurn, nUpkeepsThisGame, nCombatsThisTurn, nMainsThisTurn,
+ nEndOfTurnsThisTurn, planarDiceSpecialActionThisTurn;
+ private final List<ExtraTurn> extraTurns;
+ private final Map<PhaseType, List<ExtraPhase>> extraPhases;
+
+ private PriorityState(PhaseHandler ph) {
+ pPlayerPriority = ph.pPlayerPriority;
+ pFirstPriority = ph.pFirstPriority;
+ playerPreviousTurn = ph.playerPreviousTurn;
+ givePriorityToPlayer = ph.givePriorityToPlayer;
+ skipDamageSteps = ph.skipDamageSteps;
+ bRepeatCleanup = ph.bRepeatCleanup;
+ nUpkeepsThisTurn = ph.nUpkeepsThisTurn;
+ nUpkeepsThisGame = ph.nUpkeepsThisGame;
+ nCombatsThisTurn = ph.nCombatsThisTurn;
+ nMainsThisTurn = ph.nMainsThisTurn;
+ nEndOfTurnsThisTurn = ph.nEndOfTurnsThisTurn;
+ planarDiceSpecialActionThisTurn = ph.planarDiceSpecialActionThisTurn;
+ extraTurns = Lists.newArrayList(ph.extraTurns);
+ extraPhases = Maps.newEnumMap(PhaseType.class);
+ for (Map.Entry<PhaseType, Stack<ExtraPhase>> e : ph.extraPhases.entrySet()) {
+ extraPhases.put(e.getKey(), Lists.newArrayList(e.getValue()));
+ }
+ }
+ }
+
+ public PriorityState capturePriorityState() {
+ return new PriorityState(this);
+ }
+
+ public void restorePriorityState(final PriorityState s) {
+ pPlayerPriority = s.pPlayerPriority;
+ pFirstPriority = s.pFirstPriority;
+ playerPreviousTurn = s.playerPreviousTurn;
+ givePriorityToPlayer = s.givePriorityToPlayer;
+ skipDamageSteps = s.skipDamageSteps;
+ bRepeatCleanup = s.bRepeatCleanup;
+ nUpkeepsThisTurn = s.nUpkeepsThisTurn;
+ nUpkeepsThisGame = s.nUpkeepsThisGame;
+ nCombatsThisTurn = s.nCombatsThisTurn;
+ nMainsThisTurn = s.nMainsThisTurn;
+ nEndOfTurnsThisTurn = s.nEndOfTurnsThisTurn;
+ planarDiceSpecialActionThisTurn = s.planarDiceSpecialActionThisTurn;
+
+ extraTurns.clear();
+ extraTurns.addAll(s.extraTurns);
+ extraPhases.clear();
+ for (Map.Entry<PhaseType, List<ExtraPhase>> e : s.extraPhases.entrySet()) {
+ final Stack<ExtraPhase> stack = new Stack<>();
+ stack.addAll(e.getValue());
+ extraPhases.put(e.getKey(), stack);
+ }
+
+ for (final Player p : game.getPlayers()) {
+ p.setHasPriority(pPlayerPriority == p);
+ }
+ }
+
public final void devModeSet(final PhaseType phase0, final Player player0, boolean endCombat, int cturn) {
if (phase0 != null) {
setPhase(phase0);
diff --git a/forge-game/src/main/java/forge/game/player/PlayerController.java b/forge-game/src/main/java/forge/game/player/PlayerController.java
index ffce4494..a4bf3ea5 100644
--- a/forge-game/src/main/java/forge/game/player/PlayerController.java
+++ b/forge-game/src/main/java/forge/game/player/PlayerController.java
@@ -86,6 +86,36 @@ public abstract class PlayerController {
return false;
}
+ /**
+ * Number of own actions this controller wants rewound, consumed by the main game loop
+ * the next time this player holds priority. Only the local human controller ever
+ * returns more than 0 — see Game#rewindToActionOf.
+ */
+ public int consumeRewindRequest() {
+ return 0;
+ }
+
+ /** Called on the game thread right after a rewind this controller asked for. */
+ public void afterRewind() {
+ }
+
+ /**
+ * A saved game state waiting to be loaded, taken by the main game loop the next time
+ * this player holds priority. Applying one mid-action would pull the ground out from
+ * under whatever the game thread is doing, so it happens between actions like a rewind.
+ */
+ private GameState pendingGameState = null;
+
+ public void setPendingGameState(GameState state) {
+ pendingGameState = state;
+ }
+
+ public GameState consumePendingGameState() {
+ GameState state = pendingGameState;
+ pendingGameState = null;
+ return state;
+ }
+
public Game getGame() { return gameView.getGame(); }
public Match getMatch() { return gameView.getMatch(); }
public Player getPlayer() { return player; }
diff --git a/forge-gui-desktop/src/main/java/forge/gui/framework/DragCell.java b/forge-gui-desktop/src/main/java/forge/gui/framework/DragCell.java
index 63aedf90..5178e803 100644
--- a/forge-gui-desktop/src/main/java/forge/gui/framework/DragCell.java
+++ b/forge-gui-desktop/src/main/java/forge/gui/framework/DragCell.java
@@ -13,6 +13,8 @@ import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingConstants;
+import org.tinylog.Logger;
+
import com.google.common.collect.Lists;
import forge.localinstance.properties.ForgePreferences;
@@ -267,9 +269,24 @@ public final class DragCell extends JPanel implements ILocalRepaint {
* @param doc0   {@link forge.gui.framework.IVDoc} */
public void addDoc(final IVDoc<? extends ICDoc> doc0) {
if (doc0 instanceof VEmptyDoc) { return; }
+ if (allDocs.contains(doc0)) {
+ // Already here. Adding it again would list it twice while Swing merely moves
+ // the one tab label it has, leaving the list longer than the head bar — and the
+ // next add then aims past the end of it. The deck editor does exactly this when
+ // it puts its tabs back on the way out.
+ setSelected(getSelected());
+ return;
+ }
allDocs.add(doc0);
doc0.setParentCell(this);
- pnlHead.add(doc0.getTabLabel(), "h 100%!, gap " + tabPaddingPx + "px " + tabPaddingPx + "px 0 0", allDocs.size() - 1);
+ // Meant to append. Clamped, so a head bar that drifted out of step with the list
+ // cannot turn into an illegal position and take the whole action down with it.
+ final int position = Math.min(allDocs.size() - 1, pnlHead.getComponentCount());
+ if (position != allDocs.size() - 1) {
+ Logger.warn("Tab bar holds {} labels for {} docs; appending {} at {}",
+ pnlHead.getComponentCount(), allDocs.size(), doc0.getDocumentID(), position);
+ }
+ pnlHead.add(doc0.getTabLabel(), "h 100%!, gap " + tabPaddingPx + "px " + tabPaddingPx + "px 0 0", position);
// Ensure that a tab is selected
setSelected(getSelected());
@@ -312,13 +329,24 @@ public final class DragCell extends JPanel implements ILocalRepaint {
pnlBody.removeAll();
// Priorities are used to "remember" tab selection history.
- for (final IVDoc<? extends ICDoc> doc : allDocs) {
+ // Iterate a copy: populate() and update() can reach code that adds or removes
+ // docs from this very cell (zone tabs docking themselves in, dev mode coming
+ // and going, a match registering its views), which would blow up the iterator.
+ for (final IVDoc<? extends ICDoc> doc : Lists.newArrayList(allDocs)) {
if (doc.equals(doc0)) {
docSelected = doc0;
doc.getTabLabel().priorityOne();
doc.getTabLabel().setSelected(true);
- doc.populate();
- doc.getLayoutControl().update();
+ // A panel that fails to fill itself must not take the screen down with it.
+ // This runs inside loadLayout, so an exception here used to abort the whole
+ // layout and leave an empty window over a still-running game.
+ try {
+ doc.populate();
+ doc.getLayoutControl().update();
+ } catch (final Exception ex) {
+ Logger.error(ex, "Tab {} failed to populate; leaving it blank and carrying on",
+ doc.getDocumentID());
+ }
}
else {
doc.getTabLabel().setSelected(false);
@@ -389,7 +417,7 @@ public final class DragCell extends JPanel implements ILocalRepaint {
DragTab temp;
int lowest = Integer.MAX_VALUE;
- for (final IVDoc<? extends ICDoc> d : allDocs) {
+ for (final IVDoc<? extends ICDoc> d : Lists.newArrayList(allDocs)) {
temp = d.getTabLabel();
// This line prevents two tabs from having the same priority.
diff --git a/forge-gui-desktop/src/main/java/forge/menus/ForgeMenu.java b/forge-gui-desktop/src/main/java/forge/menus/ForgeMenu.java
index a175bb39..425abf89 100644
--- a/forge-gui-desktop/src/main/java/forge/menus/ForgeMenu.java
+++ b/forge-gui-desktop/src/main/java/forge/menus/ForgeMenu.java
@@ -78,6 +78,8 @@ public final class ForgeMenu {
add(new AudioMenu().getMenu());
add(HelpMenu.getMenu());
addSeparator();
+ add(LoadSavedGame.getMenuItem());
+ addSeparator();
add(OnlineMenu.getMenu());
addSeparator();
add(getMenuItem_Restart());
diff --git a/forge-gui-desktop/src/main/java/forge/menus/LoadSavedGame.java b/forge-gui-desktop/src/main/java/forge/menus/LoadSavedGame.java
new file mode 100644
index 00000000..2fff29d1
--- /dev/null
+++ b/forge-gui-desktop/src/main/java/forge/menus/LoadSavedGame.java
@@ -0,0 +1,124 @@
+package forge.menus;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.swing.SwingUtilities;
+
+import forge.gui.GuiBase;
+import forge.deck.Deck;
+import forge.game.GameRules;
+import forge.game.GameState;
+import forge.game.GameType;
+import forge.game.player.RegisteredPlayer;
+import forge.gamemodes.match.HostedMatch;
+import forge.gui.SOverlayUtils;
+import forge.localinstance.properties.ForgeConstants;
+import forge.player.GamePlayerUtil;
+import forge.toolbox.FSkin.SkinnedMenuItem;
+import forge.util.Localizer;
+import forge.util.FileUtil;
+import forge.gui.util.SOptionPane;
+
+/**
+ * Starts a match from a game saved during play (Game menu > Save game).
+ *
+ * A saved state carries everything the position needs, libraries included, so the match is
+ * started with empty decks and no opening hand — the same way puzzles are set up — and the
+ * state is applied once the game exists.
+ */
+public final class LoadSavedGame {
+ private static final Pattern PLAYER_KEY = Pattern.compile("^p(\\d+)life=", Pattern.MULTILINE);
+ /** What Forge uses when nobody says otherwise — see RegisteredPlayer.startingHand. */
+ private static final int DEFAULT_MAX_HAND_SIZE = 7;
+
+ private LoadSavedGame() { }
+
+ public static SkinnedMenuItem getMenuItem() {
+ final SkinnedMenuItem item = new SkinnedMenuItem(Localizer.getInstance().getMessage("lblLoadSavedGame"));
+ item.addActionListener(e -> load());
+ return item;
+ }
+
+ private static void load() {
+ final Localizer localizer = Localizer.getInstance();
+ final File dir = new File(ForgeConstants.USER_GAMES_DIR);
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ final String filename = GuiBase.getInterface().showFileDialog(
+ localizer.getMessage("lblLoadSavedGame"), ForgeConstants.USER_GAMES_DIR);
+ if (filename == null) {
+ return;
+ }
+
+ final GameState state = new GameState();
+ final int playerCount;
+ try {
+ playerCount = countPlayers(filename);
+ try (FileInputStream in = new FileInputStream(filename)) {
+ state.parse(in);
+ }
+ } catch (final Exception e) {
+ SOptionPane.showErrorDialog(localizer.getMessage("lblErrorLoadingBattleSetupFile") + "\n" + filename);
+ return;
+ }
+ if (playerCount < 2) {
+ SOptionPane.showErrorDialog(localizer.getMessage("lblSavedGameUnreadable") + "\n" + filename);
+ return;
+ }
+
+ SwingUtilities.invokeLater(() -> {
+ SOverlayUtils.startGameOverlay();
+ SOverlayUtils.showOverlay();
+ });
+
+ final HostedMatch hostedMatch = GuiBase.getInterface().hostMatch();
+ hostedMatch.setStartGameHook(() -> {
+ state.applyToGame(hostedMatch.getGame());
+ // Starting the match with no opening hand also sets the maximum hand size to
+ // zero (Game.java: setMaxHandSize(startingHand)), and a saved state does not
+ // carry that number, so cleanup would ask the player to discard their hand.
+ for (final forge.game.player.Player p : hostedMatch.getGame().getPlayers()) {
+ p.setMaxHandSize(DEFAULT_MAX_HAND_SIZE);
+ p.setStartingHandSize(DEFAULT_MAX_HAND_SIZE);
+ }
+ });
+
+ // Empty decks and no opening hand: everything comes from the saved state. The first
+ // player is the one who saved, the rest are AI, matching how the file numbers them.
+ final List<RegisteredPlayer> players = new ArrayList<>();
+ final RegisteredPlayer human = new RegisteredPlayer(new Deck()).setPlayer(GamePlayerUtil.getGuiPlayer());
+ human.setStartingHand(0);
+ players.add(human);
+ for (int i = 1; i < playerCount; i++) {
+ final RegisteredPlayer ai = new RegisteredPlayer(new Deck()).setPlayer(GamePlayerUtil.createAiPlayer());
+ ai.setStartingHand(0);
+ players.add(ai);
+ }
+
+ // Puzzle rules, because loading a position is what they are for: no mulligan over the
+ // empty opening hand, and the first player is taken rather than diced for. The saved
+ // state decides whose turn it is anyway.
+ final GameRules rules = new GameRules(GameType.Puzzle);
+ rules.setGamesPerMatch(1);
+ hostedMatch.startMatch(rules, null, players, human, GuiBase.getInterface().getNewGuiGame());
+
+ SwingUtilities.invokeLater(SOverlayUtils::hideOverlay);
+ }
+
+ /** How many players the file describes, counted from its "pNlife=" lines. */
+ private static int countPlayers(String filename) {
+ final String text = String.join("\n", FileUtil.readFile(filename));
+ final Matcher m = PLAYER_KEY.matcher(text);
+ int highest = -1;
+ while (m.find()) {
+ highest = Math.max(highest, Integer.parseInt(m.group(1)));
+ }
+ return highest + 1;
+ }
+}
diff --git a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CAllDecks.java b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CAllDecks.java
index 467db702..ab6a7c81 100644
--- a/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CAllDecks.java
+++ b/forge-gui-desktop/src/main/java/forge/screens/deckeditor/controllers/CAllDecks.java
@@ -60,11 +60,16 @@ public enum CAllDecks implements ICDoc {
// This may be default and so requiring potential update!
ACEditorBase<? extends InventoryItem, ? extends DeckBase> editorCtrl =
CDeckEditorUI.SINGLETON_INSTANCE.getCurrentEditorController();
- if (editorCtrl != null) {
+ // An editor exists well before its deck controller is wired up, and switching
+ // screens runs this in between. Asking it for a deck name then threw, which
+ // aborted the entire layout load and left the window with nothing in it.
+ if (editorCtrl != null && editorCtrl.getDeckController() != null) {
String currentDeckName = editorCtrl.getDeckController().getModelName();
if (currentDeckName != null && currentDeckName.length() > 0) {
DeckProxy deckProxy = dm.stringToItem(currentDeckName);
- if (deckProxy != null && !dm.getSelectedItem().equals(deckProxy))
+ // deckProxy on the left: having nothing selected is a normal state,
+ // and getSelectedItem() is null then.
+ if (deckProxy != null && !deckProxy.equals(dm.getSelectedItem()))
dm.setSelectedItem(deckProxy);
}
}
diff --git a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java
index 9915861a..68afd7ff 100644
--- a/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java
+++ b/forge-gui-desktop/src/main/java/forge/screens/home/settings/CSubmenuPreferences.java
@@ -211,6 +211,7 @@ public enum CSubmenuPreferences implements ICDoc {
initializeAiProfilesComboBox();
initializeAiSideboardingModeComboBox();
initializeAiTimeoutComboBox();
+ initializeRewindStepsComboBox();