@bct-app/game-engine 0.1.6-beta.2 → 0.1.6-beta.4

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/dist/index.d.mts CHANGED
@@ -10,6 +10,21 @@ type TApplyOperationToPlayersArgs = {
10
10
  };
11
11
  declare const applyOperationToPlayers: ({ players, operations, allAbilities, characters, timelines, timelineIndexAtNow, }: TApplyOperationToPlayersArgs) => TPlayer[];
12
12
 
13
+ type TCanInvokeAbilityArgs = {
14
+ ability: TAbility;
15
+ effector: TPlayer | undefined;
16
+ currentTimelineIdx: number;
17
+ timelines: TTimeline[];
18
+ priorOperations?: TOperation[];
19
+ };
20
+ type TCanInvokeAbilityResult = {
21
+ allowed: true;
22
+ } | {
23
+ allowed: false;
24
+ reason: string;
25
+ };
26
+ declare const canInvokeAbility: ({ ability, effector, currentTimelineIdx, timelines, priorOperations, }: TCanInvokeAbilityArgs) => TCanInvokeAbilityResult;
27
+
13
28
  declare const transformEmptyArray: <T>(arr: T[]) => T[];
14
29
  declare const copyPlayers: (players: TPlayer[]) => TPlayer[];
15
30
  declare const buildPlayerSeatMap: (players: TPlayer[]) => Map<number, TPlayer>;
@@ -42,4 +57,4 @@ declare const isReminder: (value: unknown) => value is TReminder;
42
57
  */
43
58
  declare const isPlayerReminderArray: (value: unknown) => value is TPlayerReminder[];
44
59
 
45
- export { adjustValueAsNumber, adjustValueAsNumberArray, applyOperationToPlayers, buildPlayerSeatMap, copyPlayers, getSourceValue, getValueFromPayloads, isNumberOrNumberArray, isPlayerReminderArray, isReminder, resolveSourceValue, transformEmptyArray };
60
+ export { type TCanInvokeAbilityArgs, type TCanInvokeAbilityResult, adjustValueAsNumber, adjustValueAsNumberArray, applyOperationToPlayers, buildPlayerSeatMap, canInvokeAbility, copyPlayers, getSourceValue, getValueFromPayloads, isNumberOrNumberArray, isPlayerReminderArray, isReminder, resolveSourceValue, transformEmptyArray };
package/dist/index.d.ts CHANGED
@@ -10,6 +10,21 @@ type TApplyOperationToPlayersArgs = {
10
10
  };
11
11
  declare const applyOperationToPlayers: ({ players, operations, allAbilities, characters, timelines, timelineIndexAtNow, }: TApplyOperationToPlayersArgs) => TPlayer[];
12
12
 
13
+ type TCanInvokeAbilityArgs = {
14
+ ability: TAbility;
15
+ effector: TPlayer | undefined;
16
+ currentTimelineIdx: number;
17
+ timelines: TTimeline[];
18
+ priorOperations?: TOperation[];
19
+ };
20
+ type TCanInvokeAbilityResult = {
21
+ allowed: true;
22
+ } | {
23
+ allowed: false;
24
+ reason: string;
25
+ };
26
+ declare const canInvokeAbility: ({ ability, effector, currentTimelineIdx, timelines, priorOperations, }: TCanInvokeAbilityArgs) => TCanInvokeAbilityResult;
27
+
13
28
  declare const transformEmptyArray: <T>(arr: T[]) => T[];
14
29
  declare const copyPlayers: (players: TPlayer[]) => TPlayer[];
15
30
  declare const buildPlayerSeatMap: (players: TPlayer[]) => Map<number, TPlayer>;
@@ -42,4 +57,4 @@ declare const isReminder: (value: unknown) => value is TReminder;
42
57
  */
43
58
  declare const isPlayerReminderArray: (value: unknown) => value is TPlayerReminder[];
44
59
 
