@drmxrcy/tcg-lorcana 0.0.0-202602060544
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -0
- package/package.json +45 -0
- package/src/__tests__/integration/move-enumeration.test.ts +256 -0
- package/src/__tests__/rules/section-01-concepts.test.ts +426 -0
- package/src/__tests__/rules/section-03-gameplay.test.ts +298 -0
- package/src/__tests__/rules/section-04-turn-structure.test.ts +708 -0
- package/src/__tests__/rules/section-05-cards.test.ts +158 -0
- package/src/__tests__/rules/section-06-card-types.test.ts +342 -0
- package/src/__tests__/rules/section-07-abilities.test.ts +333 -0
- package/src/__tests__/rules/section-08-zones.test.ts +231 -0
- package/src/__tests__/rules/section-09-damage.test.ts +148 -0
- package/src/__tests__/rules/section-10-keywords.test.ts +469 -0
- package/src/__tests__/spec-01-foundation-types.test.ts +534 -0
- package/src/__tests__/spec-02-zones-card-states.test.ts +295 -0
- package/src/card-utils.ts +302 -0
- package/src/cards/README.md +296 -0
- package/src/cards/abilities/index.ts +175 -0
- package/src/cards/index.ts +10 -0
- package/src/deck-validation.ts +175 -0
- package/src/engine/lorcana-engine.ts +625 -0
- package/src/game-definition/__tests__/core-zone-integration.test.ts +553 -0
- package/src/game-definition/__tests__/zone-operations.test.ts +362 -0
- package/src/game-definition/__tests__/zones.test.ts +176 -0
- package/src/game-definition/definition.ts +45 -0
- package/src/game-definition/flow/turn-flow.ts +216 -0
- package/src/game-definition/index.ts +31 -0
- package/src/game-definition/moves/abilities/activate-ability.ts +51 -0
- package/src/game-definition/moves/core/__tests__/move-parameter-enumeration.test.ts +316 -0
- package/src/game-definition/moves/core/challenge.test.ts +545 -0
- package/src/game-definition/moves/core/challenge.ts +81 -0
- package/src/game-definition/moves/core/play-card.ts +83 -0
- package/src/game-definition/moves/core/quest.test.ts +448 -0
- package/src/game-definition/moves/core/quest.ts +49 -0
- package/src/game-definition/moves/debug/manual-exert.ts +36 -0
- package/src/game-definition/moves/effects/resolve-bag.ts +35 -0
- package/src/game-definition/moves/effects/resolve-effect.ts +34 -0
- package/src/game-definition/moves/index.ts +85 -0
- package/src/game-definition/moves/locations/move-character-to-location.ts +42 -0
- package/src/game-definition/moves/resources/put-card-into-inkwell.test.ts +462 -0
- package/src/game-definition/moves/resources/put-card-into-inkwell.ts +51 -0
- package/src/game-definition/moves/setup/alter-hand.test.ts +395 -0
- package/src/game-definition/moves/setup/alter-hand.ts +210 -0
- package/src/game-definition/moves/setup/choose-first-player.test.ts +450 -0
- package/src/game-definition/moves/setup/choose-first-player.ts +105 -0
- package/src/game-definition/moves/setup/draw-cards.ts +37 -0
- package/src/game-definition/moves/songs/sing-together.ts +47 -0
- package/src/game-definition/moves/songs/sing.ts +56 -0
- package/src/game-definition/moves/standard/concede.test.ts +189 -0
- package/src/game-definition/moves/standard/concede.ts +72 -0
- package/src/game-definition/moves/standard/pass-turn.ts +49 -0
- package/src/game-definition/setup/game-setup.ts +19 -0
- package/src/game-definition/trackers/tracker-config.ts +23 -0
- package/src/game-definition/win-conditions/lore-victory.ts +26 -0
- package/src/game-definition/zone-operations.ts +405 -0
- package/src/game-definition/zones/zone-configs.ts +59 -0
- package/src/game-definition/zones.ts +283 -0
- package/src/index.ts +189 -0
- package/src/operations/index.ts +7 -0
- package/src/operations/lorcana-operations.ts +288 -0
- package/src/queries/README.md +56 -0
- package/src/resolvers/__tests__/condition-resolver.test.ts +301 -0
- package/src/resolvers/condition-registry.ts +70 -0
- package/src/resolvers/condition-resolver.ts +85 -0
- package/src/resolvers/conditions/basic.ts +81 -0
- package/src/resolvers/conditions/card-state.ts +12 -0
- package/src/resolvers/conditions/comparison.ts +102 -0
- package/src/resolvers/conditions/existence.ts +219 -0
- package/src/resolvers/conditions/history.ts +68 -0
- package/src/resolvers/conditions/index.ts +15 -0
- package/src/resolvers/conditions/logical.ts +55 -0
- package/src/resolvers/conditions/resolution.ts +41 -0
- package/src/resolvers/conditions/revealed.ts +42 -0
- package/src/resolvers/conditions/zone.ts +84 -0
- package/src/setup.test.ts +18 -0
- package/src/targeting/__tests__/filter-resolver.test.ts +294 -0
- package/src/targeting/__tests__/real-cards-targeting.test.ts +303 -0
- package/src/targeting/__tests__/targeting-dsl.test.ts +386 -0
- package/src/targeting/enum-expansion.ts +387 -0
- package/src/targeting/filter-registry.ts +322 -0
- package/src/targeting/filter-resolver.ts +145 -0
- package/src/targeting/index.ts +91 -0
- package/src/targeting/lorcana-target-dsl.ts +495 -0
- package/src/targeting/targeting-ui.ts +407 -0
- package/src/testing/index.ts +14 -0
- package/src/testing/lorcana-test-engine.ts +813 -0
- package/src/types/README.md +303 -0
- package/src/types/__tests__/lorcana-state.test.ts +168 -0
- package/src/types/__tests__/move-enumeration.test.ts +179 -0
- package/src/types/branded-types.ts +106 -0
- package/src/types/game-state.ts +184 -0
- package/src/types/index.ts +87 -0
- package/src/types/keywords.ts +187 -0
- package/src/types/lorcana-state.ts +260 -0
- package/src/types/move-enumeration.ts +126 -0
- package/src/types/move-params.ts +216 -0
- package/src/validators/index.ts +7 -0
- package/src/validators/move-validators.ts +374 -0
- package/src/zones/card-state.ts +234 -0
- package/src/zones/index.ts +42 -0
- package/src/zones/zone-config.ts +150 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { createPlayerId } from "@drmxrcy/tcg-core";
|
|
3
|
+
import {
|
|
4
|
+
LorcanaTestEngine,
|
|
5
|
+
PLAYER_ONE,
|
|
6
|
+
PLAYER_TWO,
|
|
7
|
+
} from "../../../testing/lorcana-test-engine";
|
|
8
|
+
|
|
9
|
+
describe("Move: Alter Hand (Mulligan)", () => {
|
|
10
|
+
let testEngine: LorcanaTestEngine;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testEngine = new LorcanaTestEngine(
|
|
14
|
+
{ hand: 7, deck: 10 },
|
|
15
|
+
{ hand: 7, deck: 10 },
|
|
16
|
+
{ skipPreGame: false },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Choose first player to get to mulligan phase
|
|
20
|
+
const ctx = testEngine.getCtx();
|
|
21
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
22
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
23
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
24
|
+
|
|
25
|
+
expect(testEngine.getGameSegment()).toBe("startingAGame");
|
|
26
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
testEngine.dispose();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ========== Basic Behavior Tests ==========
|
|
34
|
+
|
|
35
|
+
describe("Basic Mulligan Behavior", () => {
|
|
36
|
+
it("should allow keeping all cards (empty mulligan)", () => {
|
|
37
|
+
const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
|
|
38
|
+
expect(initialHand.length).toBe(7);
|
|
39
|
+
|
|
40
|
+
// Mulligan with empty array (keep all cards)
|
|
41
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
42
|
+
testEngine.alterHand([]);
|
|
43
|
+
|
|
44
|
+
const finalHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
45
|
+
expect(finalHand.length).toBe(7);
|
|
46
|
+
|
|
47
|
+
// Should have same cards
|
|
48
|
+
for (const cardId of initialHand) {
|
|
49
|
+
expect(finalHand).toContain(cardId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Player should be marked as mulliganed
|
|
53
|
+
const ctx = testEngine.getCtx();
|
|
54
|
+
expect(ctx.pendingMulligan).not.toContain(PLAYER_ONE);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should mulligan whole hand (all 7 cards)", () => {
|
|
58
|
+
const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
|
|
59
|
+
const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
|
|
60
|
+
|
|
61
|
+
expect(initialHand.length).toBe(7);
|
|
62
|
+
expect(initialDeck.length).toBe(10);
|
|
63
|
+
|
|
64
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
65
|
+
testEngine.alterHand(initialHand);
|
|
66
|
+
|
|
67
|
+
const finalHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
68
|
+
const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
|
|
69
|
+
|
|
70
|
+
// Should still have 7 cards in hand
|
|
71
|
+
expect(finalHand.length).toBe(7);
|
|
72
|
+
|
|
73
|
+
// None of the original hand cards should be in new hand
|
|
74
|
+
for (const cardId of initialHand) {
|
|
75
|
+
expect(finalHand).not.toContain(cardId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Original hand cards should be in deck (shuffled, so anywhere in deck)
|
|
79
|
+
for (const cardId of initialHand) {
|
|
80
|
+
expect(finalDeck).toContain(cardId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Deck should still have 10 cards (7 returned + 7 drawn from top = 10)
|
|
84
|
+
expect(finalDeck.length).toBe(10);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should mulligan partial hand (3 cards)", () => {
|
|
88
|
+
const initialHand = [...testEngine.getZone("hand", PLAYER_ONE)];
|
|
89
|
+
const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
|
|
90
|
+
|
|
91
|
+
const cardsToMulligan = initialHand.slice(0, 3);
|
|
92
|
+
const cardsToKeep = initialHand.slice(3);
|
|
93
|
+
|
|
94
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
95
|
+
testEngine.alterHand(cardsToMulligan);
|
|
96
|
+
|
|
97
|
+
const finalHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
98
|
+
const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
|
|
99
|
+
|
|
100
|
+
// Should still have 7 cards
|
|
101
|
+
expect(finalHand.length).toBe(7);
|
|
102
|
+
|
|
103
|
+
// Kept cards should still be in hand
|
|
104
|
+
for (const cardId of cardsToKeep) {
|
|
105
|
+
expect(finalHand).toContain(cardId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Mulliganed cards should NOT be in hand
|
|
109
|
+
for (const cardId of cardsToMulligan) {
|
|
110
|
+
expect(finalHand).not.toContain(cardId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Mulliganed cards should be in deck (shuffled, so anywhere in deck)
|
|
114
|
+
for (const cardId of cardsToMulligan) {
|
|
115
|
+
expect(finalDeck).toContain(cardId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// New cards should be from top of initial deck
|
|
119
|
+
const newCards = finalHand.filter((id) => !initialHand.includes(id));
|
|
120
|
+
expect(newCards.length).toBe(3);
|
|
121
|
+
|
|
122
|
+
const topOfInitialDeck = initialDeck.slice(0, 3);
|
|
123
|
+
for (const cardId of topOfInitialDeck) {
|
|
124
|
+
expect(finalHand).toContain(cardId);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ========== Priority & Turn Order Tests ==========
|
|
130
|
+
|
|
131
|
+
describe("Priority and Turn Order", () => {
|
|
132
|
+
it("should pass priority to next player after mulligan", () => {
|
|
133
|
+
// Player one has priority first
|
|
134
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
|
|
135
|
+
|
|
136
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
137
|
+
testEngine.alterHand([]);
|
|
138
|
+
|
|
139
|
+
// Priority should switch to player two
|
|
140
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_TWO);
|
|
141
|
+
expect(testEngine.getPriorityPlayers()).not.toContain(PLAYER_ONE);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("should transition to main game when all players mulligan", () => {
|
|
145
|
+
// Player one mulligans
|
|
146
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
147
|
+
testEngine.alterHand([]);
|
|
148
|
+
|
|
149
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
150
|
+
|
|
151
|
+
// Player two mulligans (last player)
|
|
152
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
153
|
+
testEngine.alterHand([]);
|
|
154
|
+
|
|
155
|
+
// After last mulligan, pending list should be empty
|
|
156
|
+
const ctx = testEngine.getCtx();
|
|
157
|
+
expect(ctx.pendingMulligan).toHaveLength(0);
|
|
158
|
+
|
|
159
|
+
// Note: Segment transition happens via flow manager's endIf check
|
|
160
|
+
// which is triggered by endPhase(). This works in real gameplay.
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should respect priority - can't mulligan out of turn", () => {
|
|
164
|
+
// Player one has priority
|
|
165
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
|
|
166
|
+
|
|
167
|
+
// Try to mulligan as player two (not their turn)
|
|
168
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
169
|
+
|
|
170
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
171
|
+
playerId: createPlayerId(PLAYER_TWO),
|
|
172
|
+
params: {
|
|
173
|
+
playerId: createPlayerId(PLAYER_TWO),
|
|
174
|
+
cardsToMulligan: [],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.success).toBe(false);
|
|
179
|
+
if (!result.success) {
|
|
180
|
+
expect(result.errorCode).toBe("NOT_PRIORITY_PLAYER");
|
|
181
|
+
expect(result.error).toContain("can mulligan right now");
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ========== Edge Case Tests ==========
|
|
187
|
+
|
|
188
|
+
describe("Edge Cases - Invalid Card IDs", () => {
|
|
189
|
+
it("should reject invalid card IDs", () => {
|
|
190
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
191
|
+
|
|
192
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
193
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
194
|
+
params: {
|
|
195
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
196
|
+
cardsToMulligan: ["invalid-card-id-12345"],
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.success).toBe(false);
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
expect(result.errorCode).toBe("INVALID_CARD_ID");
|
|
203
|
+
expect(result.error).toContain("invalid-card-id-12345");
|
|
204
|
+
expect(result.error).toContain("does not exist");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should reject cards not in hand", () => {
|
|
209
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
210
|
+
|
|
211
|
+
const deckCards = testEngine.getZone("deck", PLAYER_ONE);
|
|
212
|
+
const cardInDeck = deckCards[0];
|
|
213
|
+
|
|
214
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
215
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
216
|
+
params: {
|
|
217
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
218
|
+
cardsToMulligan: [cardInDeck],
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.success).toBe(false);
|
|
223
|
+
if (!result.success) {
|
|
224
|
+
expect(result.errorCode).toBe("CARD_NOT_IN_HAND");
|
|
225
|
+
expect(result.error).toContain("not in your hand");
|
|
226
|
+
expect(result.error).toContain("deck");
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("should reject opponent's cards", () => {
|
|
231
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
232
|
+
|
|
233
|
+
const opponentHand = testEngine.getZone("hand", PLAYER_TWO);
|
|
234
|
+
const opponentCard = opponentHand[0];
|
|
235
|
+
|
|
236
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
237
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
238
|
+
params: {
|
|
239
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
240
|
+
cardsToMulligan: [opponentCard],
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(result.success).toBe(false);
|
|
245
|
+
if (!result.success) {
|
|
246
|
+
expect(result.errorCode).toBe("CARD_NOT_IN_HAND");
|
|
247
|
+
expect(result.error).toContain("not in your hand");
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("should reject more cards than in hand", () => {
|
|
252
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
253
|
+
|
|
254
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
255
|
+
const tooManyCards = [...hand, ...hand]; // Duplicate to get 14 cards
|
|
256
|
+
|
|
257
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
258
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
259
|
+
params: {
|
|
260
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
261
|
+
cardsToMulligan: tooManyCards,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(result.success).toBe(false);
|
|
266
|
+
if (!result.success) {
|
|
267
|
+
expect(result.errorCode).toBe("TOO_MANY_CARDS");
|
|
268
|
+
expect(result.error).toContain("14 cards");
|
|
269
|
+
expect(result.error).toContain("only has 7");
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("Edge Cases - Phase and State Validation", () => {
|
|
275
|
+
it("should reject mulligan after mulligan phase ends", () => {
|
|
276
|
+
// Complete mulligans
|
|
277
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
278
|
+
testEngine.alterHand([]);
|
|
279
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
280
|
+
testEngine.alterHand([]);
|
|
281
|
+
|
|
282
|
+
// All players have mulliganed
|
|
283
|
+
const ctx = testEngine.getCtx();
|
|
284
|
+
expect(ctx.pendingMulligan).toHaveLength(0);
|
|
285
|
+
|
|
286
|
+
// Try to mulligan again after mulligan phase
|
|
287
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
288
|
+
|
|
289
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
290
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
291
|
+
params: {
|
|
292
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
293
|
+
cardsToMulligan: [],
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result.success).toBe(false);
|
|
298
|
+
if (!result.success) {
|
|
299
|
+
// Phase has ended (no longer in mulligan phase)
|
|
300
|
+
// After phase transition fix, game correctly advances to next phase
|
|
301
|
+
expect(result.errorCode).toBe("WRONG_PHASE");
|
|
302
|
+
expect(result.error).toContain("phase");
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should reject mulliganing twice", () => {
|
|
307
|
+
// First mulligan
|
|
308
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
309
|
+
testEngine.alterHand([]);
|
|
310
|
+
|
|
311
|
+
const ctx = testEngine.getCtx();
|
|
312
|
+
expect(ctx.pendingMulligan).not.toContain(PLAYER_ONE);
|
|
313
|
+
|
|
314
|
+
// Try to mulligan again - should fail because not in pending list
|
|
315
|
+
// (Priority has passed to PLAYER_TWO, so this will fail on priority check)
|
|
316
|
+
const result = testEngine.engine.executeMove("alterHand", {
|
|
317
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
318
|
+
params: {
|
|
319
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
320
|
+
cardsToMulligan: [],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(result.success).toBe(false);
|
|
325
|
+
if (!result.success) {
|
|
326
|
+
// Will fail on already-mulliganed check (checked before priority)
|
|
327
|
+
expect(result.errorCode).toBe("ALREADY_MULLIGANED");
|
|
328
|
+
expect(result.error).toContain("already mulliganed");
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ========== Shuffle Behavior Tests ==========
|
|
334
|
+
|
|
335
|
+
describe("Shuffle Behavior", () => {
|
|
336
|
+
it("should NOT shuffle when keeping all cards", () => {
|
|
337
|
+
const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
|
|
338
|
+
|
|
339
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
340
|
+
testEngine.alterHand([]); // Keep all cards
|
|
341
|
+
|
|
342
|
+
const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
|
|
343
|
+
|
|
344
|
+
// Deck should be unchanged (no shuffle, no draws)
|
|
345
|
+
expect(finalDeck).toEqual(initialDeck);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("should shuffle when returning cards", () => {
|
|
349
|
+
const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
|
|
350
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
351
|
+
|
|
352
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
353
|
+
testEngine.alterHand([hand[0]]); // Return 1 card
|
|
354
|
+
|
|
355
|
+
const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
|
|
356
|
+
|
|
357
|
+
// Deck should be shuffled (very unlikely to be in same order)
|
|
358
|
+
// We can't test randomness perfectly, but we can check deck was modified
|
|
359
|
+
// Note: With small deck size, there's a tiny chance they're the same
|
|
360
|
+
// This is acceptable for testing
|
|
361
|
+
expect(finalDeck.length).toBe(initialDeck.length);
|
|
362
|
+
expect(finalDeck).toContain(hand[0]); // Returned card is in deck
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ========== Cards Go to Bottom Tests ==========
|
|
367
|
+
|
|
368
|
+
describe("Cards Go to Bottom of Deck (Lorcana-Specific)", () => {
|
|
369
|
+
it("should place returned cards at bottom before shuffle", () => {
|
|
370
|
+
const initialDeck = [...testEngine.getZone("deck", PLAYER_ONE)];
|
|
371
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
372
|
+
const cardsToReturn = hand.slice(0, 3);
|
|
373
|
+
|
|
374
|
+
// We can't fully test this after shuffle, but we can verify:
|
|
375
|
+
// 1. Cards are in deck
|
|
376
|
+
// 2. Deck has correct size
|
|
377
|
+
// 3. New hand doesn't contain returned cards
|
|
378
|
+
|
|
379
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
380
|
+
testEngine.alterHand(cardsToReturn);
|
|
381
|
+
|
|
382
|
+
const finalDeck = testEngine.getZone("deck", PLAYER_ONE);
|
|
383
|
+
const finalHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
384
|
+
|
|
385
|
+
// Returned cards should be somewhere in deck
|
|
386
|
+
for (const cardId of cardsToReturn) {
|
|
387
|
+
expect(finalDeck).toContain(cardId);
|
|
388
|
+
expect(finalHand).not.toContain(cardId);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Deck size should be correct
|
|
392
|
+
expect(finalDeck.length).toBe(initialDeck.length);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CardId,
|
|
3
|
+
type ConditionFailure,
|
|
4
|
+
createMove,
|
|
5
|
+
type PlayerId,
|
|
6
|
+
type ZoneId,
|
|
7
|
+
} from "@drmxrcy/tcg-core";
|
|
8
|
+
import type {
|
|
9
|
+
LorcanaCardMeta,
|
|
10
|
+
LorcanaGameState,
|
|
11
|
+
LorcanaMoveParams,
|
|
12
|
+
} from "../../../types";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Alter Hand Move (Mulligan)
|
|
16
|
+
*
|
|
17
|
+
* Rule 3.1.6: Players may mulligan by putting cards on bottom of deck
|
|
18
|
+
*
|
|
19
|
+
* Lorcana-specific mulligan process:
|
|
20
|
+
* 1. Step 1 (Rule 3.1.6.1): Put selected cards on BOTTOM of deck (not shuffled in)
|
|
21
|
+
* 2. Step 2 (Rule 3.1.6.2): Draw until player has 7 cards
|
|
22
|
+
* 3. Step 4 (Rule 3.1.6.4): Shuffle deck ONLY if 1+ cards were returned
|
|
23
|
+
* 4. Priority passes to next player who needs to mulligan
|
|
24
|
+
* 5. When all done, transition to main game
|
|
25
|
+
*/
|
|
26
|
+
export const alterHand = createMove<
|
|
27
|
+
LorcanaGameState,
|
|
28
|
+
LorcanaMoveParams,
|
|
29
|
+
"alterHand",
|
|
30
|
+
LorcanaCardMeta
|
|
31
|
+
>({
|
|
32
|
+
// Enumerator: Returns targeting constraints for UI/AI
|
|
33
|
+
// UI will present card selection interface: "Select 0-7 cards from your hand to mulligan"
|
|
34
|
+
// AI can enumerate all valid combinations based on these constraints
|
|
35
|
+
enumerator: (state, context) => {
|
|
36
|
+
// Get cards in hand for validation constraints
|
|
37
|
+
const handCards =
|
|
38
|
+
context.zones?.getCardsInZone("hand" as ZoneId, context.playerId) || [];
|
|
39
|
+
|
|
40
|
+
// Return single parameter set with targeting information
|
|
41
|
+
// The targeting system will handle enumerating card combinations
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
playerId: context.playerId,
|
|
45
|
+
cardsToMulligan: [], // Default: keep all cards
|
|
46
|
+
// Include validation constraints for UI/AI
|
|
47
|
+
validation: {
|
|
48
|
+
maxCards: Math.min(7, handCards.length),
|
|
49
|
+
validCards: handCards,
|
|
50
|
+
},
|
|
51
|
+
// TODO: Integrate with targeting system DSL
|
|
52
|
+
// target: {
|
|
53
|
+
// filter: {
|
|
54
|
+
// zone: "hand" as ZoneId,
|
|
55
|
+
// owner: context.playerId
|
|
56
|
+
// },
|
|
57
|
+
// count: { min: 0, max: 7 }
|
|
58
|
+
// }
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
condition: (state, context): true | ConditionFailure => {
|
|
64
|
+
const { playerId, cardsToMulligan } = context.params;
|
|
65
|
+
|
|
66
|
+
// 1. Check we're in the correct phase
|
|
67
|
+
if (context.flow?.currentPhase !== "mulligan") {
|
|
68
|
+
return {
|
|
69
|
+
reason: `Cannot mulligan during ${context.flow?.currentPhase || "unknown"} phase. Must be in mulligan phase.`,
|
|
70
|
+
errorCode: "WRONG_PHASE",
|
|
71
|
+
context: {
|
|
72
|
+
currentPhase: context.flow?.currentPhase,
|
|
73
|
+
requiredPhase: "mulligan",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Check player is in pending mulligan list
|
|
79
|
+
const pendingMulligan = context.game.getPendingMulligan();
|
|
80
|
+
if (!pendingMulligan.includes(playerId)) {
|
|
81
|
+
return {
|
|
82
|
+
reason: `Player ${String(playerId)} has already mulliganed or is not eligible to mulligan.`,
|
|
83
|
+
errorCode: "ALREADY_MULLIGANED",
|
|
84
|
+
context: {
|
|
85
|
+
playerId: String(playerId),
|
|
86
|
+
pendingPlayers: pendingMulligan.map((p) => String(p)),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. Check player has priority (is current player)
|
|
92
|
+
const currentPlayer = context.flow?.currentPlayer;
|
|
93
|
+
if (currentPlayer !== playerId) {
|
|
94
|
+
return {
|
|
95
|
+
reason: `Only ${String(currentPlayer)} can mulligan right now. You are ${String(playerId)}.`,
|
|
96
|
+
errorCode: "NOT_PRIORITY_PLAYER",
|
|
97
|
+
context: {
|
|
98
|
+
currentPlayer: String(currentPlayer),
|
|
99
|
+
executingPlayer: String(playerId),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 4. Validate all card IDs are valid
|
|
105
|
+
for (const cardId of cardsToMulligan) {
|
|
106
|
+
const cardZone = context.zones.getCardZone(cardId);
|
|
107
|
+
if (cardZone === undefined) {
|
|
108
|
+
return {
|
|
109
|
+
reason: `Invalid card ID: ${cardId}. Card does not exist in any zone.`,
|
|
110
|
+
errorCode: "INVALID_CARD_ID",
|
|
111
|
+
context: {
|
|
112
|
+
cardId,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 5. Validate all cards are in player's hand
|
|
119
|
+
const handCards = context.zones.getCardsInZone("hand" as ZoneId, playerId);
|
|
120
|
+
for (const cardId of cardsToMulligan) {
|
|
121
|
+
if (!handCards.includes(cardId)) {
|
|
122
|
+
const cardZone = context.zones.getCardZone(cardId);
|
|
123
|
+
const cardOwner = context.cards.getCardOwner(cardId);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
reason: `Card ${cardId} is not in your hand. It's in ${cardZone || "unknown zone"} owned by ${cardOwner || "unknown"}.`,
|
|
127
|
+
errorCode: "CARD_NOT_IN_HAND",
|
|
128
|
+
context: {
|
|
129
|
+
cardId,
|
|
130
|
+
cardZone,
|
|
131
|
+
cardOwner: String(cardOwner),
|
|
132
|
+
playerId: String(playerId),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 6. Validate cards to mulligan don't exceed hand size
|
|
139
|
+
if (cardsToMulligan.length > handCards.length) {
|
|
140
|
+
return {
|
|
141
|
+
reason: `Cannot mulligan ${cardsToMulligan.length} cards when hand only has ${handCards.length} cards.`,
|
|
142
|
+
errorCode: "TOO_MANY_CARDS",
|
|
143
|
+
context: {
|
|
144
|
+
requested: cardsToMulligan.length,
|
|
145
|
+
handSize: handCards.length,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
reducer: (draft, context) => {
|
|
154
|
+
const { playerId, cardsToMulligan } = context.params;
|
|
155
|
+
|
|
156
|
+
// Rule 3.1.6.1: Put selected cards on BOTTOM of deck (not shuffled in yet)
|
|
157
|
+
if (cardsToMulligan.length > 0) {
|
|
158
|
+
for (const cardId of cardsToMulligan) {
|
|
159
|
+
context.zones.moveCard({
|
|
160
|
+
cardId,
|
|
161
|
+
targetZoneId: "deck" as ZoneId,
|
|
162
|
+
position: "bottom", // Lorcana-specific: cards go to bottom
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Rule 3.1.6.2: Draw until player has 7 cards
|
|
168
|
+
const currentHandSize = context.zones.getCardsInZone(
|
|
169
|
+
"hand" as ZoneId,
|
|
170
|
+
playerId,
|
|
171
|
+
).length;
|
|
172
|
+
const cardsToDraw = 7 - currentHandSize;
|
|
173
|
+
|
|
174
|
+
if (cardsToDraw > 0) {
|
|
175
|
+
const drawnCards = context.zones.drawCards({
|
|
176
|
+
from: "deck" as ZoneId,
|
|
177
|
+
to: "hand" as ZoneId,
|
|
178
|
+
count: cardsToDraw,
|
|
179
|
+
playerId,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Validate that we drew enough cards (deck exhaustion check)
|
|
183
|
+
if (drawnCards.length < cardsToDraw) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Cannot complete mulligan: deck exhausted. Needed to draw ${cardsToDraw} cards but only drew ${drawnCards.length}. This violates Lorcana Rule 3.1.6.2 (must have exactly 7 cards after mulligan).`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Rule 3.1.6.4: Shuffle deck ONLY if 1 or more cards were altered
|
|
191
|
+
if (cardsToMulligan.length > 0) {
|
|
192
|
+
context.zones.shuffleZone("deck" as ZoneId, playerId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Remove player from pending mulligan list
|
|
196
|
+
context.game.removePendingMulligan(playerId);
|
|
197
|
+
|
|
198
|
+
// Switch priority to the next pending player
|
|
199
|
+
const pendingMulligan = context.game.getPendingMulligan();
|
|
200
|
+
|
|
201
|
+
if (pendingMulligan.length > 0) {
|
|
202
|
+
if (context.flow?.setCurrentPlayer) {
|
|
203
|
+
// Set priority to the next player who needs to mulligan
|
|
204
|
+
context.flow.setCurrentPlayer(pendingMulligan[0]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// When all players complete mulligan (pending list empty), flow manager
|
|
208
|
+
// will auto-transition via its endIf condition on next move attempt or flow check
|
|
209
|
+
},
|
|
210
|
+
});
|