@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,448 @@
|
|
|
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: Quest", () => {
|
|
10
|
+
let testEngine: LorcanaTestEngine;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testEngine = new LorcanaTestEngine(
|
|
14
|
+
{ hand: 5, deck: 10, play: 2, inkwell: 0 }, // Player one with characters in play
|
|
15
|
+
{ hand: 5, deck: 10, play: 2, inkwell: 0 }, // Player two with characters in play
|
|
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 and clear summoning sickness
|
|
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, now characters have been through a turn cycle
|
|
44
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
45
|
+
testEngine.passTurn(); // main -> end -> turn 5 beginning -> main (player_one)
|
|
46
|
+
|
|
47
|
+
// Now on player_one's turn with characters no longer summoning sick
|
|
48
|
+
testEngine.changeActivePlayer(PLAYER_ONE);
|
|
49
|
+
// Now characters are dry and can quest
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
testEngine.dispose();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ========== Basic Questing Behavior ==========
|
|
57
|
+
|
|
58
|
+
describe("Basic Questing Behavior", () => {
|
|
59
|
+
it("should successfully quest with a ready character", () => {
|
|
60
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
61
|
+
const character = playZone[0];
|
|
62
|
+
const initialLore = testEngine.getLore(PLAYER_ONE);
|
|
63
|
+
|
|
64
|
+
expect(playZone.length).toBeGreaterThan(0);
|
|
65
|
+
expect(initialLore).toBe(0);
|
|
66
|
+
|
|
67
|
+
// Quest with the character
|
|
68
|
+
testEngine.quest(character);
|
|
69
|
+
|
|
70
|
+
// Verify lore increased
|
|
71
|
+
const newLore = testEngine.getLore(PLAYER_ONE);
|
|
72
|
+
expect(newLore).toBe(initialLore + 1); // Currently hardcoded to 1 lore per quest
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should exert character after questing", () => {
|
|
76
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
77
|
+
const character = playZone[0];
|
|
78
|
+
|
|
79
|
+
// Character should start ready (not exerted)
|
|
80
|
+
const cardMeta = testEngine.getCardMeta(character);
|
|
81
|
+
expect(cardMeta?.state).toBe("ready");
|
|
82
|
+
|
|
83
|
+
// Quest with the character
|
|
84
|
+
testEngine.quest(character);
|
|
85
|
+
|
|
86
|
+
// Character should now be exerted
|
|
87
|
+
const newCardMeta = testEngine.getCardMeta(character);
|
|
88
|
+
expect(newCardMeta?.state).toBe("exerted");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should mark character as having quested", () => {
|
|
92
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
93
|
+
const character = playZone[0];
|
|
94
|
+
|
|
95
|
+
// Quest with the character
|
|
96
|
+
testEngine.quest(character);
|
|
97
|
+
|
|
98
|
+
// Character should be marked as quested
|
|
99
|
+
const ctx = testEngine.getCtx();
|
|
100
|
+
const hasQuested = ctx.trackers?.check(
|
|
101
|
+
`quested:${character}`,
|
|
102
|
+
createPlayerId(PLAYER_ONE),
|
|
103
|
+
);
|
|
104
|
+
expect(hasQuested).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should allow multiple different characters to quest in one turn", () => {
|
|
108
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
109
|
+
const char1 = playZone[0];
|
|
110
|
+
const char2 = playZone[1];
|
|
111
|
+
|
|
112
|
+
const initialLore = testEngine.getLore(PLAYER_ONE);
|
|
113
|
+
|
|
114
|
+
// Quest with both characters
|
|
115
|
+
testEngine.quest(char1);
|
|
116
|
+
testEngine.quest(char2);
|
|
117
|
+
|
|
118
|
+
// Lore should increase by 2 (1 per character)
|
|
119
|
+
const newLore = testEngine.getLore(PLAYER_ONE);
|
|
120
|
+
expect(newLore).toBe(initialLore + 2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ========== Summoning Sickness Validation ==========
|
|
125
|
+
|
|
126
|
+
describe("Summoning Sickness Validation", () => {
|
|
127
|
+
it("should reject questing with drying (just played) characters", () => {
|
|
128
|
+
// Create a fresh engine without passing turns
|
|
129
|
+
const freshEngine = new LorcanaTestEngine(
|
|
130
|
+
{ hand: 5, deck: 10, play: 1 },
|
|
131
|
+
{ hand: 5, deck: 10 },
|
|
132
|
+
{ skipPreGame: false },
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Complete setup
|
|
136
|
+
const ctx = freshEngine.getCtx();
|
|
137
|
+
freshEngine.changeActivePlayer(ctx.choosingFirstPlayer || PLAYER_ONE);
|
|
138
|
+
freshEngine.chooseWhoGoesFirst(PLAYER_ONE);
|
|
139
|
+
freshEngine.changeActivePlayer(PLAYER_ONE);
|
|
140
|
+
freshEngine.alterHand([]);
|
|
141
|
+
freshEngine.changeActivePlayer(PLAYER_TWO);
|
|
142
|
+
freshEngine.alterHand([]);
|
|
143
|
+
freshEngine.changeActivePlayer(PLAYER_ONE);
|
|
144
|
+
|
|
145
|
+
// Characters in play are still "drying"
|
|
146
|
+
const playZone = freshEngine.getZone("play", PLAYER_ONE);
|
|
147
|
+
const dryingChar = playZone[0];
|
|
148
|
+
|
|
149
|
+
// Try to quest - should fail
|
|
150
|
+
const result = freshEngine.engine.executeMove("quest", {
|
|
151
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
152
|
+
params: {
|
|
153
|
+
cardId: dryingChar,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.success).toBe(false);
|
|
158
|
+
|
|
159
|
+
freshEngine.dispose();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should allow questing after character dries (next turn)", () => {
|
|
163
|
+
// This is the default behavior tested in beforeEach
|
|
164
|
+
// Characters become dry after passing turns
|
|
165
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
166
|
+
const driedChar = playZone[0];
|
|
167
|
+
|
|
168
|
+
// Should successfully quest
|
|
169
|
+
testEngine.quest(driedChar);
|
|
170
|
+
|
|
171
|
+
const lore = testEngine.getLore(PLAYER_ONE);
|
|
172
|
+
expect(lore).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ========== Exerted State Validation ==========
|
|
177
|
+
|
|
178
|
+
describe("Exerted State Validation", () => {
|
|
179
|
+
it("should reject questing with already exerted character", () => {
|
|
180
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
181
|
+
const character = playZone[0];
|
|
182
|
+
|
|
183
|
+
// Quest once (exerts the character)
|
|
184
|
+
testEngine.quest(character);
|
|
185
|
+
|
|
186
|
+
// Try to quest again with exerted character - should fail
|
|
187
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
188
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
189
|
+
params: {
|
|
190
|
+
cardId: character,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.success).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should allow questing after character is readied (next turn)", () => {
|
|
198
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
199
|
+
const character = playZone[0];
|
|
200
|
+
|
|
201
|
+
// Quest once
|
|
202
|
+
testEngine.quest(character);
|
|
203
|
+
|
|
204
|
+
// Pass turn and come back (readies characters)
|
|
205
|
+
testEngine.passTurn(); // Pass to player two
|
|
206
|
+
testEngine.passTurn(); // Pass back to player one
|
|
207
|
+
|
|
208
|
+
// Should be able to quest again (character is readied)
|
|
209
|
+
testEngine.quest(character);
|
|
210
|
+
|
|
211
|
+
const lore = testEngine.getLore(PLAYER_ONE);
|
|
212
|
+
expect(lore).toBe(2); // 1 from first quest + 1 from second
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ========== Once-Per-Turn Validation ==========
|
|
217
|
+
|
|
218
|
+
describe("Once-Per-Turn Validation", () => {
|
|
219
|
+
it("should reject questing twice with same character in one turn", () => {
|
|
220
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
221
|
+
const character = playZone[0];
|
|
222
|
+
|
|
223
|
+
// Quest once
|
|
224
|
+
testEngine.quest(character);
|
|
225
|
+
|
|
226
|
+
// Try to quest again - should fail due to tracker
|
|
227
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
228
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
229
|
+
params: {
|
|
230
|
+
cardId: character,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.success).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should allow questing again after turn passes", () => {
|
|
238
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
239
|
+
const character = playZone[0];
|
|
240
|
+
|
|
241
|
+
// Quest once
|
|
242
|
+
testEngine.quest(character);
|
|
243
|
+
|
|
244
|
+
// Pass turn and come back
|
|
245
|
+
testEngine.passTurn(); // Pass to player two
|
|
246
|
+
testEngine.passTurn(); // Pass back to player one
|
|
247
|
+
|
|
248
|
+
// Should be able to quest again
|
|
249
|
+
testEngine.quest(character);
|
|
250
|
+
|
|
251
|
+
const lore = testEngine.getLore(PLAYER_ONE);
|
|
252
|
+
expect(lore).toBe(2);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ========== Ownership & Location Validation ==========
|
|
257
|
+
|
|
258
|
+
describe("Ownership & Location Validation", () => {
|
|
259
|
+
it("should reject opponent's characters", () => {
|
|
260
|
+
const opponentPlay = testEngine.getZone("play", PLAYER_TWO);
|
|
261
|
+
const opponentChar = opponentPlay[0];
|
|
262
|
+
|
|
263
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
264
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
265
|
+
params: {
|
|
266
|
+
cardId: opponentChar,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result.success).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should reject characters not in play - character in hand", () => {
|
|
274
|
+
const hand = testEngine.getZone("hand", PLAYER_ONE);
|
|
275
|
+
const charInHand = hand[0];
|
|
276
|
+
|
|
277
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
278
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
279
|
+
params: {
|
|
280
|
+
cardId: charInHand,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(result.success).toBe(false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should reject characters not in play - character in deck", () => {
|
|
288
|
+
const deck = testEngine.getZone("deck", PLAYER_ONE);
|
|
289
|
+
const charInDeck = deck[0];
|
|
290
|
+
|
|
291
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
292
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
293
|
+
params: {
|
|
294
|
+
cardId: charInDeck,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(result.success).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("should reject invalid card IDs", () => {
|
|
302
|
+
const result = testEngine.engine.executeMove("quest", {
|
|
303
|
+
playerId: createPlayerId(PLAYER_ONE),
|
|
304
|
+
params: {
|
|
305
|
+
cardId: "invalid-card-id-12345",
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(result.success).toBe(false);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ========== Lore Accumulation ==========
|
|
314
|
+
|
|
315
|
+
describe("Lore Accumulation", () => {
|
|
316
|
+
it("should accumulate lore correctly over multiple quests", () => {
|
|
317
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
318
|
+
|
|
319
|
+
// Quest with first character
|
|
320
|
+
testEngine.quest(playZone[0]);
|
|
321
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBe(1);
|
|
322
|
+
|
|
323
|
+
// Quest with second character
|
|
324
|
+
testEngine.quest(playZone[1]);
|
|
325
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBe(2);
|
|
326
|
+
|
|
327
|
+
// Pass turn and quest again
|
|
328
|
+
testEngine.passTurn(); // Pass to player two
|
|
329
|
+
testEngine.passTurn(); // Pass back to player one
|
|
330
|
+
|
|
331
|
+
testEngine.quest(playZone[0]);
|
|
332
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBe(3);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should track lore separately for each player", () => {
|
|
336
|
+
const p1Play = testEngine.getZone("play", PLAYER_ONE);
|
|
337
|
+
const p1Char = p1Play[0];
|
|
338
|
+
|
|
339
|
+
// Player one quests
|
|
340
|
+
testEngine.quest(p1Char);
|
|
341
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBe(1);
|
|
342
|
+
expect(testEngine.getLore(PLAYER_TWO)).toBe(0);
|
|
343
|
+
|
|
344
|
+
// Pass to player two
|
|
345
|
+
testEngine.passTurn();
|
|
346
|
+
testEngine.changeActivePlayer(PLAYER_TWO);
|
|
347
|
+
|
|
348
|
+
const p2Play = testEngine.getZone("play", PLAYER_TWO);
|
|
349
|
+
const p2Char = p2Play[0];
|
|
350
|
+
|
|
351
|
+
// Player two quests
|
|
352
|
+
testEngine.quest(p2Char);
|
|
353
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBe(1);
|
|
354
|
+
expect(testEngine.getLore(PLAYER_TWO)).toBe(1);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("should persist lore across turns", () => {
|
|
358
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
359
|
+
const character = playZone[0];
|
|
360
|
+
|
|
361
|
+
// Quest
|
|
362
|
+
testEngine.quest(character);
|
|
363
|
+
const loreAfterQuest = testEngine.getLore(PLAYER_ONE);
|
|
364
|
+
|
|
365
|
+
// Pass multiple turns
|
|
366
|
+
testEngine.passTurn();
|
|
367
|
+
testEngine.passTurn();
|
|
368
|
+
testEngine.passTurn();
|
|
369
|
+
testEngine.passTurn();
|
|
370
|
+
|
|
371
|
+
// Lore should not decrease
|
|
372
|
+
const loreAfterTurns = testEngine.getLore(PLAYER_ONE);
|
|
373
|
+
expect(loreAfterTurns).toBe(loreAfterQuest);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ========== Win Condition Integration ==========
|
|
378
|
+
|
|
379
|
+
describe("Win Condition Integration", () => {
|
|
380
|
+
it("should continue game when lore is less than 20", () => {
|
|
381
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
382
|
+
|
|
383
|
+
// Quest to gain some lore (but not 20)
|
|
384
|
+
testEngine.quest(playZone[0]);
|
|
385
|
+
|
|
386
|
+
// Game should still be ongoing
|
|
387
|
+
// Game should still be ongoing
|
|
388
|
+
// Note: Win condition check happens at specific points in game flow
|
|
389
|
+
// Just verify lore is less than 20
|
|
390
|
+
expect(testEngine.getLore(PLAYER_ONE)).toBeLessThan(20);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("should end game when player reaches 20 lore", () => {
|
|
394
|
+
// This test verifies the win condition logic exists
|
|
395
|
+
// In a real game, reaching 20 lore would trigger game end
|
|
396
|
+
// For now, we'll just verify we can accumulate toward 20
|
|
397
|
+
|
|
398
|
+
let lore = 0;
|
|
399
|
+
let turns = 0;
|
|
400
|
+
const maxTurns = 100; // Prevent infinite loop
|
|
401
|
+
|
|
402
|
+
// Keep questing until we reach 20 lore or max turns
|
|
403
|
+
while (lore < 20 && turns < maxTurns) {
|
|
404
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
405
|
+
|
|
406
|
+
// Quest with available characters
|
|
407
|
+
for (const char of playZone) {
|
|
408
|
+
if (lore >= 20) break;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
testEngine.quest(char);
|
|
412
|
+
lore = testEngine.getLore(PLAYER_ONE);
|
|
413
|
+
} catch {}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Stop if we've reached 20 lore (game ended)
|
|
417
|
+
if (lore >= 20) break;
|
|
418
|
+
|
|
419
|
+
// Pass turns to reset characters
|
|
420
|
+
testEngine.passTurn();
|
|
421
|
+
testEngine.passTurn();
|
|
422
|
+
turns++;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Verify we can reach 20 lore through questing
|
|
426
|
+
expect(lore).toBeGreaterThanOrEqual(20);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ========== Phase Validation ==========
|
|
431
|
+
|
|
432
|
+
describe("Phase Validation", () => {
|
|
433
|
+
it("should only allow questing during main phase", () => {
|
|
434
|
+
const playZone = testEngine.getZone("play", PLAYER_ONE);
|
|
435
|
+
const character = playZone[0];
|
|
436
|
+
|
|
437
|
+
// We're in main phase by default in our tests
|
|
438
|
+
// Quest should succeed
|
|
439
|
+
testEngine.quest(character);
|
|
440
|
+
|
|
441
|
+
const lore = testEngine.getLore(PLAYER_ONE);
|
|
442
|
+
expect(lore).toBe(1);
|
|
443
|
+
|
|
444
|
+
// Note: Testing other phases would require more complex game flow control
|
|
445
|
+
// The move definition already has isMainPhase() check
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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, canQuest, isMainPhase } from "../../../validators";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Quest Move
|
|
12
|
+
*
|
|
13
|
+
* Rule 4.3.5: Exert character to gain lore
|
|
14
|
+
*
|
|
15
|
+
* Requirements:
|
|
16
|
+
* - Character is ready (not exerted)
|
|
17
|
+
* - Character is not drying (summoning sickness)
|
|
18
|
+
* - Character has Lore value > 0
|
|
19
|
+
* - Haven't quested with this character this turn
|
|
20
|
+
*
|
|
21
|
+
* Effects:
|
|
22
|
+
* - Exert the character
|
|
23
|
+
* - Add Lore value to player's lore count
|
|
24
|
+
* - Mark character as having quested this turn
|
|
25
|
+
*/
|
|
26
|
+
export const quest = createMove<
|
|
27
|
+
LorcanaGameState,
|
|
28
|
+
LorcanaMoveParams,
|
|
29
|
+
"quest",
|
|
30
|
+
LorcanaCardMeta
|
|
31
|
+
>({
|
|
32
|
+
condition: and(isMainPhase(), (state, context) =>
|
|
33
|
+
canQuest(context.params.cardId)(state, context),
|
|
34
|
+
),
|
|
35
|
+
reducer: (draft, context) => {
|
|
36
|
+
const { cardId } = context.params;
|
|
37
|
+
const ops = useLorcanaOps(context);
|
|
38
|
+
|
|
39
|
+
// Exert the character
|
|
40
|
+
ops.exertCard(cardId);
|
|
41
|
+
|
|
42
|
+
// Add lore (simplified - assume 1 lore per quest)
|
|
43
|
+
// In full implementation, would read Lore value from card definition
|
|
44
|
+
ops.addLore(draft, context.playerId, 1);
|
|
45
|
+
|
|
46
|
+
// Mark as quested
|
|
47
|
+
context.trackers?.mark(`quested:${cardId}`, context.playerId);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manual Exert (Debug/Testing)
|
|
11
|
+
*
|
|
12
|
+
* Manually exert a card without conditions.
|
|
13
|
+
*
|
|
14
|
+
* WARNING: This bypasses normal game rules and should only be used for:
|
|
15
|
+
* - Testing
|
|
16
|
+
* - Debugging
|
|
17
|
+
* - Development tools
|
|
18
|
+
*
|
|
19
|
+
* Do NOT use in production game logic.
|
|
20
|
+
*/
|
|
21
|
+
export const manualExert = createMove<
|
|
22
|
+
LorcanaGameState,
|
|
23
|
+
LorcanaMoveParams,
|
|
24
|
+
"manualExert",
|
|
25
|
+
LorcanaCardMeta
|
|
26
|
+
>({
|
|
27
|
+
condition: (_state, context) => {
|
|
28
|
+
// Not available during chooseFirstPlayer phase
|
|
29
|
+
return context.flow?.currentPhase !== "chooseFirstPlayer";
|
|
30
|
+
},
|
|
31
|
+
reducer: (_draft, context) => {
|
|
32
|
+
const { cardId } = context.params;
|
|
33
|
+
const ops = useLorcanaOps(context);
|
|
34
|
+
ops.exertCard(cardId);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createMove } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve Bag Effect
|
|
10
|
+
*
|
|
11
|
+
* Rule 8.7: Bag system for triggered effects
|
|
12
|
+
*
|
|
13
|
+
* The "bag" is a queue of triggered effects that need to be resolved.
|
|
14
|
+
* This move removes a resolved effect from the bag.
|
|
15
|
+
*
|
|
16
|
+
* Process:
|
|
17
|
+
* 1. Execute the effect logic
|
|
18
|
+
* 2. Remove the bag entry
|
|
19
|
+
* 3. Allow next effect in bag to be processed
|
|
20
|
+
*/
|
|
21
|
+
export const resolveBag = createMove<
|
|
22
|
+
LorcanaGameState,
|
|
23
|
+
LorcanaMoveParams,
|
|
24
|
+
"resolveBag",
|
|
25
|
+
LorcanaCardMeta
|
|
26
|
+
>({
|
|
27
|
+
condition: (state, _context) => {
|
|
28
|
+
// Only available when there are bags to resolve
|
|
29
|
+
return state.external.bag && state.external.bag.length > 0;
|
|
30
|
+
},
|
|
31
|
+
reducer: (draft, context) => {
|
|
32
|
+
const { bagId } = context.params;
|
|
33
|
+
draft.external.bag = draft.external.bag.filter((b) => b.id !== bagId);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createMove } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve Effect
|
|
10
|
+
*
|
|
11
|
+
* Generic effect resolution move for handling ongoing effects.
|
|
12
|
+
*
|
|
13
|
+
* Process:
|
|
14
|
+
* 1. Execute the effect logic
|
|
15
|
+
* 2. Remove the effect from the effects list
|
|
16
|
+
* 3. Clean up any associated state
|
|
17
|
+
*/
|
|
18
|
+
export const resolveEffect = createMove<
|
|
19
|
+
LorcanaGameState,
|
|
20
|
+
LorcanaMoveParams,
|
|
21
|
+
"resolveEffect",
|
|
22
|
+
LorcanaCardMeta
|
|
23
|
+
>({
|
|
24
|
+
condition: (state, _context) => {
|
|
25
|
+
// Only available when there are effects to resolve
|
|
26
|
+
return state.external.effects && state.external.effects.length > 0;
|
|
27
|
+
},
|
|
28
|
+
reducer: (draft, context) => {
|
|
29
|
+
const { effectId } = context.params;
|
|
30
|
+
draft.external.effects = draft.external.effects.filter(
|
|
31
|
+
(e) => e.id !== effectId,
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { GameMoveDefinitions } from "@drmxrcy/tcg-core";
|
|
2
|
+
import type {
|
|
3
|
+
LorcanaCardMeta,
|
|
4
|
+
LorcanaGameState,
|
|
5
|
+
LorcanaMoveParams,
|
|
6
|
+
} from "../../types";
|
|
7
|
+
// Ability moves
|
|
8
|
+
import { activateAbility } from "./abilities/activate-ability";
|
|
9
|
+
import { challenge } from "./core/challenge";
|
|
10
|
+
// Core moves
|
|
11
|
+
import { playCard } from "./core/play-card";
|
|
12
|
+
import { quest } from "./core/quest";
|
|
13
|
+
// Debug moves
|
|
14
|
+
import { manualExert } from "./debug/manual-exert";
|
|
15
|
+
// Effect moves
|
|
16
|
+
import { resolveBag } from "./effects/resolve-bag";
|
|
17
|
+
import { resolveEffect } from "./effects/resolve-effect";
|
|
18
|
+
// Location moves
|
|
19
|
+
import { moveCharacterToLocation } from "./locations/move-character-to-location";
|
|
20
|
+
// Resource moves
|
|
21
|
+
import { putACardIntoTheInkwell } from "./resources/put-card-into-inkwell";
|
|
22
|
+
import { alterHand } from "./setup/alter-hand";
|
|
23
|
+
// Setup moves
|
|
24
|
+
import { chooseWhoGoesFirstMove } from "./setup/choose-first-player";
|
|
25
|
+
import { drawCards } from "./setup/draw-cards";
|
|
26
|
+
// Song moves
|
|
27
|
+
import { sing } from "./songs/sing";
|
|
28
|
+
import { singTogether } from "./songs/sing-together";
|
|
29
|
+
import { concede } from "./standard/concede";
|
|
30
|
+
// Standard moves
|
|
31
|
+
import { passTurn } from "./standard/pass-turn";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lorcana Move Definitions
|
|
35
|
+
*
|
|
36
|
+
* All game moves organized by category:
|
|
37
|
+
* - Setup: Game initialization moves
|
|
38
|
+
* - Resources: Ink management
|
|
39
|
+
* - Core: Primary gameplay (play, quest, challenge)
|
|
40
|
+
* - Songs: Singing mechanics
|
|
41
|
+
* - Locations: Location interactions
|
|
42
|
+
* - Abilities: Activated abilities
|
|
43
|
+
* - Effects: Effect resolution
|
|
44
|
+
* - Debug: Testing utilities
|
|
45
|
+
* - Standard: Pass and concede
|
|
46
|
+
*/
|
|
47
|
+
export const lorcanaMoves: GameMoveDefinitions<
|
|
48
|
+
LorcanaGameState,
|
|
49
|
+
LorcanaMoveParams,
|
|
50
|
+
LorcanaCardMeta
|
|
51
|
+
> = {
|
|
52
|
+
// ===== Setup Moves =====
|
|
53
|
+
chooseWhoGoesFirstMove,
|
|
54
|
+
alterHand,
|
|
55
|
+
drawCards,
|
|
56
|
+
|
|
57
|
+
// ===== Resource Moves =====
|
|
58
|
+
putACardIntoTheInkwell,
|
|
59
|
+
|
|
60
|
+
// ===== Core Game Moves =====
|
|
61
|
+
playCard,
|
|
62
|
+
quest,
|
|
63
|
+
challenge,
|
|
64
|
+
|
|
65
|
+
// ===== Song Moves =====
|
|
66
|
+
sing,
|
|
67
|
+
singTogether,
|
|
68
|
+
|
|
69
|
+
// ===== Location Moves =====
|
|
70
|
+
moveCharacterToLocation,
|
|
71
|
+
|
|
72
|
+
// ===== Ability Moves =====
|
|
73
|
+
activateAbility,
|
|
74
|
+
|
|
75
|
+
// ===== Effect Resolution =====
|
|
76
|
+
resolveBag,
|
|
77
|
+
resolveEffect,
|
|
78
|
+
|
|
79
|
+
// ===== Manual Actions (Testing/Debug) =====
|
|
80
|
+
manualExert,
|
|
81
|
+
|
|
82
|
+
// ===== Standard Moves =====
|
|
83
|
+
passTurn,
|
|
84
|
+
concede,
|
|
85
|
+
};
|