@bct-app/game-engine 0.1.8 → 0.1.10

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
@@ -26,12 +26,14 @@ __export(index_exports, {
26
26
  buildPlayerSeatMap: () => buildPlayerSeatMap,
27
27
  canInvokeAbility: () => canInvokeAbility,
28
28
  copyPlayers: () => copyPlayers,
29
+ deriveContext: () => deriveContext,
29
30
  getSourceValue: () => getSourceValue,
30
31
  getValueFromPayloads: () => getValueFromPayloads,
31
32
  isNumberOrNumberArray: () => isNumberOrNumberArray,
32
33
  isPlayerReminderArray: () => isPlayerReminderArray,
33
34
  isReminder: () => isReminder,
34
35
  resolveSourceValue: () => resolveSourceValue,
36
+ resolveTargetRef: () => resolveTargetRef,
35
37
  transformEmptyArray: () => transformEmptyArray
36
38
  });
37
39
  module.exports = __toCommonJS(index_exports);
@@ -177,6 +179,47 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
177
179
  }
178
180
  return void 0;
179
181
  };
182
+ var resolveTargetRef = ({
183
+ ref,
184
+ players,
185
+ effectorSeat,
186
+ characterMap
187
+ }) => {
188
+ if (ref.ref === "ANY") return players;
189
+ if (ref.ref === "EFFECTOR") {
190
+ if (effectorSeat === void 0) return [];
191
+ return players.filter((p) => p.seat === effectorSeat);
192
+ }
193
+ if (ref.ref === "ROLE") {
194
+ return players.filter((p) => p.characterId === ref.characterId);
195
+ }
196
+ if (ref.ref === "KIND") {
197
+ if (!characterMap) return [];
198
+ return players.filter((p) => characterMap.get(p.characterId)?.kind === ref.kind);
199
+ }
200
+ if (ref.ref === "ALIGNMENT") {
201
+ return players.filter((p) => p.alignment === ref.alignment);
202
+ }
203
+ if (ref.ref === "NEIGHBOR") {
204
+ if (effectorSeat === void 0 || players.length === 0) return [];
205
+ const alive = players.filter((p) => !p.isDead).sort((a, b) => a.seat - b.seat);
206
+ if (alive.length === 0) return [];
207
+ const idx = alive.findIndex((p) => p.seat === effectorSeat);
208
+ if (idx < 0) return [];
209
+ const left = alive[(idx - 1 + alive.length) % alive.length];
210
+ const right = alive[(idx + 1) % alive.length];
211
+ if (ref.side === "LEFT") return left ? [left] : [];
212
+ if (ref.side === "RIGHT") return right ? [right] : [];
213
+ const both = [left, right].filter((p) => Boolean(p));
214
+ const seen = /* @__PURE__ */ new Set();
215
+ return both.filter((p) => {
216
+ if (seen.has(p.seat)) return false;
217
+ seen.add(p.seat);
218
+ return true;
219
+ });
220
+ }
221
+ return [];
222
+ };
180
223
 
181
224
  // src/effects/handler-types.ts
