@bct-app/game-engine 0.1.4 → 0.1.6-beta.1

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
@@ -21,7 +21,7 @@ declare const getSourceValue: (source: TEffect["source"], payloads: unknown[]) =
21
21
  /**
22
22
  * Resolve a value from effect source, handling PAYLOAD (with player-seat indirection), CONSTANT, and EFFECTOR.
23
23
  */
24
- declare const resolveSourceValue: <TResolved>(source: TEffect["source"], writableInputs: TAbility["inputs"], payloads: unknown[], effector: TPlayer | undefined, snapshotSeatMap: Map<number, TPlayer>, resolveFromPlayer: (player: TPlayer) => TResolved, isResolvedValue?: (value: unknown) => value is TResolved) => TResolved | undefined;
24
+ declare const resolveSourceValue: <TResolved>(source: TEffect["source"], writableInputs: TAbility["inputs"], payloads: unknown[], effector: TPlayer | undefined, snapshotSeatMap: Map<number, TPlayer>, resolveFromPlayer: (player: TPlayer) => TResolved, isResolvedValue?: (value: unknown) => value is TResolved, resolveFromCharacter?: (characterId: string) => TResolved) => TResolved | undefined;
25
25
 
26
26
  /**
27
27
  * Type guard to check if a value is a number or an array of numbers.
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ declare const getSourceValue: (source: TEffect["source"], payloads: unknown[]) =
21
21
  /**
22
22
  * Resolve a value from effect source, handling PAYLOAD (with player-seat indirection), CONSTANT, and EFFECTOR.
23
23
  */
24
- declare const resolveSourceValue: <TResolved>(source: TEffect["source"], writableInputs: TAbility["inputs"], payloads: unknown[], effector: TPlayer | undefined, snapshotSeatMap: Map<number, TPlayer>, resolveFromPlayer: (player: TPlayer) => TResolved, isResolvedValue?: (value: unknown) => value is TResolved) => TResolved | undefined;
24
+ declare const resolveSourceValue: <TResolved>(source: TEffect["source"], writableInputs: TAbility["inputs"], payloads: unknown[], effector: TPlayer | undefined, snapshotSeatMap: Map<number, TPlayer>, resolveFromPlayer: (player: TPlayer) => TResolved, isResolvedValue?: (value: unknown) => value is TResolved, resolveFromCharacter?: (characterId: string) => TResolved) => TResolved | undefined;
25
25
 