45
- export { adjustValueAsNumber, adjustValueAsNumberArray, applyOperationToPlayers, buildPlayerSeatMap, copyPlayers, getSourceValue, getValueFromPayloads, isNumberOrNumberArray, isPlayerReminderArray, isReminder, resolveSourceValue, transformEmptyArray };
60
+ export { type TCanInvokeAbilityArgs, type TCanInvokeAbilityResult, adjustValueAsNumber, adjustValueAsNumberArray, applyOperationToPlayers, buildPlayerSeatMap, canInvokeAbility, copyPlayers, getSourceValue, getValueFromPayloads, isNumberOrNumberArray, isPlayerReminderArray, isReminder, resolveSourceValue, transformEmptyArray };
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  adjustValueAsNumberArray: () => adjustValueAsNumberArray,
25
25
  applyOperationToPlayers: () => applyOperationToPlayers,
26
26
  buildPlayerSeatMap: () => buildPlayerSeatMap,
27
+ canInvokeAbility: () => canInvokeAbility,
27
28
  copyPlayers: () => copyPlayers,
28
29
  getSourceValue: () => getSourceValue,
29
30
  getValueFromPayloads: () => getValueFromPayloads,
@@ -159,8 +160,7 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
159
160
  }
160
161
  const characterIds = typeof payloadValue === "string" ? [payloadValue] : payloadValue;
161
162
  return characterIds.flatMap((id) => {
162
- const resolved = resolveFromCharacter ? resolveFromCharacter(id) : void 0;
163
- return resolved !== void 0 ? [resolved] : [];
163
+ return resolveFromCharacter ? resolveFromCharacter(id) : [];
164
164
  });
165
165
  }
166
166
  return castOrValidate(payloadValue);
@@ -193,7 +193,7 @@ var createEffectHandler = (type, handler) => {
193
193
  };
194
194
 
195
195
  // src/effects/handlers/ability-change.ts
