@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.
Files changed (100) hide show
  1. package/README.md +160 -0
  2. package/package.json +45 -0
  3. package/src/__tests__/integration/move-enumeration.test.ts +256 -0
  4. package/src/__tests__/rules/section-01-concepts.test.ts +426 -0
  5. package/src/__tests__/rules/section-03-gameplay.test.ts +298 -0
  6. package/src/__tests__/rules/section-04-turn-structure.test.ts +708 -0
  7. package/src/__tests__/rules/section-05-cards.test.ts +158 -0
  8. package/src/__tests__/rules/section-06-card-types.test.ts +342 -0
  9. package/src/__tests__/rules/section-07-abilities.test.ts +333 -0
  10. package/src/__tests__/rules/section-08-zones.test.ts +231 -0
  11. package/src/__tests__/rules/section-09-damage.test.ts +148 -0
  12. package/src/__tests__/rules/section-10-keywords.test.ts +469 -0
  13. package/src/__tests__/spec-01-foundation-types.test.ts +534 -0
  14. package/src/__tests__/spec-02-zones-card-states.test.ts +295 -0
  15. package/src/card-utils.ts +302 -0
  16. package/src/cards/README.md +296 -0
  17. package/src/cards/abilities/index.ts +175 -0
  18. package/src/cards/index.ts +10 -0
  19. package/src/deck-validation.ts +175 -0
  20. package/src/engine/lorcana-engine.ts +625 -0
  21. package/src/game-definition/__tests__/core-zone-integration.test.ts +553 -0
  22. package/src/game-definition/__tests__/zone-operations.test.ts +362 -0
  23. package/src/game-definition/__tests__/zones.test.ts +176 -0
  24. package/src/game-definition/definition.ts +45 -0
  25. package/src/game-definition/flow/turn-flow.ts +216 -0
  26. package/src/game-definition/index.ts +31 -0
  27. package/src/game-definition/moves/abilities/activate-ability.ts +51 -0
  28. package/src/game-definition/moves/core/__tests__/move-parameter-enumeration.test.ts +316 -0
  29. package/src/game-definition/moves/core/challenge.test.ts +545 -0
  30. package/src/game-definition/moves/core/challenge.ts +81 -0
  31. package/src/game-definition/moves/core/play-card.ts +83 -0
  32. package/src/game-definition/moves/core/quest.test.ts +448 -0
  33. package/src/game-definition/moves/core/quest.ts +49 -0
  34. package/src/game-definition/moves/debug/manual-exert.ts +36 -0
  35. package/src/game-definition/moves/effects/resolve-bag.ts +35 -0
  36. package/src/game-definition/moves/effects/resolve-effect.ts +34 -0
  37. package/src/game-definition/moves/index.ts +85 -0
  38. package/src/game-definition/moves/locations/move-character-to-location.ts +42 -0
  39. package/src/game-definition/moves/resources/put-card-into-inkwell.test.ts +462 -0
  40. package/src/game-definition/moves/resources/put-card-into-inkwell.ts +51 -0
  41. package/src/game-definition/moves/setup/alter-hand.test.ts +395 -0
  42. package/src/game-definition/moves/setup/alter-hand.ts +210 -0
  43. package/src/game-definition/moves/setup/choose-first-player.test.ts +450 -0
  44. package/src/game-definition/moves/setup/choose-first-player.ts +105 -0
  45. package/src/game-definition/moves/setup/draw-cards.ts +37 -0
  46. package/src/game-definition/moves/songs/sing-together.ts +47 -0
  47. package/src/game-definition/moves/songs/sing.ts +56 -0
  48. package/src/game-definition/moves/standard/concede.test.ts +189 -0
  49. package/src/game-definition/moves/standard/concede.ts +72 -0
  50. package/src/game-definition/moves/standard/pass-turn.ts +49 -0
  51. package/src/game-definition/setup/game-setup.ts +19 -0
  52. package/src/game-definition/trackers/tracker-config.ts +23 -0
  53. package/src/game-definition/win-conditions/lore-victory.ts +26 -0
  54. package/src/game-definition/zone-operations.ts +405 -0
  55. package/src/game-definition/zones/zone-configs.ts +59 -0
  56. package/src/game-definition/zones.ts +283 -0
  57. package/src/index.ts +189 -0
  58. package/src/operations/index.ts +7 -0
  59. package/src/operations/lorcana-operations.ts +288 -0
  60. package/src/queries/README.md +56 -0
  61. package/src/resolvers/__tests__/condition-resolver.test.ts +301 -0
  62. package/src/resolvers/condition-registry.ts +70 -0
  63. package/src/resolvers/condition-resolver.ts +85 -0
  64. package/src/resolvers/conditions/basic.ts +81 -0
  65. package/src/resolvers/conditions/card-state.ts +12 -0
  66. package/src/resolvers/conditions/comparison.ts +102 -0
  67. package/src/resolvers/conditions/existence.ts +219 -0
  68. package/src/resolvers/conditions/history.ts +68 -0
  69. package/src/resolvers/conditions/index.ts +15 -0
  70. package/src/resolvers/conditions/logical.ts +55 -0
  71. package/src/resolvers/conditions/resolution.ts +41 -0
  72. package/src/resolvers/conditions/revealed.ts +42 -0
  73. package/src/resolvers/conditions/zone.ts +84 -0
  74. package/src/setup.test.ts +18 -0
  75. package/src/targeting/__tests__/filter-resolver.test.ts +294 -0
  76. package/src/targeting/__tests__/real-cards-targeting.test.ts +303 -0
  77. package/src/targeting/__tests__/targeting-dsl.test.ts +386 -0
  78. package/src/targeting/enum-expansion.ts +387 -0
  79. package/src/targeting/filter-registry.ts +322 -0
  80. package/src/targeting/filter-resolver.ts +145 -0
  81. package/src/targeting/index.ts +91 -0
  82. package/src/targeting/lorcana-target-dsl.ts +495 -0
  83. package/src/targeting/targeting-ui.ts +407 -0
  84. package/src/testing/index.ts +14 -0
  85. package/src/testing/lorcana-test-engine.ts +813 -0
  86. package/src/types/README.md +303 -0
  87. package/src/types/__tests__/lorcana-state.test.ts +168 -0
  88. package/src/types/__tests__/move-enumeration.test.ts +179 -0
  89. package/src/types/branded-types.ts +106 -0
  90. package/src/types/game-state.ts +184 -0
  91. package/src/types/index.ts +87 -0
  92. package/src/types/keywords.ts +187 -0
  93. package/src/types/lorcana-state.ts +260 -0
  94. package/src/types/move-enumeration.ts +126 -0
  95. package/src/types/move-params.ts +216 -0
  96. package/src/validators/index.ts +7 -0
  97. package/src/validators/move-validators.ts +374 -0
  98. package/src/zones/card-state.ts +234 -0
  99. package/src/zones/index.ts +42 -0
  100. package/src/zones/zone-config.ts +150 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Targeting UI Utilities