26
26
  /**
27
27
  * Type guard to check if a value is a number or an array of numbers.
package/dist/index.js CHANGED
@@ -35,25 +35,6 @@ __export(index_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
37
 
38
- // src/effects/handler-types.ts
39
- var createEffectHandler = (type, handler) => {
40
- return (context) => {
41
- if (context.effect.type !== type) {
42
- console.warn("Invalid effect type for handler:", context.effect.type, "expected:", type);
43
- return;
44
- }
45
- handler({
46
- ...context,
47
- effect: context.effect
48
- });
49
- };
50
- };
51
-
52
- // src/effects/handlers/ability-change.ts
53
- var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", () => {
54
- console.warn("ABILITY_CHANGE effect handling not implemented yet.");
55
- });
56
-
57
38
  // src/guards.ts
58
39
  var isNumberOrNumberArray = (value) => {
59
40
  if (typeof value === "number") {
@@ -151,7 +132,7 @@ var getSourceValue = (source, payloads) => {
151
132
  }
152
133
  return null;
153
134
  };
154
- var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSeatMap, resolveFromPlayer, isResolvedValue) => {
135
+ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSeatMap, resolveFromPlayer, isResolvedValue, resolveFromCharacter) => {
155
136
  const castOrValidate = (value) => {
156
137
  if (!isResolvedValue) {
157
138
  return value;
@@ -167,6 +148,10 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
167
148
  const player = typeof seat === "number" ? snapshotSeatMap.get(seat) : void 0;
168
149
  return player ? resolveFromPlayer(player) : void 0;
169
150
  }
151
+ if (input?.type === "CHARACTER") {
152
+ const characterId = typeof payloadValue === "string" ? payloadValue : void 0;
153
+ return characterId && resolveFromCharacter ? resolveFromCharacter(characterId) : void 0;
154
+ }
170
155
  return castOrValidate(payloadValue);
171
156
  }
172
157
  if (source?.from === "CONSTANT") {
@@ -182,6 +167,90 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
182
167
  return void 0;
183
168
  };
184
169
 
170
+ // src/effects/handler-types.ts
171
+ var createEffectHandler = (type, handler) => {
172
+ return (context) => {
173
+ if (context.effect.type !== type) {
174
+ console.warn("Invalid effect type for handler:", context.effect.type, "expected:", type);
175
+ return;
176
+ }
177
+ handler({
178
+ ...context,
179
+ effect: context.effect
180
+ });
181
+ };
182
+ };
183
+
184
+ // src/effects/handlers/ability-change.ts
185
+ var toAbilityIdList = (value) => {
186
+ if (typeof value === "string") {
187
+ return value.length > 0 ? [value] : void 0;
188
+ }
189
+ if (Array.isArray(value)) {
190
+ const ids = value.filter((item) => typeof item === "string" && item.length > 0);
191
+ return ids.length > 0 ? ids : void 0;
192
+ }
193
+ return void 0;
194
+ };
195
+ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
196
+ effect,
197
+ operation,
198
+ payloads,
199
+ effector,
200
+ writableInputs,
201
+ getSnapshotSeatMap,
202
+ abilityMap,
203
+ makePlayersEffect,
204
+ characterMap
205
+ }) => {
206
+ const resolvedRaw = resolveSourceValue(
207
+ effect.source,
208
+ writableInputs,
209
+ payloads,
210
+ effector,
211
+ getSnapshotSeatMap(),
212
+ (player) => player.abilities,
213
+ (value) => typeof value === "string" && value.length > 0 || Array.isArray(value) && value.every((item) => typeof item === "string"),
214
+ (characterId) => {
215
+ const character = characterMap.get(characterId);
216
+ if (!character) {
217
+ console.warn("Character not found for ABILITY_CHANGE effect source:", characterId);
218
+ return [];
219
+ }
220
+ return character.abilities.map((a) => a.id);
221
+ }
222
+ );
223
+ const resolvedAbilityIds = toAbilityIdList(resolvedRaw);
224
+ if (!resolvedAbilityIds) {
225
+ console.warn("Ability ID not found for ABILITY_CHANGE effect:", effect.source);
226
+ return;
227
+ }
228
+ makePlayersEffect.forEach((player) => {
229
+ const overrideRaw = operation.abilityChangeOverrides?.[String(player.seat)];
230
+ const overrideIds = toAbilityIdList(overrideRaw);
231
+ const finalAbilityIds = overrideIds ?? resolvedAbilityIds;
232
+ finalAbilityIds.forEach((finalAbilityId) => {
233
+ const newAbility = abilityMap.get(finalAbilityId);
234
+ if (!newAbility) {
235
+ console.warn("Ability not found for ABILITY_CHANGE effect:", finalAbilityId);
236
+ return;
237
+ }
238
+ const replaceAtSameStage = (abilities) => {
239
+ const idx = abilities.findIndex((aid) => abilityMap.get(aid)?.stage === newAbility.stage);
240
+ if (idx >= 0) {
241
+ abilities[idx] = finalAbilityId;
242
+ } else {
243
+ abilities.push(finalAbilityId);
244
+ }
245
+ };
246
+ replaceAtSameStage(player.abilities);
247
+ if (player.perceivedCharacter?.asCharacter) {
248
+ replaceAtSameStage(player.perceivedCharacter.abilities);
249
+ }
250
+ });
251
+ });
252
+ });
253
+
185
254
  // src/effects/handlers/alignment-change.ts
186
255
  var applyAlignmentChange = createEffectHandler("ALIGNMENT_CHANGE", ({
187
256
  effect,
@@ -594,11 +663,111 @@ var resolveEffectTargets = ({
594
663
  return [];
595
664
  };
596
665
 
666
+ // src/effects/evaluate-lifetime.ts
667
+ var getLifetime = (effect) => {
668
+ if ("lifetime" in effect) {
669
+ return effect.lifetime;
670
+ }
671
+ return void 0;
672
+ };
673
+ var matchesLifetimeCondition = (player, condition, characterMap) => {
674
+ const { field, operator } = condition;
675
+ const value = condition.value;
676
+ if (field === "IS_DEAD") {
677
+ return Boolean(player.isDead) === Boolean(value);
678
+ }
679
+ if (field === "CHARACTER_ID") {
680
+ if (operator === "IN" && Array.isArray(value)) {
681
+ return value.includes(player.characterId);
682
+ }
683
+ return typeof value === "string" && player.characterId === value;
684
+ }
685
+ if (field === "CHARACTER_KIND") {
686
+ const kind = characterMap.get(player.characterId)?.kind;
687
+ if (!kind) return false;
688
+ if (operator === "IN" && Array.isArray(value)) {
689
+ return value.includes(kind);
690
+ }
691
+ return typeof value === "string" && kind === value;
692
+ }
693
+ if (field === "HAS_ABILITY") {
694
+ if (operator === "IN" && Array.isArray(value)) {
695
+ return value.some((id) => player.abilities.includes(id));
696
+ }
697
+ return typeof value === "string" && player.abilities.includes(value);
698
+ }
699
+ return false;
700
+ };
701
+ var evaluateConditions = (player, conditions, mode, characterMap) => {
702
+ if (!player) return false;
703
+ if (conditions.length === 0) return true;
704
+ const results = conditions.map((c) => matchesLifetimeCondition(player, c, characterMap));
705
+ return mode === "ALL" ? results.every(Boolean) : results.some(Boolean);
706
+ };
707
+ var evaluateLifetime = ({
708
+ effect,
709
+ operationTimelineIdx,
710
+ nowTimelineIndex,
711
+ timelines,
712
+ effector,
713
+ targets,
714
+ snapshotSeatMap,
715
+ characterMap
716
+ }) => {
717
+ const lifetime = getLifetime(effect);
718
+ if (!lifetime || lifetime.kind === "PERMANENT") {
719
+ return targets;
720
+ }
721
+ if (lifetime.kind === "TURNS") {
722
+ const opTurn = operationTimelineIdx >= 0 ? timelines[operationTimelineIdx]?.turn : void 0;
723
+ const nowTurn = nowTimelineIndex >= 0 ? timelines[nowTimelineIndex]?.turn : void 0;
724
+ if (typeof opTurn !== "number" || typeof nowTurn !== "number") {
725
+ return targets;
726
+ }
727
+ return nowTurn - opTurn < lifetime.count ? targets : [];
728
+ }
729
+ if (lifetime.kind === "UNTIL_EVENT") {
730
+ if (operationTimelineIdx < 0) return targets;
731
+ for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
732
+ const tl = timelines[idx];
733
+ if (!tl) continue;
734
+ if (lifetime.event === "NEXT_DAY" && tl.time === "day") return [];
735
+ if (lifetime.event === "NEXT_NIGHT" && tl.time === "night") return [];
736
+ if (lifetime.event === "NEXT_EXECUTION" && (tl.nominations?.length ?? 0) > 0) return [];
737
+ }
738
+ return targets;
739
+ }
740
+ if (lifetime.kind === "WHILE") {
741
+ if (!snapshotSeatMap) {
742
+ return targets;
743
+ }
744
+ const subject = lifetime.subject ?? "EFFECTOR";
745
+ const mode = lifetime.mode ?? "ALL";
746
+ const conditions = lifetime.conditions ?? [];
747
+ if (subject === "EFFECTOR") {
748
+ const current = effector ? snapshotSeatMap.get(effector.seat) : void 0;
749
+ return evaluateConditions(current, conditions, mode, characterMap) ? targets : [];
750
+ }
751
+ return targets.filter((target) => {
752
+ const current = snapshotSeatMap.get(target.seat);
753
+ return evaluateConditions(current, conditions, mode, characterMap);
754
+ });
755
+ }
756
+ return targets;
757
+ };
758
+
597
759
  // src/apply-operation.ts
598
760
  var isDeferredOperation = (operation, abilityMap) => {
599
761
  const ability = abilityMap.get(operation.abilityId);
600
762
  return ability?.executionTiming === "DEFER_TO_END";
601
763
  };
764
+ var hasGatedLifetime = (allAbilities) => {
765
+ return allAbilities.some((ability) => ability.effects.some((effect) => {
766
+ if (!("lifetime" in effect)) return false;
767
+ const lifetime = effect.lifetime;
768
+ return Boolean(lifetime) && lifetime?.kind !== "PERMANENT";
769
+ }));
770
+ };
602
771
  var applyOperationToPlayers = ({
603
772
  players,
604
773
  operations,
@@ -614,7 +783,6 @@ var applyOperationToPlayers = ({
614
783
  const characterMap = new Map(characters.map((character) => [character.id, character]));
615
784
  const operationTimelineMap = /* @__PURE__ */ new Map();