182
225
  var createEffectHandler = (type, handler) => {
@@ -434,17 +477,31 @@ var applySeatChange = createEffectHandler("SEAT_CHANGE", () => {
434
477
  });
435
478
 
436
479
  // src/effects/handlers/status-change.ts
437
- var applyStatusChange = createEffectHandler("STATUS_CHANGE", ({ effect, payloads, makePlayersEffect }) => {
480
+ var applyStatusChange = createEffectHandler("STATUS_CHANGE", ({
481
+ effect,
482
+ payloads,
483
+ makePlayersEffect,
484
+ operationInTimelineIdx,
485
+ abilityMap,
486
+ operation
487
+ }) => {
438
488
  const maybeStatus = getSourceValue(effect.source, payloads);
439
489
  if (maybeStatus === "DEAD") {
490
+ const ability = abilityMap.get(operation.abilityId);
491
+ const turn = operationInTimelineIdx >= 0 ? operationInTimelineIdx : 0;
492
+ const cause = effect.deathCause ?? (ability?.category === "STORYTELLER" ? "STORYTELLER" : "ABILITY");
440
493
  makePlayersEffect.forEach((player) => {
441
494
  player.isDead = true;
495
+ player.deathCause = cause;
496
+ player.deathTurn = turn;
442
497
  });
443
498
  return;
444
499
  }
445
500
  if (maybeStatus === "ALIVE") {
446
501
  makePlayersEffect.forEach((player) => {
447
502
  player.isDead = false;
503
+ delete player.deathCause;
504
+ delete player.deathTurn;
448
505
  });
449
506
  return;
450
507
  }
@@ -532,22 +589,22 @@ var getCandidatesBySelector = (dynamicTarget, sortedPlayers, anchorSeat, payload
532
589
  if (scope === "LEFT_SIDE") {
533
590
  const left2 = [];
534
591
  for (let step = 1; step < total; step += 1) {
535
- left2.push(sortedPlayers[(anchorIdx - step + total) % total]);
592
+ left2.push(sortedPlayers[(anchorIdx + step) % total]);
536
593
  }
537
594
  return left2;
538
595
  }
539
596
  if (scope === "RIGHT_SIDE") {
540
597
  const right2 = [];
541
598
  for (let step = 1; step < total; step += 1) {
542
- right2.push(sortedPlayers[(anchorIdx + step) % total]);
599
+ right2.push(sortedPlayers[(anchorIdx - step + total) % total]);
543
600
  }
544
601
  return right2;
545
602
  }
546
603
  const left = [];
547
604
  const right = [];
548
605
  for (let step = 1; step < total; step += 1) {
549
- left.push(sortedPlayers[(anchorIdx - step + total) % total]);
550
- right.push(sortedPlayers[(anchorIdx + step) % total]);
606
+ left.push(sortedPlayers[(anchorIdx + step) % total]);
607
+ right.push(sortedPlayers[(anchorIdx - step + total) % total]);
551
608
  }
552
609
  return [...left, ...right];
553
610
  };
@@ -1133,105 +1190,17 @@ var applyOperationToPlayers = ({
1133
1190
  };
1134
1191
 
1135
1192
  // src/can-invoke-ability.ts
1136
- var PHASE_ALIASES = {
1137
- NIGHT: "night",
1138
- DAY: "day"
1139
- };
1140
- var checkTurns = (turns, currentTurn) => {
1141
- if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
1142
- return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
1143
- }
1144
- if (typeof turns.from === "number" && currentTurn < turns.from) {
1145
- return `turn ${currentTurn} is before allowed start turn ${turns.from}`;
1146
- }
1147
- if (typeof turns.to === "number" && currentTurn > turns.to) {
1148
- return `turn ${currentTurn} is after allowed end turn ${turns.to}`;
1149
- }
1150
- return null;
1151
- };
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;
1193
+ var TIME_TO_PHASE = {
1194
+ night: "NIGHT",
1195
+ day: "DAY"
1163
1196
  };
1164
- var isFirstPhaseForEffector = (timelines, currentIdx, phaseTime) => {
1165
- for (let i = 0; i < currentIdx; i++) {
1166
- if (timelines[i]?.time === phaseTime) return false;
1167
- }
1197
+ var checkTurnRange = (range, turn) => {
1198
+ if (!range) return true;
1199
+ if (range.only && range.only.length > 0 && !range.only.includes(turn)) return false;
1200
+ if (typeof range.from === "number" && turn < range.from) return false;
1201
+ if (typeof range.to === "number" && turn > range.to) return false;
1168
1202
  return true;
1169
1203
  };
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
1204
  var compareCount2 = (actual, operator, expected) => {
1236
1205
  switch (operator) {
1237
1206
  case "<":
@@ -1248,149 +1217,256 @@ var compareCount2 = (actual, operator, expected) => {
1248
1217
  return actual !== expected;
1249
1218
  }
1250
1219
  };
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
- }
1220
+ var isFirstOfPhase = (timelines, lastIdx) => {
1221
+ const last = timelines[lastIdx];
1222
+ if (!last) return false;
1223
+ for (let i = 0; i < lastIdx; i++) {
1224
+ if (timelines[i]?.time === last.time) return false;
1260
1225
  }
1261
- return count;
1226
+ return true;
1262
1227
  };
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;
1228
+ var deriveContext = ({
1229
+ timelines,
1230
+ candidate,
1231
+ initialPlayers,
1232
+ allAbilities,
1233
+ characters
1234
+ }) => {
1235
+ const lastIdx = timelines.length - 1;
1236
+ const last = timelines[lastIdx];
1237
+ if (!last) {
1238
+ return { error: "timelines must contain at least one entry" };
1239
+ }
1240
+ const players = applyOperationToPlayers({
1241
+ players: initialPlayers,
1242
+ operations: timelines.flatMap((tl) => tl.operations ?? []),
1243
+ allAbilities,
1244
+ characters,
1245
+ timelines,
1246
+ timelineIndexAtNow: lastIdx
1247
+ });
1248
+ const effector = players.find((p) => p.seat === candidate.effector);
1249
+ const operationTurnMap = /* @__PURE__ */ new Map();
1250
+ const operationTimeMap = /* @__PURE__ */ new Map();
1251
+ timelines.forEach((tl) => {
1252
+ tl.operations?.forEach((op) => {
1253
+ operationTurnMap.set(op, tl.turn);
1254
+ operationTimeMap.set(op, tl.time);
1255
+ });
1256
+ });
1257
+ const allOperations = timelines.flatMap((tl) => tl.operations ?? []);
1258
+ const todayOperations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "day").flatMap((tl) => tl.operations ?? []);
1259
+ const tonightOperations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "night").flatMap((tl) => tl.operations ?? []);
1260
+ const yesterdayDayOperations = timelines.filter((tl) => tl.turn === last.turn - 1 && tl.time === "day").flatMap((tl) => tl.operations ?? []);
1261
+ const yesterdayNightOperations = timelines.filter((tl) => tl.turn === last.turn - 1 && tl.time === "night").flatMap((tl) => tl.operations ?? []);
1262
+ const todayNominations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "day").flatMap((tl) => tl.nominations ?? []).map((n) => ({ nominator: n.nominator, nominee: n.nominee }));
1263
+ return {
1264
+ currentTurn: last.turn,
1265
+ currentPhase: TIME_TO_PHASE[last.time],
1266
+ isFirstOfPhase: isFirstOfPhase(timelines, lastIdx),
1267
+ players,
1268
+ allOperations,
1269
+ todayOperations,
1270
+ tonightOperations,
1271
+ yesterdayDayOperations,
1272
+ yesterdayNightOperations,
1273
+ todayNominations,
1274
+ effectorSeat: candidate.effector,
1275
+ effector,
1276
+ abilityId: candidate.abilityId,
1277
+ characterMap: new Map(characters.map((c) => [c.id, c])),
1278
+ operationTurnMap,
1279
+ operationTimeMap
1280
+ };
1281
+ };
1282
+ var operationsForActionScope = (scope, ctx) => {
1283
+ if (scope === "TONIGHT") return ctx.tonightOperations;
1284
+ if (scope === "TODAY") return ctx.todayOperations;
1285
+ if (scope === "YESTERDAY") {
1286
+ return [...ctx.yesterdayDayOperations, ...ctx.yesterdayNightOperations];
1279
1287
  }
1280
- if (condition.kind === "NO_EXECUTION_TODAY") {
1281
- return countExecutionsToday(timelines, currentIdx) > 0 ? "condition NO_EXECUTION_TODAY violated" : null;
1288
+ return ctx.allOperations;
1289
+ };
1290
+ var seatsMatchingTargetRef = (ref, ctx) => {
1291
+ const matched = resolveTargetRef({
1292
+ ref,
1293
+ players: ctx.players,
1294
+ effectorSeat: ctx.effectorSeat,
1295
+ characterMap: ctx.characterMap
1296
+ });
1297
+ return new Set(matched.map((p) => p.seat));
1298
+ };
1299
+ var evalAtomTime = (atom, ctx) => {
1300
+ if (atom.phase !== ctx.currentPhase) {
1301
+ return `phase is ${ctx.currentPhase}, required ${atom.phase}`;
1282
1302
  }
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`;
1303
+ if (atom.occurrence === "FIRST" && !ctx.isFirstOfPhase) {
1304
+ return `expected FIRST occurrence of ${atom.phase} but is not the first`;
1287
1305
  }
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)})`;
1306
+ if (atom.occurrence === "EXCEPT_FIRST" && ctx.isFirstOfPhase) {
1307
+ return `expected EXCEPT_FIRST occurrence of ${atom.phase} but is the first`;
1308
+ }
1309
+ if (!checkTurnRange(atom.turns, ctx.currentTurn)) {
1310
+ return `turn ${ctx.currentTurn} not in allowed turn range`;
1311
+ }
1312
+ return null;
1313
+ };
1314
+ var evalAtomState = (atom, ctx) => {
1315
+ const seats = seatsMatchingTargetRef(atom.subject, ctx);
1316
+ if (seats.size === 0) return `no players match subject ${atom.subject.ref}`;
1317
+ for (const seat of seats) {
1318
+ const player = ctx.players.find((p) => p.seat === seat);
1319
+ if (!player) continue;
1320
+ const isDead = Boolean(player.isDead);
1321
+ if (atom.aliveness === "ALIVE" && isDead) continue;
1322
+ if (atom.aliveness === "DEAD" && !isDead) continue;
1323
+ if (atom.aliveness === "DEAD") {
1324
+ if (atom.deathCause && player.deathCause !== atom.deathCause) continue;
1325
+ if (atom.deathTurn && !checkTurnRange(atom.deathTurn, player.deathTurn ?? -1)) continue;
1292
1326
  }
1293
1327
  return null;
1294
1328
  }
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`;
1329
+ return `no subject in ${atom.subject.ref} satisfies aliveness=${atom.aliveness}`;
1330
+ };
1331
+ var evalAtomAction = (atom, ctx) => {
1332
+ const subjectSeats = seatsMatchingTargetRef(atom.subject, ctx);
1333
+ if (subjectSeats.size === 0) return `no players match subject ${atom.subject.ref}`;
1334
+ const actorSeats = atom.actor ? seatsMatchingTargetRef(atom.actor, ctx) : null;
1335
+ if (atom.action === "NOMINATED") {
1336
+ const noms = ctx.todayNominations;
1337
+ const found = noms.some(
1338
+ (n) => subjectSeats.has(n.nominee) && (actorSeats === null || actorSeats.has(n.nominator))
1339
+ );
1340
+ return found ? null : "no matching NOMINATED event in today";
1341
+ }
1342
+ const scoped = operationsForActionScope(atom.scope, ctx);
1343
+ if (atom.action === "CHOSEN_AS_TARGET") {
1344
+ const found = scoped.some((op) => {
1345
+ if (actorSeats !== null && !actorSeats.has(op.effector)) return false;
1346
+ const payloadSeats = (op.payloads ?? []).flatMap(
1347
+ (v) => typeof v === "number" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "number") : []
1348
+ );
1349
+ return payloadSeats.some((s) => subjectSeats.has(s));
1350
+ });
1351
+ return found ? null : "no matching CHOSEN_AS_TARGET in scope";
1298
1352
  }
1299
- return null;
1353
+ const turnsInScope = (() => {
1354
+ if (atom.scope === "TONIGHT" || atom.scope === "TODAY") return [ctx.currentTurn];
1355
+ if (atom.scope === "YESTERDAY") return [ctx.currentTurn - 1];
1356
+ return null;
1357
+ })();
1358
+ const turnInScope = (turn) => {
1359
+ if (typeof turn !== "number") return false;
1360
+ if (turnsInScope === null) return true;
1361
+ return turnsInScope.includes(turn);
1362
+ };
1363
+ if (atom.action === "KILLED") {
1364
+ for (const seat of subjectSeats) {
1365
+ const player = ctx.players.find((p) => p.seat === seat);
1366
+ if (!player?.isDead) continue;
1367
+ if (!turnInScope(player.deathTurn)) continue;
1368
+ return null;
1369
+ }
1370
+ return "no matching KILLED in scope";
1371
+ }
1372
+ if (atom.action === "EXECUTED") {
1373
+ for (const seat of subjectSeats) {
1374
+ const player = ctx.players.find((p) => p.seat === seat);
1375
+ if (!player?.isDead) continue;
1376
+ if (player.deathCause !== "EXECUTION") continue;
1377
+ if (!turnInScope(player.deathTurn)) continue;
1378
+ return null;
1379
+ }
1380
+ return "no matching EXECUTED in scope";
1381
+ }
1382
+ void scoped;
1383
+ return `unknown action ${atom.action}`;
1300
1384
  };
1301
- var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines, countAcrossEffectors) => {
1302
- return priorOperations.filter((op) => {
1303
- if (op.abilityId !== abilityId) return false;
1304
- if (!countAcrossEffectors && typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
1305
- if (matchTurn === null && matchTime === null) return true;
1306
- const idx = operationTimelineMap.get(op);
1307
- if (typeof idx !== "number") return false;
1308
- const tl = timelines[idx];
1309
- if (!tl) return false;
1310
- if (matchTurn !== null && tl.turn !== matchTurn) return false;
1311
- if (matchTime !== null && tl.time !== matchTime) return false;
1385
+ var evalAtomCount = (atom, ctx) => {
1386
+ const ops = atom.scope === "GAME" ? ctx.allOperations : atom.scope === "NIGHT" ? ctx.tonightOperations : ctx.todayOperations;
1387
+ let resetTurn = -1;
1388
+ let resetTime = null;
1389
+ if (atom.resetOn?.length && ctx.effector?.isDead && atom.resetOn.includes("DEATH")) {
1390
+ resetTurn = ctx.effector.deathTurn ?? -1;
1391
+ }
1392
+ const used = ops.filter((op) => {
1393
+ if (op.abilityId !== ctx.abilityId) return false;
1394
+ if (op.effector !== ctx.effectorSeat) return false;
1395
+ if (resetTurn >= 0) {
1396
+ const t = ctx.operationTurnMap.get(op);
1397
+ if (typeof t !== "number") return false;
1398
+ if (t < resetTurn) return false;
1399
+ if (t === resetTurn) {
1400
+ const time = ctx.operationTimeMap.get(op);
1401
+ if (resetTime !== null && time !== resetTime) return false;
1402
+ }
1403
+ }
1312
1404
  return true;
1313
1405
  }).length;
1406
+ return compareCount2(used, atom.operator, atom.value) ? null : `count ${used} fails ${atom.operator} ${atom.value} in ${atom.scope}`;
1314
1407
  };
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"})`;
1408
+ var evalAtomGlobalCount = (atom, ctx) => {
1409
+ let actual = 0;
1410
+ if (atom.subject === "ALIVE") actual = ctx.players.filter((p) => !p.isDead).length;
1411
+ else if (atom.subject === "DEAD") actual = ctx.players.filter((p) => p.isDead).length;
1412
+ else if (atom.subject === "NOMINATED_TODAY") actual = ctx.todayNominations.length;
1413
+ else if (atom.subject === "EXECUTED_TODAY") {
1414
+ actual = ctx.players.filter((p) => p.isDead && p.deathCause === "EXECUTION" && p.deathTurn === ctx.currentTurn).length;
1415
+ }
1416
+ return compareCount2(actual, atom.operator, atom.value) ? null : `global count ${atom.subject}=${actual} fails ${atom.operator} ${atom.value}`;
1417
+ };
1418
+ var evalAtom2 = (atom, ctx, customResolver) => {
1419
+ if (atom.type === "TIME") return evalAtomTime(atom, ctx);
1420
+ if (atom.type === "STATE") return evalAtomState(atom, ctx);
1421
+ if (atom.type === "ACTION") return evalAtomAction(atom, ctx);
1422
+ if (atom.type === "COUNT") return evalAtomCount(atom, ctx);
1423
+ if (atom.type === "GLOBAL_COUNT") return evalAtomGlobalCount(atom, ctx);
1424
+ if (atom.type === "CUSTOM") {
1425
+ if (!customResolver) return `no custom resolver registered for ${atom.resolverId}`;
1426
+ return customResolver({ resolverId: atom.resolverId, args: atom.args }, ctx) ? null : `custom resolver ${atom.resolverId} returned false`;
1427
+ }
1428
+ return "unknown atom";
1429
+ };
1430
+ var evalExpr2 = (expr, ctx, customResolver) => {
1431
+ if (expr.op === "AND") {
1432
+ for (const child of expr.children) {
1433
+ const reason = evalExpr2(child, ctx, customResolver);
1434
+ if (reason) return reason;
1435
+ }
1436
+ return null;
1337
1437
  }
1338
- return null;
1438
+ if (expr.op === "OR") {
1439
+ const reasons = [];
1440
+ for (const child of expr.children) {
1441
+ const reason = evalExpr2(child, ctx, customResolver);
1442
+ if (!reason) return null;
1443
+ reasons.push(reason);
1444
+ }
1445
+ return `OR: all branches failed (${reasons.join("; ")})`;
1446
+ }
1447
+ if (expr.op === "NOT") {
1448
+ const reason = evalExpr2(expr.child, ctx, customResolver);
1449
+ return reason ? null : "NOT: inner expression was true";
1450
+ }
1451
+ return evalAtom2(expr.atom, ctx, customResolver);
1339
1452
  };
1340
1453
  var canInvokeAbility = ({
1341
1454
  ability,
1342
- effector,
1343
- currentTimelineIdx,
1344
1455
  timelines,
1345
- priorOperations = [],
1346
- players,
1347
- event,
1348
- customConditionResolver
1456
+ candidate,
1457
+ initialPlayers,
1458
+ allAbilities,
1459
+ characters,
1460
+ customResolver
1349
1461
  }) => {
1350
- const trigger = ability.triggerWindow;
1351
- if (!trigger) return { allowed: true };
1352
- const currentTimeline = timelines[currentTimelineIdx];
1353
- if (!currentTimeline) {
1354
- return { allowed: false, reason: `no timeline at index ${currentTimelineIdx}` };
1355
- }
1356
- if (trigger.turns) {
1357
- const turnError = checkTurns(trigger.turns, currentTimeline.turn);
1358
- if (turnError) return { allowed: false, reason: turnError };
1359
- }
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
- }
1381
- }
1382
- if (trigger.frequency) {
1383
- const freqError = checkFrequency(
1384
- trigger.frequency,
1385
- ability,
1386
- effector?.seat,
1387
- timelines,
1388
- currentTimelineIdx,
1389
- priorOperations
1390
- );
1391
- if (freqError) return { allowed: false, reason: freqError };
1392
- }
1393
- return { allowed: true };
1462
+ const window = ability.triggerWindow;
1463
+ if (!window) return { allowed: true };
1464
+ const derived = deriveContext({ timelines, candidate, initialPlayers, allAbilities, characters });
1465
+ if ("error" in derived) {
1466
+ return { allowed: false, reason: derived.error };
1467
+ }
1468
+ const reason = evalExpr2(window, derived, customResolver);
1469
+ return reason ? { allowed: false, reason } : { allowed: true };
1394
1470
  };
1395
1471
  // Annotate the CommonJS export names for ESM import in node:
1396
1472
  0 && (module.exports = {
@@ -1400,11 +1476,13 @@ var canInvokeAbility = ({
1400
1476
  buildPlayerSeatMap,
1401
1477
  canInvokeAbility,
1402
1478
  copyPlayers,
1479
+ deriveContext,
1403
1480
  getSourceValue,
1404
1481
  getValueFromPayloads,
1405
1482
  isNumberOrNumberArray,
1406
1483
  isPlayerReminderArray,
1407
1484
  isReminder,
1408
1485
  resolveSourceValue,
1486
+ resolveTargetRef,
1409
1487
  transformEmptyArray
1410
1488
  });