3
+ *
4
+ * Provides utilities for UI integration with the targeting DSL.
5
+ * Converts DSL specifications to human-readable descriptions and
6
+ * UI hints for target selection interfaces.
7
+ *
8
+ * @module targeting/targeting-ui
9
+ */
10
+
11
+ import type { TargetingUIHint } from "@drmxrcy/tcg-core";
12
+ import {
13
+ expandCharacterTarget,
14
+ expandItemTarget,
15
+ expandLocationTarget,
16
+ isCharacterEnum,
17
+ isItemEnum,
18
+ isLocationEnum,
19
+ } from "./enum-expansion";
20
+ import type {
21
+ LorcanaCardTarget,
22
+ LorcanaCharacterTarget,
23
+ LorcanaFilter,
24
+ LorcanaItemTarget,
25
+ LorcanaLocationTarget,
26
+ } from "./lorcana-target-dsl";
27
+
28
+ // ============================================================================
29
+ // Description Generation
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Generate a human-readable description of a target specification
34
+ *
35
+ * @param target - Target DSL or enum
36
+ * @returns Human-readable description
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * generateTargetDescription("CHOSEN_OPPOSING_CHARACTER")
41
+ * // => "an opposing character"
42
+ *
43
+ * generateTargetDescription({
44
+ * selector: "all",
45
+ * owner: "opponent",
46
+ * cardType: "character",
47
+ * filters: [{ type: "damaged" }]
48
+ * })
49
+ * // => "all opposing damaged characters"
50
+ * ```
51
+ */
52
+ export function generateTargetDescription(
53
+ target: LorcanaCharacterTarget | LorcanaItemTarget | LorcanaLocationTarget,
54
+ ): string {
55
+ // Expand enum to DSL if needed
56
+ let dsl: LorcanaCardTarget;
57
+ if (typeof target === "string") {
58
+ if (isCharacterEnum(target as LorcanaCharacterTarget)) {
59
+ dsl = expandCharacterTarget(target as LorcanaCharacterTarget);
60
+ } else if (isItemEnum(target as LorcanaItemTarget)) {
61
+ dsl = expandItemTarget(target as LorcanaItemTarget);
62
+ } else if (isLocationEnum(target as LorcanaLocationTarget)) {
63
+ dsl = expandLocationTarget(target as LorcanaLocationTarget);
64
+ } else {
65
+ return target; // Fallback to raw string
66
+ }
67
+ } else {
68
+ dsl = target;
69
+ }
70
+
71
+ return generateDSLDescription(dsl);
72
+ }
73
+
74
+ /**
75
+ * Generate description from a DSL object
76
+ */
77
+ function generateDSLDescription(target: LorcanaCardTarget): string {
78
+ const parts: string[] = [];
79
+
80
+ // Handle self-reference
81
+ if (target.selector === "self") {
82
+ return `this ${target.cardType || "card"}`;
83
+ }
84
+
85
+ // Count/Selector
86
+ if (target.selector === "all" || target.selector === "each") {
87
+ parts.push("all");
88
+ } else if (target.selector === "chosen") {
89
+ const count = getCountValue(target.count);
90
+ if (count === 1) {
91
+ // Defer article choice until we know the next word
92
+ parts.push("__ARTICLE__");
93
+ } else if (typeof count === "number") {
94
+ parts.push(String(count));
95
+ } else if (count && typeof count === "object" && "upTo" in count) {
96
+ parts.push(`up to ${count.upTo}`);
97
+ }
98
+ }
99
+
100
+ // Ownership
101
+ if (target.owner === "opponent") {
102
+ parts.push("opposing");
103
+ } else if (target.owner === "you") {
104
+ parts.push("your");
105
+ }
106
+
107
+ // Filter-based adjectives (state filters come before card type)
108
+ if (target.filters) {
109
+ for (const filter of target.filters) {
110
+ const adjective = getFilterAdjective(filter);
111
+ if (adjective) {
112
+ parts.push(adjective);
113
+ }
114
+ }
115
+ }
116
+
117
+ // Card type (pluralized if multiple)
118
+ const cardType = target.cardType || "card";
119
+ const isPlural =
120
+ target.selector === "all" ||
121
+ target.selector === "each" ||
122
+ (typeof target.count === "number" && target.count > 1);
123
+ parts.push(isPlural ? pluralize(cardType) : cardType);
124
+
125
+ // Numeric filter suffixes
126
+ if (target.filters) {
127
+ for (const filter of target.filters) {
128
+ const suffix = getFilterSuffix(filter);
129
+ if (suffix) {
130
+ parts.push(suffix);
131
+ }
132
+ }
133
+ }
134
+
135
+ // Replace article placeholder with correct article based on following word
136
+ const result = parts.join(" ");
137
+ return result.replace(/__ARTICLE__\s+(\w)/, (_, nextChar) => {
138
+ const vowels = ["a", "e", "i", "o", "u"];
139
+ return vowels.includes(nextChar.toLowerCase())
140
+ ? `an ${nextChar}`
141
+ : `a ${nextChar}`;
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Get count value from TargetCount
147
+ */
148
+ function getCountValue(
149
+ count: LorcanaCardTarget["count"],
150
+ ): number | { upTo: number } | "all" | undefined {
151
+ if (count === undefined) return 1;
152
+ if (count === "all") return "all";
153
+ if (typeof count === "number") return count;
154
+ if ("exactly" in count) return count.exactly;
155
+ if ("upTo" in count) return { upTo: count.upTo };
156
+ if ("atLeast" in count) return count.atLeast;
157
+ if ("between" in count) return count.between[0];
158
+ return 1;
159
+ }
160
+
161
+ /**
162
+ * Exhaustive check helper - fails at compile time if a case is not handled
163
+ */
164
+ function assertNever(x: never): never {
165
+ throw new Error(`Unhandled filter type: ${JSON.stringify(x)}`);
166
+ }
167
+
168
+ /**
169
+ * Get adjective form of a filter (for placement before card type)
170
+ */
171
+ function getFilterAdjective(filter: LorcanaFilter): string | undefined {
172
+ switch (filter.type) {
173
+ case "damaged":
174
+ return "damaged";
175
+ case "undamaged":
176
+ return "undamaged";
177
+ case "exerted":
178
+ return "exerted";
179
+ case "ready":
180
+ return "ready";
181
+ case "dry":
182
+ return "fresh";
183
+ case "inkable":
184
+ return filter.value ? "inkable" : "non-inkable";
185
+ // Filters that don't produce adjectives (handled in getFilterSuffix)
186
+ case "has-keyword":
187
+ case "has-classification":
188
+ case "cost":
189
+ case "strength":
190
+ case "willpower":
191
+ case "lore-value":
192
+ case "name":
193
+ case "at-location":
194
+ case "move-cost":
195
+ case "and":
196
+ case "or":
197
+ case "card-type":
198
+ case "not":
199
+ return undefined;
200
+ default:
201
+ // Exhaustive check - will fail to compile if a new filter type is added
202
+ return assertNever(filter);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Get suffix form of a filter (for placement after card type)
208
+ */
209
+ function getFilterSuffix(filter: LorcanaFilter): string | undefined {
210
+ switch (filter.type) {
211
+ case "has-keyword":
212
+ return `with ${filter.keyword}`;
213
+ case "has-classification":
214
+ return `with ${filter.classification} classification`;
215
+ case "cost":
216
+ return `with cost ${getComparisonSymbol(filter.comparison)} ${filter.value}`;
217
+ case "strength":
218
+ return `with strength ${getComparisonSymbol(filter.comparison)} ${filter.value}`;
219
+ case "willpower":
220
+ return `with willpower ${getComparisonSymbol(filter.comparison)} ${filter.value}`;
221
+ case "lore-value":
222
+ return `with lore ${getComparisonSymbol(filter.comparison)} ${filter.value}`;
223
+ case "at-location":
224
+ return filter.location ? `at ${filter.location}` : "at a location";
225
+ case "move-cost":
226
+ return `with move cost ${getComparisonSymbol(filter.comparison)} ${filter.value}`;
227
+ case "name":
228
+ if ("equals" in filter) return `named ${filter.equals}`;
229
+ if ("contains" in filter) return `with "${filter.contains}" in name`;
230
+ return undefined;
231
+ // State filters handled in getFilterAdjective
232
+ case "damaged":
233
+ case "undamaged":
234
+ case "exerted":
235
+ case "ready":
236
+ case "dry":
237
+ case "inkable":
238
+ return undefined;
239
+ // Composite filters - recursively process
240
+ case "and":
241
+ case "or":
242
+ case "card-type":
243
+ case "not":
244
+ return undefined;
245
+ default:
246
+ // Exhaustive check - will fail to compile if a new filter type is added
247
+ return assertNever(filter);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Get comparison symbol for display
253
+ */
254
+ function getComparisonSymbol(
255
+ comparison: "eq" | "ne" | "gt" | "gte" | "lt" | "lte",
256
+ ): string {
257
+ switch (comparison) {
258
+ case "eq":
259
+ return "=";
260
+ case "ne":
261
+ return "!=";
262
+ case "gt":
263
+ return ">";
264
+ case "gte":
265
+ return ">=";
266
+ case "lt":
267
+ return "<";
268
+ case "lte":
269
+ return "<=";
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Pluralize a card type name
275
+ */
276
+ function pluralize(word: string): string {
277
+ if (word.endsWith("y")) {
278
+ return `${word.slice(0, -1)}ies`;
279
+ }
280
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("ch")) {
281
+ return `${word}es`;
282
+ }
283
+ return `${word}s`;
284
+ }
285
+
286
+ // ============================================================================
287
+ // UI Hint Generation
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Generate UI hints for target selection
292
+ *
293
+ * @param target - Target DSL or enum
294
+ * @returns UI hints for rendering selection interface
295
+ */
296
+ export function getTargetUIHints(
297
+ target: LorcanaCharacterTarget | LorcanaItemTarget | LorcanaLocationTarget,
298
+ ): LorcanaTargetUIHint {
299
+ // Expand enum to DSL if needed
300
+ let dsl: LorcanaCardTarget;
301
+ if (typeof target === "string") {
302
+ if (isCharacterEnum(target as LorcanaCharacterTarget)) {
303
+ dsl = expandCharacterTarget(target as LorcanaCharacterTarget);
304
+ } else if (isItemEnum(target as LorcanaItemTarget)) {
305
+ dsl = expandItemTarget(target as LorcanaItemTarget);
306
+ } else if (isLocationEnum(target as LorcanaLocationTarget)) {
307
+ dsl = expandLocationTarget(target as LorcanaLocationTarget);
308
+ } else {
309
+ // Fallback for unknown enum
310
+ return {
311
+ selectionType: "single",
312
+ minSelections: 1,
313
+ maxSelections: 1,
314
+ prompt: "Choose a target",
315
+ optional: false,
316
+ highlightZones: ["play"],
317
+ cardType: undefined,
318
+ ownerFilter: "any",
319
+ };
320
+ }
321
+ } else {
322
+ dsl = target;
323
+ }
324
+
325
+ return generateUIHintsFromDSL(dsl);
326
+ }
327
+
328
+ /**
329
+ * Extended UI hints with Lorcana-specific info
330
+ */
331
+ export interface LorcanaTargetUIHint extends TargetingUIHint {
332
+ /** Card type to filter by */
333
+ cardType: string | undefined;
334
+ /** Owner filter for highlighting */
335
+ ownerFilter: "you" | "opponent" | "any";
336
+ }
337
+
338
+ /**
339
+ * Generate UI hints from DSL object
340
+ */
341
+ function generateUIHintsFromDSL(
342
+ target: LorcanaCardTarget,
343
+ ): LorcanaTargetUIHint {
344
+ const description = generateDSLDescription(target);
345
+
346
+ // Determine selection type
347
+ let selectionType: LorcanaTargetUIHint["selectionType"];
348
+ if (target.selector === "self") {
349
+ selectionType = "none";
350
+ } else if (target.selector === "all" || target.selector === "each") {
351
+ selectionType = "automatic";
352
+ } else if (target.selector === "random" || target.selector === "any") {
353
+ selectionType = "automatic";
354
+ } else {
355
+ // "chosen" - check count for single vs multiple
356
+ const count = target.count;
357
+ if (count === "all") {
358
+ selectionType = "multiple";
359
+ } else if (typeof count === "number") {
360
+ selectionType = count > 1 ? "multiple" : "single";
361
+ } else if (count && "upTo" in count) {
362
+ selectionType = count.upTo > 1 ? "multiple" : "single";
363
+ } else if (count && "between" in count) {
364
+ selectionType = count.between[1] > 1 ? "multiple" : "single";
365
+ } else {
366
+ selectionType = "single";
367
+ }
368
+ }
369
+
370
+ // Calculate min/max
371
+ let minSelections = 1;
372
+ let maxSelections: number | "unlimited" = 1;
373
+
374
+ if (target.count === undefined) {
375
+ minSelections = 1;
376
+ maxSelections = 1;
377
+ } else if (target.count === "all") {
378
+ minSelections = 0;
379
+ maxSelections = "unlimited";
380
+ } else if (typeof target.count === "number") {
381
+ minSelections = target.count;
382
+ maxSelections = target.count;
383
+ } else if ("exactly" in target.count) {
384
+ minSelections = target.count.exactly;
385
+ maxSelections = target.count.exactly;
386
+ } else if ("upTo" in target.count) {
387
+ minSelections = 0;
388
+ maxSelections = target.count.upTo;
389
+ } else if ("atLeast" in target.count) {
390
+ minSelections = target.count.atLeast;
391
+ maxSelections = "unlimited";
392
+ } else if ("between" in target.count) {
393
+ minSelections = target.count.between[0];
394
+ maxSelections = target.count.between[1];
395
+ }
396
+
397
+ return {
398
+ selectionType,
399
+ minSelections,
400
+ maxSelections,
401
+ prompt: `Choose ${description}`,
402
+ optional: minSelections === 0,
403
+ highlightZones: target.zones || ["play"],
404
+ cardType: target.cardType,
405
+ ownerFilter: target.owner || "any",
406
+ };
407
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Lorcana Testing Utilities
3
+ *
4
+ * Exports test engine and helpers for writing Lorcana tests
5
+ */
6
+
7
+ export {
8
+ LorcanaTestEngine,
9
+ PLAYER_ONE,
10
+ PLAYER_TWO,
11
+ TestCardModel,
12
+ type TestEngineOptions,
13
+ type TestInitialState,
14
+ } from "./lorcana-test-engine";