@bct-app/game-engine 0.1.6 → 0.1.7

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.mjs CHANGED
@@ -638,44 +638,264 @@ var getLifetime = (effect, payloads) => {
638
638
  if (effect.type === "REMINDER_ADD") {
639
639
  const maybeReminder = getSourceValue(effect.source, payloads);
640
640
  if (isReminder(maybeReminder) && typeof maybeReminder.duration === "number" && maybeReminder.duration > 0) {
641
- return { kind: "PHASES", count: maybeReminder.duration };
641
+ return {
642
+ kind: "WHILE",
643
+ expr: {
644
+ op: "ATOM",
645
+ atom: {
646
+ type: "TIME",
647
+ spec: { kind: "WITHIN_PHASES", count: maybeReminder.duration }
648
+ }
649
+ }
650
+ };
642
651
  }
643
652
  }
644
653
  return void 0;
645
654
  };
646
- var matchesLifetimeCondition = (player, condition, characterMap) => {
647
- const { field, operator } = condition;
648
- const value = condition.value;
655
+ var resolveSubject = (subject, ctx) => {
656
+ if (!ctx.snapshotSeatMap) return [];
657
+ const map = ctx.snapshotSeatMap;
658
+ switch (subject.kind) {
659
+ case "EFFECTOR": {
660
+ if (!ctx.effector) return [];
661
+ const player = map.get(ctx.effector.seat);
662
+ return player ? [player] : [];
663
+ }
664
+ case "TARGET": {
665
+ if (!ctx.target) return [];
666
+ const player = map.get(ctx.target.seat);
667
+ return player ? [player] : [];
668
+ }
669
+ case "SEAT": {
670
+ const player = map.get(subject.seat);
671
+ return player ? [player] : [];
672
+ }
673
+ case "PAYLOAD_REF": {
674
+ const value = getValueFromPayloads(ctx.payloads, subject.payloadIndex);
675
+ const seat = adjustValueAsNumber(value);
676
+ if (typeof seat !== "number") return [];
677
+ const player = map.get(seat);
678
+ return player ? [player] : [];
679
+ }
680
+ case "CHARACTER": {
681
+ const matches = [];
682
+ map.forEach((player) => {
683
+ if (player.characterId === subject.characterId) matches.push(player);
684
+ });
685
+ return matches;
686
+ }
687
+ }
688
+ };
689
+ var compareString = (actual, operator, value) => {
690
+ const isInList = Array.isArray(value) && value.every((v) => typeof v === "string") ? value.includes(actual ?? "") : false;
691
+ const isEqScalar = typeof value === "string" && actual === value;
692
+ switch (operator) {
693
+ case "EQ":
694
+ return isEqScalar;
695
+ case "NE":
696
+ return !isEqScalar;
697
+ case "IN":
698
+ return actual !== void 0 && isInList;
699
+ case "NOT_IN":
700
+ return !isInList;
701
+ }
702
+ };
703
+ var compareBool = (actual, operator, value) => {
704
+ const expected = typeof value === "boolean" ? value : true;
705
+ switch (operator) {
706
+ case "EQ":
707
+ return actual === expected;
708
+ case "NE":
709
+ return actual !== expected;
710
+ case "IN":
711
+ case "NOT_IN":
712
+ return actual === expected;
713
+ }
714
+ };
715
+ var evalPlayerStateOnPlayer = (player, atom, ctx) => {
716
+ const { field, operator, value } = atom;
717
+ const op = operator ?? "EQ";
718
+ if (field === "IS_IN_PLAY") {
719
+ return compareBool(Boolean(player), op, value);
720
+ }
721
+ if (!player) return false;
722
+ if (field === "IS_ALIVE") {
723
+ return compareBool(!player.isDead, op, value);
724
+ }
649
725
  if (field === "IS_DEAD") {
650
- return Boolean(player.isDead) === Boolean(value);
726
+ return compareBool(Boolean(player.isDead), op, value);
727
+ }
728
+ if (field === "IS_POISONED" || field === "IS_DRUNK" || field === "IS_HEALTHY") {
729
+ const resolved = ctx.statusResolver ? ctx.statusResolver({ player, status: field === "IS_POISONED" ? "POISONED" : field === "IS_DRUNK" ? "DRUNK" : "HEALTHY" }) : false;
730
+ return compareBool(resolved, op, value);
651
731
  }
652
732
  if (field === "CHARACTER_ID") {
653
- if (operator === "IN" && Array.isArray(value)) {
654
- return value.includes(player.characterId);
655
- }
656
- return typeof value === "string" && player.characterId === value;
733
+ return compareString(player.characterId, op, value);
657
734
  }
658
735
  if (field === "CHARACTER_KIND") {
659
- const kind = characterMap.get(player.characterId)?.kind;
660
- if (!kind) return false;
661
- if (operator === "IN" && Array.isArray(value)) {
662
- return value.includes(kind);
663
- }
664
- return typeof value === "string" && kind === value;
736
+ const kind = ctx.characterMap.get(player.characterId)?.kind;
737
+ return compareString(kind, op, value);
738
+ }
739
+ if (field === "ALIGNMENT") {
740
+ return compareString(player.alignment, op, value);
665
741
  }
666
742
  if (field === "HAS_ABILITY") {
667
- if (operator === "IN" && Array.isArray(value)) {
668
- return value.some((id) => player.abilities.includes(id));
743
+ if (op === "EQ" || op === "NE") {
744
+ const has = typeof value === "string" && player.abilities.includes(value);
745
+ return op === "EQ" ? has : !has;
669
746
  }
670
- return typeof value === "string" && player.abilities.includes(value);
747
+ const list = Array.isArray(value) ? value : [];
748
+ const anyMatch = list.some((id) => player.abilities.includes(id));
749
+ return op === "IN" ? anyMatch : !anyMatch;
750
+ }
751
+ if (field === "HAS_REMINDER") {
752
+ const marks = (player.reminders ?? []).map((r) => r.mark);
753
+ if (op === "EQ" || op === "NE") {
754
+ const has = typeof value === "string" && marks.includes(value);
755
+ return op === "EQ" ? has : !has;
756
+ }
757
+ const list = Array.isArray(value) ? value : [];
758
+ const anyMatch = list.some((mark) => marks.includes(mark));
759
+ return op === "IN" ? anyMatch : !anyMatch;
671
760
  }
672
761
  return false;
673
762
  };
674
- var evaluateConditions = (player, conditions, mode, characterMap) => {
675
- if (!player) return false;
676
- if (conditions.length === 0) return true;
677
- const results = conditions.map((c) => matchesLifetimeCondition(player, c, characterMap));
678
- return mode === "ALL" ? results.every(Boolean) : results.some(Boolean);
763
+ var evalPlayerState = (atom, ctx) => {
764
+ const subject = atom.subject ?? { kind: "EFFECTOR" };
765
+ const players = resolveSubject(subject, ctx);
766
+ if (subject.kind === "CHARACTER") {
767
+ const quantifier = subject.quantifier ?? "ANY";
768
+ if (players.length === 0) {
769
+ return atom.field === "IS_IN_PLAY" ? evalPlayerStateOnPlayer(void 0, atom, ctx) : false;
770
+ }
771
+ const results = players.map((p) => evalPlayerStateOnPlayer(p, atom, ctx));
772
+ return quantifier === "ALL" ? results.every(Boolean) : results.some(Boolean);
773
+ }
774
+ const single = players[0];
775
+ return evalPlayerStateOnPlayer(single, atom, ctx);
776
+ };
777
+ var isExecutionTimeline = (tl) => {
778
+ if (!tl) return false;
779
+ return (tl.operations ?? []).some((op) => op.kind === "execution");
780
+ };
781
+ var evalTime = (atom, ctx) => {
782
+ const { spec } = atom;
783
+ const opIdx = ctx.operationTimelineIdx;
784
+ const nowIdx = ctx.nowTimelineIndex;
785
+ if (spec.kind === "WITHIN_TURNS") {
786
+ const opTurn = opIdx >= 0 ? ctx.timelines[opIdx]?.turn : void 0;
787
+ const nowTurn = nowIdx >= 0 ? ctx.timelines[nowIdx]?.turn : void 0;
788
+ if (typeof opTurn !== "number" || typeof nowTurn !== "number") return true;
789
+ return nowTurn - opTurn < spec.count;
790
+ }
791
+ if (spec.kind === "WITHIN_PHASES") {
792
+ if (opIdx < 0) return true;
793
+ return nowIdx - opIdx < spec.count;
794
+ }
795
+ if (spec.kind === "BEFORE_EVENT") {
796
+ if (opIdx < 0) return true;
797
+ let prevTime = ctx.timelines[opIdx]?.time;
798
+ for (let i = opIdx + 1; i <= nowIdx; i += 1) {
799
+ const tl = ctx.timelines[i];
800
+ if (!tl) continue;
801
+ if (spec.event === "NEXT_DAY" && tl.time === "day") return false;
802
+ if (spec.event === "NEXT_NIGHT" && tl.time === "night") return false;
803
+ if (spec.event === "NEXT_DUSK" && prevTime === "day" && tl.time === "night") return false;
804
+ if (spec.event === "NEXT_DAWN" && prevTime === "night" && tl.time === "day") return false;
805
+ if (spec.event === "NEXT_EXECUTION" && isExecutionTimeline(tl)) return false;
806
+ prevTime = tl.time;
807
+ }
808
+ return true;
809
+ }
810
+ if (spec.kind === "CURRENT_PHASE_IS") {
811
+ const current = nowIdx >= 0 ? ctx.timelines[nowIdx]?.time : void 0;
812
+ const want = spec.phase === "DAY" ? "day" : "night";
813
+ return current === want;
814
+ }
815
+ return true;
816
+ };
817
+ var compareCount = (actual, operator, expected) => {
818
+ switch (operator) {
819
+ case "EQ":
820
+ return actual === expected;
821
+ case "NE":
822
+ return actual !== expected;
823
+ case "GT":
824
+ return actual > expected;
825
+ case "GTE":
826
+ return actual >= expected;
827
+ case "LT":
828
+ return actual < expected;
829
+ case "LTE":
830
+ return actual <= expected;
831
+ }
832
+ };
833
+ var evalGameState = (atom, ctx) => {
834
+ const { spec } = atom;
835
+ if (spec.kind === "ALIVE_COUNT") {
836
+ if (!ctx.snapshotSeatMap) return true;
837
+ let count = 0;
838
+ ctx.snapshotSeatMap.forEach((p) => {
839
+ if (!p.isDead) count += 1;
840
+ });
841
+ return compareCount(count, spec.operator, spec.count);
842
+ }
843
+ if (spec.kind === "EXECUTION_HAPPENED_TODAY") {
844
+ const turn = ctx.timelines[ctx.nowTimelineIndex]?.turn;
845
+ if (typeof turn !== "number") return false;
846
+ return ctx.timelines.some((tl) => tl.turn === turn && tl.time === "day" && isExecutionTimeline(tl));
847
+ }
848
+ if (spec.kind === "PLAYER_DIED_TODAY") {
849
+ const turn = ctx.timelines[ctx.nowTimelineIndex]?.turn;
850
+ if (typeof turn !== "number") return false;
851
+ return ctx.timelines.some((tl) => {
852
+ if (tl.turn !== turn || tl.time !== "day") return false;
853
+ return (tl.operations ?? []).some((op) => {
854
+ const kind = op.kind;
855
+ return kind === "execution" || kind === "death";
856
+ });
857
+ });
858
+ }
859
+ return true;
860
+ };
861
+ var evalAtom = (atom, ctx) => {
862
+ switch (atom.type) {
863
+ case "TIME":
864
+ return evalTime(atom, ctx);
865
+ case "PLAYER_STATE":
866
+ if (!ctx.snapshotSeatMap) return true;
867
+ return evalPlayerState(atom, ctx);
868
+ case "GAME_STATE":
869
+ if (!ctx.snapshotSeatMap) return true;
870
+ return evalGameState(atom, ctx);
871
+ case "CUSTOM":
872
+ if (!ctx.snapshotSeatMap) return true;
873
+ return ctx.customResolver ? ctx.customResolver({
874
+ key: atom.key,
875
+ payload: atom.payload,
876
+ effector: ctx.effector,
877
+ target: ctx.target,
878
+ snapshotSeatMap: ctx.snapshotSeatMap
879
+ }) : false;
880
+ }
881
+ };
882
+ var evalExpr = (expr, ctx) => {
883
+ switch (expr.op) {
884
+ case "AND":
885
+ return expr.children.every((child) => evalExpr(child, ctx));
886
+ case "OR":
887
+ return expr.children.some((child) => evalExpr(child, ctx));
888
+ case "NOT":
889
+ return !evalExpr(expr.child, ctx);
890
+ case "ATOM":
891
+ return evalAtom(expr.atom, ctx);
892
+ }
893
+ };
894
+ var expressionReferencesTarget = (expr) => {
895
+ if (expr.op === "AND" || expr.op === "OR") return expr.children.some(expressionReferencesTarget);
896
+ if (expr.op === "NOT") return expressionReferencesTarget(expr.child);
897
+ const atom = expr.atom;
898
+ return atom.type === "PLAYER_STATE" && atom.subject?.kind === "TARGET";
679
899
  };
680
900
  var evaluateLifetime = ({
681
901
  effect,
@@ -686,52 +906,31 @@ var evaluateLifetime = ({
686
906
  targets,
687
907
  snapshotSeatMap,
688
908
  characterMap,
689
- payloads
909
+ payloads,
910
+ statusResolver,
911
+ customResolver
690
912
  }) => {
691
913
  const lifetime = getLifetime(effect, payloads);
692
914
  if (!lifetime || lifetime.kind === "PERMANENT") {
693
915
  return targets;
694
916
  }
695
- if (lifetime.kind === "TURNS") {
696
- const opTurn = operationTimelineIdx >= 0 ? timelines[operationTimelineIdx]?.turn : void 0;
697
- const nowTurn = nowTimelineIndex >= 0 ? timelines[nowTimelineIndex]?.turn : void 0;
698
- if (typeof opTurn !== "number" || typeof nowTurn !== "number") {
699
- return targets;
700
- }
701
- return nowTurn - opTurn < lifetime.count ? targets : [];
702
- }
703
- if (lifetime.kind === "PHASES") {
704
- if (operationTimelineIdx < 0) return targets;
705
- return nowTimelineIndex - operationTimelineIdx < lifetime.count ? targets : [];
706
- }
707
- if (lifetime.kind === "UNTIL_EVENT") {
708
- if (operationTimelineIdx < 0) return targets;
709
- for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
710
- const tl = timelines[idx];
711
- if (!tl) continue;
712
- if (lifetime.event === "NEXT_DAY" && tl.time === "day") return [];
713
- if (lifetime.event === "NEXT_NIGHT" && tl.time === "night") return [];
714
- if (lifetime.event === "NEXT_EXECUTION" && (tl.nominations?.length ?? 0) > 0) return [];
715
- }
716
- return targets;
717
- }
718
- if (lifetime.kind === "WHILE") {
719
- if (!snapshotSeatMap) {
720
- return targets;
721
- }
722
- const subject = lifetime.subject ?? "EFFECTOR";
723
- const mode = lifetime.mode ?? "ALL";
724
- const conditions = lifetime.conditions ?? [];
725
- if (subject === "EFFECTOR") {
726
- const current = effector ? snapshotSeatMap.get(effector.seat) : void 0;
727
- return evaluateConditions(current, conditions, mode, characterMap) ? targets : [];
728
- }
729
- return targets.filter((target) => {
730
- const current = snapshotSeatMap.get(target.seat);
731
- return evaluateConditions(current, conditions, mode, characterMap);
732
- });
917
+ const baseCtx = {
918
+ operationTimelineIdx,
919
+ nowTimelineIndex,
920
+ timelines,
921
+ effector,
922
+ snapshotSeatMap,
923
+ characterMap,
924
+ payloads,
925
+ statusResolver,
926
+ customResolver
927
+ };
928
+ const expr = lifetime.expr;
929
+ const dependsOnTarget = expressionReferencesTarget(expr);
930
+ if (!dependsOnTarget) {
931
+ return evalExpr(expr, { ...baseCtx, target: void 0 }) ? targets : [];
733
932
  }
734
- return targets;
933
+ return targets.filter((target) => evalExpr(expr, { ...baseCtx, target }));
735
934
  };
736
935
 
737
936
  // src/apply-operation.ts
@@ -752,7 +951,9 @@ var applyOperationToPlayers = ({
752
951
  allAbilities,
753
952
  characters,
754
953
  timelines,
755
- timelineIndexAtNow
954
+ timelineIndexAtNow,
955
+ statusResolver,
956
+ customResolver
756
957
  }) => {
757
958
  if (operations.length === 0) {
758
959
  return transformEmptyArray(players);
@@ -812,7 +1013,9 @@ var applyOperationToPlayers = ({
812
1013
  targets: resolvedTargets,
813
1014
  snapshotSeatMap: lifetimeSnapshotSeatMap,
814
1015
  characterMap,
815
- payloads
1016
+ payloads,
1017
+ statusResolver,
1018
+ customResolver
816
1019
  });
817
1020
  const handler = effectHandlers[effect.type];
818
1021
  if (!handler) {
@@ -892,6 +1095,10 @@ var applyOperationToPlayers = ({
892
1095
  };
893
1096
 
894
1097
  // src/can-invoke-ability.ts
1098
+ var PHASE_ALIASES = {
1099
+ NIGHT: "night",
1100
+ DAY: "day"
1101
+ };
895
1102
  var checkTurns = (turns, currentTurn) => {
896
1103
  if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
897
1104
  return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
@@ -904,10 +1111,159 @@ var checkTurns = (turns, currentTurn) => {
904
1111
  }
905
1112
  return null;
906
1113
  };
907
- var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines) => {
1114
+ var checkEffectorState = (state, effector) => {
1115
+ if (state === "ANY") return null;
1116
+ if (state === "ALIVE") {
1117
+ if (!effector || effector.isDead) return "effector is not alive";
1118
+ return null;
1119
+ }
1120
+ if (state === "DEAD") {
1121
+ if (!effector || !effector.isDead) return "effector is not dead";
1122
+ return null;
1123
+ }
1124
+ return null;
1125
+ };
1126
+ var isFirstPhaseForEffector = (timelines, currentIdx, phaseTime) => {
1127
+ for (let i = 0; i < currentIdx; i++) {
1128
+ if (timelines[i]?.time === phaseTime) return false;
1129
+ }
1130
+ return true;
1131
+ };
1132
+ var checkPhaseTrigger = (trigger, timelines, currentIdx) => {
1133
+ const expected = PHASE_ALIASES[trigger.phase];
1134
+ const current = timelines[currentIdx]?.time;
1135
+ if (!expected) {
1136
+ return `phase ${trigger.phase} not yet supported in timeline model`;
1137
+ }
1138
+ if (expected !== current) {
1139
+ return `phase ${current} does not match required ${trigger.phase}`;
1140
+ }
1141
+ const occurrence = trigger.occurrence ?? "EVERY";
1142
+ if (occurrence === "EVERY") return null;
1143
+ const isFirst = isFirstPhaseForEffector(timelines, currentIdx, expected);
1144
+ if (occurrence === "FIRST_ONLY" && !isFirst) {
1145
+ return `phase ${trigger.phase} occurrence FIRST_ONLY but this is not the first`;
1146
+ }
1147
+ if (occurrence === "EXCEPT_FIRST" && isFirst) {
1148
+ return `phase ${trigger.phase} occurrence EXCEPT_FIRST but this is the first`;
1149
+ }
1150
+ return null;
1151
+ };
1152
+ var subjectMatches = (filter, seat, effectorSeat, players) => {
1153
+ if (filter.match === "ANY") return true;
1154
+ if (filter.match === "EFFECTOR") {
1155
+ return seat !== void 0 && seat === effectorSeat;
1156
+ }
1157
+ if (filter.match === "NEIGHBOR_OF_EFFECTOR") {
1158
+ if (seat === void 0 || effectorSeat === void 0 || !players?.length) return false;
1159
+ const alive = players.filter((p) => !p.isDead).sort((a, b) => a.seat - b.seat);
1160
+ const idx = alive.findIndex((p) => p.seat === effectorSeat);
1161
+ if (idx < 0) return false;
1162
+ const left = alive[(idx - 1 + alive.length) % alive.length]?.seat;
1163
+ const right = alive[(idx + 1) % alive.length]?.seat;
1164
+ return seat === left || seat === right;
1165
+ }
1166
+ if (filter.match === "ROLE") {
1167
+ const player = players?.find((p) => p.seat === seat);
1168
+ return !!player && filter.roles.includes(player.characterId);
1169
+ }
1170
+ if (filter.match === "KIND") {
1171
+ return false;
1172
+ }
1173
+ if (filter.match === "ALIGNMENT") {
1174
+ const player = players?.find((p) => p.seat === seat);
1175
+ if (!player?.alignment) return false;
1176
+ const want = filter.value === "good" ? "GOOD" : "EVIL";
1177
+ return player.alignment === want;
1178
+ }
1179
+ return false;
1180
+ };
1181
+ var checkEventTrigger = (trigger, event, effectorSeat, players, currentTime) => {
1182
+ if (!event) return "event trigger requires event context";
1183
+ if (event.event !== trigger.event) return `event ${event.event} does not match required ${trigger.event}`;
1184
+ if (trigger.phase) {
1185
+ const expected = PHASE_ALIASES[trigger.phase];
1186
+ if (!expected) return `phase ${trigger.phase} not yet supported in timeline model`;
1187
+ if (expected !== currentTime) return `phase ${currentTime} does not match required ${trigger.phase}`;
1188
+ }
1189
+ if (trigger.target && !subjectMatches(trigger.target, event.targetSeat, effectorSeat, players)) {
1190
+ return `event target does not match filter ${trigger.target.match}`;
1191
+ }
1192
+ if (trigger.actor && !subjectMatches(trigger.actor, event.actorSeat, effectorSeat, players)) {
1193
+ return `event actor does not match filter ${trigger.actor.match}`;
1194
+ }
1195
+ return null;
1196
+ };
1197
+ var compareCount2 = (actual, operator, expected) => {
1198
+ switch (operator) {
1199
+ case "<":
1200
+ return actual < expected;
1201
+ case "<=":
1202
+ return actual <= expected;
1203
+ case ">":
1204
+ return actual > expected;
1205
+ case ">=":
1206
+ return actual >= expected;
1207
+ case "=":
1208
+ return actual === expected;
1209
+ case "!=":
1210
+ return actual !== expected;
1211
+ }
1212
+ };
1213
+ var countExecutionsToday = (timelines, currentIdx) => {
1214
+ const turn = timelines[currentIdx]?.turn;
1215
+ if (typeof turn !== "number") return 0;
1216
+ let count = 0;
1217
+ for (const tl of timelines) {
1218
+ if (tl.turn !== turn || tl.time !== "day") continue;
1219
+ for (const op of tl.operations ?? []) {
1220
+ if (op.kind === "execution") count++;
1221
+ }
1222
+ }
1223
+ return count;
1224
+ };
1225
+ var checkCondition = (condition, args) => {
1226
+ const { timelines, currentIdx, players, event, customConditionResolver } = args;
1227
+ if (condition.kind === "PLAYER_COUNT") {
1228
+ if (!players) return `condition ${condition.kind} needs players`;
1229
+ const turn = timelines[currentIdx]?.turn;
1230
+ let actual = 0;
1231
+ if (condition.subject === "ALIVE") actual = players.filter((p) => !p.isDead).length;
1232
+ else if (condition.subject === "DEAD") actual = players.filter((p) => p.isDead).length;
1233
+ else if (condition.subject === "EXECUTED_TODAY") actual = countExecutionsToday(timelines, currentIdx);
1234
+ else if (condition.subject === "NOMINATED_TODAY") {
1235
+ actual = timelines.filter((tl) => tl.turn === turn && tl.time === "day").reduce((acc, tl) => acc + (tl.nominations?.length ?? 0), 0);
1236
+ }
1237
+ if (!compareCount2(actual, condition.operator, condition.value)) {
1238
+ return `condition PLAYER_COUNT ${condition.subject} ${condition.operator} ${condition.value} (actual ${actual})`;
1239
+ }
1240
+ return null;
1241
+ }
1242
+ if (condition.kind === "NO_EXECUTION_TODAY") {
1243
+ return countExecutionsToday(timelines, currentIdx) > 0 ? "condition NO_EXECUTION_TODAY violated" : null;
1244
+ }
1245
+ if (condition.kind === "EFFECTOR_HAS_STATUS") {
1246
+ if (!customConditionResolver) return null;
1247
+ const matched = customConditionResolver({ key: `STATUS:${condition.status}`, value: !condition.negate });
1248
+ return matched ? null : `condition EFFECTOR_HAS_STATUS ${condition.status} unsatisfied`;
1249
+ }
1250
+ if (condition.kind === "EVENT_PAYLOAD") {
1251
+ const payloadValue = event?.payload?.[condition.key];
1252
+ if (payloadValue !== condition.value) {
1253
+ return `condition EVENT_PAYLOAD ${condition.key}=${JSON.stringify(condition.value)} (actual ${JSON.stringify(payloadValue)})`;
1254
+ }
1255
+ return null;
1256
+ }
1257
+ if (condition.kind === "CUSTOM") {
1258
+ if (!customConditionResolver) return null;
1259
+ return customConditionResolver({ key: condition.key, value: condition.value }) ? null : `condition CUSTOM ${condition.key} unsatisfied`;
1260
+ }
1261
+ return null;
1262
+ };
1263
+ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines, countAcrossEffectors) => {
908
1264
  return priorOperations.filter((op) => {
909
1265
  if (op.abilityId !== abilityId) return false;
910
- if (typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
1266
+ if (!countAcrossEffectors && typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
911
1267
  if (matchTurn === null && matchTime === null) return true;
912
1268
  const idx = operationTimelineMap.get(op);
913
1269
  if (typeof idx !== "number") return false;
@@ -918,12 +1274,40 @@ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operation
918
1274
  return true;
919
1275
  }).length;
920
1276
  };
1277
+ var checkFrequency = (frequency, ability, effectorSeat, timelines, currentIdx, priorOperations) => {
1278
+ if (priorOperations.length === 0) return null;
1279
+ const operationTimelineMap = /* @__PURE__ */ new Map();
1280
+ timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
1281
+ const currentTurn = timelines[currentIdx]?.turn ?? null;
1282
+ const isOncePerGame = frequency === "ONCE_PER_GAME";
1283
+ const isNightScoped = frequency === "ONCE_PER_NIGHT" || frequency === "FIRST_PER_NIGHT";
1284
+ const isFirstPer = frequency === "FIRST_PER_NIGHT" || frequency === "FIRST_PER_DAY";
1285
+ const matchTurn = isOncePerGame ? null : currentTurn;
1286
+ const matchTime = isOncePerGame ? null : isNightScoped ? "night" : "day";
1287
+ const count = countMatchingPriorOps(
1288
+ priorOperations,
1289
+ ability.id,
1290
+ effectorSeat,
1291
+ operationTimelineMap,
1292
+ matchTurn,
1293
+ matchTime,
1294
+ timelines,
1295
+ isFirstPer
1296
+ );
1297
+ if (count > 0) {
1298
+ return `frequency ${frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})`;
1299
+ }
1300
+ return null;
1301
+ };
921
1302
  var canInvokeAbility = ({
922
1303
  ability,
923
1304
  effector,
924
1305
  currentTimelineIdx,
925
1306
  timelines,
926
- priorOperations = []
1307
+ priorOperations = [],
1308
+ players,
1309
+ event,
1310
+ customConditionResolver
927
1311
  }) => {
928
1312
  const trigger = ability.triggerWindow;
929
1313
  if (!trigger) return { allowed: true };
@@ -935,29 +1319,38 @@ var canInvokeAbility = ({
935
1319
  const turnError = checkTurns(trigger.turns, currentTimeline.turn);
936
1320
  if (turnError) return { allowed: false, reason: turnError };
937
1321
  }
938
- if (trigger.phases && trigger.phases.length > 0 && !trigger.phases.includes(currentTimeline.time)) {
939
- return { allowed: false, reason: `phase ${currentTimeline.time} not in allowed phases ${JSON.stringify(trigger.phases)}` };
940
- }
941
- if (trigger.requireEffectorAlive && (!effector || effector.isDead)) {
942
- return { allowed: false, reason: "effector is not alive" };
1322
+ const effectorStateError = checkEffectorState(trigger.effectorState ?? "ALIVE", effector);
1323
+ if (effectorStateError) return { allowed: false, reason: effectorStateError };
1324
+ const spec = trigger.trigger;
1325
+ if (spec.kind === "PHASE") {
1326
+ const phaseError = checkPhaseTrigger(spec, timelines, currentTimelineIdx);
1327
+ if (phaseError) return { allowed: false, reason: phaseError };
1328
+ } else if (spec.kind === "EVENT") {
1329
+ const eventError = checkEventTrigger(spec, event, effector?.seat, players, currentTimeline.time);
1330
+ if (eventError) return { allowed: false, reason: eventError };
1331
+ }
1332
+ if (trigger.conditions && trigger.conditions.length > 0) {
1333
+ for (const condition of trigger.conditions) {
1334
+ const condError = checkCondition(condition, {
1335
+ timelines,
1336
+ currentIdx: currentTimelineIdx,
1337
+ players,
1338
+ event,
1339
+ customConditionResolver
1340
+ });
1341
+ if (condError) return { allowed: false, reason: condError };
1342
+ }
943
1343
  }
944
- if (trigger.frequency && priorOperations.length > 0) {
945
- const operationTimelineMap = /* @__PURE__ */ new Map();
946
- timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
947
- const matchTurn = trigger.frequency === "ONCE_PER_GAME" ? null : currentTimeline.turn;
948
- const matchTime = trigger.frequency === "ONCE_PER_GAME" ? null : trigger.frequency === "ONCE_PER_NIGHT" ? "night" : "day";
949
- const count = countMatchingPriorOps(
950
- priorOperations,
951
- ability.id,
1344
+ if (trigger.frequency) {
1345
+ const freqError = checkFrequency(
1346
+ trigger.frequency,
1347
+ ability,
952
1348
  effector?.seat,
953
- operationTimelineMap,
954
- matchTurn,
955
- matchTime,
956
- timelines
1349
+ timelines,
1350
+ currentTimelineIdx,
1351
+ priorOperations
957
1352
  );
958
- if (count > 0) {
959
- return { allowed: false, reason: `frequency ${trigger.frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})` };
960
- }
1353
+ if (freqError) return { allowed: false, reason: freqError };
961
1354
  }
962
1355
  return { allowed: true };
963
1356
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bct-app/game-engine",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Game engine utilities for BCT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -30,7 +30,7 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@bct-app/game-model": "0.1.3"
33
+ "@bct-app/game-model": "0.1.4"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@vitest/ui": "^4.1.3",