616
785
  timelines.forEach((timeline, idx) => timeline.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
617
- const playersWithStatus = copyPlayers(players);
618
786
  const nowTimelineIndex = typeof timelineIndexAtNow === "number" ? timelineIndexAtNow : timelines.length - 1;
619
787
  const operationsByTimeline = /* @__PURE__ */ new Map();
620
788
  operations.forEach((operation) => {
@@ -624,103 +792,124 @@ var applyOperationToPlayers = ({
624
792
  }
625
793
  operationsByTimeline.get(timelineIndex)?.push(operation);
626
794
  });
627
- const applyOpsSequence = (ops) => {
628
- ops.forEach((operation) => {
629
- const operationInTimelineIdx = operationTimelineMap.get(operation) ?? -1;
630
- const ability = abilityMap.get(operation.abilityId);
631
- if (!ability) {
632
- console.warn("Ability not found for operation:", operation);
633
- return;
634
- }
635
- const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
636
- const effector = playerSeatMap.get(operation.effector);
637
- const payloads = operation.payloads || [];
638
- const writableInputs = ability.inputs.filter((part) => !part.readonly);
639
- let snapshotSeatMap = null;
640
- const getSnapshotSeatMap = () => {
641
- if (!snapshotSeatMap) {
642
- snapshotSeatMap = buildPlayerSeatMap(copyPlayers(playersWithStatus));
643
- }
644
- return snapshotSeatMap;
645
- };
646
- ability.effects.forEach((effect) => {
647
- const makePlayersEffect = resolveEffectTargets({
648
- effect,
649
- operation,
650
- payloads,
651
- effector,
652
- playerSeatMap,
653
- characterMap
654
- });
655
- if (!makePlayersEffect) {
795
+ const runReplay = (lifetimeSnapshotSeatMap) => {
796
+ const playersWithStatus = copyPlayers(players);
797
+ const applyOpsSequence = (ops) => {
798
+ ops.forEach((operation) => {
799
+ const operationInTimelineIdx = operationTimelineMap.get(operation) ?? -1;
800
+ const ability = abilityMap.get(operation.abilityId);
801
+ if (!ability) {
802
+ console.warn("Ability not found for operation:", operation);
656
803
  return;
657
804
  }
658
- const handler = effectHandlers[effect.type];
659
- if (!handler) {
660
- console.warn("Unknown effect type:", effect.type);
661
- return;
662
- }
663
- const context = {
664
- effect,
665
- operation,
666
- payloads,
667
- effector,
668
- writableInputs,
669
- playerSeatMap,
670
- getSnapshotSeatMap,
671
- characterMap,
672
- nowTimelineIndex,
673
- operationInTimelineIdx,
674
- makePlayersEffect
805
+ const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
806
+ const effector = playerSeatMap.get(operation.effector);
807
+ const payloads = operation.payloads || [];
808
+ const writableInputs = ability.inputs.filter((part) => !part.readonly);
809
+ let snapshotSeatMap = null;
810
+ const getSnapshotSeatMap = () => {
811
+ if (!snapshotSeatMap) {
812
+ snapshotSeatMap = buildPlayerSeatMap(copyPlayers(playersWithStatus));
813
+ }
814
+ return snapshotSeatMap;
675
815
  };
676
- handler(context);
816
+ ability.effects.forEach((effect) => {
817
+ const resolvedTargets = resolveEffectTargets({
818
+ effect,
819
+ operation,
820
+ payloads,
821
+ effector,
822
+ playerSeatMap,
823
+ characterMap
824
+ });
825
+ if (!resolvedTargets) {
826
+ return;
827
+ }
828
+ const makePlayersEffect = evaluateLifetime({
829
+ effect,
830
+ operationTimelineIdx: operationInTimelineIdx,
831
+ nowTimelineIndex,
832
+ timelines,
833
+ effector,
834
+ targets: resolvedTargets,
835
+ snapshotSeatMap: lifetimeSnapshotSeatMap,
836
+ characterMap
837
+ });
838
+ const handler = effectHandlers[effect.type];
839
+ if (!handler) {
840
+ console.warn("Unknown effect type:", effect.type);
841
+ return;
842
+ }
843
+ const context = {
844
+ effect,
845
+ operation,
846
+ payloads,
847
+ effector,
848
+ writableInputs,
849
+ playerSeatMap,
850
+ getSnapshotSeatMap,
851
+ characterMap,
852
+ abilityMap,
853
+ nowTimelineIndex,
854
+ operationInTimelineIdx,
855
+ makePlayersEffect
856
+ };
857
+ handler(context);
858
+ });
677
859
  });
678
- });
679
- };
680
- const applyOps = (ops) => {
681
- const normalOps = [];
682
- const deferredOps = [];
683
- ops.forEach((operation) => {
684
- if (isDeferredOperation(operation, abilityMap)) {
685
- deferredOps.push(operation);
686
- return;
687
- }
688
- normalOps.push(operation);
689
- });
690
- applyOpsSequence(normalOps);
691
- applyOpsSequence(deferredOps);
692
- };
693
- const settingsOps = operationsByTimeline.get(-1);
694
- if (settingsOps) {
695
- applyOps(settingsOps);
696
- }
697
- for (let i = 0; i <= nowTimelineIndex; i += 1) {
698
- const timeline = timelines[i];
699
- if (!timeline) {
700
- continue;
701
- }
702
- const nominations = timeline.nominations;
703
- if (nominations && nominations.length > 0) {
704
- const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
705
- nominations.forEach((nomination) => {
706
- const voterSeats = nomination.voterSeats;
707
- if (!voterSeats || voterSeats.length === 0) {
860
+ };
861
+ const applyOps = (ops) => {
862
+ const normalOps = [];
863
+ const deferredOps = [];
864
+ ops.forEach((operation) => {
865
+ if (isDeferredOperation(operation, abilityMap)) {
866
+ deferredOps.push(operation);
708
867
  return;
709
868
  }
710
- voterSeats.forEach((seat) => {
711
- const player = playerSeatMap.get(seat);
712
- if (player && player.isDead && !player.hasUsedDeadVote) {
713
- player.hasUsedDeadVote = true;
714
- }
715
- });
869
+ normalOps.push(operation);
716
870
  });
871
+ applyOpsSequence(normalOps);
872
+ applyOpsSequence(deferredOps);
873
+ };
874
+ const settingsOps = operationsByTimeline.get(-1);
875
+ if (settingsOps) {
876
+ applyOps(settingsOps);
717
877
  }
718
- const timelineOps = operationsByTimeline.get(i);
719
- if (timelineOps) {
720
- applyOps(timelineOps);
878
+ for (let i = 0; i <= nowTimelineIndex; i += 1) {
879
+ const timeline = timelines[i];
880
+ if (!timeline) {
881
+ continue;
882
+ }
883
+ const nominations = timeline.nominations;
884
+ if (nominations && nominations.length > 0) {
885
+ const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
886
+ nominations.forEach((nomination) => {
887
+ const voterSeats = nomination.voterSeats;
888
+ if (!voterSeats || voterSeats.length === 0) {
889
+ return;
890
+ }
891
+ voterSeats.forEach((seat) => {
892
+ const player = playerSeatMap.get(seat);
893
+ if (player && player.isDead && !player.hasUsedDeadVote) {
894
+ player.hasUsedDeadVote = true;
895
+ }
896
+ });
897
+ });
898
+ }
899
+ const timelineOps = operationsByTimeline.get(i);
900
+ if (timelineOps) {
901
+ applyOps(timelineOps);
902
+ }
721
903
  }
904
+ return playersWithStatus;
905
+ };
906
+ if (!hasGatedLifetime(allAbilities)) {
907
+ return transformEmptyArray(runReplay(null));
722
908
  }
723
- return transformEmptyArray(playersWithStatus);
909
+ const groundTruth = runReplay(null);
910
+ const groundTruthSeatMap = buildPlayerSeatMap(groundTruth);
911
+ const final = runReplay(groundTruthSeatMap);
912
+ return transformEmptyArray(final);
724
913
  };
725
914
  // Annotate the CommonJS export names for ESM import in node:
726
915
  0 && (module.exports = {
package/dist/index.mjs CHANGED
@@ -1,22 +1,3 @@
1
- // src/effects/handler-types.ts
2
- var createEffectHandler = (type, handler) => {
3
- return (context) => {
4
- if (context.effect.type !== type) {
5
- console.warn("Invalid effect type for handler:", context.effect.type, "expected:", type);
6
- return;
7
- }
8
- handler({
9
- ...context,
10
- effect: context.effect
11
- });
12
- };
13
- };
14
-
15
- // src/effects/handlers/ability-change.ts
16
- var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", () => {
17
- console.warn("ABILITY_CHANGE effect handling not implemented yet.");
18
- });
19
-
20
1
  // src/guards.ts
21
2
  var isNumberOrNumberArray = (value) => {
22
3
  if (typeof value === "number") {
@@ -114,7 +95,7 @@ var getSourceValue = (source, payloads) => {
114
95
  }
115
96
  return null;
116
97
  };
117
- var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSeatMap, resolveFromPlayer, isResolvedValue) => {
98
+ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSeatMap, resolveFromPlayer, isResolvedValue, resolveFromCharacter) => {
118
99
  const castOrValidate = (value) => {
119
100
  if (!isResolvedValue) {
120
101
  return value;
@@ -130,6 +111,10 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
130
111
  const player = typeof seat === "number" ? snapshotSeatMap.get(seat) : void 0;
131
112
  return player ? resolveFromPlayer(player) : void 0;
132
113
  }
114
+ if (input?.type === "CHARACTER") {
115
+ const characterId = typeof payloadValue === "string" ? payloadValue : void 0;
116
+ return characterId && resolveFromCharacter ? resolveFromCharacter(characterId) : void 0;
117
+ }
133
118
  return castOrValidate(payloadValue);
134
119
  }
135
120
  if (source?.from === "CONSTANT") {
@@ -145,6 +130,90 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
145
130
  return void 0;
146
131
  };
147
132
 
133
+ // src/effects/handler-types.ts
134
+ var createEffectHandler = (type, handler) => {
135
+ return (context) => {
136
+ if (context.effect.type !== type) {
137
+ console.warn("Invalid effect type for handler:", context.effect.type, "expected:", type);
138
+ return;
139
+ }
140
+ handler({
141
+ ...context,
142
+ effect: context.effect
143
+ });
144
+ };
145
+ };
146
+
147
+ // src/effects/handlers/ability-change.ts
148
+ var toAbilityIdList = (value) => {
149
+ if (typeof value === "string") {
150
+ return value.length > 0 ? [value] : void 0;
151
+ }
152
+ if (Array.isArray(value)) {
153
+ const ids = value.filter((item) => typeof item === "string" && item.length > 0);
154
+ return ids.length > 0 ? ids : void 0;
155
+ }
156
+ return void 0;
157
+ };
158
+ var applyAbilityChange = createEffectHandler("ABILITY_CHANGE", ({
159
+ effect,
160
+ operation,
161
+ payloads,
162
+ effector,
163
+ writableInputs,
164
+ getSnapshotSeatMap,
165
+ abilityMap,
166
+ makePlayersEffect,
167
+ characterMap
168
+ }) => {
169
+ const resolvedRaw = resolveSourceValue(
170
+ effect.source,
171
+ writableInputs,
172
+ payloads,
173
+ effector,
174
+ getSnapshotSeatMap(),
175
+ (player) => player.abilities,
176
+ (value) => typeof value === "string" && value.length > 0 || Array.isArray(value) && value.every((item) => typeof item === "string"),
177
+ (characterId) => {
178
+ const character = characterMap.get(characterId);
179
+ if (!character) {
180
+ console.warn("Character not found for ABILITY_CHANGE effect source:", characterId);
181
+ return [];
182
+ }
183
+ return character.abilities.map((a) => a.id);
184
+ }
185
+ );
186
+ const resolvedAbilityIds = toAbilityIdList(resolvedRaw);
187
+ if (!resolvedAbilityIds) {
188
+ console.warn("Ability ID not found for ABILITY_CHANGE effect:", effect.source);
189
+ return;
190
+ }
191
+ makePlayersEffect.forEach((player) => {
192
+ const overrideRaw = operation.abilityChangeOverrides?.[String(player.seat)];
193
+ const overrideIds = toAbilityIdList(overrideRaw);
194
+ const finalAbilityIds = overrideIds ?? resolvedAbilityIds;
195
+ finalAbilityIds.forEach((finalAbilityId) => {
196
+ const newAbility = abilityMap.get(finalAbilityId);
197
+ if (!newAbility) {
198
+ console.warn("Ability not found for ABILITY_CHANGE effect:", finalAbilityId);
199
+ return;
200
+ }
201
+ const replaceAtSameStage = (abilities) => {
202
+ const idx = abilities.findIndex((aid) => abilityMap.get(aid)?.stage === newAbility.stage);
203
+ if (idx >= 0) {
204
+ abilities[idx] = finalAbilityId;
205
+ } else {
206
+ abilities.push(finalAbilityId);
207
+ }
208
+ };
209
+ replaceAtSameStage(player.abilities);
210
+ if (player.perceivedCharacter?.asCharacter) {
211
+ replaceAtSameStage(player.perceivedCharacter.abilities);
212
+ }
213
+ });
214
+ });
215
+ });
216
+
148
217
  // src/effects/handlers/alignment-change.ts
149
218
  var applyAlignmentChange = createEffectHandler("ALIGNMENT_CHANGE", ({
150
219
  effect,
@@ -557,11 +626,111 @@ var resolveEffectTargets = ({
557
626
  return [];
558
627
  };
559
628
 
629
+ // src/effects/evaluate-lifetime.ts
630
+ var getLifetime = (effect) => {
631
+ if ("lifetime" in effect) {
632
+ return effect.lifetime;
633
+ }
634
+ return void 0;
635
+ };
636
+ var matchesLifetimeCondition = (player, condition, characterMap) => {
637
+ const { field, operator } = condition;
638
+ const value = condition.value;
639
+ if (field === "IS_DEAD") {
640
+ return Boolean(player.isDead) === Boolean(value);
641
+ }
642
+ if (field === "CHARACTER_ID") {
643
+ if (operator === "IN" && Array.isArray(value)) {
644
+ return value.includes(player.characterId);
645
+ }
646
+ return typeof value === "string" && player.characterId === value;
647
+ }
648
+ if (field === "CHARACTER_KIND") {
649
+ const kind = characterMap.get(player.characterId)?.kind;
650
+ if (!kind) return false;
651
+ if (operator === "IN" && Array.isArray(value)) {
652
+ return value.includes(kind);
653
+ }
654
+ return typeof value === "string" && kind === value;
655
+ }
656
+ if (field === "HAS_ABILITY") {
657
+ if (operator === "IN" && Array.isArray(value)) {
658
+ return value.some((id) => player.abilities.includes(id));
659
+ }
660
+ return typeof value === "string" && player.abilities.includes(value);
661
+ }
662
+ return false;
663
+ };
664
+ var evaluateConditions = (player, conditions, mode, characterMap) => {
665
+ if (!player) return false;
666
+ if (conditions.length === 0) return true;
667
+ const results = conditions.map((c) => matchesLifetimeCondition(player, c, characterMap));
668
+ return mode === "ALL" ? results.every(Boolean) : results.some(Boolean);
669
+ };
670
+ var evaluateLifetime = ({
671
+ effect,
672
+ operationTimelineIdx,
673
+ nowTimelineIndex,
674
+ timelines,
675
+ effector,
676
+ targets,
677
+ snapshotSeatMap,
678
+ characterMap
679
+ }) => {
680
+ const lifetime = getLifetime(effect);
681
+ if (!lifetime || lifetime.kind === "PERMANENT") {
682
+ return targets;
683
+ }
684
+ if (lifetime.kind === "TURNS") {
685
+ const opTurn = operationTimelineIdx >= 0 ? timelines[operationTimelineIdx]?.turn : void 0;
686
+ const nowTurn = nowTimelineIndex >= 0 ? timelines[nowTimelineIndex]?.turn : void 0;
687
+ if (typeof opTurn !== "number" || typeof nowTurn !== "number") {
688
+ return targets;
689
+ }
690
+ return nowTurn - opTurn < lifetime.count ? targets : [];
691
+ }
692
+ if (lifetime.kind === "UNTIL_EVENT") {
693
+ if (operationTimelineIdx < 0) return targets;
694
+ for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
695
+ const tl = timelines[idx];
696
+ if (!tl) continue;
697
+ if (lifetime.event === "NEXT_DAY" && tl.time === "day") return [];
698
+ if (lifetime.event === "NEXT_NIGHT" && tl.time === "night") return [];
699
+ if (lifetime.event === "NEXT_EXECUTION" && (tl.nominations?.length ?? 0) > 0) return [];
700
+ }
701
+ return targets;
702
+ }
703
+ if (lifetime.kind === "WHILE") {
704
+ if (!snapshotSeatMap) {
705
+ return targets;
706
+ }
707
+ const subject = lifetime.subject ?? "EFFECTOR";
708
+ const mode = lifetime.mode ?? "ALL";
709
+ const conditions = lifetime.conditions ?? [];
710
+ if (subject === "EFFECTOR") {
711
+ const current = effector ? snapshotSeatMap.get(effector.seat) : void 0;
712
+ return evaluateConditions(current, conditions, mode, characterMap) ? targets : [];
713
+ }
714
+ return targets.filter((target) => {
715
+ const current = snapshotSeatMap.get(target.seat);
716
+ return evaluateConditions(current, conditions, mode, characterMap);
717
+ });
718
+ }
719
+ return targets;
720
+ };
721
+
560
722
  // src/apply-operation.ts
561
723
  var isDeferredOperation = (operation, abilityMap) => {
562
724
  const ability = abilityMap.get(operation.abilityId);
563
725
  return ability?.executionTiming === "DEFER_TO_END";
564
726
  };
727
+ var hasGatedLifetime = (allAbilities) => {
728
+ return allAbilities.some((ability) => ability.effects.some((effect) => {
729
+ if (!("lifetime" in effect)) return false;
730
+ const lifetime = effect.lifetime;
731
+ return Boolean(lifetime) && lifetime?.kind !== "PERMANENT";
732
+ }));
733
+ };
565
734
  var applyOperationToPlayers = ({
566
735
  players,
567
736
  operations,
@@ -577,7 +746,6 @@ var applyOperationToPlayers = ({
577
746
  const characterMap = new Map(characters.map((character) => [character.id, character]));
578
747
  const operationTimelineMap = /* @__PURE__ */ new Map();
579
748
  timelines.forEach((timeline, idx) => timeline.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
580
- const playersWithStatus = copyPlayers(players);
581
749
  const nowTimelineIndex = typeof timelineIndexAtNow === "number" ? timelineIndexAtNow : timelines.length - 1;
582
750
  const operationsByTimeline = /* @__PURE__ */ new Map();
583
751
  operations.forEach((operation) => {
@@ -587,103 +755,124 @@ var applyOperationToPlayers = ({
587
755
  }
588
756
  operationsByTimeline.get(timelineIndex)?.push(operation);
589
757
  });
590
- const applyOpsSequence = (ops) => {
591
- ops.forEach((operation) => {
592
- const operationInTimelineIdx = operationTimelineMap.get(operation) ?? -1;
593
- const ability = abilityMap.get(operation.abilityId);
594
- if (!ability) {
595
- console.warn("Ability not found for operation:", operation);
596
- return;
597
- }
598
- const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
599
- const effector = playerSeatMap.get(operation.effector);
600
- const payloads = operation.payloads || [];
601
- const writableInputs = ability.inputs.filter((part) => !part.readonly);
602
- let snapshotSeatMap = null;
603
- const getSnapshotSeatMap = () => {
604
- if (!snapshotSeatMap) {
605
- snapshotSeatMap = buildPlayerSeatMap(copyPlayers(playersWithStatus));
606
- }
607
- return snapshotSeatMap;
608
- };
609
- ability.effects.forEach((effect) => {
610
- const makePlayersEffect = resolveEffectTargets({
611
- effect,
612
- operation,
613
- payloads,
614
- effector,
615
- playerSeatMap,
616
- characterMap
617
- });
618
- if (!makePlayersEffect) {
758
+ const runReplay = (lifetimeSnapshotSeatMap) => {
759
+ const playersWithStatus = copyPlayers(players);
760
+ const applyOpsSequence = (ops) => {
761
+ ops.forEach((operation) => {
762
+ const operationInTimelineIdx = operationTimelineMap.get(operation) ?? -1;
763
+ const ability = abilityMap.get(operation.abilityId);
764
+ if (!ability) {
765
+ console.warn("Ability not found for operation:", operation);
619
766
  return;
620
767
  }
621
- const handler = effectHandlers[effect.type];
622
- if (!handler) {
623
- console.warn("Unknown effect type:", effect.type);
624
- return;
625
- }
626
- const context = {
627
- effect,
628
- operation,
629
- payloads,
630
- effector,
631
- writableInputs,
632
- playerSeatMap,
633
- getSnapshotSeatMap,
634
- characterMap,
635
- nowTimelineIndex,
636
- operationInTimelineIdx,
637
- makePlayersEffect
768
+ const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
769
+ const effector = playerSeatMap.get(operation.effector);
770
+ const payloads = operation.payloads || [];
771
+ const writableInputs = ability.inputs.filter((part) => !part.readonly);
772
+ let snapshotSeatMap = null;
773
+ const getSnapshotSeatMap = () => {
774
+ if (!snapshotSeatMap) {
775
+ snapshotSeatMap = buildPlayerSeatMap(copyPlayers(playersWithStatus));
776
+ }
777
+ return snapshotSeatMap;
638
778
  };
639
- handler(context);
779
+ ability.effects.forEach((effect) => {
780
+ const resolvedTargets = resolveEffectTargets({
781
+ effect,
782
+ operation,
783
+ payloads,
784
+ effector,
785
+ playerSeatMap,
786
+ characterMap
787
+ });
788
+ if (!resolvedTargets) {
789
+ return;
790
+ }
791
+ const makePlayersEffect = evaluateLifetime({
792
+ effect,
793
+ operationTimelineIdx: operationInTimelineIdx,
794
+ nowTimelineIndex,
795
+ timelines,
796
+ effector,
797
+ targets: resolvedTargets,
798
+ snapshotSeatMap: lifetimeSnapshotSeatMap,
799
+ characterMap
800
+ });
801
+ const handler = effectHandlers[effect.type];
802
+ if (!handler) {
803
+ console.warn("Unknown effect type:", effect.type);
804
+ return;
805
+ }
806
+ const context = {
807
+ effect,
808
+ operation,
809
+ payloads,
810
+ effector,
811
+ writableInputs,
812
+ playerSeatMap,
813
+ getSnapshotSeatMap,
814
+ characterMap,
815
+ abilityMap,
816
+ nowTimelineIndex,
817
+ operationInTimelineIdx,
818
+ makePlayersEffect
819
+ };
820
+ handler(context);
821
+ });
640
822
  });
641
- });
642
- };
643
- const applyOps = (ops) => {
644
- const normalOps = [];
645
- const deferredOps = [];
646
- ops.forEach((operation) => {
647
- if (isDeferredOperation(operation, abilityMap)) {
648
- deferredOps.push(operation);
649
- return;
650
- }
651
- normalOps.push(operation);
652
- });
653
- applyOpsSequence(normalOps);
654
- applyOpsSequence(deferredOps);
655
- };
656
- const settingsOps = operationsByTimeline.get(-1);
657
- if (settingsOps) {
658
- applyOps(settingsOps);
659
- }
660
- for (let i = 0; i <= nowTimelineIndex; i += 1) {
661
- const timeline = timelines[i];
662
- if (!timeline) {
663
- continue;
664
- }
665
- const nominations = timeline.nominations;
666
- if (nominations && nominations.length > 0) {
667
- const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
668
- nominations.forEach((nomination) => {
669
- const voterSeats = nomination.voterSeats;
670
- if (!voterSeats || voterSeats.length === 0) {
823
+ };
824
+ const applyOps = (ops) => {
825
+ const normalOps = [];
826
+ const deferredOps = [];
827
+ ops.forEach((operation) => {
828
+ if (isDeferredOperation(operation, abilityMap)) {
829
+ deferredOps.push(operation);
671
830
  return;
672
831
  }
673
- voterSeats.forEach((seat) => {
674
- const player = playerSeatMap.get(seat);
675
- if (player && player.isDead && !player.hasUsedDeadVote) {
676
- player.hasUsedDeadVote = true;
677
- }
678
- });
832
+ normalOps.push(operation);
679
833
  });
834
+ applyOpsSequence(normalOps);
835
+ applyOpsSequence(deferredOps);
836
+ };
837
+ const settingsOps = operationsByTimeline.get(-1);
838
+ if (settingsOps) {
839
+ applyOps(settingsOps);
680
840
  }
681
- const timelineOps = operationsByTimeline.get(i);
682
- if (timelineOps) {
683
- applyOps(timelineOps);
841
+ for (let i = 0; i <= nowTimelineIndex; i += 1) {
842
+ const timeline = timelines[i];
843
+ if (!timeline) {
844
+ continue;
845
+ }
846
+ const nominations = timeline.nominations;
847
+ if (nominations && nominations.length > 0) {
848
+ const playerSeatMap = buildPlayerSeatMap(playersWithStatus);
849
+ nominations.forEach((nomination) => {
850
+ const voterSeats = nomination.voterSeats;
851
+ if (!voterSeats || voterSeats.length === 0) {
852
+ return;
853
+ }
854
+ voterSeats.forEach((seat) => {
855
+ const player = playerSeatMap.get(seat);
856
+ if (player && player.isDead && !player.hasUsedDeadVote) {
857
+ player.hasUsedDeadVote = true;
858
+ }
859
+ });
860
+ });
861
+ }
862
+ const timelineOps = operationsByTimeline.get(i);
863
+ if (timelineOps) {
864
+ applyOps(timelineOps);
865
+ }
684
866
  }
867
+ return playersWithStatus;
868
+ };
869
+ if (!hasGatedLifetime(allAbilities)) {
870
+ return transformEmptyArray(runReplay(null));
685
871
  }
686
- return transformEmptyArray(playersWithStatus);
872
+ const groundTruth = runReplay(null);
873
+ const groundTruthSeatMap = buildPlayerSeatMap(groundTruth);
874
+ const final = runReplay(groundTruthSeatMap);
875
+ return transformEmptyArray(final);
687
876
  };
688
877
  export {
689
878
  adjustValueAsNumber,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bct-app/game-engine",
3
- "version": "0.1.4",
3
+ "version": "0.1.6-beta.1",
4
4
  "description": "Game engine utilities for BCT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,6 +15,13 @@
15
15
  "dist",
16
16
  "README.md"
17
17
  ],
18
+ "scripts": {
19
+ "clean": "rm -rf dist",
20
+ "build": "tsup src/index.ts --dts --format cjs,esm",
21
+ "dev": "tsup src/index.ts --dts --format cjs,esm --watch",
22
+ "test": "vitest",
23
+ "test:run": "vitest run"
24
+ },
18
25
  "keywords": [
19
26
  "game-engine",
20
27
  "bct"
@@ -30,19 +37,12 @@
30
37
  "access": "public"
31
38
  },
32
39
  "dependencies": {
33
- "@bct-app/game-model": "0.1.1"
40
+ "@bct-app/game-model": "0.1.3-beta.0"
34
41
  },
35
42
  "devDependencies": {
36
43
  "@vitest/ui": "^4.1.3",
37
44
  "tsup": "^8.0.2",
38
45
  "typescript": "^5.9.3",
39
46
  "vitest": "^4.1.3"
40
- },
41
- "scripts": {
42
- "clean": "rm -rf dist",
43
- "build": "tsup src/index.ts --dts --format cjs,esm",
44
- "dev": "tsup src/index.ts --dts --format cjs,esm --watch",
45
- "test": "vitest",
46
- "test:run": "vitest run"
47
47
  }
48
- }
48
+ }