@bct-app/game-engine 0.1.8 → 0.1.9

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
@@ -139,6 +139,47 @@ var resolveSourceValue = (source, writableInputs, payloads, effector, snapshotSe
139
139
  }
140
140
  return void 0;
141
141
  };
142
+ var resolveTargetRef = ({
143
+ ref,
144
+ players,
145
+ effectorSeat,
146
+ characterMap
147
+ }) => {
148
+ if (ref.ref === "ANY") return players;
149
+ if (ref.ref === "EFFECTOR") {
150
+ if (effectorSeat === void 0) return [];
151
+ return players.filter((p) => p.seat === effectorSeat);
152
+ }
153
+ if (ref.ref === "ROLE") {
154
+ return players.filter((p) => p.characterId === ref.characterId);
155
+ }
156
+ if (ref.ref === "KIND") {
157
+ if (!characterMap) return [];
158
+ return players.filter((p) => characterMap.get(p.characterId)?.kind === ref.kind);
159
+ }
160
+ if (ref.ref === "ALIGNMENT") {
161
+ return players.filter((p) => p.alignment === ref.alignment);
162
+ }
163
+ if (ref.ref === "NEIGHBOR") {
164
+ if (effectorSeat === void 0 || players.length === 0) return [];
165
+ const alive = players.filter((p) => !p.isDead).sort((a, b) => a.seat - b.seat);
166
+ if (alive.length === 0) return [];
167
+ const idx = alive.findIndex((p) => p.seat === effectorSeat);
168
+ if (idx < 0) return [];
169
+ const left = alive[(idx - 1 + alive.length) % alive.length];
170
+ const right = alive[(idx + 1) % alive.length];
171
+ if (ref.side === "LEFT") return left ? [left] : [];
172
+ if (ref.side === "RIGHT") return right ? [right] : [];
173
+ const both = [left, right].filter((p) => Boolean(p));
174
+ const seen = /* @__PURE__ */ new Set();
175
+ return both.filter((p) => {
176
+ if (seen.has(p.seat)) return false;
177
+ seen.add(p.seat);
178
+ return true;
179
+ });
180
+ }
181
+ return [];
182
+ };
142
183
 
143
184
  // src/effects/handler-types.ts
