@bct-app/game-engine 0.1.6 → 0.1.8

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.js CHANGED
@@ -160,7 +160,7 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
160
160
  }
161
161
  const characterIds = typeof payloadValue === "string" ? [payloadValue] : payloadValue;
162
162
  return characterIds.flatMap((id) => {
163
- return resolveFromCharacter ? resolveFromCharacter(id) : [];
163
+ return resolveFromCharacter ? resolveFromCharacter(id) : [id];
164
164
  });
165
165
  }
166
166
  return castOrValidate(payloadValue);
@@ -676,44 +676,264 @@ var getLifetime = (effect, payloads) => {
676
676
  if (effect.type === "REMINDER_ADD") {
677
677
  const maybeReminder = getSourceValue(effect.source, payloads);
678
678
  if (isReminder(maybeReminder) && typeof maybeReminder.duration === "number" && maybeReminder.duration > 0) {
679
- return { kind: "PHASES", count: maybeReminder.duration };
679
+ return {
680
+ kind: "WHILE",
681
+ expr: {
682
+ op: "ATOM",
683
+ atom: {
684
+ type: "TIME",
685
+ spec: { kind: "WITHIN_PHASES", count: maybeReminder.duration }
686
+ }
687
+ }
688
+ };
680
689
  }
681
690
  }
682
691
  return void 0;
683
692
  };
