Combine current battle decisions with turn commitments

This commit is contained in:
Quintine committed 2026-10-26 23:11:48 +11:00
1 parent 1a79fd3131
commit f76c8dfb93
15 files changed
+1380 -66

No files matched your search

+106 -1
View File
@@ -5,8 +5,10 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { BattleClaimFields } from '../components/battle-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
@@ -26,6 +28,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
const traitorClaim = (call = true): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle: 'controlled-native-battle', territory: 'arrakeen', call } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
@@ -84,6 +90,17 @@ function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), payment()] };
return game;
}
function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3, support: 2, bankerSpice: 1, allyPayment: 1,
leader: 'controlled-leader', weapon: 'controlled-weapon', defense: 'controlled-defense', kwisatz: true,
planetologistSpecial: true, reinforcementsSources: { arrakeen: { normal: 1, elite: 0 } } }): GameView {
const game = projected(6, next);
game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
game.truthMixedBattleTargets = ['p', 'a']; game.truthMixedTraitorTargets = revealed ? ['p', 'a'] : [];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
@@ -116,6 +133,11 @@ function answering(phase = 5): GameView {
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
return game;
}
function editPlan(tree: ReactNode, path: string, claim: MixedBattleActionClaim) {
const fields = elements(find(tree, 'data-turn-action-plan-fields', `truth-turn-action-${path}`))
.find(element => element.type === BattleClaimFields); assert.ok(fields);
(fields.props.onChange as (claim: MixedBattleActionClaim) => void)(claim);
}
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
@@ -374,6 +396,89 @@ void test('turn-action Nexus callbacks expire with public consent, membership, r
source.nexus = true; press(retained);
});
void test('turn-action current battle editor reuses atomic plan fields and public declaration sources within the global logical budget', () => {
const game = combat(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'cards', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
select(e.tree(), '1', 'action', 'battlePlan'); editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: 3 });
assert.deepEqual(e.value, planClaim()); assert.equal(e.error(), null);
for (const invalid of [NaN, -1, 0.25, Infinity, 40.5]) {
editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: invalid }); assert.ok(e.error());
editPlan(e.tree(), '1', { kind: 'dial', compare: 'gte', value: 0.5 }); assert.equal(e.error(), null);
}
const foreign = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(foreign);
editPlan(e.tree(), '1', { kind: 'leader', leader: foreign.id }); assert.equal(e.error(), null);
assert.match(e.html(), /Cheap Hero \/ Heroine/);
editPlan(e.tree(), '1', { kind: 'weapon', name: 'Shield' }); assert.equal(e.error(), null);
assert.match(e.html(), /already sealed or revealed/);
game.battle!.event = 'later-native-battle'; assert.ok(e.error()); select(e.tree(), '1', 'battle', 'later-native-battle'); assert.equal(e.error(), null);
game.battle!.revealed = true; assert.equal(e.error(), null, 'A publicly fixed plan remains a valid question source.');
select(e.tree(), '1', 'action', 'traitorCall'); assert.ok(e.error()); game.truthMixedTraitorTargets = ['p', 'a'];
select(e.tree(), '1', 'call', 'decline'); assert.equal(e.error(), null);
game.battle!.traitorSubmitted.push('p'); assert.ok(e.error());
const frozen = editor(planClaim(), true, combat()); editPlan(frozen.tree(), '1', { kind: 'support', compare: 'eq', value: 7 });
select(frozen.tree(), '1', 'battle', ''); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, planClaim());
const fullGame = combat(), full = editor({ kind: 'or', terms: [planClaim(), fact()] }, false, fullGame);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePlan'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.doesNotMatch(full.html(), /truth-battle-plan-expression/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), planClaim()] }, false, fullGame);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
});
void test('turn-action battle-plan guide fills the exact native packet for human review and never seals automatically', () => {
const game = combat(), filled: Plan[] = [], sent: Action[] = [];
for (const key of ['combat', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
const props = { game, busy: false, fillPlan(plan: Plan) { filled.push(plan); }, act(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.equal(filled[0], game.turnActionGuide!.action); assert.deepEqual(sent, []);
assert.equal(filled[0].dial, 3); assert.equal(filled[0].support, 2); assert.equal(filled[0].allyPayment, 1);
assert.equal(filled[0].bankerSpice, 1); assert.equal(filled[0].weapon, 'controlled-weapon'); assert.equal(filled[0].defense, 'controlled-defense');
assert.deepEqual(filled[0].reinforcementsSources, { arrakeen: { normal: 1, elite: 0 } });
assert.equal(filled[0].kwisatz, true); assert.equal(filled[0].planetologistSpecial, true);
assert.equal((filled[0] as Plan & Action).turnActionPromiseEvent, event);
assert.match(renderToStaticMarkup(createElement(TurnActionPromises, props)), /Fill the quoted battle plan for review before sealing/);
const noFill = guide({ game, busy: false, act() { assert.fail('A human plan guide cannot auto seal without a fill callback'); } });
assert.equal(noFill.props.disabled, true); press(noFill);
props.fillPlan = () => assert.fail('A replaced fill callback cannot receive a saved plan'); press(button); assert.equal(filled.length, 1);
const preparation = combat(false, { type: 'card', card: 'controlled-ghola', leader: 'controlled-dead-leader' }), prepared: Action[] = [];
press(guide({ game: preparation, busy: false, act(action) { prepared.push(action); } }));
assert.equal(prepared[0], preparation.turnActionGuide!.action);
const declaration = combat(true, { type: 'traitorCall', call: false }), declared: Action[] = [];
declaration.battle!.attacker = 'r'; declaration.battle!.traitorVoters = ['p'];
press(guide({ game: declaration, busy: false, act(action) { declared.push(action); } }));
assert.equal(declared[0], declaration.turnActionGuide!.action, 'A native allied voter retains their own declaration packet.');
});
void test('turn-action combat callbacks retain current battle ownership and raw history without private stage reconstruction', () => {
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
[() => combat(), [game => { game.battle!.submitted.push('p'); }, game => { game.battle!.revealed = true; }, game => { game.battle!.attacker = 'r'; }]],
[() => combat(true, { type: 'traitorCall', call: true }), [game => { game.battle!.traitorSubmitted.push('p'); },
game => { game.battle!.revealed = false; }, game => { game.battle!.traitorVoters = ['a']; }]],
];
for (const [build, specific] of cases) for (const change of [...specific,
(game: GameView) => { game.battle = { ...game.battle! }; }, (game: GameView) => { game.battle!.event = 'later-battle'; },
(game: GameView) => { game.battle!.territory = 'carthag'; }, (game: GameView) => { game.phase = 5; },
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
]) {
const game = build(), button = guide({ game, busy: false, fillPlan() { assert.fail('Stale battle cannot fill'); }, act() { assert.fail('Stale battle cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = combat(); game.turnActionGuide = null; game.battle = null;
for (const key of ['combat', 'compiled', 'binding', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /original Reveal or decline declaration/);
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(