Bind Truthtrance card plays to original Storm and spice windows
This commit is contained in:
1 parent
09a81a9b65
commit
e88f871385
20 files changed
+1519
-26
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
|
||||
import type { WindowCardEffect } from '../game/window-card-play';
|
||||
import {
|
||||
compileMixedActionExpression,
|
||||
type MixedActionExpression,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
closeTurnActionBidding,
|
||||
closeTurnActionCombat,
|
||||
closeTurnActionBattlePower,
|
||||
closeTurnActionWindowCardPlay,
|
||||
closeTurnActionPayments,
|
||||
closeTurnActionRevival,
|
||||
closeTurnActionTransport,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
recordTurnActionBid,
|
||||
recordTurnActionCardPlay,
|
||||
recordTurnActionBattlePower,
|
||||
recordTurnActionWindowCardPlay,
|
||||
recordTurnActionPlan,
|
||||
recordTurnActionTraitor,
|
||||
recordTurnActionMovement,
|
||||
@@ -56,6 +59,8 @@ const call = (call = true, battle = 'battle:1'): Expression =>
|
||||
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
|
||||
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = effect === 'harvester' ? 'spice-blow:1' : 'storm:1'): Expression =>
|
||||
({ kind: 'action', action: { kind: 'windowCardPlay', effect, source } });
|
||||
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
|
||||
@@ -104,6 +109,16 @@ function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnAct
|
||||
visit(mixed);
|
||||
return row;
|
||||
}
|
||||
function windowed(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
|
||||
const row: TurnActionPromise = { ...promise(mixed, answer, id), windowCards: {} };
|
||||
const visit = (node: Expression): void => {
|
||||
if (node.kind === 'action' && node.action.kind === 'windowCardPlay')
|
||||
row.windowCards![node.action.effect] = { source: node.action.source, played: false, closed: false };
|
||||
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
return row;
|
||||
}
|
||||
|
||||
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
|
||||
for (const kind of ['ship', 'move'] as const) {
|
||||
@@ -1251,3 +1266,133 @@ void test('power predicates deduplicate only exact original clauses and retain t
|
||||
assert.doesNotThrow(() => turnActionValue(powers(group('or', ...Array.from({ length: 16 }, () => power())))));
|
||||
assert.throws(() => turnActionValue(powers(group('or', ...Array.from({ length: 17 }, () => power())))), TurnActionError);
|
||||
});
|
||||
|
||||
void test('window-card raw leaves name only a printed role and exact original source, including folded clauses', () => {
|
||||
for (const effect of ['weather', 'atomics', 'harvester'] as const) {
|
||||
const original = { kind: 'windowCardPlay', effect, source: effect === 'harvester' ? 'spice-blow:1' : 'storm:1' };
|
||||
assert.deepEqual(parseTurnActionLeaf(original), original);
|
||||
assert.notEqual(parseTurnActionLeaf(original), original);
|
||||
for (const invalid of [{ ...original, source: '' }, { ...original, card: 'physical-id' }, { ...original, amount: 12 },
|
||||
{ ...original, distance: 4 }, { ...original, answer: true }, { ...original, played: true }])
|
||||
assert.throws(() => parseTurnActionLeaf(invalid), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(windowCard(effect))), TurnActionError);
|
||||
assert.throws(() => turnActionValue(promise(group('or', fact(), windowCard(effect)))), TurnActionError);
|
||||
}
|
||||
for (const effect of ['hajr', 'ghola', 'Weather Control', null])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect, source: 'storm:1' }), TurnActionError);
|
||||
});
|
||||
|
||||
void test('whole window-card AND and OR keep dynamic printed-play alternatives with no effect amount', () => {
|
||||
const weather = { kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: true };
|
||||
const atomics = { kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: true };
|
||||
const and = windowed(group('and', windowCard(), windowCard('atomics'), pay()));
|
||||
const or = windowed(group('or', windowCard(), windowCard('atomics')), true, 'turn-action:2');
|
||||
assert.deepEqual(branches(and), [[weather, atomics, signedPay()]]);
|
||||
assert.deepEqual(branches(or), [[weather], [atomics]]);
|
||||
const played = recordTurnActionWindowCardPlay([and, or], 'respondent', 1, 'weather', 'storm:1');
|
||||
assert.deepEqual(branches(played[0]), [[atomics, signedPay()]]);
|
||||
assert.equal(played[1].fulfilled, true);
|
||||
const both = recordTurnActionWindowCardPlay(played, 'respondent', 1, 'atomics', 'storm:1');
|
||||
assert.equal(both[0].fulfilled, false);
|
||||
assert.deepEqual(branches(both[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(both, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(and.windowCards!.weather!.played, false);
|
||||
});
|
||||
|
||||
void test('whole window-card No negates the aggregate and actual plays or later payments reject atomically', () => {
|
||||
const no = windowed(group('and', windowCard(), windowCard('atomics')), false);
|
||||
assert.deepEqual(branches(no), [[{ kind: 'windowCardPlay', effect: 'weather', source: 'storm:1', answer: false }],
|
||||
[{ kind: 'windowCardPlay', effect: 'atomics', source: 'storm:1', answer: false }]]);
|
||||
const weather = recordTurnActionWindowCardPlay([no], 'respondent', 1, 'weather', 'storm:1');
|
||||
const before = structuredClone(weather);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay(weather, 'respondent', 1, 'atomics', 'storm:1'), TurnActionError);
|
||||
assert.deepEqual(weather, before);
|
||||
const orNo = windowed(group('or', windowCard(), windowCard('atomics')), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError);
|
||||
const paidNo = windowed(group('and', windowCard(), pay()), false);
|
||||
const declared = recordTurnActionWindowCardPlay([paidNo], 'respondent', 1, 'weather', 'storm:1');
|
||||
assert.throws(() => recordTurnActionPayment(declared, 'respondent', 1, payment()), TurnActionError);
|
||||
const yes = windowed(group('and', windowCard(), pay()), true, 'turn-action:2');
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([yes, orNo], 'respondent', 1, 'weather', 'storm:1'), TurnActionError);
|
||||
assert.equal(yes.windowCards!.weather!.played, false);
|
||||
});
|
||||
|
||||
void test('exact player turn effect and source binding preserves separate original-window answer intervals', () => {
|
||||
const mixed = group('and', windowCard('harvester'), pay());
|
||||
const original = windowed(mixed);
|
||||
for (const [player, turn, effect, source] of [
|
||||
['other', 1, 'harvester', 'spice-blow:1'], ['respondent', 2, 'harvester', 'spice-blow:1'],
|
||||
['respondent', 1, 'weather', 'spice-blow:1'], ['respondent', 1, 'harvester', 'spice-blow:2'],
|
||||
] as const) {
|
||||
assert.equal(recordTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original);
|
||||
assert.equal(closeTurnActionWindowCardPlay([original], player, turn, effect, source)[0], original);
|
||||
}
|
||||
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'harvester', 'spice-blow:1')[0];
|
||||
const fresh = windowed(mixed, true, 'turn-action:2');
|
||||
assert.equal(fresh.windowCards!.harvester!.played, false);
|
||||
const replay = recordTurnActionWindowCardPlay([played, fresh], 'respondent', 1, 'harvester', 'spice-blow:1');
|
||||
assert.equal(replay[0], played);
|
||||
assert.equal(replay[1].windowCards!.harvester!.played, true);
|
||||
assert.equal(recordTurnActionWindowCardPlay(replay, 'respondent', 1, 'harvester', 'spice-blow:1')[1], replay[1]);
|
||||
});
|
||||
|
||||
void test('closing one native window records absence only there and retains other window and later payment alternatives', () => {
|
||||
const original = windowed(group('or', windowCard(), windowCard('atomics'), pay()));
|
||||
const closed = closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1');
|
||||
assert.deepEqual(closed[0].windowCards!.weather, { source: 'storm:1', played: false, closed: true });
|
||||
assert.equal(closed[0].windowCards!.atomics, original.windowCards!.atomics);
|
||||
assert.equal(closed[0].paymentClosed, false);
|
||||
assert.equal(branches(closed[0]).length, 2);
|
||||
assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'weather', 'storm:1')[0], closed[0]);
|
||||
assert.equal(recordTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1')[0].fulfilled, true);
|
||||
const all = closeTurnActionWindowCardPlay(closed, 'respondent', 1, 'atomics', 'storm:1');
|
||||
assert.deepEqual(branches(all[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(all, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
const played = recordTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', 'storm:1')[0];
|
||||
assert.equal(played.fulfilled, true);
|
||||
assert.equal(closeTurnActionWindowCardPlay([played], 'respondent', 1, 'weather', 'storm:1')[0], played);
|
||||
});
|
||||
|
||||
void test('full-turn expiry closes remaining window stages while payment or transport closure preserves their real deadline', () => {
|
||||
const no = windowed(windowCard('harvester'), false);
|
||||
assert.equal(closeTurnActionPayments([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester);
|
||||
assert.equal(closeTurnActionTransport([no], 'respondent', 1)[0].windowCards!.harvester, no.windowCards!.harvester);
|
||||
const finished = finishTurnActionTurn([no], 'respondent', 1)[0];
|
||||
assert.equal(finished.fulfilled, true);
|
||||
assert.deepEqual(finished.windowCards!.harvester, { source: 'spice-blow:1', played: false, closed: true });
|
||||
const yes = windowed(windowCard(), true, 'turn-action:2');
|
||||
assert.equal(finishTurnActionTurn([yes], 'respondent', 1)[0].released, true);
|
||||
const terminal = [finished, finishTurnActionTurn([yes], 'respondent', 1)[0]];
|
||||
for (const transform of [
|
||||
(rows: TurnActionPromise[]) => recordTurnActionWindowCardPlay(rows, 'respondent', 1, 'harvester', 'spice-blow:1'),
|
||||
(rows: TurnActionPromise[]) => closeTurnActionWindowCardPlay(rows, 'respondent', 1, 'weather', 'storm:1'),
|
||||
(rows: TurnActionPromise[]) => finishTurnActionTurn(rows, 'respondent', 1),
|
||||
]) assert.ok(transform(terminal).every((row, index) => row === terminal[index]));
|
||||
validateTurnActionPromises(terminal, seats);
|
||||
});
|
||||
|
||||
void test('strict window stages contain only exact raw named effects and sources without extra counters or physical IDs', () => {
|
||||
const original = windowed(group('and', windowCard(), pay()));
|
||||
const stage = original.windowCards!.weather!;
|
||||
for (const windowCards of [undefined, null, {}, { weather: null }, { weather: { ...stage, source: 'storm:2' } },
|
||||
{ weather: { ...stage, played: 1 } }, { weather: { ...stage, closed: 'yes' } }, { weather: { ...stage, count: 1 } },
|
||||
{ weather: stage, atomics: { source: 'storm:1', played: false, closed: false } }, { weather: stage, ghola: stage }])
|
||||
assert.throws(() => turnActionValue({ ...original, windowCards } as TurnActionPromise), TurnActionError);
|
||||
assert.throws(() => turnActionValue(windowed(group('or', windowCard(), windowCard('weather', 'storm:2')))), TurnActionError);
|
||||
const old = cards(group('and', card(), pay()));
|
||||
assert.equal('windowCards' in recordTurnActionWindowCardPlay([old], 'respondent', 1, 'weather', 'storm:1')[0], false);
|
||||
assert.equal('windowCards' in finishTurnActionTurn([old], 'respondent', 1)[0], false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([original], 'respondent', 1, 'hajr' as never, 'storm:1'), TurnActionError);
|
||||
assert.throws(() => closeTurnActionWindowCardPlay([original], 'respondent', 1, 'weather', ''), TurnActionError);
|
||||
});
|
||||
|
||||
void test('window-card clauses deduplicate only their exact original source and share the sixteen-leaf budget', () => {
|
||||
const repeated = windowed(group('and', windowCard(), windowCard(), pay()));
|
||||
assert.equal(branches(repeated)[0].length, 2);
|
||||
assert.doesNotThrow(() => turnActionValue(windowed(group('or', ...Array.from({ length: 16 }, () => windowCard())))));
|
||||
assert.throws(() => turnActionValue(windowed(group('or', ...Array.from({ length: 17 }, () => windowCard())))), TurnActionError);
|
||||
const foreign = { ...windowed(windowCard()), compiled: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'weather', source: 'storm:2',
|
||||
} } };
|
||||
assert.throws(() => turnActionValue(foreign as TurnActionPromise), TurnActionError);
|
||||
});
|
||||
Reference in new issue
Block a user