@bct-app/game-engine 0.1.16 → 0.1.18

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.
Files changed (3) hide show
  1. package/dist/index.js +125 -135
  2. package/dist/index.mjs +125 -135
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -572,11 +572,6 @@ var resolveDynamicValue = (value, payloads, isExpected) => {
572
572
  var getSortedPlayers = (playerSeatMap) => {
573
573
  return [...playerSeatMap.values()].sort((left, right) => left.seat - right.seat);
574
574
  };
575
- var getCircularDistance = (anchorIdx, targetIdx, total) => {
576
- const clockwise = (targetIdx - anchorIdx + total) % total;
577
- const anticlockwise = (anchorIdx - targetIdx + total) % total;
578
- return Math.min(clockwise, anticlockwise);
579
- };
580
575
  var getAnchorSeat = (dynamicTarget, payloads, effector) => {
581
576
  const anchor = dynamicTarget.anchor;
582
577
  if (!anchor || anchor.from === "EFFECTOR") {
@@ -595,42 +590,6 @@ var resolveSelectorScope = (dynamicTarget, payloads) => {
595
590
  (value) => value === "BOTH_SIDES" || value === "LEFT_SIDE" || value === "RIGHT_SIDE"
596
591
  ) ?? "BOTH_SIDES";
597
592
  };
598
- var getCandidatesBySelector = (dynamicTarget, sortedPlayers, anchorSeat, payloads) => {
599
- const selector = dynamicTarget.selector;
600
- if (!selector) {
601
- return sortedPlayers;
602
- }
603
- const scope = resolveSelectorScope(dynamicTarget, payloads);
604
- if (!anchorSeat) {
605
- return [];
606
- }
607
- const anchorIdx = sortedPlayers.findIndex((player) => player.seat === anchorSeat);
608
- if (anchorIdx < 0) {
609
- return [];
610
- }
611
- const total = sortedPlayers.length;
612
- if (scope === "LEFT_SIDE") {
613
- const left2 = [];
614
- for (let step = 1; step < total; step += 1) {
615
- left2.push(sortedPlayers[(anchorIdx + step) % total]);
616
- }
617
- return left2;
618
- }
619
- if (scope === "RIGHT_SIDE") {
620
- const right2 = [];
621
- for (let step = 1; step < total; step += 1) {
622
- right2.push(sortedPlayers[(anchorIdx - step + total) % total]);
623
- }
624
- return right2;
625
- }
626
- const left = [];
627
- const right = [];
628
- for (let step = 1; step < total; step += 1) {
629
- left.push(sortedPlayers[(anchorIdx + step) % total]);
630
- right.push(sortedPlayers[(anchorIdx - step + total) % total]);
631
- }
632
- return [...left, ...right];
633
- };
634
593
  var matchesCondition = (player, condition, payloads, characterMap) => {
635
594
  if (condition.field === "IS_DEAD") {
636
595
  const expected = resolveDynamicValue(condition.value, payloads, (value) => typeof value === "boolean");
@@ -659,55 +618,72 @@ var matchesCondition = (player, condition, payloads, characterMap) => {
659
618
  const expectedKind = resolveDynamicValue(condition.value, payloads, (value) => typeof value === "string");
660
619
  return typeof expectedKind === "string" && kind === expectedKind;
661
620
  };
662
- var filterByWhere = (candidates, dynamicTarget, payloads, characterMap) => {
621
+ var passesWhere = (player, dynamicTarget, payloads, characterMap) => {
663
622
  const where = dynamicTarget.where;
664
623
  const conditions = where?.conditions ?? [];
665
- if (conditions.length === 0) {
666
- return candidates;
667
- }
624
+ if (conditions.length === 0) return true;
668
625
  const isAllMode = where?.mode !== "ANY";
669
- return candidates.filter((player) => {
670
- const results = conditions.map((condition) => matchesCondition(player, condition, payloads, characterMap));
671
- return isAllMode ? results.every(Boolean) : results.some(Boolean);
672
- });
626
+ const results = conditions.map((condition) => matchesCondition(player, condition, payloads, characterMap));
627
+ return isAllMode ? results.every(Boolean) : results.some(Boolean);
673
628
  };
674
- var applySort = (candidates, dynamicTarget, payloads, sortedPlayers, anchorSeat) => {
629
+ var walkSidesWithFilter = (dynamicTarget, sortedPlayers, anchorSeat, payloads, characterMap) => {
675
630
  const scope = resolveSelectorScope(dynamicTarget, payloads);
676
- if (scope !== "BOTH_SIDES") {
677
- return [...candidates];
678
- }
679
- if (!anchorSeat || sortedPlayers.length <= 1) {
680
- return [...candidates].sort((left, right) => left.seat - right.seat);
681
- }
682
- const seatIndexMap = new Map(sortedPlayers.map((player, idx) => [player.seat, idx]));
683
- const anchorIdx = seatIndexMap.get(anchorSeat);
684
- if (typeof anchorIdx !== "number") {
685
- return [...candidates].sort((left, right) => left.seat - right.seat);
686
- }
687
- return [...candidates].sort((left, right) => {
688
- const leftIdx = seatIndexMap.get(left.seat);
689
- const rightIdx = seatIndexMap.get(right.seat);
690
- if (typeof leftIdx !== "number" || typeof rightIdx !== "number") {
691
- return left.seat - right.seat;
631
+ if (!anchorSeat) return { left: [], right: [] };
632
+ const anchorIdx = sortedPlayers.findIndex((player) => player.seat === anchorSeat);
633
+ if (anchorIdx < 0) return { left: [], right: [] };
634
+ const total = sortedPlayers.length;
635
+ const left = [];
636
+ const right = [];
637
+ if (scope === "LEFT_SIDE" || scope === "BOTH_SIDES") {
638
+ for (let step = 1; step < total; step += 1) {
639
+ const player = sortedPlayers[(anchorIdx + step) % total];
640
+ if (passesWhere(player, dynamicTarget, payloads, characterMap)) left.push(player);
692
641
  }
693
- const leftDistance = getCircularDistance(anchorIdx, leftIdx, sortedPlayers.length);
694
- const rightDistance = getCircularDistance(anchorIdx, rightIdx, sortedPlayers.length);
695
- if (leftDistance !== rightDistance) {
696
- return leftDistance - rightDistance;
642
+ }
643
+ if (scope === "RIGHT_SIDE" || scope === "BOTH_SIDES") {
644
+ for (let step = 1; step < total; step += 1) {
645
+ const player = sortedPlayers[(anchorIdx - step + total) % total];
646
+ if (passesWhere(player, dynamicTarget, payloads, characterMap)) right.push(player);
697
647
  }
698
- return left.seat - right.seat;
699
- });
648
+ }
649
+ return { left, right };
700
650
  };
701
- var pickTargets = (sortedCandidates, dynamicTarget, payloads) => {
702
- const dedupedCandidates = [...new Map(sortedCandidates.map((player) => [player.seat, player])).values()];
651
+ var pickBalanced = (sides, dynamicTarget, payloads) => {
703
652
  const configuredLimit = resolveDynamicValue(dynamicTarget.limit, payloads, (value) => typeof value === "number");
653
+ const seen = /* @__PURE__ */ new Set();
654
+ const out = [];
655
+ const pushUnique = (player) => {
656
+ if (!player || seen.has(player.seat)) return false;
657
+ seen.add(player.seat);
658
+ out.push(player);
659
+ return true;
660
+ };
704
661
  if (typeof configuredLimit === "undefined") {
705
- return dedupedCandidates;
706
- }
707
- if (configuredLimit <= 0) {
708
- return [];
662
+ sides.left.forEach(pushUnique);
663
+ sides.right.forEach(pushUnique);
664
+ return out;
665
+ }
666
+ if (configuredLimit <= 0) return [];
667
+ let li = 0;
668
+ let ri = 0;
669
+ let preferLeft = true;
670
+ while (out.length < configuredLimit && (li < sides.left.length || ri < sides.right.length)) {
671
+ if (preferLeft && li < sides.left.length) {
672
+ if (pushUnique(sides.left[li])) li += 1;
673
+ else li += 1;
674
+ } else if (!preferLeft && ri < sides.right.length) {
675
+ if (pushUnique(sides.right[ri])) ri += 1;
676
+ else ri += 1;
677
+ } else if (li < sides.left.length) {
678
+ if (pushUnique(sides.left[li])) li += 1;
679
+ else li += 1;
680
+ } else if (ri < sides.right.length) {
681
+ if (pushUnique(sides.right[ri])) ri += 1;
682
+ else ri += 1;
683
+ }
684
+ preferLeft = !preferLeft;
709
685
  }
710
- return dedupedCandidates.slice(0, configuredLimit);
686
+ return out;
711
687
  };
712
688
  var resolveEffectTargets = ({
713
689
  effect,
@@ -740,10 +716,12 @@ var resolveEffectTargets = ({
740
716
  const dynamicTarget = effect.target;
741
717
  const sortedPlayers = getSortedPlayers(playerSeatMap);
742
718
  const anchorSeat = getAnchorSeat(dynamicTarget, payloads, effector);
743
- const candidates = getCandidatesBySelector(dynamicTarget, sortedPlayers, anchorSeat, payloads);
744
- const filtered = filterByWhere(candidates, dynamicTarget, payloads, characterMap);
745
- const ordered = applySort(filtered, dynamicTarget, payloads, sortedPlayers, anchorSeat);
746
- return pickTargets(ordered, dynamicTarget, payloads);
719
+ if (!dynamicTarget.selector) {
720
+ const matches = sortedPlayers.filter((player) => passesWhere(player, dynamicTarget, payloads, characterMap));
721
+ return pickBalanced({ left: matches, right: [] }, dynamicTarget, payloads);
722
+ }
723
+ const sides = walkSidesWithFilter(dynamicTarget, sortedPlayers, anchorSeat, payloads, characterMap);
724
+ return pickBalanced(sides, dynamicTarget, payloads);
747
725
  }
748
726
  return [];
749
727
  };
@@ -1074,11 +1052,16 @@ var evaluatePrecondition = ({
1074
1052
  };
1075
1053
 
1076
1054
  // src/apply-operation.ts
1077
- var isDeferredOperation = (operation, abilityMap) => {
1078
- const ability = abilityMap.get(operation.abilityId);
1079
- return ability?.executionTiming === "DEFER_TO_END";
1080
- };
1081
1055
  var FIXED_POINT_MAX_ITERATIONS = 32;
1056
+ var seatSetsEqual = (left, right) => {
1057
+ if (left.length !== right.length) return false;
1058
+ const sortedLeft = [...left].sort((a, b) => a - b);
1059
+ const sortedRight = [...right].sort((a, b) => a - b);
1060
+ for (let i = 0; i < sortedLeft.length; i += 1) {
1061
+ if (sortedLeft[i] !== sortedRight[i]) return false;
1062
+ }
1063
+ return true;
1064
+ };
1082
1065
  var applyOperationToPlayers = ({
1083
1066
  players,
1084
1067
  operations,
@@ -1104,20 +1087,11 @@ var applyOperationToPlayers = ({
1104
1087
  }
1105
1088
  operationsByTimeline.get(timelineIndex)?.push(operation);
1106
1089
  });
1107
- const orderedOpsForTimeline = (timelineIdx) => {
1108
- const ops = operationsByTimeline.get(timelineIdx) ?? [];
1109
- const normal = [];
1110
- const deferred = [];
1111
- ops.forEach((op) => {
1112
- if (isDeferredOperation(op, abilityMap)) deferred.push(op);
1113
- else normal.push(op);
1114
- });
1115
- return [...normal, ...deferred];
1116
- };
1090
+ const opsForTimeline = (timelineIdx) => operationsByTimeline.get(timelineIdx) ?? [];
1117
1091
  const allOpsInOrder = [];
1118
- allOpsInOrder.push(...orderedOpsForTimeline(-1));
1092
+ allOpsInOrder.push(...opsForTimeline(-1));
1119
1093
  for (let i = 0; i <= nowTimelineIndex; i += 1) {
1120
- if (timelines[i]) allOpsInOrder.push(...orderedOpsForTimeline(i));
1094
+ if (timelines[i]) allOpsInOrder.push(...opsForTimeline(i));
1121
1095
  }
1122
1096
  const processedOps = /* @__PURE__ */ new Set();
1123
1097
  const records = [];
@@ -1166,7 +1140,7 @@ var applyOperationToPlayers = ({
1166
1140
  };
1167
1141
  const rebuildState = () => {
1168
1142
  const playersWithStatus = copyPlayers(players);
1169
- const settingsOps = orderedOpsForTimeline(-1);
1143
+ const settingsOps = opsForTimeline(-1);
1170
1144
  settingsOps.forEach((op) => {
1171
1145
  if (processedOps.has(op)) applyRecordsForOp(op, playersWithStatus);
1172
1146
  });
@@ -1187,7 +1161,7 @@ var applyOperationToPlayers = ({
1187
1161
  });
1188
1162
  });
1189
1163
  }
1190
- orderedOpsForTimeline(i).forEach((op) => {
1164
+ opsForTimeline(i).forEach((op) => {
1191
1165
  if (processedOps.has(op)) applyRecordsForOp(op, playersWithStatus);
1192
1166
  });
1193
1167
  }
@@ -1199,10 +1173,10 @@ var applyOperationToPlayers = ({
1199
1173
  console.warn("Ability not found for operation:", op);
1200
1174
  return [];
1201
1175
  }
1176
+ const operationInTimelineIdx = operationTimelineMap.get(op) ?? -1;
1202
1177
  const playerSeatMap = buildPlayerSeatMap(state);
1203
1178
  const effector = playerSeatMap.get(op.effector);
1204
1179
  const payloads = op.payloads || [];
1205
- const operationInTimelineIdx = operationTimelineMap.get(op) ?? -1;
1206
1180
  const newRecords = [];
1207
1181
  ability.effects.forEach((effect, effectIdx) => {
1208
1182
  const resolvedTargets = resolveEffectTargets({
@@ -1227,9 +1201,7 @@ var applyOperationToPlayers = ({
1227
1201
  payloads,
1228
1202
  customResolver
1229
1203
  }) : resolvedTargets;
1230
- if (gatedTargets.length === 0 && resolvedTargets.length > 0) {
1231
- return;
1232
- }
1204
+ if (gatedTargets.length === 0 && resolvedTargets.length > 0) return;
1233
1205
  const lifeTargets = evaluateLifetime({
1234
1206
  effect,
1235
1207
  operationTimelineIdx: operationInTimelineIdx,
@@ -1246,42 +1218,60 @@ var applyOperationToPlayers = ({
1246
1218
  operation: op,
1247
1219
  effectIdx,
1248
1220
  operationInTimelineIdx,
1249
- activeTargetSeats: lifeTargets.map((t) => t.seat)
1221
+ activeTargetSeats: lifeTargets.map((t) => t.seat),
1222
+ resolvedSeatsAtApply: new Set(resolvedTargets.map((t) => t.seat)),
1223
+ gatedSeatsAtApply: new Set(gatedTargets.map((t) => t.seat))
1250
1224
  });
1251
1225
  });
1252
1226
  return newRecords;
1253
1227
  };
1254
- const reEvaluateLifetimes = (state) => {
1228
+ const reResolveRecord = (record, state) => {
1229
+ const ability = abilityMap.get(record.operation.abilityId);
1230
+ if (!ability) return [];
1231
+ const effect = ability.effects[record.effectIdx];
1232
+ if (!effect) return [];
1255
1233
  const playerSeatMap = buildPlayerSeatMap(state);
1256
- let anyShrunk = false;
1234
+ const effector = playerSeatMap.get(record.operation.effector);
1235
+ const payloads = record.operation.payloads || [];
1236
+ const resolvedTargets = resolveEffectTargets({
1237
+ effect,
1238
+ operation: record.operation,
1239
+ payloads,
1240
+ effector,
1241
+ playerSeatMap,
1242
+ characterMap
1243
+ });
1244
+ if (!resolvedTargets) return [];
1245
+ const gatedTargets = resolvedTargets.filter((target) => {
1246
+ if (record.resolvedSeatsAtApply.has(target.seat)) {
1247
+ return record.gatedSeatsAtApply.has(target.seat);
1248
+ }
1249
+ return true;
1250
+ });
1251
+ const lifeTargets = evaluateLifetime({
1252
+ effect,
1253
+ operationTimelineIdx: record.operationInTimelineIdx,
1254
+ nowTimelineIndex,
1255
+ timelines,
1256
+ effector,
1257
+ targets: gatedTargets,
1258
+ snapshotSeatMap: playerSeatMap,
1259
+ characterMap,
1260
+ payloads,
1261
+ customResolver
1262
+ });
1263
+ return lifeTargets.map((t) => t.seat);
1264
+ };
1265
+ const reResolveRecords = (state) => {
1266
+ let anyChanged = false;
1257
1267
  records.forEach((record) => {
1258
- if (record.activeTargetSeats.length === 0) return;
1259
- const ability = abilityMap.get(record.operation.abilityId);
1260
- if (!ability) return;
1261
- const effect = ability.effects[record.effectIdx];
1262
- if (!effect) return;
1263
- const effector = playerSeatMap.get(record.operation.effector);
1264
- const payloads = record.operation.payloads || [];
1265
- const targets = record.activeTargetSeats.map((seat) => playerSeatMap.get(seat)).filter((p) => Boolean(p));
1266
- const liveTargets = evaluateLifetime({
1267
- effect,
1268
- operationTimelineIdx: record.operationInTimelineIdx,
1269
- nowTimelineIndex,
1270
- timelines,
1271
- effector,
1272
- targets,
1273
- snapshotSeatMap: playerSeatMap,
1274
- characterMap,
1275
- payloads,
1276
- customResolver
1277
- });
1278
- const newSeats = liveTargets.map((t) => t.seat);
1279
- if (newSeats.length < record.activeTargetSeats.length) {
1268
+ const newSeats = reResolveRecord(record, state);
1269
+ if (!seatSetsEqual(newSeats, record.activeTargetSeats)) {
1280
1270
  record.activeTargetSeats = newSeats;
1281
- anyShrunk = true;
1271
+ anyChanged = true;
1282
1272
  }
1283
1273
  });
1284
- return anyShrunk;
1274
+ return anyChanged;
1285
1275
  };
1286
1276
  let lastState = copyPlayers(players);
1287
1277
  allOpsInOrder.forEach((op) => {
@@ -1296,13 +1286,13 @@ var applyOperationToPlayers = ({
1296
1286
  let state = rebuildState();
1297
1287
  let iter = 0;
1298
1288
  while (iter < FIXED_POINT_MAX_ITERATIONS) {
1299
- const shrunk = reEvaluateLifetimes(state);
1300
- if (!shrunk) break;
1289
+ const changed = reResolveRecords(state);
1290
+ if (!changed) break;
1301
1291
  state = rebuildState();
1302
1292
  iter += 1;
1303
1293
  }
1304
1294
  if (iter >= FIXED_POINT_MAX_ITERATIONS) {
1305
- console.warn("applyOperationToPlayers: lifetime fixed-point did not converge within %d iterations after op %s", FIXED_POINT_MAX_ITERATIONS, op.abilityId);
1295
+ console.warn("applyOperationToPlayers: target re-resolution fixed-point did not converge within %d iterations after op %s", FIXED_POINT_MAX_ITERATIONS, op.abilityId);
1306
1296
  }
1307
1297
  lastState = state;
1308
1298
  });
package/dist/index.mjs CHANGED
@@ -532,11 +532,6 @@ var resolveDynamicValue = (value, payloads, isExpected) => {
532
532
  var getSortedPlayers = (playerSeatMap) => {
533
533
  return [...playerSeatMap.values()].sort((left, right) => left.seat - right.seat);
534
534
  };
535
- var getCircularDistance = (anchorIdx, targetIdx, total) => {
536
- const clockwise = (targetIdx - anchorIdx + total) % total;
537
- const anticlockwise = (anchorIdx - targetIdx + total) % total;
538
- return Math.min(clockwise, anticlockwise);
539
- };
540
535
  var getAnchorSeat = (dynamicTarget, payloads, effector) => {
541
536
  const anchor = dynamicTarget.anchor;
542
537
  if (!anchor || anchor.from === "EFFECTOR") {
@@ -555,42 +550,6 @@ var resolveSelectorScope = (dynamicTarget, payloads) => {
555
550
  (value) => value === "BOTH_SIDES" || value === "LEFT_SIDE" || value === "RIGHT_SIDE"
556
551
  ) ?? "BOTH_SIDES";
557
552
  };
558
- var getCandidatesBySelector = (dynamicTarget, sortedPlayers, anchorSeat, payloads) => {
559
- const selector = dynamicTarget.selector;
560
- if (!selector) {
561
- return sortedPlayers;
562
- }
563
- const scope = resolveSelectorScope(dynamicTarget, payloads);
564
- if (!anchorSeat) {
565
- return [];
566
- }
567
- const anchorIdx = sortedPlayers.findIndex((player) => player.seat === anchorSeat);
568
- if (anchorIdx < 0) {
569
- return [];
570
- }
571
- const total = sortedPlayers.length;
572
- if (scope === "LEFT_SIDE") {
573
- const left2 = [];
574
- for (let step = 1; step < total; step += 1) {
575
- left2.push(sortedPlayers[(anchorIdx + step) % total]);
576
- }
577
- return left2;
578
- }
579
- if (scope === "RIGHT_SIDE") {
580
- const right2 = [];
581
- for (let step = 1; step < total; step += 1) {
582
- right2.push(sortedPlayers[(anchorIdx - step + total) % total]);
583
- }
584
- return right2;
585
- }
586
- const left = [];
587
- const right = [];
588
- for (let step = 1; step < total; step += 1) {
589
- left.push(sortedPlayers[(anchorIdx + step) % total]);
590
- right.push(sortedPlayers[(anchorIdx - step + total) % total]);
591
- }
592
- return [...left, ...right];
593
- };
594
553
  var matchesCondition = (player, condition, payloads, characterMap) => {
595
554
  if (condition.field === "IS_DEAD") {
596
555
  const expected = resolveDynamicValue(condition.value, payloads, (value) => typeof value === "boolean");
@@ -619,55 +578,72 @@ var matchesCondition = (player, condition, payloads, characterMap) => {
619
578
  const expectedKind = resolveDynamicValue(condition.value, payloads, (value) => typeof value === "string");
620
579
  return typeof expectedKind === "string" && kind === expectedKind;
621
580
  };
622
- var filterByWhere = (candidates, dynamicTarget, payloads, characterMap) => {
581
+ var passesWhere = (player, dynamicTarget, payloads, characterMap) => {
623
582
  const where = dynamicTarget.where;
624
583
  const conditions = where?.conditions ?? [];
625
- if (conditions.length === 0) {
626
- return candidates;
627
- }
584
+ if (conditions.length === 0) return true;
628
585
  const isAllMode = where?.mode !== "ANY";
629
- return candidates.filter((player) => {
630
- const results = conditions.map((condition) => matchesCondition(player, condition, payloads, characterMap));
631
- return isAllMode ? results.every(Boolean) : results.some(Boolean);
632
- });
586
+ const results = conditions.map((condition) => matchesCondition(player, condition, payloads, characterMap));
587
+ return isAllMode ? results.every(Boolean) : results.some(Boolean);
633
588
  };
634
- var applySort = (candidates, dynamicTarget, payloads, sortedPlayers, anchorSeat) => {
589
+ var walkSidesWithFilter = (dynamicTarget, sortedPlayers, anchorSeat, payloads, characterMap) => {
635
590
  const scope = resolveSelectorScope(dynamicTarget, payloads);
636
- if (scope !== "BOTH_SIDES") {
637
- return [...candidates];
638
- }
639
- if (!anchorSeat || sortedPlayers.length <= 1) {
640
- return [...candidates].sort((left, right) => left.seat - right.seat);
641
- }
642
- const seatIndexMap = new Map(sortedPlayers.map((player, idx) => [player.seat, idx]));
643
- const anchorIdx = seatIndexMap.get(anchorSeat);
644
- if (typeof anchorIdx !== "number") {
645
- return [...candidates].sort((left, right) => left.seat - right.seat);
646
- }
647
- return [...candidates].sort((left, right) => {
648
- const leftIdx = seatIndexMap.get(left.seat);
649
- const rightIdx = seatIndexMap.get(right.seat);
650
- if (typeof leftIdx !== "number" || typeof rightIdx !== "number") {
651
- return left.seat - right.seat;
591
+ if (!anchorSeat) return { left: [], right: [] };
592
+ const anchorIdx = sortedPlayers.findIndex((player) => player.seat === anchorSeat);
593
+ if (anchorIdx < 0) return { left: [], right: [] };
594
+ const total = sortedPlayers.length;
595
+ const left = [];
596
+ const right = [];
597
+ if (scope === "LEFT_SIDE" || scope === "BOTH_SIDES") {
598
+ for (let step = 1; step < total; step += 1) {
599
+ const player = sortedPlayers[(anchorIdx + step) % total];
600
+ if (passesWhere(player, dynamicTarget, payloads, characterMap)) left.push(player);
652
601
  }
653
- const leftDistance = getCircularDistance(anchorIdx, leftIdx, sortedPlayers.length);
654
- const rightDistance = getCircularDistance(anchorIdx, rightIdx, sortedPlayers.length);
655
- if (leftDistance !== rightDistance) {
656
- return leftDistance - rightDistance;
602
+ }
603
+ if (scope === "RIGHT_SIDE" || scope === "BOTH_SIDES") {
604
+ for (let step = 1; step < total; step += 1) {
605
+ const player = sortedPlayers[(anchorIdx - step + total) % total];
606
+ if (passesWhere(player, dynamicTarget, payloads, characterMap)) right.push(player);
657
607
  }
658
- return left.seat - right.seat;
659
- });
608
+ }
609
+ return { left, right };
660
610
  };
661
- var pickTargets = (sortedCandidates, dynamicTarget, payloads) => {
662
- const dedupedCandidates = [...new Map(sortedCandidates.map((player) => [player.seat, player])).values()];
611
+ var pickBalanced = (sides, dynamicTarget, payloads) => {
663
612
  const configuredLimit = resolveDynamicValue(dynamicTarget.limit, payloads, (value) => typeof value === "number");
613
+ const seen = /* @__PURE__ */ new Set();
614
+ const out = [];
615
+ const pushUnique = (player) => {
616
+ if (!player || seen.has(player.seat)) return false;
617
+ seen.add(player.seat);
618
+ out.push(player);
619
+ return true;
620
+ };
664
621
  if (typeof configuredLimit === "undefined") {
665
- return dedupedCandidates;
666
- }
667
- if (configuredLimit <= 0) {
668
- return [];
622
+ sides.left.forEach(pushUnique);
623
+ sides.right.forEach(pushUnique);
624
+ return out;
625
+ }
626
+ if (configuredLimit <= 0) return [];
627
+ let li = 0;
628
+ let ri = 0;
629
+ let preferLeft = true;
630
+ while (out.length < configuredLimit && (li < sides.left.length || ri < sides.right.length)) {
631
+ if (preferLeft && li < sides.left.length) {
632
+ if (pushUnique(sides.left[li])) li += 1;
633
+ else li += 1;
634
+ } else if (!preferLeft && ri < sides.right.length) {
635
+ if (pushUnique(sides.right[ri])) ri += 1;
636
+ else ri += 1;
637
+ } else if (li < sides.left.length) {
638
+ if (pushUnique(sides.left[li])) li += 1;
639
+ else li += 1;
640
+ } else if (ri < sides.right.length) {
641
+ if (pushUnique(sides.right[ri])) ri += 1;
642
+ else ri += 1;
643
+ }
644
+ preferLeft = !preferLeft;
669
645
  }
670
- return dedupedCandidates.slice(0, configuredLimit);
646
+ return out;
671
647
  };
672
648
  var resolveEffectTargets = ({
673
649
  effect,
@@ -700,10 +676,12 @@ var resolveEffectTargets = ({
700
676
  const dynamicTarget = effect.target;
701
677
  const sortedPlayers = getSortedPlayers(playerSeatMap);
702
678
  const anchorSeat = getAnchorSeat(dynamicTarget, payloads, effector);
703
- const candidates = getCandidatesBySelector(dynamicTarget, sortedPlayers, anchorSeat, payloads);
704
- const filtered = filterByWhere(candidates, dynamicTarget, payloads, characterMap);
705
- const ordered = applySort(filtered, dynamicTarget, payloads, sortedPlayers, anchorSeat);
706
- return pickTargets(ordered, dynamicTarget, payloads);
679
+ if (!dynamicTarget.selector) {
680
+ const matches = sortedPlayers.filter((player) => passesWhere(player, dynamicTarget, payloads, characterMap));
681
+ return pickBalanced({ left: matches, right: [] }, dynamicTarget, payloads);
682
+ }
683
+ const sides = walkSidesWithFilter(dynamicTarget, sortedPlayers, anchorSeat, payloads, characterMap);
684
+ return pickBalanced(sides, dynamicTarget, payloads);
707
685
  }
708
686
  return [];
709
687
  };
@@ -1034,11 +1012,16 @@ var evaluatePrecondition = ({
1034
1012
  };
1035
1013
 
1036
1014
  // src/apply-operation.ts
1037
- var isDeferredOperation = (operation, abilityMap) => {
1038
- const ability = abilityMap.get(operation.abilityId);
1039
- return ability?.executionTiming === "DEFER_TO_END";
1040
- };
1041
1015
  var FIXED_POINT_MAX_ITERATIONS = 32;
1016
+ var seatSetsEqual = (left, right) => {
1017
+ if (left.length !== right.length) return false;
1018
+ const sortedLeft = [...left].sort((a, b) => a - b);
1019
+ const sortedRight = [...right].sort((a, b) => a - b);
1020
+ for (let i = 0; i < sortedLeft.length; i += 1) {
1021
+ if (sortedLeft[i] !== sortedRight[i]) return false;
1022
+ }
1023
+ return true;
1024
+ };
1042
1025
  var applyOperationToPlayers = ({
1043
1026
  players,
1044
1027
  operations,
@@ -1064,20 +1047,11 @@ var applyOperationToPlayers = ({
1064
1047
  }
1065
1048
  operationsByTimeline.get(timelineIndex)?.push(operation);
1066
1049
  });
1067
- const orderedOpsForTimeline = (timelineIdx) => {
1068
- const ops = operationsByTimeline.get(timelineIdx) ?? [];
1069
- const normal = [];
1070
- const deferred = [];
1071
- ops.forEach((op) => {
1072
- if (isDeferredOperation(op, abilityMap)) deferred.push(op);
1073
- else normal.push(op);
1074
- });
1075
- return [...normal, ...deferred];
1076
- };
1050
+ const opsForTimeline = (timelineIdx) => operationsByTimeline.get(timelineIdx) ?? [];
1077
1051
  const allOpsInOrder = [];
1078
- allOpsInOrder.push(...orderedOpsForTimeline(-1));
1052
+ allOpsInOrder.push(...opsForTimeline(-1));
1079
1053
  for (let i = 0; i <= nowTimelineIndex; i += 1) {
1080
- if (timelines[i]) allOpsInOrder.push(...orderedOpsForTimeline(i));
1054
+ if (timelines[i]) allOpsInOrder.push(...opsForTimeline(i));
1081
1055
  }
1082
1056
  const processedOps = /* @__PURE__ */ new Set();
1083
1057
  const records = [];
@@ -1126,7 +1100,7 @@ var applyOperationToPlayers = ({
1126
1100
  };
1127
1101
  const rebuildState = () => {
1128
1102
  const playersWithStatus = copyPlayers(players);
1129
- const settingsOps = orderedOpsForTimeline(-1);
1103
+ const settingsOps = opsForTimeline(-1);
1130
1104
  settingsOps.forEach((op) => {
1131
1105
  if (processedOps.has(op)) applyRecordsForOp(op, playersWithStatus);
1132
1106
  });
@@ -1147,7 +1121,7 @@ var applyOperationToPlayers = ({
1147
1121
  });
1148
1122
  });
1149
1123
  }
1150
- orderedOpsForTimeline(i).forEach((op) => {
1124
+ opsForTimeline(i).forEach((op) => {
1151
1125
  if (processedOps.has(op)) applyRecordsForOp(op, playersWithStatus);
1152
1126
  });
1153
1127
  }
@@ -1159,10 +1133,10 @@ var applyOperationToPlayers = ({
1159
1133
  console.warn("Ability not found for operation:", op);
1160
1134
  return [];
1161
1135
  }
1136
+ const operationInTimelineIdx = operationTimelineMap.get(op) ?? -1;
1162
1137
  const playerSeatMap = buildPlayerSeatMap(state);
1163
1138
  const effector = playerSeatMap.get(op.effector);
1164
1139
  const payloads = op.payloads || [];
1165
- const operationInTimelineIdx = operationTimelineMap.get(op) ?? -1;
1166
1140
  const newRecords = [];
1167
1141
  ability.effects.forEach((effect, effectIdx) => {
1168
1142
  const resolvedTargets = resolveEffectTargets({
@@ -1187,9 +1161,7 @@ var applyOperationToPlayers = ({
1187
1161
  payloads,
1188
1162
  customResolver
1189
1163
  }) : resolvedTargets;
1190
- if (gatedTargets.length === 0 && resolvedTargets.length > 0) {
1191
- return;
1192
- }
1164
+ if (gatedTargets.length === 0 && resolvedTargets.length > 0) return;
1193
1165
  const lifeTargets = evaluateLifetime({
1194
1166
  effect,
1195
1167
  operationTimelineIdx: operationInTimelineIdx,
@@ -1206,42 +1178,60 @@ var applyOperationToPlayers = ({
1206
1178
  operation: op,
1207
1179
  effectIdx,
1208
1180
  operationInTimelineIdx,
1209
- activeTargetSeats: lifeTargets.map((t) => t.seat)
1181
+ activeTargetSeats: lifeTargets.map((t) => t.seat),
1182
+ resolvedSeatsAtApply: new Set(resolvedTargets.map((t) => t.seat)),
1183
+ gatedSeatsAtApply: new Set(gatedTargets.map((t) => t.seat))
1210
1184
  });
1211
1185
  });
1212
1186
  return newRecords;
1213
1187
  };
1214
- const reEvaluateLifetimes = (state) => {
1188
+ const reResolveRecord = (record, state) => {
1189
+ const ability = abilityMap.get(record.operation.abilityId);
1190
+ if (!ability) return [];
1191
+ const effect = ability.effects[record.effectIdx];
1192
+ if (!effect) return [];
1215
1193
  const playerSeatMap = buildPlayerSeatMap(state);
1216
- let anyShrunk = false;
1194
+ const effector = playerSeatMap.get(record.operation.effector);
1195
+ const payloads = record.operation.payloads || [];
1196
+ const resolvedTargets = resolveEffectTargets({
1197
+ effect,
1198
+ operation: record.operation,
1199
+ payloads,
1200
+ effector,
1201
+ playerSeatMap,
1202
+ characterMap
1203
+ });
1204
+ if (!resolvedTargets) return [];
1205
+ const gatedTargets = resolvedTargets.filter((target) => {
1206
+ if (record.resolvedSeatsAtApply.has(target.seat)) {
1207
+ return record.gatedSeatsAtApply.has(target.seat);
1208
+ }
1209
+ return true;
1210
+ });
1211
+ const lifeTargets = evaluateLifetime({
1212
+ effect,
1213
+ operationTimelineIdx: record.operationInTimelineIdx,
1214
+ nowTimelineIndex,
1215
+ timelines,
1216
+ effector,
1217
+ targets: gatedTargets,
1218
+ snapshotSeatMap: playerSeatMap,
1219
+ characterMap,
1220
+ payloads,
1221
+ customResolver
1222
+ });
1223
+ return lifeTargets.map((t) => t.seat);
1224
+ };
1225
+ const reResolveRecords = (state) => {
1226
+ let anyChanged = false;
1217
1227
  records.forEach((record) => {
1218
- if (record.activeTargetSeats.length === 0) return;
1219
- const ability = abilityMap.get(record.operation.abilityId);
1220
- if (!ability) return;
1221
- const effect = ability.effects[record.effectIdx];
1222
- if (!effect) return;
1223
- const effector = playerSeatMap.get(record.operation.effector);
1224
- const payloads = record.operation.payloads || [];
1225
- const targets = record.activeTargetSeats.map((seat) => playerSeatMap.get(seat)).filter((p) => Boolean(p));
1226
- const liveTargets = evaluateLifetime({
1227
- effect,
1228
- operationTimelineIdx: record.operationInTimelineIdx,
1229
- nowTimelineIndex,
1230
- timelines,
1231
- effector,
1232
- targets,
1233
- snapshotSeatMap: playerSeatMap,
1234
- characterMap,
1235
- payloads,
1236
- customResolver
1237
- });
1238
- const newSeats = liveTargets.map((t) => t.seat);
1239
- if (newSeats.length < record.activeTargetSeats.length) {
1228
+ const newSeats = reResolveRecord(record, state);
1229
+ if (!seatSetsEqual(newSeats, record.activeTargetSeats)) {
1240
1230
  record.activeTargetSeats = newSeats;
1241
- anyShrunk = true;
1231
+ anyChanged = true;
1242
1232
  }
1243
1233
  });
1244
- return anyShrunk;
1234
+ return anyChanged;
1245
1235
  };
1246
1236
  let lastState = copyPlayers(players);
1247
1237
  allOpsInOrder.forEach((op) => {
@@ -1256,13 +1246,13 @@ var applyOperationToPlayers = ({
1256
1246
  let state = rebuildState();
1257
1247
  let iter = 0;
1258
1248
  while (iter < FIXED_POINT_MAX_ITERATIONS) {
1259
- const shrunk = reEvaluateLifetimes(state);
1260
- if (!shrunk) break;
1249
+ const changed = reResolveRecords(state);
1250
+ if (!changed) break;
1261
1251
  state = rebuildState();
1262
1252
  iter += 1;
1263
1253
  }
1264
1254
  if (iter >= FIXED_POINT_MAX_ITERATIONS) {
1265
- console.warn("applyOperationToPlayers: lifetime fixed-point did not converge within %d iterations after op %s", FIXED_POINT_MAX_ITERATIONS, op.abilityId);
1255
+ console.warn("applyOperationToPlayers: target re-resolution fixed-point did not converge within %d iterations after op %s", FIXED_POINT_MAX_ITERATIONS, op.abilityId);
1266
1256
  }
1267
1257
  lastState = state;
1268
1258
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bct-app/game-engine",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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.10"
33
+ "@bct-app/game-model": "0.1.11"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@vitest/ui": "^4.1.3",