@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,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
+ }