684
- var matchesLifetimeCondition = (player, condition, characterMap) => {
685
- const { field, operator } = condition;
686
- const value = condition.value;
693
+ var resolveSubject = (subject, ctx) => {
694
+ if (!ctx.snapshotSeatMap) return [];
695
+ const map = ctx.snapshotSeatMap;
696
+ switch (subject.kind) {
697
+ case "EFFECTOR": {
698
+ if (!ctx.effector) return [];
699
+ const player = map.get(ctx.effector.seat);
700
+ return player ? [player] : [];
701
+ }
702
+ case "TARGET": {
703
+ if (!ctx.target) return [];
704
+ const player = map.get(ctx.target.seat);
705
+ return player ? [player] : [];
706
+ }
707
+ case "SEAT": {
708
+ const player = map.get(subject.seat);
709
+ return player ? [player] : [];
710
+ }
711
+ case "PAYLOAD_REF": {
712
+ const value = getValueFromPayloads(ctx.payloads, subject.payloadIndex);
713
+ const seat = adjustValueAsNumber(value);
714
+ if (typeof seat !== "number") return [];
715
+ const player = map.get(seat);
716
+ return player ? [player] : [];
717
+ }
718
+ case "CHARACTER": {
719
+ const matches = [];
720
+ map.forEach((player) => {
721
+ if (player.characterId === subject.characterId) matches.push(player);
722
+ });
723
+ return matches;
724
+ }
725
+ }
726
+ };
727
+ var compareString = (actual, operator, value) => {
728
+ const isInList = Array.isArray(value) && value.every((v) => typeof v === "string") ? value.includes(actual ?? "") : false;
729
+ const isEqScalar = typeof value === "string" && actual === value;
730
+ switch (operator) {
731
+ case "EQ":
732
+ return isEqScalar;
733
+ case "NE":
734
+ return !isEqScalar;
735
+ case "IN":
736
+ return actual !== void 0 && isInList;
737
+ case "NOT_IN":
738
+ return !isInList;
739
+ }
740
+ };
741
+ var compareBool = (actual, operator, value) => {
742
+ const expected = typeof value === "boolean" ? value : true;
743
+ switch (operator) {
744
+ case "EQ":
745
+ return actual === expected;
746
+ case "NE":
747
+ return actual !== expected;
748
+ case "IN":
749
+ case "NOT_IN":
750
+ return actual === expected;
751
+ }
752
+ };
753
+ var evalPlayerStateOnPlayer = (player, atom, ctx) => {
754
+ const { field, operator, value } = atom;
755
+ const op = operator ?? "EQ";
756
+ if (field === "IS_IN_PLAY") {
757
+ return compareBool(Boolean(player), op, value);
758
+ }
759
+ if (!player) return false;
760
+ if (field === "IS_ALIVE") {
761
+ return compareBool(!player.isDead, op, value);
762
+ }
687
763
  if (field === "IS_DEAD") {
688
- return Boolean(player.isDead) === Boolean(value);
764
+ return compareBool(Boolean(player.isDead), op, value);
765
+ }
766
+ if (field === "IS_POISONED" || field === "IS_DRUNK" || field === "IS_HEALTHY") {
767
+ const resolved = ctx.statusResolver ? ctx.statusResolver({ player, status: field === "IS_POISONED" ? "POISONED" : field === "IS_DRUNK" ? "DRUNK" : "HEALTHY" }) : false;
768
+ return compareBool(resolved, op, value);
689
769
  }
690
770
  if (field === "CHARACTER_ID") {
691
- if (operator === "IN" && Array.isArray(value)) {
692
- return value.includes(player.characterId);
693
- }
694
- return typeof value === "string" && player.characterId === value;
771
+ return compareString(player.characterId, op, value);
695
772
  }
696
773
  if (field === "CHARACTER_KIND") {
697
- const kind = characterMap.get(player.characterId)?.kind;
698
- if (!kind) return false;
699
- if (operator === "IN" && Array.isArray(value)) {
700
- return value.includes(kind);
701
- }
702
- return typeof value === "string" && kind === value;
774
+ const kind = ctx.characterMap.get(player.characterId)?.kind;
775
+ return compareString(kind, op, value);
776
+ }
777
+ if (field === "ALIGNMENT") {
778
+ return compareString(player.alignment, op, value);
703
779
  }
704
780
  if (field === "HAS_ABILITY") {
705
- if (operator === "IN" && Array.isArray(value)) {
706
- return value.some((id) => player.abilities.includes(id));
781
+ if (op === "EQ" || op === "NE") {
782
+ const has = typeof value === "string" && player.abilities.includes(value);
783
+ return op === "EQ" ? has : !has;
707
784
  }
708
- return typeof value === "string" && player.abilities.includes(value);
785
+ const list = Array.isArray(value) ? value : [];
786
+ const anyMatch = list.some((id) => player.abilities.includes(id));
787
+ return op === "IN" ? anyMatch : !anyMatch;
788
+ }
789
+ if (field === "HAS_REMINDER") {
790
+ const marks = (player.reminders ?? []).map((r) => r.mark);
791
+ if (op === "EQ" || op === "NE") {
792
+ const has = typeof value === "string" && marks.includes(value);
793
+ return op === "EQ" ? has : !has;
794
+ }
795
+ const list = Array.isArray(value) ? value : [];
796
+ const anyMatch = list.some((mark) => marks.includes(mark));
797
+ return op === "IN" ? anyMatch : !anyMatch;
709
798
  }
710
799
  return false;
711
800
  };
712
- var evaluateConditions = (player, conditions, mode, characterMap) => {
713
- if (!player) return false;
714
- if (conditions.length === 0) return true;
715
- const results = conditions.map((c) => matchesLifetimeCondition(player, c, characterMap));
716
- return mode === "ALL" ? results.every(Boolean) : results.some(Boolean);
801
+ var evalPlayerState = (atom, ctx) => {
802
+ const subject = atom.subject ?? { kind: "EFFECTOR" };
803
+ const players = resolveSubject(subject, ctx);
804
+ if (subject.kind === "CHARACTER") {
805
+ const quantifier = subject.quantifier ?? "ANY";
806
+ if (players.length === 0) {
807
+ return atom.field === "IS_IN_PLAY" ? evalPlayerStateOnPlayer(void 0, atom, ctx) : false;
808
+ }
809
+ const results = players.map((p) => evalPlayerStateOnPlayer(p, atom, ctx));
810
+ return quantifier === "ALL" ? results.every(Boolean) : results.some(Boolean);
811
+ }
812
+ const single = players[0];
813
+ return evalPlayerStateOnPlayer(single, atom, ctx);
814
+ };
815
+ var isExecutionTimeline = (tl) => {
816
+ if (!tl) return false;
817
+ return (tl.operations ?? []).some((op) => op.kind === "execution");
818
+ };
819
+ var evalTime = (atom, ctx) => {
820
+ const { spec } = atom;
821
+ const opIdx = ctx.operationTimelineIdx;
822
+ const nowIdx = ctx.nowTimelineIndex;
823
+ if (spec.kind === "WITHIN_TURNS") {
824
+ const opTurn = opIdx >= 0 ? ctx.timelines[opIdx]?.turn : void 0;
825
+ const nowTurn = nowIdx >= 0 ? ctx.timelines[nowIdx]?.turn : void 0;
826
+ if (typeof opTurn !== "number" || typeof nowTurn !== "number") return true;
827
+ return nowTurn - opTurn < spec.count;
828
+ }
829
+ if (spec.kind === "WITHIN_PHASES") {
830
+ if (opIdx < 0) return true;
831
+ return nowIdx - opIdx < spec.count;
832
+ }
833
+ if (spec.kind === "BEFORE_EVENT") {
834
+ if (opIdx < 0) return true;
835
+ let prevTime = ctx.timelines[opIdx]?.time;
836
+ for (let i = opIdx + 1; i <= nowIdx; i += 1) {
837
+ const tl = ctx.timelines[i];
838
+ if (!tl) continue;
839
+ if (spec.event === "NEXT_DAY" && tl.time === "day") return false;
840
+ if (spec.event === "NEXT_NIGHT" && tl.time === "night") return false;
841
+ if (spec.event === "NEXT_DUSK" && prevTime === "day" && tl.time === "night") return false;
842
+ if (spec.event === "NEXT_DAWN" && prevTime === "night" && tl.time === "day") return false;
843
+ if (spec.event === "NEXT_EXECUTION" && isExecutionTimeline(tl)) return false;
844
+ prevTime = tl.time;
845
+ }
846
+ return true;
847
+ }
848
+ if (spec.kind === "CURRENT_PHASE_IS") {
849
+ const current = nowIdx >= 0 ? ctx.timelines[nowIdx]?.time : void 0;
850
+ const want = spec.phase === "DAY" ? "day" : "night";
851
+ return current === want;
852
+ }
853
+ return true;
854
+ };
855
+ var compareCount = (actual, operator, expected) => {
856
+ switch (operator) {
857
+ case "EQ":
858
+ return actual === expected;
859
+ case "NE":
860
+ return actual !== expected;
861
+ case "GT":
862
+ return actual > expected;
863
+ case "GTE":
864
+ return actual >= expected;
865
+ case "LT":
866
+ return actual < expected;
867
+ case "LTE":
868
+ return actual <= expected;
869
+ }
870
+ };
871
+ var evalGameState = (atom, ctx) => {
872
+ const { spec } = atom;
873
+ if (spec.kind === "ALIVE_COUNT") {
874
+ if (!ctx.snapshotSeatMap) return true;
875
+ let count = 0;
876
+ ctx.snapshotSeatMap.forEach((p) => {
877
+ if (!p.isDead) count += 1;
878
+ });
879
+ return compareCount(count, spec.operator, spec.count);
880
+ }
881
+ if (spec.kind === "EXECUTION_HAPPENED_TODAY") {
882
+ const turn = ctx.timelines[ctx.nowTimelineIndex]?.turn;
883
+ if (typeof turn !== "number") return false;
884
+ return ctx.timelines.some((tl) => tl.turn === turn && tl.time === "day" && isExecutionTimeline(tl));
885
+ }
886
+ if (spec.kind === "PLAYER_DIED_TODAY") {
887
+ const turn = ctx.timelines[ctx.nowTimelineIndex]?.turn;
888
+ if (typeof turn !== "number") return false;
889
+ return ctx.timelines.some((tl) => {
890
+ if (tl.turn !== turn || tl.time !== "day") return false;
891
+ return (tl.operations ?? []).some((op) => {
892
+ const kind = op.kind;
893
+ return kind === "execution" || kind === "death";
894
+ });
895
+ });
896
+ }
897
+ return true;
898
+ };
899
+ var evalAtom = (atom, ctx) => {
900
+ switch (atom.type) {
901
+ case "TIME":
902
+ return evalTime(atom, ctx);
903
+ case "PLAYER_STATE":
904
+ if (!ctx.snapshotSeatMap) return true;
905
+ return evalPlayerState(atom, ctx);
906
+ case "GAME_STATE":
907
+ if (!ctx.snapshotSeatMap) return true;
908
+ return evalGameState(atom, ctx);
909
+ case "CUSTOM":
910
+ if (!ctx.snapshotSeatMap) return true;
911
+ return ctx.customResolver ? ctx.customResolver({
912
+ key: atom.key,
913
+ payload: atom.payload,
914
+ effector: ctx.effector,
915
+ target: ctx.target,
916
+ snapshotSeatMap: ctx.snapshotSeatMap
917
+ }) : false;
918
+ }
919
+ };
920
+ var evalExpr = (expr, ctx) => {
921
+ switch (expr.op) {
922
+ case "AND":
923
+ return expr.children.every((child) => evalExpr(child, ctx));
924
+ case "OR":
925
+ return expr.children.some((child) => evalExpr(child, ctx));
926
+ case "NOT":
927
+ return !evalExpr(expr.child, ctx);
928
+ case "ATOM":
929
+ return evalAtom(expr.atom, ctx);
930
+ }
931
+ };
932
+ var expressionReferencesTarget = (expr) => {
933
+ if (expr.op === "AND" || expr.op === "OR") return expr.children.some(expressionReferencesTarget);
934
+ if (expr.op === "NOT") return expressionReferencesTarget(expr.child);
935
+ const atom = expr.atom;
936
+ return atom.type === "PLAYER_STATE" && atom.subject?.kind === "TARGET";
717
937
  };
718
938
  var evaluateLifetime = ({
719
939
  effect,
@@ -724,52 +944,31 @@ var evaluateLifetime = ({
724
944
  targets,
725
945
  snapshotSeatMap,
726
946
  characterMap,
727
- payloads
947
+ payloads,
948
+ statusResolver,
949
+ customResolver
728
950
  }) => {
729
951
  const lifetime = getLifetime(effect, payloads);
730
952
  if (!lifetime || lifetime.kind === "PERMANENT") {
731
953
  return targets;
732
954
  }
733
- if (lifetime.kind === "TURNS") {
734
- const opTurn = operationTimelineIdx >= 0 ? timelines[operationTimelineIdx]?.turn : void 0;
735
- const nowTurn = nowTimelineIndex >= 0 ? timelines[nowTimelineIndex]?.turn : void 0;
736
- if (typeof opTurn !== "number" || typeof nowTurn !== "number") {
737
- return targets;
738
- }
739
- return nowTurn - opTurn < lifetime.count ? targets : [];
740
- }
741
- if (lifetime.kind === "PHASES") {
742
- if (operationTimelineIdx < 0) return targets;
743
- return nowTimelineIndex - operationTimelineIdx < lifetime.count ? targets : [];
744
- }
745
- if (lifetime.kind === "UNTIL_EVENT") {
746
- if (operationTimelineIdx < 0) return targets;
747
- for (let idx = operationTimelineIdx + 1; idx <= nowTimelineIndex; idx += 1) {
748
- const tl = timelines[idx];
749
- if (!tl) continue;
750
- if (lifetime.event === "NEXT_DAY" && tl.time === "day") return [];
751
- if (lifetime.event === "NEXT_NIGHT" && tl.time === "night") return [];
752
- if (lifetime.event === "NEXT_EXECUTION" && (tl.nominations?.length ?? 0) > 0) return [];
753
- }
754
- return targets;
755
- }
756
- if (lifetime.kind === "WHILE") {
757
- if (!snapshotSeatMap) {
758
- return targets;
759
- }
760
- const subject = lifetime.subject ?? "EFFECTOR";
761
- const mode = lifetime.mode ?? "ALL";
762
- const conditions = lifetime.conditions ?? [];
763
- if (subject === "EFFECTOR") {
764
- const current = effector ? snapshotSeatMap.get(effector.seat) : void 0;
765
- return evaluateConditions(current, conditions, mode, characterMap) ? targets : [];
766
- }
767
- return targets.filter((target) => {
768
- const current = snapshotSeatMap.get(target.seat);
769
- return evaluateConditions(current, conditions, mode, characterMap);
770
- });
955
+ const baseCtx = {
956
+ operationTimelineIdx,
957
+ nowTimelineIndex,
958
+ timelines,
959
+ effector,
960
+ snapshotSeatMap,
961
+ characterMap,
962
+ payloads,
963
+ statusResolver,
964
+ customResolver
965
+ };
966
+ const expr = lifetime.expr;
967
+ const dependsOnTarget = expressionReferencesTarget(expr);
968
+ if (!dependsOnTarget) {
969
+ return evalExpr(expr, { ...baseCtx, target: void 0 }) ? targets : [];
771
970
  }
772
- return targets;
971
+ return targets.filter((target) => evalExpr(expr, { ...baseCtx, target }));
773
972
  };
774
973
 
775
974
  // src/apply-operation.ts
@@ -790,7 +989,9 @@ var applyOperationToPlayers = ({
790
989
  allAbilities,
791
990
  characters,
792
991
  timelines,
793
- timelineIndexAtNow
992
+ timelineIndexAtNow,
993
+ statusResolver,
994
+ customResolver
794
995
  }) => {
795
996
  if (operations.length === 0) {
796
997
  return transformEmptyArray(players);
@@ -850,7 +1051,9 @@ var applyOperationToPlayers = ({
850
1051
  targets: resolvedTargets,
851
1052
  snapshotSeatMap: lifetimeSnapshotSeatMap,
852
1053
  characterMap,
853
- payloads
1054
+ payloads,
1055
+ statusResolver,
1056
+ customResolver
854
1057
  });
855
1058
  const handler = effectHandlers[effect.type];
856
1059
  if (!handler) {
@@ -930,6 +1133,10 @@ var applyOperationToPlayers = ({
930
1133
  };
931
1134
 
932
1135
  // src/can-invoke-ability.ts
1136
+ var PHASE_ALIASES = {
1137
+ NIGHT: "night",
1138
+ DAY: "day"
1139
+ };
933
1140
  var checkTurns = (turns, currentTurn) => {
934
1141
  if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
935
1142
  return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
@@ -942,10 +1149,159 @@ var checkTurns = (turns, currentTurn) => {
942
1149
  }
943
1150
  return null;
944
1151
  };
945
- var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines) => {
1152
+ var checkEffectorState = (state, effector) => {
1153
+ if (state === "ANY") return null;
1154
+ if (state === "ALIVE") {
1155
+ if (!effector || effector.isDead) return "effector is not alive";
1156
+ return null;
1157
+ }
1158
+ if (state === "DEAD") {
1159
+ if (!effector || !effector.isDead) return "effector is not dead";
1160
+ return null;
1161
+ }
1162
+ return null;
1163
+ };
1164
+ var isFirstPhaseForEffector = (timelines, currentIdx, phaseTime) => {
1165
+ for (let i = 0; i < currentIdx; i++) {
1166
+ if (timelines[i]?.time === phaseTime) return false;
1167
+ }
1168
+ return true;
1169
+ };
1170
+ var checkPhaseTrigger = (trigger, timelines, currentIdx) => {
1171
+ const expected = PHASE_ALIASES[trigger.phase];
1172
+ const current = timelines[currentIdx]?.time;
1173
+ if (!expected) {
1174
+ return `phase ${trigger.phase} not yet supported in timeline model`;
1175
+ }
1176
+ if (expected !== current) {
1177
+ return `phase ${current} does not match required ${trigger.phase}`;
1178
+ }
1179
+ const occurrence = trigger.occurrence ?? "EVERY";
1180
+ if (occurrence === "EVERY") return null;
1181
+ const isFirst = isFirstPhaseForEffector(timelines, currentIdx, expected);
1182
+ if (occurrence === "FIRST_ONLY" && !isFirst) {
1183
+ return `phase ${trigger.phase} occurrence FIRST_ONLY but this is not the first`;
1184
+ }
1185
+ if (occurrence === "EXCEPT_FIRST" && isFirst) {
1186
+ return `phase ${trigger.phase} occurrence EXCEPT_FIRST but this is the first`;
1187
+ }
1188
+ return null;
1189
+ };
1190
+ var subjectMatches = (filter, seat, effectorSeat, players) => {
1191
+ if (filter.match === "ANY") return true;
1192
+ if (filter.match === "EFFECTOR") {
1193
+ return seat !== void 0 && seat === effectorSeat;
1194
+ }
1195
+ if (filter.match === "NEIGHBOR_OF_EFFECTOR") {
1196
+ if (seat === void 0 || effectorSeat === void 0 || !players?.length) return false;
1197
+ const alive = players.filter((p) => !p.isDead).sort((a, b) => a.seat - b.seat);
1198
+ const idx = alive.findIndex((p) => p.seat === effectorSeat);
1199
+ if (idx < 0) return false;
1200
+ const left = alive[(idx - 1 + alive.length) % alive.length]?.seat;
1201
+ const right = alive[(idx + 1) % alive.length]?.seat;
1202
+ return seat === left || seat === right;
1203
+ }
1204
+ if (filter.match === "ROLE") {
1205
+ const player = players?.find((p) => p.seat === seat);
1206
+ return !!player && filter.roles.includes(player.characterId);
1207
+ }
1208
+ if (filter.match === "KIND") {
1209
+ return false;
1210
+ }
1211
+ if (filter.match === "ALIGNMENT") {
1212
+ const player = players?.find((p) => p.seat === seat);
1213
+ if (!player?.alignment) return false;
1214
+ const want = filter.value === "good" ? "GOOD" : "EVIL";
1215
+ return player.alignment === want;
1216
+ }
1217
+ return false;
1218
+ };
1219
+ var checkEventTrigger = (trigger, event, effectorSeat, players, currentTime) => {
1220
+ if (!event) return "event trigger requires event context";
1221
+ if (event.event !== trigger.event) return `event ${event.event} does not match required ${trigger.event}`;
1222
+ if (trigger.phase) {
1223
+ const expected = PHASE_ALIASES[trigger.phase];
1224
+ if (!expected) return `phase ${trigger.phase} not yet supported in timeline model`;
1225
+ if (expected !== currentTime) return `phase ${currentTime} does not match required ${trigger.phase}`;
1226
+ }
1227
+ if (trigger.target && !subjectMatches(trigger.target, event.targetSeat, effectorSeat, players)) {
1228
+ return `event target does not match filter ${trigger.target.match}`;
1229
+ }
1230
+ if (trigger.actor && !subjectMatches(trigger.actor, event.actorSeat, effectorSeat, players)) {
1231
+ return `event actor does not match filter ${trigger.actor.match}`;
1232
+ }
1233
+ return null;
1234
+ };
1235
+ var compareCount2 = (actual, operator, expected) => {
1236
+ switch (operator) {
1237
+ case "<":
1238
+ return actual < expected;
1239
+ case "<=":
1240
+ return actual <= expected;
1241
+ case ">":
1242
+ return actual > expected;
1243
+ case ">=":
1244
+ return actual >= expected;
1245
+ case "=":
1246
+ return actual === expected;
1247
+ case "!=":
1248
+ return actual !== expected;
1249
+ }
1250
+ };
1251
+ var countExecutionsToday = (timelines, currentIdx) => {
1252
+ const turn = timelines[currentIdx]?.turn;
1253
+ if (typeof turn !== "number") return 0;
1254
+ let count = 0;
1255
+ for (const tl of timelines) {
1256
+ if (tl.turn !== turn || tl.time !== "day") continue;
1257
+ for (const op of tl.operations ?? []) {
1258
+ if (op.kind === "execution") count++;
1259
+ }
1260
+ }
1261
+ return count;
1262
+ };
1263
+ var checkCondition = (condition, args) => {
1264
+ const { timelines, currentIdx, players, event, customConditionResolver } = args;
1265
+ if (condition.kind === "PLAYER_COUNT") {
1266
+ if (!players) return `condition ${condition.kind} needs players`;
1267
+ const turn = timelines[currentIdx]?.turn;
1268
+ let actual = 0;
1269
+ if (condition.subject === "ALIVE") actual = players.filter((p) => !p.isDead).length;
1270
+ else if (condition.subject === "DEAD") actual = players.filter((p) => p.isDead).length;
1271
+ else if (condition.subject === "EXECUTED_TODAY") actual = countExecutionsToday(timelines, currentIdx);
1272
+ else if (condition.subject === "NOMINATED_TODAY") {
1273
+ actual = timelines.filter((tl) => tl.turn === turn && tl.time === "day").reduce((acc, tl) => acc + (tl.nominations?.length ?? 0), 0);
1274
+ }
1275
+ if (!compareCount2(actual, condition.operator, condition.value)) {
1276
+ return `condition PLAYER_COUNT ${condition.subject} ${condition.operator} ${condition.value} (actual ${actual})`;
1277
+ }
1278
+ return null;
1279
+ }
1280
+ if (condition.kind === "NO_EXECUTION_TODAY") {
1281
+ return countExecutionsToday(timelines, currentIdx) > 0 ? "condition NO_EXECUTION_TODAY violated" : null;
1282
+ }
1283
+ if (condition.kind === "EFFECTOR_HAS_STATUS") {
1284
+ if (!customConditionResolver) return null;
1285
+ const matched = customConditionResolver({ key: `STATUS:${condition.status}`, value: !condition.negate });
1286
+ return matched ? null : `condition EFFECTOR_HAS_STATUS ${condition.status} unsatisfied`;
1287
+ }
1288
+ if (condition.kind === "EVENT_PAYLOAD") {
1289
+ const payloadValue = event?.payload?.[condition.key];
1290
+ if (payloadValue !== condition.value) {
1291
+ return `condition EVENT_PAYLOAD ${condition.key}=${JSON.stringify(condition.value)} (actual ${JSON.stringify(payloadValue)})`;
1292
+ }
1293
+ return null;
1294
+ }
1295
+ if (condition.kind === "CUSTOM") {
1296
+ if (!customConditionResolver) return null;
1297
+ return customConditionResolver({ key: condition.key, value: condition.value }) ? null : `condition CUSTOM ${condition.key} unsatisfied`;
1298
+ }
1299
+ return null;
1300
+ };
1301
+ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines, countAcrossEffectors) => {
946
1302
  return priorOperations.filter((op) => {
947
1303
  if (op.abilityId !== abilityId) return false;
948
- if (typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
1304
+ if (!countAcrossEffectors && typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
949
1305
  if (matchTurn === null && matchTime === null) return true;
950
1306
  const idx = operationTimelineMap.get(op);
951
1307
  if (typeof idx !== "number") return false;
@@ -956,12 +1312,40 @@ var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operation
956
1312
  return true;
957
1313
  }).length;
958
1314
  };
1315
+ var checkFrequency = (frequency, ability, effectorSeat, timelines, currentIdx, priorOperations) => {
1316
+ if (priorOperations.length === 0) return null;
1317
+ const operationTimelineMap = /* @__PURE__ */ new Map();
1318
+ timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
1319
+ const currentTurn = timelines[currentIdx]?.turn ?? null;
1320
+ const isOncePerGame = frequency === "ONCE_PER_GAME";
1321
+ const isNightScoped = frequency === "ONCE_PER_NIGHT" || frequency === "FIRST_PER_NIGHT";
1322
+ const isFirstPer = frequency === "FIRST_PER_NIGHT" || frequency === "FIRST_PER_DAY";
1323
+ const matchTurn = isOncePerGame ? null : currentTurn;
1324
+ const matchTime = isOncePerGame ? null : isNightScoped ? "night" : "day";
1325
+ const count = countMatchingPriorOps(
1326
+ priorOperations,
1327
+ ability.id,
1328
+ effectorSeat,
1329
+ operationTimelineMap,
1330
+ matchTurn,
1331
+ matchTime,
1332
+ timelines,
1333
+ isFirstPer
1334
+ );
1335
+ if (count > 0) {
1336
+ return `frequency ${frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})`;
1337
+ }
1338
+ return null;
1339
+ };
959
1340
  var canInvokeAbility = ({
960
1341
  ability,
961
1342
  effector,
962
1343
  currentTimelineIdx,
963
1344
  timelines,
964
- priorOperations = []
1345
+ priorOperations = [],
1346
+ players,
1347
+ event,
1348
+ customConditionResolver
965
1349
  }) => {
966
1350
  const trigger = ability.triggerWindow;
967
1351
  if (!trigger) return { allowed: true };
@@ -973,29 +1357,38 @@ var canInvokeAbility = ({
973
1357
  const turnError = checkTurns(trigger.turns, currentTimeline.turn);
974
1358
  if (turnError) return { allowed: false, reason: turnError };
975
1359
  }
976
- if (trigger.phases && trigger.phases.length > 0 && !trigger.phases.includes(currentTimeline.time)) {
977
- return { allowed: false, reason: `phase ${currentTimeline.time} not in allowed phases ${JSON.stringify(trigger.phases)}` };
978
- }
979
- if (trigger.requireEffectorAlive && (!effector || effector.isDead)) {
980
- return { allowed: false, reason: "effector is not alive" };
1360
+ const effectorStateError = checkEffectorState(trigger.effectorState ?? "ALIVE", effector);
1361
+ if (effectorStateError) return { allowed: false, reason: effectorStateError };
1362
+ const spec = trigger.trigger;
1363
+ if (spec.kind === "PHASE") {
1364
+ const phaseError = checkPhaseTrigger(spec, timelines, currentTimelineIdx);
1365
+ if (phaseError) return { allowed: false, reason: phaseError };
1366
+ } else if (spec.kind === "EVENT") {
1367
+ const eventError = checkEventTrigger(spec, event, effector?.seat, players, currentTimeline.time);
1368
+ if (eventError) return { allowed: false, reason: eventError };
1369
+ }
1370
+ if (trigger.conditions && trigger.conditions.length > 0) {
1371
+ for (const condition of trigger.conditions) {
1372
+ const condError = checkCondition(condition, {
1373
+ timelines,
1374
+ currentIdx: currentTimelineIdx,
1375
+ players,
1376
+ event,
1377
+ customConditionResolver
1378
+ });
1379
+ if (condError) return { allowed: false, reason: condError };
1380
+ }
981
1381
  }
982
- if (trigger.frequency && priorOperations.length > 0) {
983
- const operationTimelineMap = /* @__PURE__ */ new Map();
984
- timelines.forEach((tl, idx) => tl.operations?.forEach((op) => operationTimelineMap.set(op, idx)));
985
- const matchTurn = trigger.frequency === "ONCE_PER_GAME" ? null : currentTimeline.turn;
986
- const matchTime = trigger.frequency === "ONCE_PER_GAME" ? null : trigger.frequency === "ONCE_PER_NIGHT" ? "night" : "day";
987
- const count = countMatchingPriorOps(
988
- priorOperations,
989
- ability.id,
1382
+ if (trigger.frequency) {
1383
+ const freqError = checkFrequency(
1384
+ trigger.frequency,
1385
+ ability,
990
1386
  effector?.seat,
991
- operationTimelineMap,
992
- matchTurn,
993
- matchTime,
994
- timelines
1387
+ timelines,
1388
+ currentTimelineIdx,
1389
+ priorOperations
995
1390
  );
996
- if (count > 0) {
997
- return { allowed: false, reason: `frequency ${trigger.frequency} already exhausted (${count} prior op${count === 1 ? "" : "s"})` };
998
- }
1391
+ if (freqError) return { allowed: false, reason: freqError };
999
1392
  }
1000
1393
  return { allowed: true };
1001
1394
  };