196
- var toAbilityIdList = (value) => {
196
+ var toIdList = (value) => {
197
197
  if (typeof value === "string") {
198
198
  return value.length > 0 ? [value] : void 0;
199
199
  }
@@ -211,8 +211,8 @@ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
211
211
  writableInputs,
212
212
  getSnapshotSeatMap,
213
213
  abilityMap,
214
- makePlayersEffect,
215
- characterMap
214
+ characterMap,
215
+ makePlayersEffect
216
216
  }) => {
217
217
  const resolvedRaw = resolveSourceValue(
218
218
  effect.source,
@@ -220,45 +220,49 @@ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
220
220
  payloads,
221
221
  effector,
222
222
  getSnapshotSeatMap(),
223
- (player) => player.abilities,
223
+ (player) => player.characterId,
224
224
  (value) => typeof value === "string" && value.length > 0 || Array.isArray(value) && value.every((item) => typeof item === "string"),
225
- (characterId) => {
226
- const character = characterMap.get(characterId);
227
- if (!character) {
228
- console.warn("Character not found for ABILITY_CHANGE effect source:", characterId);
229
- return [];
230
- }
231
- return character.abilities.map((a) => a.id);
232
- }
225
+ (characterId) => characterId
233
226
  );
234
- const resolvedAbilityIds = toAbilityIdList(resolvedRaw);
235
- if (!resolvedAbilityIds) {
236
- console.warn("Ability ID not found for ABILITY_CHANGE effect:", effect.source);
227
+ const resolvedCharacterIds = toIdList(resolvedRaw);
228
+ if (!resolvedCharacterIds) {
229
+ console.warn("Character ID not found for ABILITY_CHANGE effect:", effect.source);
237
230
  return;
238
231
  }
232
+ const mode = effect.mode ?? "REPLACE";
239
233
  makePlayersEffect.forEach((player) => {
240
234
  const overrideRaw = operation.abilityChangeOverrides?.[String(player.seat)];
241
- const overrideIds = toAbilityIdList(overrideRaw);
242
- const finalAbilityIds = overrideIds ?? resolvedAbilityIds;
243
- finalAbilityIds.forEach((finalAbilityId) => {
244
- const newAbility = abilityMap.get(finalAbilityId);
245
- if (!newAbility) {
246
- console.warn("Ability not found for ABILITY_CHANGE effect:", finalAbilityId);
247
- return;
248
- }
249
- const replaceAtSameStage = (abilities) => {
250
- const idx = abilities.findIndex((aid) => abilityMap.get(aid)?.stage === newAbility.stage);
251
- if (idx >= 0) {
252
- abilities[idx] = finalAbilityId;
253
- } else {
254
- abilities.push(finalAbilityId);
235
+ const overrideIds = toIdList(overrideRaw);
236
+ const finalCharacterIds = overrideIds ?? resolvedCharacterIds;
237
+ if (!player.gainCharactersAbility) {
238
+ player.gainCharactersAbility = [];
239
+ }
240
+ if (mode === "REPLACE") {
241
+ player.gainCharactersAbility = [...finalCharacterIds];
242
+ } else {
243
+ for (const characterId of finalCharacterIds) {
244
+ if (!player.gainCharactersAbility.includes(characterId)) {
245
+ player.gainCharactersAbility.push(characterId);
255
246
  }
256
- };
257
- replaceAtSameStage(player.abilities);
258
- if (player.perceivedCharacter?.asCharacter) {
259
- replaceAtSameStage(player.perceivedCharacter.abilities);
260
247
  }
261
- });
248
+ }
249
+ if (player.perceivedCharacter?.asCharacter) {
250
+ const perceived = player.perceivedCharacter;
251
+ finalCharacterIds.forEach((characterId) => {
252
+ const character = characterMap.get(characterId);
253
+ if (!character) return;
254
+ character.abilities.forEach((newAbility) => {
255
+ const idx = perceived.abilities.findIndex(
256
+ (aid) => abilityMap.get(aid)?.stage === newAbility.stage
257
+ );
258
+ if (idx >= 0) {
259
+ perceived.abilities[idx] = newAbility.id;
260
+ } else {
261
+ perceived.abilities.push(newAbility.id);
262
+ }
263
+ });
264
+ });
265
+ }
262
266
  });
263
267
  });
264
268
 
@@ -393,8 +397,6 @@ var applyPerceivedCharacterChange = createEffectHandler("PERCEIVED_CHARACTER_CHA
393
397
  var applyReminderAdd = createEffectHandler("REMINDER_ADD", ({
394
398
  effect,
395
399
  payloads,
396
- nowTimelineIndex,
397
- operationInTimelineIdx,
398
400
  makePlayersEffect
399
401
  }) => {
400
402
  const maybeReminder = getSourceValue(effect.source, payloads);
@@ -402,10 +404,6 @@ var applyReminderAdd = createEffectHandler("REMINDER_ADD", ({
402
404
  console.warn("Invalid reminder data for REMINDER_ADD effect:", maybeReminder);
403
405
  return;
404
406
  }
405
- const timelineDistance = nowTimelineIndex - operationInTimelineIdx;
406
- if (typeof maybeReminder.duration === "number" && maybeReminder.duration > 0 && timelineDistance >= maybeReminder.duration) {
407
- return;
408
- }
409
407
  makePlayersEffect.forEach((player) => {
410
408
  if (!player.reminders) {
411
409
  player.reminders = [];
@@ -675,10 +673,16 @@ var resolveEffectTargets = ({
675
673
  };
676
674
 
677
675
  // src/effects/evaluate-lifetime.ts
678
- var getLifetime = (effect) => {
679
- if ("lifetime" in effect) {
676
+ var getLifetime = (effect, payloads) => {
677
+ if ("lifetime" in effect && effect.lifetime) {
680
678
  return effect.lifetime;
681
679
  }
680
+ if (effect.type === "REMINDER_ADD") {
681
+ const maybeReminder = getSourceValue(effect.source, payloads);
682
+ if (isReminder(maybeReminder) && typeof maybeReminder.duration === "number" && maybeReminder.duration > 0) {
683
+ return { kind: "PHASES", count: maybeReminder.duration };
684
+ }
685
+ }
682
686
  return void 0;
683
687
  };
684
688
  var matchesLifetimeCondition = (player, condition, characterMap) => {
@@ -723,9 +727,10 @@ var evaluateLifetime = ({
723
727
  effector,
724
728
  targets,
725
729
  snapshotSeatMap,
726
- characterMap
730
+ characterMap,
731
+ payloads
727
732
  }) => {
728
- const lifetime = getLifetime(effect);
733
+ const lifetime = getLifetime(effect, payloads);
729
734
  if (!lifetime || lifetime.kind === "PERMANENT") {
730
735
  return targets;
731
736
  }
@@ -737,6 +742,10 @@ var evaluateLifetime = ({
737
742
  }
738
743
  return nowTurn - opTurn < lifetime.count ? targets : [];
739
744
  }
745
+ if (lifetime.kind === "PHASES") {
746
+ if (operationTimelineIdx < 0) return targets;
747
+ return nowTimelineIndex - operationTimelineIdx < lifetime.count ? targets : [];
748
+ }
740
749
  if (lifetime.kind === "UNTIL_EVENT") {
741
750
  if (operationTimelineIdx < 0) return targets;
742
751
  for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
@@ -844,7 +853,8 @@ var applyOperationToPlayers = ({
844
853
  effector,
845
854
  targets: resolvedTargets,
846
855
  snapshotSeatMap: lifetimeSnapshotSeatMap,
847
- characterMap
856
+ characterMap,
857
+ payloads
848
858
  });
849
859
  const handler = effectHandlers[effect.type];
850
860
  if (!handler) {
@@ -922,12 +932,84 @@ var applyOperationToPlayers = ({
922
932
  const final = runReplay(groundTruthSeatMap);
923
933
  return transformEmptyArray(final);
924
934
  };
935
+
936
+ // src/can-invoke-ability.ts
937
+ var checkTurns = (turns, currentTurn) => {
938
+ if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
939
+ return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
940
+ }
941
+ if (typeof turns.from === "number" && currentTurn < turns.from) {
942
+ return `turn ${currentTurn} is before allowed start turn ${turns.from}`;
943
+ }
944
+ if (typeof turns.to === "number" && currentTurn > turns.to) {
945
+ return `turn ${currentTurn} is after allowed end turn ${turns.to}`;
946
+ }
947
+ return null;
948
+ };
949
+ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines) => {
950
+ return priorOperations.filter((op) => {
951
+ if (op.abilityId !== abilityId) return false;
952
+ if (typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
953
+ if (matchTurn === null && matchTime === null) return true;
954
+ const idx = operationTimelineMap.get(op);
955
+ if (typeof idx !== "number") return false;
956
+ const tl = timelines[idx];
957
+ if (!tl) return false;
958
+ if (matchTurn !== null && tl.turn !== matchTurn) return false;
959
+ if (matchTime !== null && tl.time !== matchTime) return false;
960
+ return true;
961
+ }).length;
962
+ };
963
+ var canInvokeAbility = ({
964
+ ability,
965
+ effector,
966
+ currentTimelineIdx,
967
+ timelines,
968
+ priorOperations = []
969
+ }) => {
970
+ const trigger = ability.triggerWindow;
971
+ if (!trigger) return { allowed: true };
972
+ const currentTimeline = timelines[currentTimelineIdx];
973
+ if (!currentTimeline) {
974
+ return { allowed: false, reason: `no timeline at index ${currentTimelineIdx}` };
975
+ }
976
+ if (trigger.turns) {
977
+ const turnError = checkTurns(trigger.turns, currentTimeline.turn);
978
+ if (turnError) return { allowed: false, reason: turnError };
979
+ }
980
+ if (trigger.phases && trigger.phases.length > 0 && !trigger.phases.includes(currentTimeline.time)) {
981
+ return { allowed: false, reason: `phase ${currentTimeline.time} not in allowed phases ${JSON.stringify(trigger.phases)}` };
982
+ }
983
+ if (trigger.requireEffectorAlive && (!effector || effector.isDead)) {
984
+ return { allowed: false, reason: "effector is not alive" };
985
+ }
986
+ if (trigger.frequency && priorOperations.length > 0) {
987
+ const operationTimelineMap = /* @__PURE__ */ new Map();
988
+ timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
989
+ const matchTurn = trigger.frequency === "ONCE_PER_GAME" ? null : currentTimeline.turn;
990
+ const matchTime = trigger.frequency === "ONCE_PER_GAME" ? null : trigger.frequency === "ONCE_PER_NIGHT" ? "night" : "day";
991
+ const count = countMatchingPriorOps(
992
+ priorOperations,
993
+ ability.id,
994
+ effector?.seat,
995
+ operationTimelineMap,
996
+ matchTurn,
997
+ matchTime,
998
+ timelines
999
+ );
1000
+ if (count > 0) {
1001
+ return { allowed: false, reason: `frequency ${trigger.frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})` };
1002
+ }
1003
+ }
1004
+ return { allowed: true };
1005
+ };
925
1006
  // Annotate the CommonJS export names for ESM import in node:
926
1007
  0 && (module.exports = {
927
1008
  adjustValueAsNumber,
928
1009
  adjustValueAsNumberArray,
929
1010
  applyOperationToPlayers,
930
1011
  buildPlayerSeatMap,
1012
+ canInvokeAbility,
931
1013
  copyPlayers,
932
1014
  getSourceValue,
933
1015
  getValueFromPayloads,
package/dist/index.mjs CHANGED
@@ -122,8 +122,7 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
122
122
  }
123
123
  const characterIds = typeof payloadValue === "string" ? [payloadValue] : payloadValue;
124
124
  return characterIds.flatMap((id) => {
125
- const resolved = resolveFromCharacter ? resolveFromCharacter(id) : void 0;
126
- return resolved !== void 0 ? [resolved] : [];
125
+ return resolveFromCharacter ? resolveFromCharacter(id) : [];
127
126
  });
128
127
  }
129
128
  return castOrValidate(payloadValue);
@@ -156,7 +155,7 @@ var createEffectHandler = (type, handler) => {
156
155
  };
157
156
 
158
157
  // src/effects/handlers/ability-change.ts
159
- var toAbilityIdList = (value) => {
158
+ var toIdList = (value) => {
160
159
  if (typeof value === "string") {
161
160
  return value.length > 0 ? [value] : void 0;
162
161
  }
@@ -174,8 +173,8 @@ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
174
173
  writableInputs,
175
174
  getSnapshotSeatMap,
176
175
  abilityMap,
177
- makePlayersEffect,
178
- characterMap
176
+ characterMap,
177
+ makePlayersEffect
179
178
  }) => {
180
179
  const resolvedRaw = resolveSourceValue(
181
180
  effect.source,
@@ -183,45 +182,49 @@ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
183
182
  payloads,
184
183
  effector,
185
184
  getSnapshotSeatMap(),
186
- (player) => player.abilities,
185
+ (player) => player.characterId,
187
186
  (value) => typeof value === "string" && value.length > 0 || Array.isArray(value) && value.every((item) => typeof item === "string"),
188
- (characterId) => {
189
- const character = characterMap.get(characterId);
190
- if (!character) {
191
- console.warn("Character not found for ABILITY_CHANGE effect source:", characterId);
192
- return [];
193
- }
194
- return character.abilities.map((a) => a.id);
195
- }
187
+ (characterId) => characterId
196
188
  );
197
- const resolvedAbilityIds = toAbilityIdList(resolvedRaw);
198
- if (!resolvedAbilityIds) {
199
- console.warn("Ability ID not found for ABILITY_CHANGE effect:", effect.source);
189
+ const resolvedCharacterIds = toIdList(resolvedRaw);
190
+ if (!resolvedCharacterIds) {
191
+ console.warn("Character ID not found for ABILITY_CHANGE effect:", effect.source);
200
192
  return;
201
193
  }
194
+ const mode = effect.mode ?? "REPLACE";
202
195
  makePlayersEffect.forEach((player) => {
203
196
  const overrideRaw = operation.abilityChangeOverrides?.[String(player.seat)];
204
- const overrideIds = toAbilityIdList(overrideRaw);
205
- const finalAbilityIds = overrideIds ?? resolvedAbilityIds;
206
- finalAbilityIds.forEach((finalAbilityId) => {
207
- const newAbility = abilityMap.get(finalAbilityId);
208
- if (!newAbility) {
209
- console.warn("Ability not found for ABILITY_CHANGE effect:", finalAbilityId);
210
- return;
211
- }
212
- const replaceAtSameStage = (abilities) => {
213
- const idx = abilities.findIndex((aid) => abilityMap.get(aid)?.stage === newAbility.stage);
214
- if (idx >= 0) {
215
- abilities[idx] = finalAbilityId;
216
- } else {
217
- abilities.push(finalAbilityId);
197
+ const overrideIds = toIdList(overrideRaw);
198
+ const finalCharacterIds = overrideIds ?? resolvedCharacterIds;
199
+ if (!player.gainCharactersAbility) {
200
+ player.gainCharactersAbility = [];
201
+ }
202
+ if (mode === "REPLACE") {
203
+ player.gainCharactersAbility = [...finalCharacterIds];
204
+ } else {
205
+ for (const characterId of finalCharacterIds) {
206
+ if (!player.gainCharactersAbility.includes(characterId)) {
207
+ player.gainCharactersAbility.push(characterId);
218
208
  }
219
- };
220
- replaceAtSameStage(player.abilities);
221
- if (player.perceivedCharacter?.asCharacter) {
222
- replaceAtSameStage(player.perceivedCharacter.abilities);
223
209
  }
224
- });
210
+ }
211
+ if (player.perceivedCharacter?.asCharacter) {
212
+ const perceived = player.perceivedCharacter;
213
+ finalCharacterIds.forEach((characterId) => {
214
+ const character = characterMap.get(characterId);
215
+ if (!character) return;
216
+ character.abilities.forEach((newAbility) => {
217
+ const idx = perceived.abilities.findIndex(
218
+ (aid) => abilityMap.get(aid)?.stage === newAbility.stage
219
+ );
220
+ if (idx >= 0) {
221
+ perceived.abilities[idx] = newAbility.id;
222
+ } else {
223
+ perceived.abilities.push(newAbility.id);
224
+ }
225
+ });
226
+ });
227
+ }
225
228
  });
226
229
  });
227
230
 
@@ -356,8 +359,6 @@ var applyPerceivedCharacterChange = createEffectHandler("PERCEIVED_CHARACTER_CHA
356
359
  var applyReminderAdd = createEffectHandler("REMINDER_ADD", ({
357
360
  effect,
358
361
  payloads,
359
- nowTimelineIndex,
360
- operationInTimelineIdx,
361
362
  makePlayersEffect
362
363
  }) => {
363
364
  const maybeReminder = getSourceValue(effect.source, payloads);
@@ -365,10 +366,6 @@ var applyReminderAdd = createEffectHandler("REMINDER_ADD", ({
365
366
  console.warn("Invalid reminder data for REMINDER_ADD effect:", maybeReminder);
366
367
  return;
367
368
  }
368
- const timelineDistance = nowTimelineIndex - operationInTimelineIdx;
369
- if (typeof maybeReminder.duration === "number" && maybeReminder.duration > 0 && timelineDistance >= maybeReminder.duration) {
370
- return;
371
- }
372
369
  makePlayersEffect.forEach((player) => {
373
370
  if (!player.reminders) {
374
371
  player.reminders = [];
@@ -638,10 +635,16 @@ var resolveEffectTargets = ({
638
635
  };
639
636
 
640
637
  // src/effects/evaluate-lifetime.ts
641
- var getLifetime = (effect) => {
642
- if ("lifetime" in effect) {
638
+ var getLifetime = (effect, payloads) => {
639
+ if ("lifetime" in effect && effect.lifetime) {
643
640
  return effect.lifetime;
644
641
  }
642
+ if (effect.type === "REMINDER_ADD") {
643
+ const maybeReminder = getSourceValue(effect.source, payloads);
644
+ if (isReminder(maybeReminder) && typeof maybeReminder.duration === "number" && maybeReminder.duration > 0) {
645
+ return { kind: "PHASES", count: maybeReminder.duration };
646
+ }
647
+ }
645
648
  return void 0;
646
649
  };
647
650
  var matchesLifetimeCondition = (player, condition, characterMap) => {
@@ -686,9 +689,10 @@ var evaluateLifetime = ({
686
689
  effector,
687
690
  targets,
688
691
  snapshotSeatMap,
689
- characterMap
692
+ characterMap,
693
+ payloads
690
694
  }) => {
691
- const lifetime = getLifetime(effect);
695
+ const lifetime = getLifetime(effect, payloads);
692
696
  if (!lifetime || lifetime.kind === "PERMANENT") {
693
697
  return targets;
694
698
  }
@@ -700,6 +704,10 @@ var evaluateLifetime = ({
700
704
  }
701
705
  return nowTurn - opTurn < lifetime.count ? targets : [];
702
706
  }
707
+ if (lifetime.kind === "PHASES") {
708
+ if (operationTimelineIdx < 0) return targets;
709
+ return nowTimelineIndex - operationTimelineIdx < lifetime.count ? targets : [];
710
+ }
703
711
  if (lifetime.kind === "UNTIL_EVENT") {
704
712
  if (operationTimelineIdx < 0) return targets;
705
713
  for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
@@ -807,7 +815,8 @@ var applyOperationToPlayers = ({
807
815
  effector,
808
816
  targets: resolvedTargets,
809
817
  snapshotSeatMap: lifetimeSnapshotSeatMap,
810
- characterMap
818
+ characterMap,
819
+ payloads
811
820
  });
812
821
  const handler = effectHandlers[effect.type];
813
822
  if (!handler) {
@@ -885,11 +894,83 @@ var applyOperationToPlayers = ({
885
894
  const final = runReplay(groundTruthSeatMap);
886
895
  return transformEmptyArray(final);
887
896
  };
897
+
898
+ // src/can-invoke-ability.ts
899
+ var checkTurns = (turns, currentTurn) => {
900
+ if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
901
+ return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
902
+ }
903
+ if (typeof turns.from === "number" && currentTurn < turns.from) {
904
+ return `turn ${currentTurn} is before allowed start turn ${turns.from}`;
905
+ }
906
+ if (typeof turns.to === "number" && currentTurn > turns.to) {
907
+ return `turn ${currentTurn} is after allowed end turn ${turns.to}`;
908
+ }
909
+ return null;
910
+ };
911
+ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines) => {
912
+ return priorOperations.filter((op) => {
913
+ if (op.abilityId !== abilityId) return false;
914
+ if (typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
915
+ if (matchTurn === null && matchTime === null) return true;
916
+ const idx = operationTimelineMap.get(op);
917
+ if (typeof idx !== "number") return false;
918
+ const tl = timelines[idx];
919
+ if (!tl) return false;
920
+ if (matchTurn !== null && tl.turn !== matchTurn) return false;
921
+ if (matchTime !== null && tl.time !== matchTime) return false;
922
+ return true;
923
+ }).length;
924
+ };
925
+ var canInvokeAbility = ({
926
+ ability,
927
+ effector,
928
+ currentTimelineIdx,
929
+ timelines,
930
+ priorOperations = []
931
+ }) => {
932
+ const trigger = ability.triggerWindow;
933
+ if (!trigger) return { allowed: true };
934
+ const currentTimeline = timelines[currentTimelineIdx];
935
+ if (!currentTimeline) {
936
+ return { allowed: false, reason: `no timeline at index ${currentTimelineIdx}` };
937
+ }
938
+ if (trigger.turns) {
939
+ const turnError = checkTurns(trigger.turns, currentTimeline.turn);
940
+ if (turnError) return { allowed: false, reason: turnError };
941
+ }
942
+ if (trigger.phases && trigger.phases.length > 0 && !trigger.phases.includes(currentTimeline.time)) {
943
+ return { allowed: false, reason: `phase ${currentTimeline.time} not in allowed phases ${JSON.stringify(trigger.phases)}` };
944
+ }
945
+ if (trigger.requireEffectorAlive && (!effector || effector.isDead)) {
946
+ return { allowed: false, reason: "effector is not alive" };
947
+ }
948
+ if (trigger.frequency && priorOperations.length > 0) {
949
+ const operationTimelineMap = /* @__PURE__ */ new Map();
950
+ timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
951
+ const matchTurn = trigger.frequency === "ONCE_PER_GAME" ? null : currentTimeline.turn;
952
+ const matchTime = trigger.frequency === "ONCE_PER_GAME" ? null : trigger.frequency === "ONCE_PER_NIGHT" ? "night" : "day";
953
+ const count = countMatchingPriorOps(
954
+ priorOperations,
955
+ ability.id,
956
+ effector?.seat,
957
+ operationTimelineMap,
958
+ matchTurn,
959
+ matchTime,
960
+ timelines
961
+ );
962
+ if (count > 0) {
963
+ return { allowed: false, reason: `frequency ${trigger.frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})` };
964
+ }
965
+ }
966
+ return { allowed: true };
967
+ };
888
968
  export {
889
969
  adjustValueAsNumber,
890
970
  adjustValueAsNumberArray,
891
971
  applyOperationToPlayers,
892
972
  buildPlayerSeatMap,
973
+ canInvokeAbility,
893
974
  copyPlayers,
894
975
  getSourceValue,
895
976
  getValueFromPayloads,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bct-app/game-engine",
3
- "version": "0.1.6-beta.2",
3
+ "version": "0.1.6-beta.4",
4
4
  "description": "Game engine utilities for BCT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@bct-app/game-model": "0.1.3-beta.0"
40
+ "@bct-app/game-model": "0.1.3-beta.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitest/ui": "^4.1.3",