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