@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,42 @@
|
|
|
1
|
+
import { createMove } from "@drmxrcy/tcg-core";
|
|
2
|
+
import { useLorcanaOps } from "../../../operations";
|
|
3
|
+
import type {
|
|
4
|
+
LorcanaCardMeta,
|
|
5
|
+
LorcanaGameState,
|
|
6
|
+
LorcanaMoveParams,
|
|
7
|
+
} from "../../../types";
|
|
8
|
+
import { and, cardInPlay, isMainPhase } from "../../../validators";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Move Character to Location
|
|
12
|
+
*
|
|
13
|
+
* Rule 6.5: Characters can move to locations
|
|
14
|
+
*
|
|
15
|
+
* Requirements:
|
|
16
|
+
* - Character must be in play
|
|
17
|
+
* - Location must be in play
|
|
18
|
+
* - Location must have available slots
|
|
19
|
+
* - Character must meet location requirements
|
|
20
|
+
*
|
|
21
|
+
* Effects:
|
|
22
|
+
* - Character gains location bonuses
|
|
23
|
+
* - Character counts toward location conditions
|
|
24
|
+
*/
|
|
25
|
+
export const moveCharacterToLocation = createMove<
|
|
26
|
+
LorcanaGameState,
|
|
27
|
+
LorcanaMoveParams,
|
|
28
|
+
"moveCharacterToLocation",
|
|
29
|
+
LorcanaCardMeta
|
|
30
|
+
>({
|
|
31
|
+
condition: and(
|
|
32
|
+
isMainPhase(),
|
|
33
|
+
(state, context) => cardInPlay(context.params.characterId)(state, context),
|
|
34
|
+
(state, context) => cardInPlay(context.params.locationId)(state, context),
|
|
35
|
+
),
|
|
36
|
+
reducer: (_draft, context) => {
|
|
37
|
+
const { characterId, locationId } = context.params;
|
|
38
|
+
const ops = useLorcanaOps(context);
|
|
39
|
+
|
|
40
|
+
ops.moveToLocation(characterId, locationId);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,462 @@
|
|
|
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: Put a Card Into The Inkwell", () => {
|
|
10
|
+
let testEngine: LorcanaTestEngine;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testEngine = new LorcanaTestEngine(
|
|
14
|
+
{ hand: 7, deck: 10, inkwell: 0 },
|
|
15
|
+
{ hand: 7, deck: 10, inkwell: 0 },
|
|
16
|
+
{ skipPreGame: false },
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// Complete pre-game setup to get to main phase
|
|
20
|
+
const ctx = testEngine.getCtx();
|
|
21
|
+
const choosingPlayer = ctx.choosingFirstPlayer;
|
|
22
|
+
testEngine.changeActivePlayer(choosingPlayer || PLAYER_ONE);
|
|
23
|
+
testEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
24
|
+
|
|
25
|
+
// Complete mulligans for both players
|
|
26
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
27
|
+
testEngine.alterHand([]);
|
|
28
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
29
|
+
testEngine.alterHand([]);
|
|
30
|
+
|
|
31
|
+
// After mulligans, game is in mainGame segment, turn 2, player_two's turn
|
|
32
|
+
// Need to pass turns to get to a stable state
|
|
33
|
+
|
|
34
|
+
// Player two takes their turn (beginning -> main -> end -> next turn)
|
|
35
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
36
|
+
testEngine.passTurn(); // beginning -> main
|
|
37
|
+
testEngine.passTurn(); // main -> end -> turn 3 beginning -> main (player_one)
|
|
38
|
+
|
|
39
|
+
// Player one takes their turn
|
|
40
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
41
|
+
testEngine.passTurn(); // main -> end -> turn 4 beginning -> main (player_two)
|
|
42
|
+
|
|
43
|
+
// Back to player two
|
|
44
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
45
|
+
testEngine.passTurn(); // main -> end -> turn 5 beginning -> main (player_one)
|
|
46
|
+
|
|
47
|
+
// Now on player_one's turn in main phase
|
|
48
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
49
|
+
// Now we're in main phase and can ink cards
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
testEngine.dispose();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ========== Basic Behavior Tests ==========
|
|
57
|
+
|
|
58
|
+
describe("Basic Inking Behavior", () => {
|
|
59
|
+
it("should successfully ink a card from hand", () => {
|
|
60
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
61
|
+
const cardToInk = hand[0];
|
|
62
|
+
const initialHandSize = hand.length;
|
|
63
|
+
|
|
64
|
+
expect(initialHandSize).toBeGreaterThan(0);
|
|
65
|
+
expect(testEngine.getZone("inkwell", PLAYER_ONE).length).toBe(0);
|
|
66
|
+
|
|
67
|
+
// Ink the card
|
|
68
|
+
testEngine.putCardInInkwell(cardToInk);
|
|
69
|
+
|
|
70
|
+
// Verify card moved zones
|
|
71
|
+
const newHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
72
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
73
|
+
|
|
74
|
+
expect(newHand.length).toBe(initialHandSize - 1);
|
|
75
|
+
expect(newHand).not.toContain(cardToInk);
|
|
76
|
+
expect(inkwell.length).toBe(1);
|
|
77
|
+
expect(inkwell).toContain(cardToInk);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should mark hasInked tracker after inking", () => {
|
|
81
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
82
|
+
const cardToInk = hand[0];
|
|
83
|
+
|
|
84
|
+
// Initially should not be marked (may be undefined or false)
|
|
85
|
+
const ctx = testEngine.getCtx();
|
|
86
|
+
const initialInked = ctx.trackers?.check(
|
|
87
|
+
"hasInked",
|
|
88
|
+
createPlayerId(PLAYER_ONE),
|
|
89
|
+
);
|
|
90
|
+
expect(initialInked).toBeFalsy(); // undefined or false
|
|
91
|
+
|
|
92
|
+
// Ink the card
|
|
93
|
+
testEngine.putCardInInkwell(cardToInk);
|
|
94
|
+
|
|
95
|
+
// Should now be marked
|
|
96
|
+
const newCtx = testEngine.getCtx();
|
|
97
|
+
expect(
|
|
98
|
+
newCtx.trackers?.check("hasInked", createPlayerId(PLAYER_ONE)),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should allow inking different cards in different turns", () => {
|
|
103
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
104
|
+
const firstCard = hand[0];
|
|
105
|
+
|
|
106
|
+
// Ink first card
|
|
107
|
+
testEngine.putCardInInkwell(firstCard);
|
|
108
|
+
|
|
109
|
+
// Pass turn and come back to player one
|
|
110
|
+
testEngine.passTurn(); // Pass to player two
|
|
111
|
+
testEngine.passTurn(); // Pass back to player one
|
|
112
|
+
|
|
113
|
+
const newHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
114
|
+
const secondCard = newHand[0];
|
|
115
|
+
|
|
116
|
+
// Should be able to ink again
|
|
117
|
+
testEngine.putCardInInkwell(secondCard);
|
|
118
|
+
|
|
119
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
120
|
+
expect(inkwell.length).toBe(2);
|
|
121
|
+
expect(inkwell).toContain(firstCard);
|
|
122
|
+
expect(inkwell).toContain(secondCard);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ========== Once-Per-Turn Validation ==========
|
|
127
|
+
|
|
128
|
+
describe("Once-Per-Turn Validation", () => {
|
|
129
|
+
it("should reject second ink attempt in same turn", () => {
|
|
130
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
131
|
+
const firstCard = hand[0];
|
|
132
|
+
const secondCard = hand[1];
|
|
133
|
+
|
|
134
|
+
// First ink succeeds
|
|
135
|
+
testEngine.putCardInInkwell(firstCard);
|
|
136
|
+
|
|
137
|
+
// Second ink should fail
|
|
138
|
+
const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
|
|
139
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
140
|
+
params: {
|
|
141
|
+
cardId: secondCard,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
// The error should indicate the action was already used
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
// Accept either specific or generic error code
|
|
149
|
+
expect(
|
|
150
|
+
result.errorCode === "ALREADY_USED_ACTION" ||
|
|
151
|
+
result.errorCode === "CONDITION_FAILED",
|
|
152
|
+
).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Verify second card still in hand
|
|
156
|
+
const newHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
157
|
+
expect(newHand).toContain(secondCard);
|
|
158
|
+
|
|
159
|
+
// Verify only one card in inkwell
|
|
160
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
161
|
+
expect(inkwell.length).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should allow inking after turn passes", () => {
|
|
165
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
166
|
+
const firstCard = hand[0];
|
|
167
|
+
|
|
168
|
+
// Ink first card
|
|
169
|
+
testEngine.putCardInInkwell(firstCard);
|
|
170
|
+
|
|
171
|
+
// Pass turn and come back to player one
|
|
172
|
+
testEngine.passTurn(); // Pass to player two
|
|
173
|
+
testEngine.passTurn(); // Pass back to player one
|
|
174
|
+
const newHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
175
|
+
const secondCard = newHand[0];
|
|
176
|
+
|
|
177
|
+
// Should succeed
|
|
178
|
+
testEngine.putCardInInkwell(secondCard);
|
|
179
|
+
|
|
180
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
181
|
+
expect(inkwell.length).toBe(2);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ========== Card Location Validation ==========
|
|
186
|
+
|
|
187
|
+
describe("Card Location Validation", () => {
|
|
188
|
+
it("should reject cards not in hand - card in deck", () => {
|
|
189
|
+
const deck = testEngine.getZone("deck", PLAYER_ONE);
|
|
190
|
+
const cardInDeck = deck[0];
|
|
191
|
+
|
|
192
|
+
const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
|
|
193
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
194
|
+
params: {
|
|
195
|
+
cardId: cardInDeck,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result.success).toBe(false);
|
|
200
|
+
// Card should still be in deck
|
|
201
|
+
expect(testEngine.getZone("deck", PLAYER_ONE)).toContain(cardInDeck);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should reject cards not in hand - card in play", () => {
|
|
205
|
+
// Set up with a card in play
|
|
206
|
+
const testEngineWithPlay = new LorcanaTestEngine(
|
|
207
|
+
{ hand: 7, deck: 10, play: 1 },
|
|
208
|
+
{ hand: 7, deck: 10 },
|
|
209
|
+
{ skipPreGame: false },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Complete setup
|
|
213
|
+
const ctx = testEngineWithPlay.getCtx();
|
|
214
|
+
testEngineWithPlay.changeActivePlayer(
|
|
215
|
+
ctx.choosingFirstPlayer || PLAYER_ONE,
|
|
216
|
+
);
|
|
217
|
+
testEngineWithPlay.chooseWhoGoesFirst(PLAYER_ONE);
|
|
218
|
+
testEngineWithPlay.changeActivePlayer(PLAYER_ONE);
|
|
219
|
+
testEngineWithPlay.alterHand([]);
|
|
220
|
+
testEngineWithPlay.changeActivePlayer(PLAYER_TWO);
|
|
221
|
+
testEngineWithPlay.alterHand([]);
|
|
222
|
+
testEngineWithPlay.changeActivePlayer(PLAYER_ONE);
|
|
223
|
+
|
|
224
|
+
const playZone = testEngineWithPlay.getZone("play", PLAYER_ONE);
|
|
225
|
+
const cardInPlay = playZone[0];
|
|
226
|
+
|
|
227
|
+
const result = testEngineWithPlay.engine.executeMove(
|
|
228
|
+
"putACardIntoTheInkwell",
|
|
229
|
+
{
|
|
230
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
231
|
+
params: {
|
|
232
|
+
cardId: cardInPlay,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(result.success).toBe(false);
|
|
238
|
+
|
|
239
|
+
testEngineWithPlay.dispose();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("should reject invalid card IDs", () => {
|
|
243
|
+
const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
|
|
244
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
245
|
+
params: {
|
|
246
|
+
cardId: "invalid-card-id-12345",
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(result.success).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should reject opponent's cards", () => {
|
|
254
|
+
const opponentHand = testEngine.getZone("hand", PLAYER_TWO);
|
|
255
|
+
const opponentCard = opponentHand[0];
|
|
256
|
+
|
|
257
|
+
const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
|
|
258
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
259
|
+
params: {
|
|
260
|
+
cardId: opponentCard,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(result.success).toBe(false);
|
|
265
|
+
|
|
266
|
+
// Card should still be in opponent's hand
|
|
267
|
+
expect(testEngine.getZone("hand", PLAYER_TWO)).toContain(opponentCard);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should reject cards already in inkwell", () => {
|
|
271
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
272
|
+
const cardToInk = hand[0];
|
|
273
|
+
|
|
274
|
+
// Ink the card first time
|
|
275
|
+
testEngine.putCardInInkwell(cardToInk);
|
|
276
|
+
|
|
277
|
+
// Pass turn to reset hasInked tracker
|
|
278
|
+
testEngine.passTurn();
|
|
279
|
+
testEngine.passTurn();
|
|
280
|
+
|
|
281
|
+
// Try to ink the same card again (now it's in inkwell)
|
|
282
|
+
const result = testEngine.engine.executeMove("putACardIntoTheInkwell", {
|
|
283
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
284
|
+
params: {
|
|
285
|
+
cardId: cardToInk,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ========== Priority Tests ==========
|
|
294
|
+
|
|
295
|
+
describe("Priority and Turn Order", () => {
|
|
296
|
+
it("should allow both players to ink on their respective turns", () => {
|
|
297
|
+
// Player one inks
|
|
298
|
+
const p1Hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
299
|
+
const p1Card = p1Hand[0];
|
|
300
|
+
testEngine.putCardInInkwell(p1Card);
|
|
301
|
+
|
|
302
|
+
// Pass to player two
|
|
303
|
+
testEngine.passTurn();
|
|
304
|
+
|
|
305
|
+
// Player two inks
|
|
306
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
307
|
+
const p2Hand = testEngine.getZone("hand", PLAYER_TWO);
|
|
308
|
+
const p2Card = p2Hand[0];
|
|
309
|
+
testEngine.putCardInInkwell(p2Card);
|
|
310
|
+
|
|
311
|
+
// Verify both inkwells
|
|
312
|
+
expect(testEngine.getZone("inkwell", PLAYER_ONE)).toContain(p1Card);
|
|
313
|
+
expect(testEngine.getZone("inkwell", PLAYER_TWO)).toContain(p2Card);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ========== Edge Cases ==========
|
|
318
|
+
|
|
319
|
+
describe("Edge Cases", () => {
|
|
320
|
+
it("should handle empty hand gracefully", () => {
|
|
321
|
+
const emptyHandEngine = new LorcanaTestEngine(
|
|
322
|
+
{ hand: 0, deck: 10 },
|
|
323
|
+
{ hand: 7, deck: 10 },
|
|
324
|
+
{ skipPreGame: false },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Complete setup
|
|
328
|
+
const ctx = emptyHandEngine.getCtx();
|
|
329
|
+
emptyHandEngine.changeActivePlayer(ctx.choosingFirstPlayer || PLAYER_ONE);
|
|
330
|
+
emptyHandEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
331
|
+
emptyHandEngine.changeActivePlayer(PLAYER_ONE);
|
|
332
|
+
emptyHandEngine.alterHand([]);
|
|
333
|
+
emptyHandEngine.changeActivePlayer(PLAYER_TWO);
|
|
334
|
+
emptyHandEngine.alterHand([]);
|
|
335
|
+
emptyHandEngine.changeActivePlayer(PLAYER_ONE);
|
|
336
|
+
|
|
337
|
+
// Try to ink with no cards in hand
|
|
338
|
+
const result = emptyHandEngine.engine.executeMove(
|
|
339
|
+
"putACardIntoTheInkwell",
|
|
340
|
+
{
|
|
341
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
342
|
+
params: {
|
|
343
|
+
cardId: "any-card-id",
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(result.success).toBe(false);
|
|
349
|
+
|
|
350
|
+
emptyHandEngine.dispose();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should accumulate cards in inkwell over multiple turns", () => {
|
|
354
|
+
const cardsInked: string[] = [];
|
|
355
|
+
|
|
356
|
+
// Ink a card each turn for 3 turns
|
|
357
|
+
for (let i = 0; i < 3; i++) {
|
|
358
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
359
|
+
if (hand.length > 0) {
|
|
360
|
+
const cardToInk = hand[0];
|
|
361
|
+
cardsInked.push(cardToInk);
|
|
362
|
+
|
|
363
|
+
testEngine.putCardInInkwell(cardToInk);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Pass both players' turns
|
|
367
|
+
testEngine.passTurn();
|
|
368
|
+
testEngine.passTurn();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Verify all cards are in inkwell
|
|
372
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
373
|
+
expect(inkwell.length).toBe(3);
|
|
374
|
+
|
|
375
|
+
for (const card of cardsInked) {
|
|
376
|
+
expect(inkwell).toContain(card);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("should verify inkwell is a public zone", () => {
|
|
381
|
+
// This is verified by the zone configuration
|
|
382
|
+
// Just ensure inking works and cards are visible
|
|
383
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
384
|
+
const cardToInk = hand[0];
|
|
385
|
+
|
|
386
|
+
testEngine.putCardInInkwell(cardToInk);
|
|
387
|
+
|
|
388
|
+
// Both players should be able to see inkwell (public zone)
|
|
389
|
+
const p1Inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
390
|
+
const p2View = testEngine.getZone("inkwell", PLAYER_ONE); // Same zone
|
|
391
|
+
|
|
392
|
+
expect(p1Inkwell).toEqual(p2View);
|
|
393
|
+
expect(p1Inkwell).toContain(cardToInk);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ========== Integration Tests ==========
|
|
398
|
+
|
|
399
|
+
describe("Integration with Game Flow", () => {
|
|
400
|
+
it("should reset hasInked tracker at start of turn", () => {
|
|
401
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
402
|
+
const firstCard = hand[0];
|
|
403
|
+
|
|
404
|
+
// Ink a card
|
|
405
|
+
testEngine.putCardInInkwell(firstCard);
|
|
406
|
+
|
|
407
|
+
// Verify tracker is marked
|
|
408
|
+
let ctx = testEngine.getCtx();
|
|
409
|
+
expect(ctx.trackers?.check("hasInked", createPlayerId(PLAYER_ONE))).toBe(
|
|
410
|
+
true,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Pass turn and come back
|
|
414
|
+
testEngine.passTurn();
|
|
415
|
+
testEngine.passTurn();
|
|
416
|
+
|
|
417
|
+
// Tracker should be reset (undefined or false)
|
|
418
|
+
ctx = testEngine.getCtx();
|
|
419
|
+
const trackerValue = ctx.trackers?.check(
|
|
420
|
+
"hasInked",
|
|
421
|
+
createPlayerId(PLAYER_ONE),
|
|
422
|
+
);
|
|
423
|
+
expect(trackerValue).toBeFalsy(); // undefined or false
|
|
424
|
+
|
|
425
|
+
// Should be able to ink again
|
|
426
|
+
const newHand = testEngine.getZone("hand", PLAYER_ONE);
|
|
427
|
+
if (newHand.length > 0) {
|
|
428
|
+
const secondCard = newHand[0];
|
|
429
|
+
testEngine.putCardInInkwell(secondCard);
|
|
430
|
+
|
|
431
|
+
const inkwell = testEngine.getZone("inkwell", PLAYER_ONE);
|
|
432
|
+
expect(inkwell.length).toBe(2);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("should work correctly in a realistic game scenario", () => {
|
|
437
|
+
// Simulate several turns of gameplay with inking
|
|
438
|
+
for (let turn = 0; turn < 3; turn++) {
|
|
439
|
+
// Player one's turn
|
|
440
|
+
const p1Hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
441
|
+
if (p1Hand.length > 0) {
|
|
442
|
+
testEngine.putCardInInkwell(p1Hand[0]);
|
|
443
|
+
}
|
|
444
|
+
testEngine.passTurn();
|
|
445
|
+
|
|
446
|
+
// Player two's turn
|
|
447
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
448
|
+
const p2Hand = testEngine.getZone("hand", PLAYER_TWO);
|
|
449
|
+
if (p2Hand.length > 0) {
|
|
450
|
+
testEngine.putCardInInkwell(p2Hand[0]);
|
|
451
|
+
}
|
|
452
|
+
testEngine.passTurn();
|
|
453
|
+
|
|
454
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Verify both players have accumulated ink
|
|
458
|
+
expect(testEngine.getZone("inkwell", PLAYER_ONE).length).toBe(3);
|
|
459
|
+
expect(testEngine.getZone("inkwell", PLAYER_TWO).length).toBe(3);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createMove, type ZoneId } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../../types";
|
|
7
|
+
import {
|
|
8
|
+
and,
|
|
9
|
+
cardInHand,
|
|
10
|
+
cardOwnedByPlayer,
|
|
11
|
+
hasNotUsedAction,
|
|
12
|
+
isMainPhase,
|
|
13
|
+
} from "../../../validators";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Put a Card Into The Inkwell
|
|
17
|
+
*
|
|
18
|
+
* Rule 4.3.3: Once per turn, put an inkable card into inkwell
|
|
19
|
+
*
|
|
20
|
+
* Conditions:
|
|
21
|
+
* - Must be in Main phase
|
|
22
|
+
* - Card must be in hand
|
|
23
|
+
* - Card must be owned by current player
|
|
24
|
+
* - Player hasn't inked this turn
|
|
25
|
+
*/
|
|
26
|
+
export const putACardIntoTheInkwell = createMove<
|
|
27
|
+
LorcanaGameState,
|
|
28
|
+
LorcanaMoveParams,
|
|
29
|
+
"putACardIntoTheInkwell",
|
|
30
|
+
LorcanaCardMeta
|
|
31
|
+
>({
|
|
32
|
+
condition: and(
|
|
33
|
+
isMainPhase(),
|
|
34
|
+
(state, context) => cardInHand(context.params.cardId)(state, context),
|
|
35
|
+
(state, context) =>
|
|
36
|
+
cardOwnedByPlayer(context.params.cardId)(state, context),
|
|
37
|
+
hasNotUsedAction("hasInked"),
|
|
38
|
+
),
|
|
39
|
+
reducer: (_draft, context) => {
|
|
40
|
+
const { cardId } = context.params;
|
|
41
|
+
|
|
42
|
+
// Move card to inkwell
|
|
43
|
+
context.zones.moveCard({
|
|
44
|
+
cardId,
|
|
45
|
+
targetZoneId: "inkwell" as ZoneId,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Mark action as used
|
|
49
|
+
context.trackers?.mark("hasInked", context.playerId);
|
|
50
|
+
},
|
|
51
|
+
});
|