144
185
  var createEffectHandler = (type, handler) => {
@@ -396,17 +437,31 @@ var applySeatChange = createEffectHandler("SEAT_CHANGE", () => {
396
437
  });
397
438
 
398
439
  // src/effects/handlers/status-change.ts
399
- var applyStatusChange = createEffectHandler("STATUS_CHANGE", ({ effect, payloads, makePlayersEffect }) => {
440
+ var applyStatusChange = createEffectHandler("STATUS_CHANGE", ({
441
+ effect,
442
+ payloads,
443
+ makePlayersEffect,
444
+ operationInTimelineIdx,
445
+ abilityMap,
446
+ operation
447
+ }) => {
400
448
  const maybeStatus = getSourceValue(effect.source, payloads);
401
449
  if (maybeStatus === "DEAD") {
450
+ const ability = abilityMap.get(operation.abilityId);
451
+ const turn = operationInTimelineIdx >= 0 ? operationInTimelineIdx : 0;
452
+ const cause = effect.deathCause ?? (ability?.category === "STORYTELLER" ? "STORYTELLER" : "ABILITY");
402
453
  makePlayersEffect.forEach((player) => {
403
454
  player.isDead = true;
455
+ player.deathCause = cause;
456
+ player.deathTurn = turn;
404
457
  });
405
458
  return;
406
459
  }
407
460
  if (maybeStatus === "ALIVE") {
408
461
  makePlayersEffect.forEach((player) => {
409
462
  player.isDead = false;
463
+ delete player.deathCause;
464
+ delete player.deathTurn;
410
465
  });
411
466
  return;
412
467
  }
@@ -1095,105 +1150,17 @@ var applyOperationToPlayers = ({
1095
1150
  };
1096
1151
 
1097
1152
  // src/can-invoke-ability.ts
1098
- var PHASE_ALIASES = {
1099
- NIGHT: "night",
1100
- DAY: "day"
1101
- };
1102
- var checkTurns = (turns, currentTurn) => {
1103
- if (turns.only && turns.only.length > 0 && !turns.only.includes(currentTurn)) {
1104
- return `turn ${currentTurn} not in allowed turns ${JSON.stringify(turns.only)}`;
1105
- }
1106
- if (typeof turns.from === "number" && currentTurn < turns.from) {
1107
- return `turn ${currentTurn} is before allowed start turn ${turns.from}`;
1108
- }
1109
- if (typeof turns.to === "number" && currentTurn > turns.to) {
1110
- return `turn ${currentTurn} is after allowed end turn ${turns.to}`;
1111
- }
1112
- return null;
1113
- };
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;
1153
+ var TIME_TO_PHASE = {
1154
+ night: "NIGHT",
1155
+ day: "DAY"
1125
1156
  };
1126
- var isFirstPhaseForEffector = (timelines, currentIdx, phaseTime) => {
1127
- for (let i = 0; i < currentIdx; i++) {
1128
- if (timelines[i]?.time === phaseTime) return false;
1129
- }
1157
+ var checkTurnRange = (range, turn) => {
1158
+ if (!range) return true;
1159
+ if (range.only && range.only.length > 0 && !range.only.includes(turn)) return false;
1160
+ if (typeof range.from === "number" && turn < range.from) return false;
1161
+ if (typeof range.to === "number" && turn > range.to) return false;
1130
1162
  return true;
1131
1163
  };
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
1164
  var compareCount2 = (actual, operator, expected) => {
1198
1165
  switch (operator) {
1199
1166
  case "<":
@@ -1210,149 +1177,256 @@ var compareCount2 = (actual, operator, expected) => {
1210
1177
  return actual !== expected;
1211
1178
  }
1212
1179
  };
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
- }
1180
+ var isFirstOfPhase = (timelines, lastIdx) => {
1181
+ const last = timelines[lastIdx];
1182
+ if (!last) return false;
1183
+ for (let i = 0; i < lastIdx; i++) {
1184
+ if (timelines[i]?.time === last.time) return false;
1222
1185
  }
1223
- return count;
1186
+ return true;
1224
1187
  };
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;
1188
+ var deriveContext = ({
1189
+ timelines,
1190
+ candidate,
1191
+ initialPlayers,
1192
+ allAbilities,
1193
+ characters
1194
+ }) => {
1195
+ const lastIdx = timelines.length - 1;
1196
+ const last = timelines[lastIdx];
1197
+ if (!last) {
1198
+ return { error: "timelines must contain at least one entry" };
1199
+ }
1200
+ const players = applyOperationToPlayers({
1201
+ players: initialPlayers,
1202
+ operations: timelines.flatMap((tl) => tl.operations ?? []),
1203
+ allAbilities,
1204
+ characters,
1205
+ timelines,
1206
+ timelineIndexAtNow: lastIdx
1207
+ });
1208
+ const effector = players.find((p) => p.seat === candidate.effector);
1209
+ const operationTurnMap = /* @__PURE__ */ new Map();
1210
+ const operationTimeMap = /* @__PURE__ */ new Map();
1211
+ timelines.forEach((tl) => {
1212
+ tl.operations?.forEach((op) => {
1213
+ operationTurnMap.set(op, tl.turn);
1214
+ operationTimeMap.set(op, tl.time);
1215
+ });
1216
+ });
1217
+ const allOperations = timelines.flatMap((tl) => tl.operations ?? []);
1218
+ const todayOperations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "day").flatMap((tl) => tl.operations ?? []);
1219
+ const tonightOperations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "night").flatMap((tl) => tl.operations ?? []);
1220
+ const yesterdayDayOperations = timelines.filter((tl) => tl.turn === last.turn - 1 && tl.time === "day").flatMap((tl) => tl.operations ?? []);
1221
+ const yesterdayNightOperations = timelines.filter((tl) => tl.turn === last.turn - 1 && tl.time === "night").flatMap((tl) => tl.operations ?? []);
1222
+ const todayNominations = timelines.filter((tl) => tl.turn === last.turn && tl.time === "day").flatMap((tl) => tl.nominations ?? []).map((n) => ({ nominator: n.nominator, nominee: n.nominee }));
1223
+ return {
1224
+ currentTurn: last.turn,
1225
+ currentPhase: TIME_TO_PHASE[last.time],
1226
+ isFirstOfPhase: isFirstOfPhase(timelines, lastIdx),
1227
+ players,
1228
+ allOperations,
1229
+ todayOperations,
1230
+ tonightOperations,
1231
+ yesterdayDayOperations,
1232
+ yesterdayNightOperations,
1233
+ todayNominations,
1234
+ effectorSeat: candidate.effector,
1235
+ effector,
1236
+ abilityId: candidate.abilityId,
1237
+ characterMap: new Map(characters.map((c) => [c.id, c])),
1238
+ operationTurnMap,
1239
+ operationTimeMap
1240
+ };
1241
+ };
1242
+ var operationsForActionScope = (scope, ctx) => {
1243
+ if (scope === "TONIGHT") return ctx.tonightOperations;
1244
+ if (scope === "TODAY") return ctx.todayOperations;
1245
+ if (scope === "YESTERDAY") {
1246
+ return [...ctx.yesterdayDayOperations, ...ctx.yesterdayNightOperations];
1241
1247
  }
1242
- if (condition.kind === "NO_EXECUTION_TODAY") {
1243
- return countExecutionsToday(timelines, currentIdx) > 0 ? "condition NO_EXECUTION_TODAY violated" : null;
1248
+ return ctx.allOperations;
1249
+ };
1250
+ var seatsMatchingTargetRef = (ref, ctx) => {
1251
+ const matched = resolveTargetRef({
1252
+ ref,
1253
+ players: ctx.players,
1254
+ effectorSeat: ctx.effectorSeat,
1255
+ characterMap: ctx.characterMap
1256
+ });
1257
+ return new Set(matched.map((p) => p.seat));
1258
+ };
1259
+ var evalAtomTime = (atom, ctx) => {
1260
+ if (atom.phase !== ctx.currentPhase) {
1261
+ return `phase is ${ctx.currentPhase}, required ${atom.phase}`;
1244
1262
  }
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`;
1263
+ if (atom.occurrence === "FIRST" && !ctx.isFirstOfPhase) {
1264
+ return `expected FIRST occurrence of ${atom.phase} but is not the first`;
1249
1265
  }
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)})`;
1266
+ if (atom.occurrence === "EXCEPT_FIRST" && ctx.isFirstOfPhase) {
1267
+ return `expected EXCEPT_FIRST occurrence of ${atom.phase} but is the first`;
1268
+ }
1269
+ if (!checkTurnRange(atom.turns, ctx.currentTurn)) {
1270
+ return `turn ${ctx.currentTurn} not in allowed turn range`;
1271
+ }
1272
+ return null;
1273
+ };
1274
+ var evalAtomState = (atom, ctx) => {
1275
+ const seats = seatsMatchingTargetRef(atom.subject, ctx);
1276
+ if (seats.size === 0) return `no players match subject ${atom.subject.ref}`;
1277
+ for (const seat of seats) {
1278
+ const player = ctx.players.find((p) => p.seat === seat);
1279
+ if (!player) continue;
1280
+ const isDead = Boolean(player.isDead);
1281
+ if (atom.aliveness === "ALIVE" && isDead) continue;
1282
+ if (atom.aliveness === "DEAD" && !isDead) continue;
1283
+ if (atom.aliveness === "DEAD") {
1284
+ if (atom.deathCause && player.deathCause !== atom.deathCause) continue;
1285
+ if (atom.deathTurn && !checkTurnRange(atom.deathTurn, player.deathTurn ?? -1)) continue;
1254
1286
  }
1255
1287
  return null;
1256
1288
  }
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`;
1289
+ return `no subject in ${atom.subject.ref} satisfies aliveness=${atom.aliveness}`;
1290
+ };
1291
+ var evalAtomAction = (atom, ctx) => {
1292
+ const subjectSeats = seatsMatchingTargetRef(atom.subject, ctx);
1293
+ if (subjectSeats.size === 0) return `no players match subject ${atom.subject.ref}`;
1294
+ const actorSeats = atom.actor ? seatsMatchingTargetRef(atom.actor, ctx) : null;
1295
+ if (atom.action === "NOMINATED") {
1296
+ const noms = ctx.todayNominations;
1297
+ const found = noms.some(
1298
+ (n) => subjectSeats.has(n.nominee) && (actorSeats === null || actorSeats.has(n.nominator))
1299
+ );
1300
+ return found ? null : "no matching NOMINATED event in today";
1301
+ }
1302
+ const scoped = operationsForActionScope(atom.scope, ctx);
1303
+ if (atom.action === "CHOSEN_AS_TARGET") {
1304
+ const found = scoped.some((op) => {
1305
+ if (actorSeats !== null && !actorSeats.has(op.effector)) return false;
1306
+ const payloadSeats = (op.payloads ?? []).flatMap(
1307
+ (v) => typeof v === "number" ? [v] : Array.isArray(v) ? v.filter((x) => typeof x === "number") : []
1308
+ );
1309
+ return payloadSeats.some((s) => subjectSeats.has(s));
1310
+ });
1311
+ return found ? null : "no matching CHOSEN_AS_TARGET in scope";
1260
1312
  }
1261
- return null;
1313
+ const turnsInScope = (() => {
1314
+ if (atom.scope === "TONIGHT" || atom.scope === "TODAY") return [ctx.currentTurn];
1315
+ if (atom.scope === "YESTERDAY") return [ctx.currentTurn - 1];
1316
+ return null;
1317
+ })();
1318
+ const turnInScope = (turn) => {
1319
+ if (typeof turn !== "number") return false;
1320
+ if (turnsInScope === null) return true;
1321
+ return turnsInScope.includes(turn);
1322
+ };
1323
+ if (atom.action === "KILLED") {
1324
+ for (const seat of subjectSeats) {
1325
+ const player = ctx.players.find((p) => p.seat === seat);
1326
+ if (!player?.isDead) continue;
1327
+ if (!turnInScope(player.deathTurn)) continue;
1328
+ return null;
1329
+ }
1330
+ return "no matching KILLED in scope";
1331
+ }
1332
+ if (atom.action === "EXECUTED") {
1333
+ for (const seat of subjectSeats) {
1334
+ const player = ctx.players.find((p) => p.seat === seat);
1335
+ if (!player?.isDead) continue;
1336
+ if (player.deathCause !== "EXECUTION") continue;
1337
+ if (!turnInScope(player.deathTurn)) continue;
1338
+ return null;
1339
+ }
1340
+ return "no matching EXECUTED in scope";
1341
+ }
1342
+ void scoped;
1343
+ return `unknown action ${atom.action}`;
1262
1344
  };
1263
- var countMatchingPriorOps = (priorOperations, abilityId, effectorSeat, operationTimelineMap, matchTurn, matchTime, timelines, countAcrossEffectors) => {
1264
- return priorOperations.filter((op) => {
1265
- if (op.abilityId !== abilityId) return false;
1266
- if (!countAcrossEffectors && typeof effectorSeat === "number" && op.effector !== effectorSeat) return false;
1267
- if (matchTurn === null && matchTime === null) return true;
1268
- const idx = operationTimelineMap.get(op);
1269
- if (typeof idx !== "number") return false;
1270
- const tl = timelines[idx];
1271
- if (!tl) return false;
1272
- if (matchTurn !== null && tl.turn !== matchTurn) return false;
1273
- if (matchTime !== null && tl.time !== matchTime) return false;
1345
+ var evalAtomCount = (atom, ctx) => {
1346
+ const ops = atom.scope === "GAME" ? ctx.allOperations : atom.scope === "NIGHT" ? ctx.tonightOperations : ctx.todayOperations;
1347
+ let resetTurn = -1;
1348
+ let resetTime = null;
1349
+ if (atom.resetOn?.length && ctx.effector?.isDead && atom.resetOn.includes("DEATH")) {
1350
+ resetTurn = ctx.effector.deathTurn ?? -1;
1351
+ }
1352
+ const used = ops.filter((op) => {
1353
+ if (op.abilityId !== ctx.abilityId) return false;
1354
+ if (op.effector !== ctx.effectorSeat) return false;
1355
+ if (resetTurn >= 0) {
1356
+ const t = ctx.operationTurnMap.get(op);
1357
+ if (typeof t !== "number") return false;
1358
+ if (t < resetTurn) return false;
1359
+ if (t === resetTurn) {
1360
+ const time = ctx.operationTimeMap.get(op);
1361
+ if (resetTime !== null && time !== resetTime) return false;
1362
+ }
1363
+ }
1274
1364
  return true;
1275
1365
  }).length;
1366
+ return compareCount2(used, atom.operator, atom.value) ? null : `count ${used} fails ${atom.operator} ${atom.value} in ${atom.scope}`;
1276
1367
  };
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"})`;
1368
+ var evalAtomGlobalCount = (atom, ctx) => {
1369
+ let actual = 0;
1370
+ if (atom.subject === "ALIVE") actual = ctx.players.filter((p) => !p.isDead).length;
1371
+ else if (atom.subject === "DEAD") actual = ctx.players.filter((p) => p.isDead).length;
1372
+ else if (atom.subject === "NOMINATED_TODAY") actual = ctx.todayNominations.length;
1373
+ else if (atom.subject === "EXECUTED_TODAY") {
1374
+ actual = ctx.players.filter((p) => p.isDead && p.deathCause === "EXECUTION" && p.deathTurn === ctx.currentTurn).length;
1375
+ }
1376
+ return compareCount2(actual, atom.operator, atom.value) ? null : `global count ${atom.subject}=${actual} fails ${atom.operator} ${atom.value}`;
1377
+ };
1378
+ var evalAtom2 = (atom, ctx, customResolver) => {
1379
+ if (atom.type === "TIME") return evalAtomTime(atom, ctx);
1380
+ if (atom.type === "STATE") return evalAtomState(atom, ctx);
1381
+ if (atom.type === "ACTION") return evalAtomAction(atom, ctx);
1382
+ if (atom.type === "COUNT") return evalAtomCount(atom, ctx);
1383
+ if (atom.type === "GLOBAL_COUNT") return evalAtomGlobalCount(atom, ctx);
1384
+ if (atom.type === "CUSTOM") {
1385
+ if (!customResolver) return `no custom resolver registered for ${atom.resolverId}`;
1386
+ return customResolver({ resolverId: atom.resolverId, args: atom.args }, ctx) ? null : `custom resolver ${atom.resolverId} returned false`;
1387
+ }
1388
+ return "unknown atom";
1389
+ };
1390
+ var evalExpr2 = (expr, ctx, customResolver) => {
1391
+ if (expr.op === "AND") {
1392
+ for (const child of expr.children) {
1393
+ const reason = evalExpr2(child, ctx, customResolver);
1394
+ if (reason) return reason;
1395
+ }
1396
+ return null;
1299
1397
  }
1300
- return null;
1398
+ if (expr.op === "OR") {
1399
+ const reasons = [];
1400
+ for (const child of expr.children) {
1401
+ const reason = evalExpr2(child, ctx, customResolver);
1402
+ if (!reason) return null;
1403
+ reasons.push(reason);
1404
+ }
1405
+ return `OR: all branches failed (${reasons.join("; ")})`;
1406
+ }
1407
+ if (expr.op === "NOT") {
1408
+ const reason = evalExpr2(expr.child, ctx, customResolver);
1409
+ return reason ? null : "NOT: inner expression was true";
1410
+ }
1411
+ return evalAtom2(expr.atom, ctx, customResolver);
1301
1412
  };
1302
1413
  var canInvokeAbility = ({
1303
1414
  ability,
1304
- effector,
1305
- currentTimelineIdx,
1306
1415
  timelines,
1307
- priorOperations = [],
1308
- players,
1309
- event,
1310
- customConditionResolver
1416
+ candidate,
1417
+ initialPlayers,
1418
+ allAbilities,
1419
+ characters,
1420
+ customResolver
1311
1421
  }) => {
1312
- const trigger = ability.triggerWindow;
1313
- if (!trigger) return { allowed: true };
1314
- const currentTimeline = timelines[currentTimelineIdx];
1315
- if (!currentTimeline) {
1316
- return { allowed: false, reason: `no timeline at index ${currentTimelineIdx}` };
1317
- }
1318
- if (trigger.turns) {
1319
- const turnError = checkTurns(trigger.turns, currentTimeline.turn);
1320
- if (turnError) return { allowed: false, reason: turnError };
1321
- }
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
- }
1343
- }
1344
- if (trigger.frequency) {
1345
- const freqError = checkFrequency(
1346
- trigger.frequency,
1347
- ability,
1348
- effector?.seat,
1349
- timelines,
1350
- currentTimelineIdx,
1351
- priorOperations
1352
- );
1353
- if (freqError) return { allowed: false, reason: freqError };
1354
- }
1355
- return { allowed: true };
1422
+ const window = ability.triggerWindow;
1423
+ if (!window) return { allowed: true };
1424
+ const derived = deriveContext({ timelines, candidate, initialPlayers, allAbilities, characters });
1425
+ if ("error" in derived) {
1426
+ return { allowed: false, reason: derived.error };
1427
+ }
1428
+ const reason = evalExpr2(window, derived, customResolver);
1429
+ return reason ? { allowed: false, reason } : { allowed: true };
1356
1430
  };
1357
1431
  export {
1358
1432
  adjustValueAsNumber,
@@ -1361,11 +1435,13 @@ export {
1361
1435
  buildPlayerSeatMap,
1362
1436
  canInvokeAbility,
1363
1437
  copyPlayers,
1438
+ deriveContext,
1364
1439
  getSourceValue,
1365
1440
  getValueFromPayloads,
1366
1441
  isNumberOrNumberArray,
1367
1442
  isPlayerReminderArray,
1368
1443
  isReminder,
1369
1444
  resolveSourceValue,
1445
+ resolveTargetRef,
1370
1446
  transformEmptyArray
1371
1447
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bct-app/game-engine",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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.4"
33
+ "@bct-app/game-model": "0.1.5"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@vitest/ui": "^4.1.3",