@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded Types for Type-Safe IDs
|
|
3
|
+
*
|
|
4
|
+
* Task 1.2: Create branded types for domain-specific IDs
|
|
5
|
+
*
|
|
6
|
+
* Re-exports core branded types from @drmxrcy/tcg-core for consistency across the monorepo.
|
|
7
|
+
* Adds Lorcana-specific branded types using the same pattern.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* const playerId = createPlayerId("player1");
|
|
12
|
+
* const cardId = createCardId("card-1");
|
|
13
|
+
*
|
|
14
|
+
* // TypeScript error: Type 'CardId' is not assignable to type 'PlayerId'
|
|
15
|
+
* const wrong: PlayerId = cardId;
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Re-export core branded types for consistency
|
|
20
|
+
export type { CardId, GameId, PlayerId, ZoneId } from "@drmxrcy/tcg-core";
|
|
21
|
+
|
|
22
|
+
// Import the Brand type from core for creating additional branded types
|
|
23
|
+
import type { CardId, GameId, PlayerId, ZoneId } from "@drmxrcy/tcg-core";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* AbilityId - Branded string for ability identification (Lorcana-specific)
|
|
27
|
+
*
|
|
28
|
+
* Uses the same pattern as @drmxrcy/tcg-core branded types for compatibility.
|
|
29
|
+
*/
|
|
30
|
+
declare const abilityIdBrand: unique symbol;
|
|
31
|
+
export type AbilityId = string & { readonly [abilityIdBrand]: "AbilityId" };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a PlayerId from a string
|
|
35
|
+
*
|
|
36
|
+
* @param value - The string value to brand as PlayerId
|
|
37
|
+
* @returns Branded PlayerId
|
|
38
|
+
*/
|
|
39
|
+
export const createPlayerId = (value: string): PlayerId => {
|
|
40
|
+
if (!value || value.length === 0) {
|
|
41
|
+
throw new Error("PlayerId cannot be empty");
|
|
42
|
+
}
|
|
43
|
+
return value as PlayerId;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a CardId from a string
|
|
48
|
+
*
|
|
49
|
+
* @param value - The string value to brand as CardId
|
|
50
|
+
* @returns Branded CardId
|
|
51
|
+
*/
|
|
52
|
+
export const createCardId = (value: string): CardId => {
|
|
53
|
+
if (!value || value.length === 0) {
|
|
54
|
+
throw new Error("CardId cannot be empty");
|
|
55
|
+
}
|
|
56
|
+
return value as CardId;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a ZoneId from a string
|
|
61
|
+
*
|
|
62
|
+
* @param value - The string value to brand as ZoneId
|
|
63
|
+
* @returns Branded ZoneId
|
|
64
|
+
*/
|
|
65
|
+
export const createZoneId = (value: string): ZoneId => {
|
|
66
|
+
if (!value || value.length === 0) {
|
|
67
|
+
throw new Error("ZoneId cannot be empty");
|
|
68
|
+
}
|
|
69
|
+
return value as ZoneId;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create an AbilityId from a string
|
|
74
|
+
*
|
|
75
|
+
* @param value - The string value to brand as AbilityId
|
|
76
|
+
* @returns Branded AbilityId
|
|
77
|
+
*/
|
|
78
|
+
export const createAbilityId = (value: string): AbilityId => {
|
|
79
|
+
if (!value || value.length === 0) {
|
|
80
|
+
throw new Error("AbilityId cannot be empty");
|
|
81
|
+
}
|
|
82
|
+
return value as AbilityId;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a GameId from a string
|
|
87
|
+
*
|
|
88
|
+
* @param value - The string value to brand as GameId
|
|
89
|
+
* @returns Branded GameId
|
|
90
|
+
*/
|
|
91
|
+
export const createGameId = (value: string): GameId => {
|
|
92
|
+
if (!value || value.length === 0) {
|
|
93
|
+
throw new Error("GameId cannot be empty");
|
|
94
|
+
}
|
|
95
|
+
return value as GameId;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Type guard to check if a value is a non-empty string
|
|
100
|
+
*
|
|
101
|
+
* @param value - Value to check
|
|
102
|
+
* @returns True if value is a non-empty string
|
|
103
|
+
*/
|
|
104
|
+
export const isNonEmptyString = (value: unknown): value is string => {
|
|
105
|
+
return typeof value === "string" && value.length > 0;
|
|
106
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Game State Types
|
|
3
|
+
*
|
|
4
|
+
* Core game state types for Lorcana engine.
|
|
5
|
+
* Implements the @drmxrcy/tcg-core IState pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CardId, IState, PlayerId } from "@drmxrcy/tcg-core";
|
|
9
|
+
import type { LorcanaCardDefinition } from "@drmxrcy/tcg-lorcana-types";
|
|
10
|
+
|
|
11
|
+
/** Card ready/exerted state */
|
|
12
|
+
export type CardReadyState = "ready" | "exerted";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stack position for shifted cards (Rule 5.1.5-5.1.7)
|
|
16
|
+
*/
|
|
17
|
+
export interface StackPosition {
|
|
18
|
+
/** Is this card underneath another card? */
|
|
19
|
+
isUnder: boolean;
|
|
20
|
+
/** If this is the top card, what's its ID? */
|
|
21
|
+
topCardId?: CardId;
|
|
22
|
+
/** If this is the top card, IDs of cards underneath */
|
|
23
|
+
cardsUnderneath?: CardId[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Active effect tracking (for "this turn" effects, etc.)
|
|
28
|
+
*/
|
|
29
|
+
export interface ActiveEffect {
|
|
30
|
+
id: string;
|
|
31
|
+
sourceCardId: CardId;
|
|
32
|
+
type: string;
|
|
33
|
+
params: Record<string, unknown>;
|
|
34
|
+
expiresAt?: "endOfTurn" | "startOfNextTurn" | "custom";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bag entry for triggered abilities
|
|
39
|
+
*/
|
|
40
|
+
export interface BagEntry {
|
|
41
|
+
id: string;
|
|
42
|
+
abilityId: string;
|
|
43
|
+
sourceCardId: CardId;
|
|
44
|
+
controllerId: PlayerId;
|
|
45
|
+
triggerEvent: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lorcana Card Metadata (Dynamic State)
|
|
51
|
+
*
|
|
52
|
+
* Stores mutable, game-specific card properties:
|
|
53
|
+
* - Ready/Exerted state
|
|
54
|
+
* - Damage
|
|
55
|
+
* - Drying (summoning sickness)
|
|
56
|
+
* - Stack position (Shift)
|
|
57
|
+
* - Location attachment
|
|
58
|
+
*/
|
|
59
|
+
export interface LorcanaCardMeta {
|
|
60
|
+
/** Ready or exerted (Rule 5.1.1-5.1.2) */
|
|
61
|
+
state: CardReadyState;
|
|
62
|
+
|
|
63
|
+
/** Damage counters (Rule 5.1.3-5.1.4) */
|
|
64
|
+
damage: number;
|
|
65
|
+
|
|
66
|
+
/** Drying = summoning sickness - can't quest/challenge/use exert abilities */
|
|
67
|
+
isDrying: boolean;
|
|
68
|
+
|
|
69
|
+
/** Stack position for Shift (Rule 5.1.5-5.1.7) */
|
|
70
|
+
stackPosition?: StackPosition;
|
|
71
|
+
|
|
72
|
+
/** Location this character is at (if any) */
|
|
73
|
+
atLocationId?: CardId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Lorcana External State (Game Logic State)
|
|
78
|
+
*
|
|
79
|
+
* Game-specific state not managed by the framework.
|
|
80
|
+
*/
|
|
81
|
+
export interface LorcanaExternalState {
|
|
82
|
+
/** Lore scores for each player (win at 20+) */
|
|
83
|
+
loreScores: Record<PlayerId, number>;
|
|
84
|
+
|
|
85
|
+
/** The bag - triggered abilities waiting to resolve */
|
|
86
|
+
bag: BagEntry[];
|
|
87
|
+
|
|
88
|
+
/** Active effects (temporary modifiers) */
|
|
89
|
+
effects: ActiveEffect[];
|
|
90
|
+
|
|
91
|
+
/** Turn tracking */
|
|
92
|
+
turnNumber: number;
|
|
93
|
+
activePlayerId: PlayerId;
|
|
94
|
+
hasInkedThisTurn: boolean;
|
|
95
|
+
startingPlayerId: PlayerId;
|
|
96
|
+
|
|
97
|
+
/** Events that happened this turn (for conditions like "If you played a song this turn") */
|
|
98
|
+
turnHistory: TurnHistoryEvent[];
|
|
99
|
+
|
|
100
|
+
/** Current phase and step */
|
|
101
|
+
currentPhase: "beginning" | "main" | "end";
|
|
102
|
+
currentStep?: "ready" | "set" | "draw";
|
|
103
|
+
|
|
104
|
+
/** Game end state */
|
|
105
|
+
isGameOver: boolean;
|
|
106
|
+
winner?: PlayerId;
|
|
107
|
+
gameEndReason?: string;
|
|
108
|
+
|
|
109
|
+
/** Name of the card named by "Name a card" effects */
|
|
110
|
+
namedCard?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TurnHistoryEvent {
|
|
114
|
+
type:
|
|
115
|
+
| "played-song"
|
|
116
|
+
| "played-character"
|
|
117
|
+
| "played-action"
|
|
118
|
+
| "played-floodborn"
|
|
119
|
+
| "challenged"
|
|
120
|
+
| "quested"
|
|
121
|
+
| "banished-character"
|
|
122
|
+
| "damaged-character"
|
|
123
|
+
| "was-damaged"
|
|
124
|
+
| "inked";
|
|
125
|
+
sourceId?: CardId;
|
|
126
|
+
controllerId: PlayerId;
|
|
127
|
+
count: number; // For bulk events or just 1
|
|
128
|
+
params?: Record<string, any>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Complete Lorcana Game State
|
|
133
|
+
*
|
|
134
|
+
* Combines framework-managed state (internal) with game-specific state (external).
|
|
135
|
+
*/
|
|
136
|
+
export type LorcanaGameState = IState<
|
|
137
|
+
LorcanaExternalState,
|
|
138
|
+
LorcanaCardDefinition,
|
|
139
|
+
LorcanaCardMeta
|
|
140
|
+
>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Default card meta state for new cards
|
|
144
|
+
*/
|
|
145
|
+
export function createDefaultCardMeta(): LorcanaCardMeta {
|
|
146
|
+
return {
|
|
147
|
+
state: "ready",
|
|
148
|
+
damage: 0,
|
|
149
|
+
isDrying: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create initial Lorcana game state
|
|
155
|
+
*/
|
|
156
|
+
export function createInitialLorcanaState(
|
|
157
|
+
player1Id: PlayerId,
|
|
158
|
+
player2Id: PlayerId,
|
|
159
|
+
startingPlayerId: PlayerId,
|
|
160
|
+
): LorcanaGameState {
|
|
161
|
+
return {
|
|
162
|
+
internal: {
|
|
163
|
+
zones: {}, // Zones are initialized by the framework zone manager
|
|
164
|
+
cards: {}, // Cards are initialized by the framework
|
|
165
|
+
cardMetas: {}, // Card metas are initialized as cards are created
|
|
166
|
+
},
|
|
167
|
+
external: {
|
|
168
|
+
loreScores: {
|
|
169
|
+
[player1Id]: 0,
|
|
170
|
+
[player2Id]: 0,
|
|
171
|
+
},
|
|
172
|
+
bag: [],
|
|
173
|
+
effects: [],
|
|
174
|
+
turnNumber: 1,
|
|
175
|
+
activePlayerId: startingPlayerId,
|
|
176
|
+
hasInkedThisTurn: false,
|
|
177
|
+
turnHistory: [],
|
|
178
|
+
startingPlayerId,
|
|
179
|
+
currentPhase: "beginning",
|
|
180
|
+
currentStep: "ready",
|
|
181
|
+
isGameOver: false,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lorcana Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* Public exports for all Lorcana-specific types
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Card types and classifications - re-export from lorcana-types
|
|
8
|
+
// Game state types - re-export from lorcana-types
|
|
9
|
+
export type {
|
|
10
|
+
AbilityDefinition,
|
|
11
|
+
ActionAbilityDefinition,
|
|
12
|
+
ActionCard,
|
|
13
|
+
ActionSubtype,
|
|
14
|
+
ActivatedAbilityDefinition,
|
|
15
|
+
BaseAbilityDefinition,
|
|
16
|
+
BaseCardProperties,
|
|
17
|
+
CardType,
|
|
18
|
+
ChallengeState,
|
|
19
|
+
CharacterCard,
|
|
20
|
+
CharacterState,
|
|
21
|
+
Classification,
|
|
22
|
+
DeckStats,
|
|
23
|
+
DeckValidationError,
|
|
24
|
+
DeckValidationResult,
|
|
25
|
+
InkType,
|
|
26
|
+
ItemCard,
|
|
27
|
+
KeywordAbilityDefinition,
|
|
28
|
+
LocationCard,
|
|
29
|
+
LorcanaCard,
|
|
30
|
+
LorcanaCardDefinition,
|
|
31
|
+
LorcanaPhase,
|
|
32
|
+
LorcanaState,
|
|
33
|
+
PermanentState,
|
|
34
|
+
ReplacementAbilityDefinition,
|
|
35
|
+
StaticAbilityDefinition,
|
|
36
|
+
TooFewCardsError,
|
|
37
|
+
TooManyCopiesError,
|
|
38
|
+
TooManyInkTypesError,
|
|
39
|
+
TriggeredAbilityDefinition,
|
|
40
|
+
TurnMetadata,
|
|
41
|
+
} from "@drmxrcy/tcg-lorcana-types";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
CARD_TYPES,
|
|
45
|
+
CLASSIFICATIONS,
|
|
46
|
+
getFullName,
|
|
47
|
+
getInkColor,
|
|
48
|
+
getInkTypes,
|
|
49
|
+
INK_COLORS,
|
|
50
|
+
INK_TYPES,
|
|
51
|
+
isActionCard,
|
|
52
|
+
isCardType,
|
|
53
|
+
isCharacterCard,
|
|
54
|
+
isClassification,
|
|
55
|
+
isDreamborn,
|
|
56
|
+
isDualInk,
|
|
57
|
+
isFloodborn,
|
|
58
|
+
isItemCard,
|
|
59
|
+
isLocationCard,
|
|
60
|
+
isStoryborn,
|
|
61
|
+
isValidInkType,
|
|
62
|
+
MAX_COPIES_PER_CARD,
|
|
63
|
+
MAX_INK_TYPES,
|
|
64
|
+
MIN_DECK_SIZE,
|
|
65
|
+
} from "@drmxrcy/tcg-lorcana-types";
|
|
66
|
+
// Branded types (primary source for type-safe IDs)
|
|
67
|
+
export * from "./branded-types";
|
|
68
|
+
// Game state - exclude PlayerId/CardId/ZoneId (use branded-types)
|
|
69
|
+
export type {
|
|
70
|
+
ActiveEffect,
|
|
71
|
+
BagEntry,
|
|
72
|
+
CardReadyState,
|
|
73
|
+
LorcanaCardMeta,
|
|
74
|
+
LorcanaExternalState,
|
|
75
|
+
LorcanaGameState,
|
|
76
|
+
StackPosition,
|
|
77
|
+
} from "./game-state";
|
|
78
|
+
export {
|
|
79
|
+
createDefaultCardMeta,
|
|
80
|
+
createInitialLorcanaState,
|
|
81
|
+
} from "./game-state";
|
|
82
|
+
|
|
83
|
+
// Move params - exclude LorcanaGameState to avoid conflict with game-state.ts
|
|
84
|
+
export type {
|
|
85
|
+
LorcanaMoveParams,
|
|
86
|
+
PlayCardCost,
|
|
87
|
+
} from "./move-params";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keywords (Section 10)
|
|
3
|
+
*
|
|
4
|
+
* Lorcana has 12 keywords:
|
|
5
|
+
* - Simple keywords: Bodyguard, Evasive, Reckless, Rush, Support, Vanish, Ward
|
|
6
|
+
* - Parameterized keywords: Challenger +X, Resist +X (stacking)
|
|
7
|
+
* - Complex keywords: Shift, Singer X, Sing Together X
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Simple keywords that have no parameters */
|
|
11
|
+
export const SIMPLE_KEYWORDS = [
|
|
12
|
+
"Alert",
|
|
13
|
+
"Bodyguard",
|
|
14
|
+
"Evasive",
|
|
15
|
+
"Reckless",
|
|
16
|
+
"Rush",
|
|
17
|
+
"Support",
|
|
18
|
+
"Vanish",
|
|
19
|
+
"Ward",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export type SimpleKeyword = (typeof SIMPLE_KEYWORDS)[number];
|
|
23
|
+
|
|
24
|
+
/** Parameterized keywords with numeric values that can stack */
|
|
25
|
+
export interface ChallengerKeyword {
|
|
26
|
+
type: "Challenger";
|
|
27
|
+
value: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResistKeyword {
|
|
31
|
+
type: "Resist";
|
|
32
|
+
value: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ParameterizedKeyword = ChallengerKeyword | ResistKeyword;
|
|
36
|
+
|
|
37
|
+
/** Complex keywords with special behaviors */
|
|
38
|
+
export interface ShiftKeyword {
|
|
39
|
+
type: "Shift";
|
|
40
|
+
cost: number;
|
|
41
|
+
targetName: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SingerKeyword {
|
|
45
|
+
type: "Singer";
|
|
46
|
+
value: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SingTogetherKeyword {
|
|
50
|
+
type: "SingTogether";
|
|
51
|
+
value: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ComplexKeyword = ShiftKeyword | SingerKeyword | SingTogetherKeyword;
|
|
55
|
+
|
|
56
|
+
/** All keyword types */
|
|
57
|
+
export type Keyword = SimpleKeyword | ParameterizedKeyword | ComplexKeyword;
|
|
58
|
+
|
|
59
|
+
/** All keyword type names for parameterized and complex keywords */
|
|
60
|
+
export type KeywordType =
|
|
61
|
+
| SimpleKeyword
|
|
62
|
+
| "Challenger"
|
|
63
|
+
| "Resist"
|
|
64
|
+
| "Shift"
|
|
65
|
+
| "Singer"
|
|
66
|
+
| "SingTogether";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a keyword is a simple keyword
|
|
70
|
+
*/
|
|
71
|
+
export function isSimpleKeyword(keyword: Keyword): keyword is SimpleKeyword {
|
|
72
|
+
return typeof keyword === "string";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if a keyword is a parameterized keyword (Challenger or Resist)
|
|
77
|
+
*/
|
|
78
|
+
export function isParameterizedKeyword(
|
|
79
|
+
keyword: Keyword,
|
|
80
|
+
): keyword is ParameterizedKeyword {
|
|
81
|
+
return (
|
|
82
|
+
typeof keyword === "object" &&
|
|
83
|
+
(keyword.type === "Challenger" || keyword.type === "Resist")
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a keyword is a complex keyword (Shift, Singer, SingTogether)
|
|
89
|
+
*/
|
|
90
|
+
export function isComplexKeyword(keyword: Keyword): keyword is ComplexKeyword {
|
|
91
|
+
return (
|
|
92
|
+
typeof keyword === "object" &&
|
|
93
|
+
(keyword.type === "Shift" ||
|
|
94
|
+
keyword.type === "Singer" ||
|
|
95
|
+
keyword.type === "SingTogether")
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the type name of a keyword
|
|
101
|
+
*/
|
|
102
|
+
export function getKeywordTypeName(keyword: Keyword): KeywordType {
|
|
103
|
+
if (typeof keyword === "string") {
|
|
104
|
+
return keyword;
|
|
105
|
+
}
|
|
106
|
+
return keyword.type;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a keyword array contains a specific keyword type
|
|
111
|
+
*/
|
|
112
|
+
export function hasKeywordType(
|
|
113
|
+
keywords: Keyword[] | undefined,
|
|
114
|
+
type: KeywordType,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (!keywords) return false;
|
|
117
|
+
return keywords.some((k) => getKeywordTypeName(k) === type);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the value of a parameterized keyword (Challenger or Resist)
|
|
122
|
+
* Returns null if the keyword is not found or is not parameterized
|
|
123
|
+
*/
|
|
124
|
+
export function getKeywordValue(
|
|
125
|
+
keywords: Keyword[] | undefined,
|
|
126
|
+
type: "Challenger" | "Resist",
|
|
127
|
+
): number | null {
|
|
128
|
+
if (!keywords) return null;
|
|
129
|
+
const keyword = keywords.find(
|
|
130
|
+
(k) => typeof k === "object" && k.type === type,
|
|
131
|
+
) as ParameterizedKeyword | undefined;
|
|
132
|
+
return keyword?.value ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the total value of a stacking keyword (sums all instances)
|
|
137
|
+
*/
|
|
138
|
+
export function getTotalKeywordValue(
|
|
139
|
+
keywords: Keyword[] | undefined,
|
|
140
|
+
type: "Challenger" | "Resist",
|
|
141
|
+
): number {
|
|
142
|
+
if (!keywords) return 0;
|
|
143
|
+
return keywords
|
|
144
|
+
.filter(
|
|
145
|
+
(k): k is ParameterizedKeyword =>
|
|
146
|
+
typeof k === "object" && k.type === type,
|
|
147
|
+
)
|
|
148
|
+
.reduce((sum, k) => sum + k.value, 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get Shift keyword data if present
|
|
153
|
+
*/
|
|
154
|
+
export function getShiftKeyword(
|
|
155
|
+
keywords: Keyword[] | undefined,
|
|
156
|
+
): ShiftKeyword | null {
|
|
157
|
+
if (!keywords) return null;
|
|
158
|
+
const keyword = keywords.find(
|
|
159
|
+
(k): k is ShiftKeyword => typeof k === "object" && k.type === "Shift",
|
|
160
|
+
);
|
|
161
|
+
return keyword ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get Singer keyword value if present
|
|
166
|
+
*/
|
|
167
|
+
export function getSingerValue(keywords: Keyword[] | undefined): number | null {
|
|
168
|
+
if (!keywords) return null;
|
|
169
|
+
const keyword = keywords.find(
|
|
170
|
+
(k): k is SingerKeyword => typeof k === "object" && k.type === "Singer",
|
|
171
|
+
);
|
|
172
|
+
return keyword?.value ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get Sing Together keyword value if present
|
|
177
|
+
*/
|
|
178
|
+
export function getSingTogetherValue(
|
|
179
|
+
keywords: Keyword[] | undefined,
|
|
180
|
+
): number | null {
|
|
181
|
+
if (!keywords) return null;
|
|
182
|
+
const keyword = keywords.find(
|
|
183
|
+
(k): k is SingTogetherKeyword =>
|
|
184
|
+
typeof k === "object" && k.type === "SingTogether",
|
|
185
|
+
);
|
|
186
|
+
return keyword?.value ?? null;
|
|
187
|
+
}
|