@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,450 @@
|
|
|
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: Choose First Player", () => {
|
|
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
|
+
expect(testEngine.getGameSegment()).toBe("startingAGame");
|
|
20
|
+
expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
testEngine.dispose();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("Choosing player_one as first player", () => {
|
|
28
|
+
const ctx = testEngine.getCtx();
|
|
29
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
30
|
+
|
|
31
|
+
// Execute the move as the designated choosing player
|
|
32
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
33
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
34
|
+
const updatedCtx = testEngine.getCtx();
|
|
35
|
+
|
|
36
|
+
// Verify OTP is set
|
|
37
|
+
expect(updatedCtx.otp).toBe(PLAYER_ONE);
|
|
38
|
+
|
|
39
|
+
// Verify pending mulligan is set for both players
|
|
40
|
+
expect(updatedCtx.pendingMulligan?.length).toBe(2);
|
|
41
|
+
expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
|
|
42
|
+
PLAYER_ONE,
|
|
43
|
+
);
|
|
44
|
+
expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
|
|
45
|
+
PLAYER_TWO,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Verify phase transition occurred
|
|
49
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("Choosing player_two as first player", () => {
|
|
53
|
+
const ctx = testEngine.getCtx();
|
|
54
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
55
|
+
|
|
56
|
+
// Execute the move as the designated choosing player
|
|
57
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
58
|
+
testEngine.chooseWhoGoesFirst(PLAYER_TWO);
|
|
59
|
+
const updatedCtx = testEngine.getCtx();
|
|
60
|
+
|
|
61
|
+
// Verify OTP is set
|
|
62
|
+
expect(updatedCtx.otp).toBe(PLAYER_TWO);
|
|
63
|
+
|
|
64
|
+
// Verify pending mulligan is set for both players
|
|
65
|
+
expect(updatedCtx.pendingMulligan?.length).toBe(2);
|
|
66
|
+
expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
|
|
67
|
+
PLAYER_ONE,
|
|
68
|
+
);
|
|
69
|
+
expect(updatedCtx.pendingMulligan?.map((p) => String(p))).toContain(
|
|
70
|
+
PLAYER_TWO,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Verify phase transition occurred
|
|
74
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("Only the designated player can choose first player", () => {
|
|
78
|
+
// Rule 3.1.2: First, use a method for randomly determining WHO CHOOSES who is the starting player
|
|
79
|
+
// One player is randomly designated to make the choice (like winning a coin flip)
|
|
80
|
+
|
|
81
|
+
const ctx = testEngine.getCtx();
|
|
82
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
83
|
+
|
|
84
|
+
// Verify that a choosing player was randomly designated
|
|
85
|
+
expect(choosingPlayer).toBeDefined();
|
|
86
|
+
expect([PLAYER_ONE, PLAYER_TWO]).toContain(choosingPlayer!);
|
|
87
|
+
|
|
88
|
+
// The designated player should be able to choose
|
|
89
|
+
if (choosingPlayer === PLAYER_ONE) {
|
|
90
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
91
|
+
testEngine.chooseWhoGoesFirst(PLAYER_TWO); // Can choose either player
|
|
92
|
+
expect(testEngine.getCtx().otp).toBe(PLAYER_TWO);
|
|
93
|
+
} else {
|
|
94
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
95
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE); // Can choose either player
|
|
96
|
+
expect(testEngine.getCtx().otp).toBe(PLAYER_ONE);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("Non-designated player cannot choose first player", () => {
|
|
103
|
+
// Check who is the designated chooser
|
|
104
|
+
const ctx = testEngine.getCtx();
|
|
105
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
106
|
+
const otherPlayer = choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
|
|
107
|
+
|
|
108
|
+
// Try to have the OTHER player choose - should fail
|
|
109
|
+
testEngine.changeActivePlayer(otherPlayer);
|
|
110
|
+
const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
|
|
111
|
+
playerId: createPlayerId(otherPlayer),
|
|
112
|
+
params: { playerId: createPlayerId(PLAYER_ONE) },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Should fail with detailed error information
|
|
116
|
+
expect(result.success).toBe(false);
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
expect(result.errorCode).toBe("NOT_CHOOSING_PLAYER");
|
|
119
|
+
expect(result.error).toContain("can choose the first player");
|
|
120
|
+
expect(result.error).toContain(String(choosingPlayer));
|
|
121
|
+
expect(result.error).toContain(String(otherPlayer));
|
|
122
|
+
expect(result.errorContext?.choosingPlayer).toBe(choosingPlayer);
|
|
123
|
+
expect(result.errorContext?.executingPlayer).toBe(otherPlayer);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Verify OTP was not set
|
|
127
|
+
expect(testEngine.getCtx().otp).toBeUndefined();
|
|
128
|
+
expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ========== Turn Player and Priority Tests ==========
|
|
132
|
+
|
|
133
|
+
it("should have no turn player but priority during chooseFirstPlayer phase", () => {
|
|
134
|
+
// Before choosing, turn player should be undefined
|
|
135
|
+
expect(testEngine.getTurnPlayer()).toBeUndefined();
|
|
136
|
+
|
|
137
|
+
// But priority should be the choosingFirstPlayer
|
|
138
|
+
const ctx = testEngine.getCtx();
|
|
139
|
+
expect(testEngine.getPriorityPlayers()).toContain(ctx.choosingFirstPlayer!);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should set turn player after OTP is chosen", () => {
|
|
143
|
+
const ctx = testEngine.getCtx();
|
|
144
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
145
|
+
|
|
146
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
147
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
148
|
+
|
|
149
|
+
// After choosing, turn player should be set to OTP
|
|
150
|
+
expect(testEngine.getTurnPlayer()).toBe(PLAYER_ONE);
|
|
151
|
+
|
|
152
|
+
// And priority should also be OTP (for mulligan)
|
|
153
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should switch priority after each player mulligans", () => {
|
|
157
|
+
const ctx = testEngine.getCtx();
|
|
158
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
159
|
+
|
|
160
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
161
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
162
|
+
|
|
163
|
+
// OTP has priority first
|
|
164
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_ONE);
|
|
165
|
+
|
|
166
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
167
|
+
testEngine.alterHand([]);
|
|
168
|
+
|
|
169
|
+
// After OTP mulligans, priority switches to other player
|
|
170
|
+
expect(testEngine.getPriorityPlayers()).toContain(PLAYER_TWO);
|
|
171
|
+
expect(testEngine.getPriorityPlayers()).not.toContain(PLAYER_ONE);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ========== Invalid Move Tests ==========
|
|
175
|
+
|
|
176
|
+
it("should reject invalid player ID", () => {
|
|
177
|
+
// Get the designated chooser so we can execute as them
|
|
178
|
+
const ctx = testEngine.getCtx();
|
|
179
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
180
|
+
|
|
181
|
+
// Attempt to choose an invalid player ID as the designated chooser
|
|
182
|
+
const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
|
|
183
|
+
playerId: createPlayerId(choosingPlayer || PLAYER_ONE),
|
|
184
|
+
params: { playerId: createPlayerId("invalid_player_id") },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Should fail with detailed error information
|
|
188
|
+
expect(result.success).toBe(false);
|
|
189
|
+
if (!result.success) {
|
|
190
|
+
expect(result.errorCode).toBe("INVALID_PLAYER_ID");
|
|
191
|
+
expect(result.error).toContain("Invalid player ID");
|
|
192
|
+
expect(result.error).toContain("invalid_player_id");
|
|
193
|
+
expect(result.errorContext?.playerId).toBe("invalid_player_id");
|
|
194
|
+
expect(result.errorContext?.validPlayers).toEqual([
|
|
195
|
+
PLAYER_ONE,
|
|
196
|
+
PLAYER_TWO,
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Verify OTP was not set
|
|
201
|
+
const updatedCtx = testEngine.getCtx();
|
|
202
|
+
expect(updatedCtx.otp).toBeUndefined();
|
|
203
|
+
expect(testEngine.getGamePhase()).toBe("chooseFirstPlayer");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should reject choosing first player twice", () => {
|
|
207
|
+
// Get the designated chooser
|
|
208
|
+
const ctx = testEngine.getCtx();
|
|
209
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
210
|
+
|
|
211
|
+
// Choose first player successfully as the designated chooser
|
|
212
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
213
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
214
|
+
const ctxAfterFirst = testEngine.getCtx();
|
|
215
|
+
expect(ctxAfterFirst.otp).toBe(PLAYER_ONE);
|
|
216
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
217
|
+
|
|
218
|
+
// Try to choose again - should fail
|
|
219
|
+
// Note: We need to be in chooseFirstPlayer phase, but we already transitioned to mulligan
|
|
220
|
+
// So this test verifies that once OTP is set, it can't be changed
|
|
221
|
+
// We'll need to manually set phase back for this specific test scenario
|
|
222
|
+
|
|
223
|
+
// For now, let's test by creating a new engine and trying to choose twice before phase transition
|
|
224
|
+
const testEngine2 = new LorcanaTestEngine(
|
|
225
|
+
{ hand: 7, deck: 10 },
|
|
226
|
+
{ hand: 7, deck: 10 },
|
|
227
|
+
{ skipPreGame: false },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Get the designated chooser for the new engine
|
|
231
|
+
const ctx2 = testEngine2.getCtx();
|
|
232
|
+
const choosingPlayer2 = ctx2.choosingFirstPlayer;
|
|
233
|
+
|
|
234
|
+
// Execute the move as the designated chooser
|
|
235
|
+
const result = testEngine2.engine.executeMove("chooseWhoGoesFirstMove", {
|
|
236
|
+
playerId: createPlayerId(choosingPlayer2 || PLAYER_ONE),
|
|
237
|
+
params: { playerId: createPlayerId(PLAYER_ONE) },
|
|
238
|
+
});
|
|
239
|
+
expect(result.success).toBe(true);
|
|
240
|
+
|
|
241
|
+
// Now try to choose again - should fail because phase has changed to mulligan
|
|
242
|
+
// Note: The move transitions to mulligan phase after successful execution,
|
|
243
|
+
// so the wrong phase error takes precedence over OTP already being set
|
|
244
|
+
const result2 = testEngine2.engine.executeMove("chooseWhoGoesFirstMove", {
|
|
245
|
+
playerId: createPlayerId(choosingPlayer2 || PLAYER_ONE),
|
|
246
|
+
params: { playerId: createPlayerId(PLAYER_TWO) },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result2.success).toBe(false);
|
|
250
|
+
if (!result2.success) {
|
|
251
|
+
expect(result2.errorCode).toBe("WRONG_PHASE");
|
|
252
|
+
expect(result2.error).toContain(
|
|
253
|
+
"Cannot choose first player during mulligan phase",
|
|
254
|
+
);
|
|
255
|
+
expect(result2.errorContext?.currentPhase).toBe("mulligan");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
testEngine2.dispose();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should reject choosing first player during wrong phase", () => {
|
|
262
|
+
// Get the designated chooser
|
|
263
|
+
const ctx = testEngine.getCtx();
|
|
264
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
265
|
+
|
|
266
|
+
// Choose first player successfully - this transitions to mulligan phase
|
|
267
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
268
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
269
|
+
expect(testEngine.getGamePhase()).toBe("mulligan");
|
|
270
|
+
|
|
271
|
+
// Try to choose first player again while in mulligan phase
|
|
272
|
+
const result = testEngine.engine.executeMove("chooseWhoGoesFirstMove", {
|
|
273
|
+
playerId: createPlayerId(choosingPlayer || PLAYER_ONE),
|
|
274
|
+
params: { playerId: createPlayerId(PLAYER_TWO) },
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Should fail with detailed error about wrong phase
|
|
278
|
+
expect(result.success).toBe(false);
|
|
279
|
+
if (!result.success) {
|
|
280
|
+
expect(result.errorCode).toBe("WRONG_PHASE");
|
|
281
|
+
expect(result.error).toContain(
|
|
282
|
+
"Cannot choose first player during mulligan phase",
|
|
283
|
+
);
|
|
284
|
+
expect(result.error).toContain("Must be in chooseFirstPlayer phase");
|
|
285
|
+
expect(result.errorContext?.currentPhase).toBe("mulligan");
|
|
286
|
+
expect(result.errorContext?.requiredPhase).toBe("chooseFirstPlayer");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Verify OTP hasn't changed
|
|
290
|
+
const updatedCtx = testEngine.getCtx();
|
|
291
|
+
expect(updatedCtx.otp).toBe(PLAYER_ONE); // Still player_one, not player_two
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ========== Move Enumeration Tests ==========
|
|
295
|
+
|
|
296
|
+
describe("Move Enumeration", () => {
|
|
297
|
+
it("should list chooseWhoGoesFirstMove as available for designated player", () => {
|
|
298
|
+
const ctx = testEngine.getCtx();
|
|
299
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
300
|
+
|
|
301
|
+
// Get available moves for the designated chooser
|
|
302
|
+
const availableMoves = testEngine.getAvailableMoves(
|
|
303
|
+
choosingPlayer || PLAYER_ONE,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Should include chooseWhoGoesFirstMove
|
|
307
|
+
expect(availableMoves).toContain("chooseWhoGoesFirstMove");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should NOT list chooseWhoGoesFirstMove for non-designated player", () => {
|
|
311
|
+
const ctx = testEngine.getCtx();
|
|
312
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
313
|
+
const otherPlayer =
|
|
314
|
+
choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
|
|
315
|
+
|
|
316
|
+
// Get available moves for the OTHER player
|
|
317
|
+
const availableMoves = testEngine.getAvailableMoves(otherPlayer);
|
|
318
|
+
|
|
319
|
+
// Should NOT include chooseWhoGoesFirstMove
|
|
320
|
+
expect(availableMoves).not.toContain("chooseWhoGoesFirstMove");
|
|
321
|
+
|
|
322
|
+
// Should be empty (no moves available during this phase)
|
|
323
|
+
expect(availableMoves).toEqual([]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should enumerate valid parameters for chooseWhoGoesFirstMove", () => {
|
|
327
|
+
const ctx = testEngine.getCtx();
|
|
328
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
329
|
+
|
|
330
|
+
// Enumerate parameters
|
|
331
|
+
const params = testEngine.enumerateMoveParameters(
|
|
332
|
+
"chooseWhoGoesFirstMove",
|
|
333
|
+
choosingPlayer || PLAYER_ONE,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// Should have valid combinations for both players
|
|
337
|
+
expect(params).not.toBeNull();
|
|
338
|
+
expect(params?.validCombinations).toHaveLength(2);
|
|
339
|
+
|
|
340
|
+
// Should include both player IDs
|
|
341
|
+
const playerIds = params?.validCombinations.map((c: any) => c.playerId);
|
|
342
|
+
expect(playerIds).toContain(PLAYER_ONE);
|
|
343
|
+
expect(playerIds).toContain(PLAYER_TWO);
|
|
344
|
+
|
|
345
|
+
// Should have parameter info
|
|
346
|
+
expect(params?.parameterInfo.playerId).toBeDefined();
|
|
347
|
+
expect(params?.parameterInfo.playerId.type).toBe("playerId");
|
|
348
|
+
expect(params?.parameterInfo.playerId.validValues).toEqual([
|
|
349
|
+
PLAYER_ONE,
|
|
350
|
+
PLAYER_TWO,
|
|
351
|
+
]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("should NOT enumerate parameters for non-designated player", () => {
|
|
355
|
+
const ctx = testEngine.getCtx();
|
|
356
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
357
|
+
const otherPlayer =
|
|
358
|
+
choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
|
|
359
|
+
|
|
360
|
+
// Try to enumerate parameters as other player
|
|
361
|
+
const params = testEngine.enumerateMoveParameters(
|
|
362
|
+
"chooseWhoGoesFirstMove",
|
|
363
|
+
otherPlayer,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Should return null (move not available)
|
|
367
|
+
expect(params).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("should provide detailed move information", () => {
|
|
371
|
+
const ctx = testEngine.getCtx();
|
|
372
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
373
|
+
|
|
374
|
+
// Get detailed move info
|
|
375
|
+
const moves = testEngine.getAvailableMovesDetailed(
|
|
376
|
+
choosingPlayer || PLAYER_ONE,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Should have one move
|
|
380
|
+
expect(moves).toHaveLength(1);
|
|
381
|
+
|
|
382
|
+
const moveInfo = moves[0];
|
|
383
|
+
expect(moveInfo.moveId).toBe("chooseWhoGoesFirstMove");
|
|
384
|
+
expect(moveInfo.displayName).toBe("Choose First Player");
|
|
385
|
+
expect(moveInfo.description).toContain("first");
|
|
386
|
+
expect(moveInfo.paramSchema).toBeDefined();
|
|
387
|
+
expect(moveInfo.paramSchema?.required).toHaveLength(1);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("should explain why non-designated player cannot choose", () => {
|
|
391
|
+
const ctx = testEngine.getCtx();
|
|
392
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
393
|
+
const otherPlayer =
|
|
394
|
+
choosingPlayer === PLAYER_ONE ? PLAYER_TWO : PLAYER_ONE;
|
|
395
|
+
|
|
396
|
+
// Try to execute as other player
|
|
397
|
+
const error = testEngine.whyCannotExecuteMove("chooseWhoGoesFirstMove", {
|
|
398
|
+
playerId: createPlayerId(otherPlayer),
|
|
399
|
+
params: { playerId: createPlayerId(PLAYER_ONE) },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Should have error
|
|
403
|
+
expect(error).not.toBeNull();
|
|
404
|
+
expect(error?.errorCode).toBe("NOT_CHOOSING_PLAYER");
|
|
405
|
+
expect(error?.reason).toContain("can choose the first player");
|
|
406
|
+
expect(error?.context?.choosingPlayer).toBe(choosingPlayer);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should remove chooseWhoGoesFirstMove after OTP is set", () => {
|
|
410
|
+
const ctx = testEngine.getCtx();
|
|
411
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
412
|
+
|
|
413
|
+
// Choose first player
|
|
414
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
415
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
416
|
+
|
|
417
|
+
// Get available moves for OTP (who has priority in mulligan phase)
|
|
418
|
+
const otpMoves = testEngine.getAvailableMoves(PLAYER_ONE);
|
|
419
|
+
|
|
420
|
+
expect(otpMoves).not.toContain("chooseWhoGoesFirstMove");
|
|
421
|
+
|
|
422
|
+
// OTP should have alterHand available (mulligan phase, has priority)
|
|
423
|
+
expect(otpMoves).toContain("alterHand");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should transition to mulligan moves after choosing first player", () => {
|
|
427
|
+
const ctx = testEngine.getCtx();
|
|
428
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
429
|
+
|
|
430
|
+
// Before choosing, should have chooseWhoGoesFirstMove
|
|
431
|
+
let availableMoves = testEngine.getAvailableMoves(
|
|
432
|
+
choosingPlayer || PLAYER_ONE,
|
|
433
|
+
);
|
|
434
|
+
expect(availableMoves).toContain("chooseWhoGoesFirstMove");
|
|
435
|
+
|
|
436
|
+
// Choose first player
|
|
437
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
438
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
439
|
+
|
|
440
|
+
// After choosing, OTP should have alterHand available (has priority)
|
|
441
|
+
availableMoves = testEngine.getAvailableMoves(PLAYER_ONE);
|
|
442
|
+
expect(availableMoves).toContain("alterHand");
|
|
443
|
+
|
|
444
|
+
// Other player should NOT have alterHand yet (doesn't have priority)
|
|
445
|
+
// Priority passes to them after OTP completes their mulligan
|
|
446
|
+
availableMoves = testEngine.getAvailableMoves(PLAYER_TWO);
|
|
447
|
+
expect(availableMoves).not.toContain("alterHand");
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type ConditionFailure, createMove, type PlayerId } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Choose Who Goes First Move
|
|
10
|
+
*
|
|
11
|
+
* Rule 3.1.1: First player determined randomly
|
|
12
|
+
*
|
|
13
|
+
* This move:
|
|
14
|
+
* - Marks the chosen player as OTP (On The Play)
|
|
15
|
+
* - Initializes pending mulligan list with all players
|
|
16
|
+
*
|
|
17
|
+
* The engine handles:
|
|
18
|
+
* - Setting activePlayer
|
|
19
|
+
* - Initializing turn counter
|
|
20
|
+
* - Transitioning to first phase
|
|
21
|
+
*
|
|
22
|
+
* Validation:
|
|
23
|
+
* - Player ID must be valid (exists in game)
|
|
24
|
+
* - OTP must not be already set (can't choose twice)
|
|
25
|
+
* - Must be in chooseFirstPlayer phase
|
|
26
|
+
*/
|
|
27
|
+
export const chooseWhoGoesFirstMove = createMove<
|
|
28
|
+
LorcanaGameState,
|
|
29
|
+
LorcanaMoveParams,
|
|
30
|
+
"chooseWhoGoesFirstMove",
|
|
31
|
+
LorcanaCardMeta
|
|
32
|
+
>({
|
|
33
|
+
condition: (state, context): true | ConditionFailure => {
|
|
34
|
+
const { playerId } = context.params;
|
|
35
|
+
|
|
36
|
+
// 1. Check we're in the correct phase (most fundamental constraint)
|
|
37
|
+
if (context.flow?.currentPhase !== "chooseFirstPlayer") {
|
|
38
|
+
return {
|
|
39
|
+
reason: `Cannot choose first player during ${context.flow?.currentPhase || "unknown"} phase. Must be in chooseFirstPlayer phase.`,
|
|
40
|
+
errorCode: "WRONG_PHASE",
|
|
41
|
+
context: {
|
|
42
|
+
currentPhase: context.flow?.currentPhase,
|
|
43
|
+
requiredPhase: "chooseFirstPlayer",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Check that the executing player is the one designated to choose
|
|
49
|
+
// Rule 3.1.2: One player is randomly determined to choose who is the starting player
|
|
50
|
+
const choosingPlayer = context.game.getChoosingFirstPlayer();
|
|
51
|
+
if (choosingPlayer && context.playerId !== choosingPlayer) {
|
|
52
|
+
return {
|
|
53
|
+
reason: `Only ${String(choosingPlayer)} can choose the first player. You are ${String(context.playerId)}.`,
|
|
54
|
+
errorCode: "NOT_CHOOSING_PLAYER",
|
|
55
|
+
context: {
|
|
56
|
+
choosingPlayer: String(choosingPlayer),
|
|
57
|
+
executingPlayer: String(context.playerId),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Check OTP hasn't been set yet (prevent choosing twice)
|
|
63
|
+
const currentOTP = context.game.getOTP();
|
|
64
|
+
if (currentOTP) {
|
|
65
|
+
return {
|
|
66
|
+
reason: "First player has already been chosen",
|
|
67
|
+
errorCode: "FIRST_PLAYER_ALREADY_CHOSEN",
|
|
68
|
+
context: {
|
|
69
|
+
currentOTP: String(currentOTP),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Validate player exists in the game
|
|
75
|
+
const validPlayers = Object.keys(state.external.loreScores) as PlayerId[];
|
|
76
|
+
if (!validPlayers.includes(playerId)) {
|
|
77
|
+
return {
|
|
78
|
+
reason: `Invalid player ID: ${playerId}. Valid players: ${validPlayers.join(", ")}`,
|
|
79
|
+
errorCode: "INVALID_PLAYER_ID",
|
|
80
|
+
context: {
|
|
81
|
+
playerId: String(playerId),
|
|
82
|
+
validPlayers: validPlayers.map((p) => String(p)),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
reducer: (draft, context) => {
|
|
91
|
+
const { playerId } = context.params;
|
|
92
|
+
|
|
93
|
+
context.game.setOTP(playerId);
|
|
94
|
+
|
|
95
|
+
// All players can mulligan after first player is chosen
|
|
96
|
+
// Get all player IDs from the game state
|
|
97
|
+
context.game.setPendingMulligan(
|
|
98
|
+
Object.keys(draft.external.loreScores) as PlayerId[],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
if (context.flow) {
|
|
102
|
+
context.flow.endPhase("chooseFirstPlayer");
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Draw Cards Move
|
|
10
|
+
*
|
|
11
|
+
* Utility move for drawing cards from deck to hand.
|
|
12
|
+
* Used by:
|
|
13
|
+
* - Beginning phase (draw for turn)
|
|
14
|
+
* - Card effects that draw cards
|
|
15
|
+
* - Testing/debugging
|
|
16
|
+
*/
|
|
17
|
+
export const drawCards = createMove<
|
|
18
|
+
LorcanaGameState,
|
|
19
|
+
LorcanaMoveParams,
|
|
20
|
+
"drawCards",
|
|
21
|
+
LorcanaCardMeta
|
|
22
|
+
>({
|
|
23
|
+
condition: (_state, context) => {
|
|
24
|
+
// Not available during chooseFirstPlayer phase
|
|
25
|
+
return context.flow?.currentPhase !== "chooseFirstPlayer";
|
|
26
|
+
},
|
|
27
|
+
reducer: (_draft, context) => {
|
|
28
|
+
const { playerId, count } = context.params;
|
|
29
|
+
|
|
30
|
+
context.zones.drawCards({
|
|
31
|
+
from: "deck" as ZoneId,
|
|
32
|
+
to: "hand" as ZoneId,
|
|
33
|
+
count,
|
|
34
|
+
playerId,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
|
|
2
|
+
import { useLorcanaOps } from "../../../operations";
|
|
3
|
+
import type {
|
|
4
|
+
LorcanaCardMeta,
|
|
5
|
+
LorcanaGameState,
|
|
6
|
+
LorcanaMoveParams,
|
|
7
|
+
} from "../../../types";
|
|
8
|
+
import { and, cardInHand, isMainPhase } from "../../../validators";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sing Together Move (Legacy)
|
|
12
|
+
*
|
|
13
|
+
* Rule 10.10: Multiple characters exert to sing together
|
|
14
|
+
*
|
|
15
|
+
* Note: Prefer using playCard with "singTogether" cost parameter instead.
|
|
16
|
+
* This move is kept for backward compatibility.
|
|
17
|
+
*
|
|
18
|
+
* Requirements:
|
|
19
|
+
* - Song must be in hand
|
|
20
|
+
* - All singers must be ready (not exerted)
|
|
21
|
+
* - Combined ink values of singers must meet or exceed song cost
|
|
22
|
+
*/
|
|
23
|
+
export const singTogether = createMove<
|
|
24
|
+
LorcanaGameState,
|
|
25
|
+
LorcanaMoveParams,
|
|
26
|
+
"singTogether",
|
|
27
|
+
LorcanaCardMeta
|
|
28
|
+
>({
|
|
29
|
+
condition: and(isMainPhase(), (state, context) =>
|
|
30
|
+
cardInHand(context.params.songId)(state, context),
|
|
31
|
+
),
|
|
32
|
+
reducer: (draft, context) => {
|
|
33
|
+
const { singersIds, songId } = context.params;
|
|
34
|
+
const ops = useLorcanaOps(context);
|
|
35
|
+
|
|
36
|
+
// Exert all singers
|
|
37
|
+
for (const singerId of singersIds) {
|
|
38
|
+
ops.exertCard(singerId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Play song to discard
|
|
42
|
+
context.zones.moveCard({
|
|
43
|
+
cardId: songId,
|
|
44
|
+
targetZoneId: "discard" as ZoneId,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
|
|
2
|
+
import { useLorcanaOps } from "../../../operations";
|
|
3
|
+
import type {
|
|
4
|
+
LorcanaCardMeta,
|
|
5
|
+
LorcanaGameState,
|
|
6
|
+
LorcanaMoveParams,
|
|
7
|
+
} from "../../../types";
|
|
8
|
+
import {
|
|
9
|
+
and,
|
|
10
|
+
cardInHand,
|
|
11
|
+
cardInPlay,
|
|
12
|
+
cardOwnedByPlayer,
|
|
13
|
+
isMainPhase,
|
|
14
|
+
} from "../../../validators";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Sing Move (Legacy)
|
|
18
|
+
*
|
|
19
|
+
* Rule 6.3.3: Exert character to play song for free
|
|
20
|
+
*
|
|
21
|
+
* Note: Prefer using playCard with "sing" cost parameter instead.
|
|
22
|
+
* This move is kept for backward compatibility.
|
|
23
|
+
*
|
|
24
|
+
* Requirements:
|
|
25
|
+
* - Song must be in hand
|
|
26
|
+
* - Singer must be in play
|
|
27
|
+
* - Singer must be owned by current player
|
|
28
|
+
* - Singer must be able to sing (ready, not drying, meets cost requirement)
|
|
29
|
+
*/
|
|
30
|
+
export const sing = createMove<
|
|
31
|
+
LorcanaGameState,
|
|
32
|
+
LorcanaMoveParams,
|
|
33
|
+
"sing",
|
|
34
|
+
LorcanaCardMeta
|
|
35
|
+
>({
|
|
36
|
+
condition: and(
|
|
37
|
+
isMainPhase(),
|
|
38
|
+
(state, context) => cardInHand(context.params.songId)(state, context),
|
|
39
|
+
(state, context) => cardInPlay(context.params.singerId)(state, context),
|
|
40
|
+
(state, context) =>
|
|
41
|
+
cardOwnedByPlayer(context.params.singerId)(state, context),
|
|
42
|
+
),
|
|
43
|
+
reducer: (draft, context) => {
|
|
44
|
+
const { singerId, songId } = context.params;
|
|
45
|
+
const ops = useLorcanaOps(context);
|
|
46
|
+
|
|
47
|
+
// Exert singer
|
|
48
|
+
ops.exertCard(singerId);
|
|
49
|
+
|
|
50
|
+
// Play song to discard (actions go to discard)
|
|
51
|
+
context.zones.moveCard({
|
|
52
|
+
cardId: songId,
|
|
53
|
+
targetZoneId: "discard" as ZoneId,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
});
|