Support Fremen special Karama truth commitments
Verify and publish checkpoint container / container (push) Successful in 10m22s
Verify and publish checkpoint container / container (push) Successful in 10m22s
This commit is contained in:
1 parent
5079ab556f
commit
5b8d510470
14 files changed
+418
-18
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { windowCardOpportunity } from '../game/window-card-play';
|
||||
import { actIxSpice as act, advanceIxSpice as advance, beforeIxSpiceDraw } from './fixture-ix-spice-cards';
|
||||
import { truthBidInventory as inventory, truthBidPlayer as player } from './fixture-truth-bid';
|
||||
|
||||
/** Fresh Advanced classic setup and native first Spice Blow. Only original
|
||||
* unopened deal positions are chosen; no played state, cards or forces are
|
||||
* fabricated. The selected empty sand territory preserves the bounded preview. */
|
||||
function fixture() {
|
||||
const asker = 'harkonnen', owner = 'fremen', observer = 'atreides';
|
||||
let game = createGame('TRUTHFREMENSPECIAL', newPlayer(asker, 'Harkonnen', 'harkonnen'), true, []);
|
||||
joinGame(game, newPlayer(observer, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(owner, 'Fremen', 'fremen'));
|
||||
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
|
||||
game = act(game, asker, { type: 'start', advancedPreview: true });
|
||||
const originalCards = inventory(game);
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance');
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const filler = game.deck.find(card => card.kind === 'shield');
|
||||
assert.ok(truths.length === 2 && karama && filler);
|
||||
for (const [position, id] of [truths[0].id, truths[1].id, filler.id, karama.id].entries()) {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
}
|
||||
game = advance(game, state => beforeIxSpiceDraw(state));
|
||||
assert.ok(player(game, asker).hand.some(card => card.id === truths[0].id));
|
||||
assert.ok(player(game, owner).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(inventory(game), originalCards);
|
||||
const opportunity = windowCardOpportunity(game, 'karamaFremenSpecial'); assert.ok(opportunity);
|
||||
const territory = 'hagga_basin';
|
||||
assert.ok(game.players.every(seat => Object.entries(seat.forces).every(([key, amount]) => !key.startsWith(`${territory}:`) || !amount)));
|
||||
assert.ok(Object.entries(game.spice).every(([key, amount]) => !key.startsWith(`${territory}:`) || !amount));
|
||||
return { game, asker, owner, observer, truth: truths[0].id, karama: karama.id,
|
||||
territory, source: opportunity.source, originalCards };
|
||||
}
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function declared(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: 'karamaFremenSpecial', source: f.source,
|
||||
} } };
|
||||
}
|
||||
function asked(f: ReturnType<typeof fixture>) {
|
||||
return act(declared(f), f.asker, { type: 'truthAsk', question: question(f) });
|
||||
}
|
||||
const special = (f: ReturnType<typeof fixture>): Action =>
|
||||
({ type: 'card', mode: 'special', card: f.karama, territory: f.territory });
|
||||
|
||||
void test('Advanced Fremen special Yes spends the real Karama and summons the original empty-territory worm', () => {
|
||||
const f = fixture(), questionState = asked(f);
|
||||
assert.ok(viewGame(questionState, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(questionState, f.observer).truthTurnActionAnswers, null);
|
||||
let game = act(questionState, 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 view = viewGame(game, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view)[0], guide.action);
|
||||
}
|
||||
game = act(game, f.owner, special(f));
|
||||
assert.equal(player(game, f.owner).specialKaramaUsed, true);
|
||||
assert.ok(game.turnActionPromises!.find(row => row.player === f.owner)?.fulfilled);
|
||||
assert.equal(game.turnActionPromises!.find(row => row.player === f.owner)?.windowCards!.karamaFremenSpecial!.played, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.ok(game.summonedBeforeBlow || game.summonedWorm, 'The native summoned-worm sequence must actually begin');
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('Advanced Fremen special No blocks that role while retaining the actual physical card', () => {
|
||||
const f = fixture(), state = asked(f);
|
||||
assert.ok(viewGame(state, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
const game = act(state, f.owner, { type: 'truthAnswer', answer: 'no' });
|
||||
rejected(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.originalCards);
|
||||
});
|
||||
|
||||
void test('Fremen special source cannot bind another respondent or a forged turn', () => {
|
||||
const f = fixture(), game = declared(f);
|
||||
rejected(game, f.asker, { type: 'truthAsk', question: question(f, f.observer) });
|
||||
const forged = question(f);
|
||||
forged.mixed.action.source = JSON.stringify(['karamaFremenSpecial', game.turn + 1, f.owner]);
|
||||
rejected(game, f.asker, { type: 'truthAsk', question: forged });
|
||||
});
|
||||
|
||||
void test('a special summon cannot promise a future card that is not actually held', () => {
|
||||
const f = fixture(), game = declared(f);
|
||||
assert.ok(!player(game, f.owner).hand.some(card => card.effect === 'hajr'));
|
||||
const mixed = { kind: 'and', terms: [question(f).mixed,
|
||||
{ kind: 'action', action: { kind: 'cardPlay', effect: 'hajr' } }] };
|
||||
const state = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.owner, turn: game.turn, mixed,
|
||||
} });
|
||||
assert.ok(!viewGame(state, f.owner).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.ok(viewGame(state, f.owner).truthTurnActionAnswers?.includes('no'));
|
||||
rejected(state, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
});
|
||||
|
||||
void test('a sole special-use Yes permits a populated sand territory and resolves the native worm protection', () => {
|
||||
const original = fixture(), game = structuredClone(original.game), key = `${original.territory}:12`;
|
||||
// Controlled physical positioning supplement: move one Fremen from its real
|
||||
// Sietch Tabr starting group and one Atreides reserve counter into Hagga.
|
||||
// This conserves both force pools but is not presented as an earned move.
|
||||
// Swap a second physical Karama into Atreides' hand so its native response
|
||||
// stays open instead of automatically passing when no counter is possible.
|
||||
const fremen = player(game, original.owner), opponent = player(game, original.observer);
|
||||
const counter = game.deck.findIndex(card => card.effect === 'karama' && card.id !== original.karama);
|
||||
assert.ok(counter >= 0 && opponent.hand.length > 0);
|
||||
[game.deck[counter], opponent.hand[0]] = [opponent.hand[0], game.deck[counter]];
|
||||
assert.deepEqual(inventory(game), original.originalCards);
|
||||
assert.ok((fremen.forces['sietch_tabr:14'] ?? 0) >= 1 && opponent.reserves >= 1);
|
||||
fremen.forces['sietch_tabr:14'] -= 1;
|
||||
fremen.forces[key] = (fremen.forces[key] ?? 0) + 1;
|
||||
opponent.reserves -= 1;
|
||||
opponent.forces[key] = (opponent.forces[key] ?? 0) + 1;
|
||||
const f = { ...original, game };
|
||||
let state = act(asked(f), f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const opponentTanks = player(state, f.observer).tanks;
|
||||
state = act(state, f.owner, special(f));
|
||||
assert.equal(state.response?.kind, 'wormSurvival');
|
||||
assert.equal(state.response?.owner, f.owner);
|
||||
assert.equal(state.response?.location, f.territory);
|
||||
assert.equal(player(state, f.owner).forces[key], 1);
|
||||
assert.equal(player(state, f.observer).forces[key], 1);
|
||||
assert.ok(state.turnActionPromises!.find(row => row.player === f.owner)?.fulfilled);
|
||||
while (state.response?.kind === 'wormSurvival') {
|
||||
const actor = state.players.find(p => !state.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
state = act(state, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(player(state, f.owner).forces[key], 1, 'Fremen survive the allowed worm');
|
||||
assert.equal(player(state, f.observer).forces[key] ?? 0, 0, 'The enemy force is devoured');
|
||||
assert.equal(player(state, f.observer).tanks, opponentTanks + 1);
|
||||
assert.equal(state.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.ok(state.summonedBeforeBlow || state.summonedWorm || state.nexus);
|
||||
assert.deepEqual(inventory(state), f.originalCards);
|
||||
});
|
||||
@@ -2230,3 +2230,43 @@ void test('Atreides special Karama guide forwards only the live target battle pa
|
||||
game.battle!.defender = 'p'; press(button); assert.equal(sent.length, 1);
|
||||
game.battle!.defender = 'r'; owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
void test('Fremen special Karama authoring uses only its public Spice Blow opportunity', () => {
|
||||
const game = projected(1);
|
||||
game.advanced = true; game.spiceWindow = null; game.nexus = false;
|
||||
const fremen = game.players.find(player => player.id === 'p')!;
|
||||
fremen.faction = 'fremen'; fremen.specialKaramaUsed = false;
|
||||
const source = JSON.stringify(['karamaFremenSpecial', game.turn, 'p']);
|
||||
const initial = windowClaim('karamaFremenSpecial', source), e = editor(initial, false, game);
|
||||
Object.defineProperty(fremen, 'hand', { get() { throw Error('Question authoring read private hand'); }, configurable: true });
|
||||
assert.equal(e.error(), null);
|
||||
assert.match(e.html(), /Use Fremen’s special Karama during Spice Blow/);
|
||||
assert.match(e.html(), /Turn 2 · Fremen special Karama during Spice Blow/);
|
||||
assert.match(e.html(), /guide uses empty sand without spice/);
|
||||
assert.ok(turnActionInputError(initial, game, 'r'));
|
||||
fremen.specialKaramaUsed = true; assert.ok(e.error());
|
||||
fremen.specialKaramaUsed = false; game.phase = 2; assert.ok(e.error());
|
||||
});
|
||||
|
||||
void test('Fremen special Karama guide forwards only a current sand summon packet', () => {
|
||||
const game = projected(1, { type:'card', mode:'special', card:'controlled-printed-karama', territory:'hagga_basin' });
|
||||
game.advanced = true; game.spiceWindow = null; game.nexus = false;
|
||||
const owner = game.players.find(player => player.id === 'p')!;
|
||||
owner.faction = 'fremen'; owner.specialKaramaUsed = false;
|
||||
const source = JSON.stringify(['karamaFremenSpecial', game.turn, 'p']);
|
||||
game.turnActionPromises[0].mixed = windowClaim('karamaFremenSpecial', 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), /Fremen’s special Karama/);
|
||||
press(button); assert.deepEqual(sent, [game.turnActionGuide!.action]);
|
||||
game.spice['hagga_basin:5'] = 1; press(button); assert.equal(sent.length, 1);
|
||||
delete game.spice['hagga_basin:5'];
|
||||
owner.specialKaramaUsed = true; press(button); assert.equal(sent.length, 1);
|
||||
owner.specialKaramaUsed = false; game.phase = 2; press(button); assert.equal(sent.length, 1);
|
||||
const invalid = projected(1, { type:'card', mode:'special', card:'controlled-printed-karama', territory:'arrakeen' });
|
||||
invalid.advanced = true; invalid.spiceWindow = null; invalid.nexus = false;
|
||||
const invalidOwner = invalid.players.find(player => player.id === 'p')!;
|
||||
invalidOwner.faction = 'fremen'; invalidOwner.specialKaramaUsed = false;
|
||||
invalid.turnActionPromises[0].mixed = windowClaim('karamaFremenSpecial', source);
|
||||
assert.equal(guide({ game:invalid, busy:false, act() { assert.fail('Invalid territory'); } }).props.disabled, true);
|
||||
});
|
||||
@@ -64,7 +64,8 @@ const windowSource = (effect: WindowCardEffect) => effect === 'karamaShipment'
|
||||
? JSON.stringify(['karamaShipment', 1, 'recipient', 'original']) : effect === 'karamaHarkonnenSpecial'
|
||||
? JSON.stringify(['karamaHarkonnenSpecial', 1, 'respondent']) : effect === 'karamaEmperorSpecial'
|
||||
? JSON.stringify(['karamaEmperorSpecial', 1, 'respondent']) : effect === 'karamaAtreidesSpecial'
|
||||
? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : effect === 'karamaAuction' ? 'ordinary-auction:1:1' :
|
||||
? JSON.stringify(['karamaAtreidesSpecial', 1, 'respondent', 'battle:1', 'carthag']) : effect === 'karamaFremenSpecial'
|
||||
? JSON.stringify(['karamaFremenSpecial', 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 =>
|
||||
@@ -1936,6 +1937,41 @@ void test('Emperor special No and source binding retain exact responder, turn an
|
||||
});
|
||||
|
||||
|
||||
void test('Fremen special Karama window records only the original owner and phase after the answer', () => {
|
||||
const source = windowSource('karamaFremenSpecial');
|
||||
const question = windowed(group('and', windowCard('karamaFremenSpecial'), pay()));
|
||||
const signed = { kind: 'windowCardPlay', effect: 'karamaFremenSpecial', source, answer: true };
|
||||
assert.deepEqual(branches(question), [[signed, signedPay()]]);
|
||||
const otherOwner = recordTurnActionWindowCardPlay([question], 'other', 1, 'karamaFremenSpecial', source);
|
||||
assert.equal(otherOwner[0], question);
|
||||
const actual = recordTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaFremenSpecial', source);
|
||||
assert.deepEqual(branches(actual[0]), [[signedPay()]]);
|
||||
assert.equal(recordTurnActionPayment(actual, 'respondent', 1, payment())[0].fulfilled, true);
|
||||
assert.equal(question.windowCards!.karamaFremenSpecial!.played, false);
|
||||
validateTurnActionPromises(actual, seats);
|
||||
const closed = closeTurnActionWindowCardPlay([question], 'respondent', 1, 'karamaFremenSpecial', source);
|
||||
assert.equal(closed[0].windowCards!.karamaFremenSpecial!.closed, true);
|
||||
assert.deepEqual(branches(closed[0]), []);
|
||||
});
|
||||
|
||||
void test('Fremen special No and source binding retain exact responder, turn and old receipts', () => {
|
||||
const source = windowSource('karamaFremenSpecial');
|
||||
const negative = windowed(windowCard('karamaFremenSpecial'), false);
|
||||
assert.throws(() => recordTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaFremenSpecial', source), TurnActionError);
|
||||
const passed = closeTurnActionWindowCardPlay([negative], 'respondent', 1, 'karamaFremenSpecial', source);
|
||||
assert.equal(passed[0].fulfilled, true);
|
||||
for (const bad of ['', 'storm:1', JSON.stringify(['karamaFremenSpecial', 0, 'respondent']),
|
||||
JSON.stringify(['karamaFremenSpecial', 1, '']), JSON.stringify(['karamaFremenSpecial', 1, 'respondent', 'extra'])])
|
||||
assert.throws(() => parseTurnActionLeaf({ kind: 'windowCardPlay', effect: 'karamaFremenSpecial', source: bad }), TurnActionError);
|
||||
for (const bad of [JSON.stringify(['karamaFremenSpecial', 2, 'respondent']),
|
||||
JSON.stringify(['karamaFremenSpecial', 1, 'other'])])
|
||||
assert.throws(() => turnActionValue(windowed(windowCard('karamaFremenSpecial', bad))), TurnActionError);
|
||||
const old = windowed(windowCard('karamaAuction'));
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(old)), old);
|
||||
assert.doesNotThrow(() => validateTurnActionPromises([old], seats));
|
||||
});
|
||||
|
||||
|
||||
void test('Atreides special window retains owner, battle and ordinary Yes/No outcomes', () => {
|
||||
const source = windowSource('karamaAtreidesSpecial');
|
||||
const yes = windowed(group('and', windowCard('karamaAtreidesSpecial'), pay()));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseKaramaFremenSpecialSource as parse, windowCardNativeEffect,
|
||||
windowCardOpportunity, type WindowCardGame } from '../game/window-card-play';
|
||||
|
||||
const source = (): WindowCardGame => ({ status: 'playing', advanced: true, phase: 1, turn: 2,
|
||||
shieldWallDestroyed: false, players: [{ id: 'h', faction: 'fremen', specialKaramaUsed: false },
|
||||
{ id: 'a', faction: 'atreides', specialKaramaUsed: false }] });
|
||||
const event = JSON.stringify(['karamaFremenSpecial', 2, 'h']);
|
||||
|
||||
void test('Fremen special window is public, turn-bound and independent of private custody', () => {
|
||||
const game = source(), before = structuredClone(game);
|
||||
assert.deepEqual(windowCardOpportunity(game, 'karamaFremenSpecial'),
|
||||
{ effect: 'karamaFremenSpecial', source: event, turn: 2 });
|
||||
assert.deepEqual(parse(event), { turn: 2, owner: 'h' });
|
||||
assert.equal(windowCardNativeEffect('karamaFremenSpecial'), 'karama');
|
||||
assert.equal(windowCardOpportunity({ ...game, response: { kind: 'voice' }, decision: { kind: 'other' } },
|
||||
'karamaFremenSpecial')?.source, event);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaFremenSpecial', 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: 'fremen', specialKaramaUsed: true }] },
|
||||
{ players: [{ id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'fremen' }, { id: 'h', faction: 'atreides' }] },
|
||||
{ players: [{ id: 'h', faction: 'fremen' }, { id: 'h2', faction: 'fremen' }] }])
|
||||
assert.equal(windowCardOpportunity({ ...game, ...changes } as WindowCardGame, 'karamaFremenSpecial'), null);
|
||||
for (const value of ['', '[]', '["karamaFremenSpecial",2," h"]',
|
||||
'["karamaFremenSpecial", 2,"h"]', '["karamaFremenSpecial",0,"h"]',
|
||||
'["karamaFremenSpecial",2,"h","extra"]']) assert.equal(parse(value), null);
|
||||
assert.equal(windowCardOpportunity(game, 'karamaFremenSpecial', JSON.stringify(['karamaFremenSpecial', 2, 'a'])), null);
|
||||
assert.equal(windowCardOpportunity({ ...game, turn: 3 }, 'karamaFremenSpecial', event), null);
|
||||
assert.ok(windowCardOpportunity({ ...game, players: [{ id: 'h', faction: 'fremen' }] }, 'karamaFremenSpecial'));
|
||||
});
|
||||
@@ -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', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', 'karama', 'karama', 'karama', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS, ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment', 'karamaBattleCancel', 'karamaHarkonnenSpecial', 'karamaEmperorSpecial', 'karamaAtreidesSpecial', 'karamaFremenSpecial', 'thumper', 'amal']);
|
||||
assert.deepEqual(WINDOW_CARD_EFFECTS.map(windowCardNativeEffect), ['weather', 'atomics', 'harvester', 'karama', 'karama', 'karama', '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