Support Harkonnen special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m17s
Verify and publish checkpoint container / container (push) Successful in 10m17s
This commit is contained in:
1 parent
15a4f3038a
commit
b448144c52
15 files changed
+449
-35
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { actTruthBid as act, createTruthBidFixture, truthBidInventory as inventory,
|
||||
truthBidPlayer as player } from './fixture-truth-bid';
|
||||
|
||||
/** Genuine Advanced setup and native Bidding; swap one original Truthtrance
|
||||
* and one original Karama between dealt hands as a controlled custody fixture. */
|
||||
function fixture() {
|
||||
const f = createTruthBidFixture({ advanced: true, targetKarama: true });
|
||||
const game = structuredClone(f.game), harkonnen = player(game, f.asker), emperor = player(game, f.target);
|
||||
const truth = harkonnen.hand.findIndex(card => card.effect === 'truthtrance');
|
||||
const karama = emperor.hand.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(truth >= 0 && karama >= 0);
|
||||
const truthCard = harkonnen.hand[truth], karamaCard = emperor.hand[karama];
|
||||
harkonnen.hand[truth] = karamaCard; emperor.hand[karama] = truthCard;
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
const window = windowCardOpportunity(game, 'karamaHarkonnenSpecial'); assert.ok(window);
|
||||
return { game, asker: emperor.id, owner: harkonnen.id, opponent: f.opponent,
|
||||
truth: truthCard.id, karama: karamaCard.id, source: window.source, cards: f.originalCards };
|
||||
}
|
||||
function reject(game: Game, id: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, id, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function declare(f: ReturnType<typeof fixture>) {
|
||||
let game = act(f.game, f.asker, { type: 'card', card: f.truth });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const seat = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(seat);
|
||||
game = act(game, seat.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return game;
|
||||
}
|
||||
function question(f: ReturnType<typeof fixture>, target = f.owner) {
|
||||
return { kind: 'turnActions', target, turn: f.game.turn, mixed: { kind: 'action', action: {
|
||||
kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function ask(f: ReturnType<typeof fixture>, game = declare(f)) {
|
||||
return act(game, f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
function special(f: ReturnType<typeof fixture>): Action {
|
||||
return { type: 'card', card: f.karama, mode: 'special', target: f.opponent, amount: 1 };
|
||||
}
|
||||
|
||||
void test('Advanced Harkonnen special Yes records the physical special role and retains private hand exchange', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.opponent).truthTurnActionAnswers, null);
|
||||
let game = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const guide = viewGame(game, f.owner).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'card');
|
||||
assert.equal(guide.action.mode, 'special');
|
||||
assert.equal(guide.action.card, f.karama);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.owner);
|
||||
bot.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
// A normal free-purchase use of the sole Karama cannot evade this Yes.
|
||||
reject(game, f.owner, { type: 'card', card: f.karama, mode: 'purchase' });
|
||||
const bound = game, before = structuredClone(game), lot = structuredClone(game.auction);
|
||||
game = act(game, f.owner, special(f));
|
||||
assert.deepEqual(bound, before, 'Native action works on an immutable input');
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.fulfilled, true);
|
||||
assert.equal(game.turnActionPromises!.find(p => p.player === f.owner)!.windowCards!.karamaHarkonnenSpecial!.played, true);
|
||||
assert.equal(game.decision?.kind, 'handExchange');
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
const observer = viewGame(game, f.asker);
|
||||
assert.equal(observer.players.find(p => p.id === f.owner)!.hand, undefined);
|
||||
assert.equal(observer.players.find(p => p.id === f.opponent)!.hand, undefined);
|
||||
assert.deepEqual(observer.turnActionPromises, []);
|
||||
const returned = player(game, f.owner).hand[0]; assert.ok(returned);
|
||||
game = act(game, f.owner, { type: 'decision', returnCards: [returned.id] });
|
||||
assert.deepEqual(game.auction, lot);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('Advanced Harkonnen special No rejects that role and leaves the original Karama held', () => {
|
||||
const f = fixture(), asked = ask(f);
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(game, f.owner, special(f));
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.notEqual(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('a Harkonnen special source cannot bind another respondent or a forged turn', () => {
|
||||
const f = fixture(), game = declare(f);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: question(f, f.opponent) });
|
||||
const malformed = question(f);
|
||||
malformed.mixed.action.source = JSON.stringify(['karamaHarkonnenSpecial', game.turn + 1, f.owner]);
|
||||
reject(game, f.asker, { type: 'truthAsk', question: malformed });
|
||||
});
|
||||
|
||||
/** Stage an existing Hajr into the other player's private hand without adding
|
||||
* or duplicating a physical card. The Harkonnen respondent does not know that
|
||||
* face and cannot treat it as an owned future Movement play. */
|
||||
function withOpponentHajr() {
|
||||
const f = fixture(), game = structuredClone(f.game), other = player(game, f.opponent);
|
||||
const index = game.deck.findIndex(card => card.effect === 'hajr');
|
||||
assert.ok(index >= 0, 'The original undealt Hajr must be available to stage');
|
||||
const prior = other.hand.findIndex(card => card.effect !== 'hajr'); assert.ok(prior >= 0);
|
||||
[game.deck[index], other.hand[prior]] = [other.hand[prior], game.deck[index]];
|
||||
assert.ok(!player(game, f.owner).hand.some(card => card.effect === 'hajr'));
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
return { ...f, game };
|
||||
}
|
||||
function compound(f: ReturnType<typeof fixture>, kind: 'and' | 'or') {
|
||||
const simple = question(f);
|
||||
return { ...simple, mixed: { kind, terms: [simple.mixed,
|
||||
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } },
|
||||
] } };
|
||||
}
|
||||
|
||||
void test('a hidden opponent Hajr cannot fund special-Karama AND Hajr Yes', () => {
|
||||
const f = withOpponentHajr(), declared = declare(f);
|
||||
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'and') });
|
||||
assert.ok(!viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(asked, f.owner).players.find(p => p.id === f.opponent)!.hand, undefined);
|
||||
// The same Hajr in the undealt deck rather than that opponent's hidden hand
|
||||
// must not change the respondent's available answers.
|
||||
const alternate = structuredClone(f.game), other = player(alternate, f.opponent);
|
||||
const held = other.hand.findIndex(card => card.effect === 'hajr');
|
||||
const spare = alternate.deck.findIndex(card => card.name === 'Baliset');
|
||||
assert.ok(held >= 0 && spare >= 0);
|
||||
[other.hand[held], alternate.deck[spare]] = [alternate.deck[spare], other.hand[held]];
|
||||
const f2 = { ...f, game: alternate };
|
||||
const asked2 = act(declare(f2), f2.asker, { type: 'truthAsk', question: compound(f2, 'and') });
|
||||
assert.deepEqual(viewGame(asked2, f2.owner).truthTurnActionAnswers,
|
||||
viewGame(asked, f.owner).truthTurnActionAnswers);
|
||||
assert.deepEqual(inventory(alternate), f.cards);
|
||||
reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const game = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
assert.deepEqual(inventory(game), f.cards);
|
||||
});
|
||||
|
||||
void test('special-Karama OR Hajr preserves either alternative and No forbids the actual special', () => {
|
||||
const f = withOpponentHajr(), declared = declare(f);
|
||||
const asked = act(declared, f.asker, { type: 'truthAsk', question: compound(f, 'or') });
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.ok(viewGame(asked, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const yes = act(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const after = act(yes, f.owner, special(f));
|
||||
assert.equal(after.turnActionPromises!.find(row => row.player === f.owner)!.fulfilled, true);
|
||||
const no = act(asked, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
reject(no, f.owner, special(f));
|
||||
assert.ok(player(no, f.owner).hand.some(card => card.id === f.karama));
|
||||
assert.deepEqual(inventory(no), f.cards);
|
||||
});
|
||||
@@ -2123,3 +2123,42 @@ void test('turn-action answer callbacks reject stale target, source queue, priva
|
||||
const replaced = { game: answering(), busy: false, act() { assert.fail('Replaced view cannot send'); } };
|
||||
const old = find(TurnActionAnswers(replaced), 'data-turn-action-answer', 'yes'); replaced.game = { ...replaced.game }; click(old);
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama authoring uses only its public Bidding opportunity', () => {
|
||||
const game = auction();
|
||||
game.advanced = true;
|
||||
const harkonnen = game.players.find(player => player.id === 'p')!;
|
||||
const other = game.players.find(player => player.id === 'r')!;
|
||||
harkonnen.faction = 'harkonnen'; harkonnen.specialKaramaUsed = false; other.faction = 'emperor';
|
||||
const source = JSON.stringify(['karamaHarkonnenSpecial', game.turn, 'p']);
|
||||
const initial = windowClaim('karamaHarkonnenSpecial', source);
|
||||
const e = editor(initial, false, game);
|
||||
Object.defineProperty(harkonnen, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Harkonnen’s special Karama during Bidding/);
|
||||
assert.match(e.html(), /Turn 2 · Harkonnen special Karama during Bidding/);
|
||||
select(e.tree(), '1', 'window-source', ''); assert.ok(e.error());
|
||||
select(e.tree(), '1', 'window-source', source); assert.equal(e.error(), null);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'), 'Only Harkonnen is a valid respondent');
|
||||
harkonnen.specialKaramaUsed = true;
|
||||
assert.ok(e.error(), 'The completed once-per-game power closes the opportunity');
|
||||
harkonnen.specialKaramaUsed = false; game.phase = 4;
|
||||
assert.ok(e.error(), 'The original Bidding window has closed');
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama guide forwards only the exact live printed-card packet', () => {
|
||||
const source = JSON.stringify(['karamaHarkonnenSpecial', 2, 'p']);
|
||||
const game = projected(3, { type: 'card', mode: 'special', card: 'controlled-printed-karama', target: 'r', amount: 1 });
|
||||
game.advanced = true;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
const target = game.players.find(player => player.id === 'r')!;
|
||||
owner.faction = 'harkonnen'; owner.specialKaramaUsed = false; target.faction = 'emperor'; target.handCount = 1;
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaHarkonnenSpecial', source);
|
||||
const sent: Action[] = [], props = { game, busy: false, act(action: Action) { sent.push(action); } };
|
||||
const button = guide(props);
|
||||
assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(button), /Harkonnen’s special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
target.handCount = 0; press(button); assert.equal(sent.length, 1);
|
||||
target.handCount = 1; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -61,7 +61,8 @@ const card = (effect: PrintedCardEffect = 'hajr'): Expression => ({ kind: 'actio
|
||||
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
|
||||
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
|
||||
const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
effect === 'amal' ? 'phase-opening:1:2' : effect === 'thumper' ? 'first-spice-reveal:1' :
|
||||
effect === 'harvester' ? 'spice-blow:1' : 'storm:1';
|
||||
const windowCard = (effect: WindowCardEffect = 'weather', source = windowSource(effect)): Expression =>
|
||||
@@ -1863,3 +1864,37 @@ void test('Ornithopter and Residual Poison whole receipts preserve dynamic alter
|
||||
assert.equal(recordTurnActionCardPlay([fresh], 'respondent', 1, 'ornithopter')[0].fulfilled, true);
|
||||
validateTurnActionPromises(poisoned, seats);
|
||||
});
|
||||
|
||||
void test('Harkonnen special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaHarkonnenSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaHarkonnenSpecial'), pay()));
|
||||
const signed = { kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source, answer: true };
|
||||
assert.deepEqual(branches(question), [[signed, signedPay()]]);
|
||||
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(otherOwner[0], question);
|
||||
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(question.windowCards!.karamaHarkonnenSpecial!.played, false);
|
||||
validateTurnActionPromises(actual, seats);
|
||||
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(closed[0].windowCards!.karamaHarkonnenSpecial!.closed, true);
|
||||
assert.deepEqual(branches(closed[0]), []);
|
||||
});
|
||||
|
||||
void test('Harkonnen special No and source binding retain exact responder, turn and old receipts', () => {
|
||||
const source = windowSource('karamaHarkonnenSpecial');
|
||||
const negative = windowed(windowCard('karamaHarkonnenSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaHarkonnenSpecial', source), TurnActionError);
|
||||
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaHarkonnenSpecial', source);
|
||||
assert.equal(passed[0].fulfilled, true);
|
||||
for (const bad of ['', 'storm:1', JSON.stringify(['karamaHarkonnenSpecial', 0, 'respondent']),
|
||||
JSON.stringify(['karamaHarkonnenSpecial', 1, '']), JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent', 'extra'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaHarkonnenSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaHarkonnenSpecial', 2, 'respondent']),
|
||||
JSON.stringify(['karamaHarkonnenSpecial', 1, 'other'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaHarkonnenSpecial', bad))), TurnActionError);
|
||||
const old = windowed(windowCard('karamaAuction'));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaHarkonnenSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 3, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'h', faction: 'harkonnen', specialKaramaUsed: false },
|
||||
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
|
||||
const event = JSON.stringify(['karamaHarkonnenSpecial', 2, 'h']);
|
||||
|
||||
void test('Harkonnen special window is public, turn-bound and independent of private custody', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaHarkonnenSpecial'),
|
||||
{ effect: 'karamaHarkonnenSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
|
||||
assert.equal(windowCardNativeEffect('karamaHarkonnenSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaHarkonnenSpecial')?.source, event);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaHarkonnenSpecial', event)?.source, event);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('special opportunity ends with use, turn or phase; malformed sources reject', () => {
|
||||
const game = source();
|
||||
for (const changes of [{ status: 'lobby' }, { advanced: false }, { phase: 4 }, { turn: 0 },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen' }, { id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'harkonnen' }, { id: 'h2', faction: 'harkonnen' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaHarkonnenSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaHarkonnenSpecial",2," h"]',
|
||||
'["karamaHarkonnenSpecial", 2,"h"]', '["karamaHarkonnenSpecial",0,"h"]',
|
||||
'["karamaHarkonnenSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaHarkonnenSpecial', JSON.stringify(['karamaHarkonnenSpecial', 2, 'a'])), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaHarkonnenSpecial', event), null);
|
||||
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'harkonnen' }] }, 'karamaHarkonnenSpecial'));
|
||||
});
|
||||
@@ -23,8 +23,8 @@ function shipments(): WindowCardGame {
|
||||
}
|
||||
|
||||
void test('window roles expose a shared exhaustive tuple and exact native printed-card mapping', () => {
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
});
|
||||
|
||||
void test('Amal names each original phase opening with only its public turn and phase identity', () => {
|
||||
|
||||
Reference in new issue
Block a user