Connect bounded Orgiz spice transfers to Discovery Collection
This commit is contained in:
1 parent
c9a3625ff2
commit
209fb18a27
11 files changed
+327
-123
No files matched your search
@@ -66,15 +66,11 @@ function reveal(game: Game, id: DiscoveryLocationId) {
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
}
|
||||
|
||||
function composed(game: Game, orgizObservableDeposits = false) {
|
||||
function composed(game: Game) {
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
return {
|
||||
ordinary,
|
||||
discovery: quoteDiscoveryCollection(
|
||||
game,
|
||||
ordinary,
|
||||
orgizObservableDeposits ? { orgizObservableDeposits: true } : {},
|
||||
),
|
||||
discovery: quoteDiscoveryCollection(game, ordinary),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +119,7 @@ void test('a sole Cistern occupant receives two bank spice in Basic and Advanced
|
||||
}
|
||||
});
|
||||
|
||||
void test('Cistern requires non-advisor occupation and has no effect while unrevealed or empty', () => {
|
||||
void test('Cistern follows Collection advisor releases and excludes remaining advisors', () => {
|
||||
const hidden = fixture(true);
|
||||
const hiddenToken = hidden.discoveries!.tokens.find(
|
||||
(entry) => entry.face === CISTERN,
|
||||
@@ -142,7 +138,17 @@ void test('Cistern requires non-advisor occupation and has no effect while unrev
|
||||
reveal(advisors, CISTERN);
|
||||
advisors.players[0].forces = { [`${CISTERN}:0`]: 1 };
|
||||
advisors.players[0].advisors = { [CISTERN]: {} };
|
||||
assert.deepEqual(composed(advisors).discovery.effects, []);
|
||||
const released = composed(advisors);
|
||||
assert.deepEqual(released.ordinary.released, [{ player: 'b', territory: CISTERN }]);
|
||||
assert.deepEqual(released.discovery.effects, [
|
||||
{ kind: 'cistern', player: 'b', amount: 2, source: 'bank' },
|
||||
]);
|
||||
advisors.players[1].forces = { [`${CISTERN}:0`]: 1 };
|
||||
const remainingAdvisor = composed(advisors);
|
||||
assert.deepEqual(remainingAdvisor.ordinary.released, []);
|
||||
assert.deepEqual(remainingAdvisor.discovery.effects, [
|
||||
{ kind: 'cistern', player: 'h', amount: 2, source: 'bank' },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('the bounded Orgiz quote transfers one spice from each uniquely collected observable board deposit', () => {
|
||||
@@ -160,7 +166,7 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
|
||||
'cielago_south:2': 3,
|
||||
};
|
||||
const before = structuredClone(game);
|
||||
const { ordinary, discovery } = composed(game, true);
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(discovery.effects, [
|
||||
{
|
||||
kind: 'orgiz',
|
||||
@@ -199,6 +205,25 @@ void test('the bounded Orgiz quote transfers one spice from each uniquely collec
|
||||
assert.deepEqual(quoteSpiceCollection(game), ordinary);
|
||||
});
|
||||
|
||||
void test('one rival pays once per collected pile, not once per player or stacked spice amount', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1, 'cielago_south:2': 1 };
|
||||
game.spice = { 'hagga_basin:12': 8, 'cielago_south:2': 2 };
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(
|
||||
discovery.effects.filter(effect => effect.kind === 'orgiz')
|
||||
.map(effect => [effect.from, effect.location]),
|
||||
[['h', 'cielago_south:2'], ['h', 'hagga_basin:12']],
|
||||
);
|
||||
assert.equal(balance(discovery, 'a'), 2);
|
||||
assert.equal(balance(discovery, 'h'), balance({
|
||||
...discovery, receipts: ordinary.receipts,
|
||||
}, 'h') - 2);
|
||||
assert.equal(ordinary.spice['hagga_basin:12'], 6);
|
||||
});
|
||||
|
||||
void test('Orgiz does not transfer spice from its occupant to itself and ignores untouched deposits', () => {
|
||||
const game = fixture();
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
@@ -212,10 +237,7 @@ void test('Orgiz does not transfer spice from its occupant to itself and ignores
|
||||
'hagga_basin:12': 2,
|
||||
'cielago_south:2': 6,
|
||||
};
|
||||
const disabled = quoteDiscoveryCollection(game, quoteSpiceCollection(game));
|
||||
assert.deepEqual(disabled.effects, []);
|
||||
assert.deepEqual(disabled.receipts, quoteSpiceCollection(game).receipts);
|
||||
const { ordinary, discovery } = composed(game, true);
|
||||
const { ordinary, discovery } = composed(game);
|
||||
assert.deepEqual(discovery.effects, [
|
||||
{
|
||||
kind: 'orgiz',
|
||||
@@ -234,7 +256,27 @@ void test('Orgiz does not transfer spice from its occupant to itself and ignores
|
||||
assert.equal(discovery.receipts[0].desert, ordinary.receipts[0].desert);
|
||||
});
|
||||
|
||||
void test('Orgiz retains unresolved Ecaz shared custody instead of creating or assigning spice', () => {
|
||||
void test('Orgiz charges a collector whose advisors become fighters before Collection', () => {
|
||||
const game = fixture(true, [
|
||||
['a', 'atreides'], ['b', 'beneGesserit'], ['h', 'harkonnen'],
|
||||
]);
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[1].advisors = { hagga_basin: { lockedTurn: 1 } };
|
||||
game.spice = { 'hagga_basin:12': 2 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.deepEqual(ordinary.released, [{ player: 'b', territory: 'hagga_basin' }]);
|
||||
const discovery = quoteDiscoveryCollection(game, ordinary);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'a', from: 'b', location: 'hagga_basin:12',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'a'), 1);
|
||||
assert.equal(balance(discovery, 'b'), 1);
|
||||
});
|
||||
|
||||
void test('Orgiz withholds uncertain Ecaz shared theft while collecting an independent deposit', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'],
|
||||
['a', 'atreides'],
|
||||
@@ -243,25 +285,56 @@ void test('Orgiz retains unresolved Ecaz shared custody instead of creating or a
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[0].forces = { 'hagga_basin:12': 1, 'red_chasm:7': 1 };
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[2].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.spice = { 'hagga_basin:12': 4 };
|
||||
game.spice = { 'hagga_basin:12': 4, 'red_chasm:7': 2 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.deepEqual(ordinary.shared, [
|
||||
{ territory: 'hagga_basin', ecaz: 'e', ally: 'a', amount: 4 },
|
||||
]);
|
||||
const before = structuredClone({ game, ordinary });
|
||||
assert.throws(
|
||||
() =>
|
||||
quoteDiscoveryCollection(game, ordinary, {
|
||||
orgizObservableDeposits: true,
|
||||
}),
|
||||
/unresolved Ecaz shared lot/,
|
||||
);
|
||||
const discovery = quoteDiscoveryCollection(game, ordinary);
|
||||
assert.deepEqual(discovery.effects, [{
|
||||
kind: 'orgiz', player: 'g', from: 'e', location: 'red_chasm:7',
|
||||
amount: 1, source: 'player',
|
||||
}]);
|
||||
assert.equal(balance(discovery, 'e'), 1);
|
||||
assert.equal(balance(discovery, 'g'), 1);
|
||||
assert.deepEqual({ game, ordinary }, before);
|
||||
});
|
||||
|
||||
void test('two allied Orgiz occupants leave ordinary collection playable without uncertain theft', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'], ['a', 'atreides'], ['g', 'guild'],
|
||||
]);
|
||||
game.discoveryEnabled = true;
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'e';
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[1].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1 };
|
||||
game.players[2].forces = { 'hagga_basin:12': 1 };
|
||||
game.players.forEach(player => {
|
||||
Object.assign(player, {
|
||||
hand: [], traitors: [], traitorChoices: [], shipped: true,
|
||||
moved: 1, reserves: 19,
|
||||
});
|
||||
});
|
||||
game.spice = { 'hagga_basin:12': 2 };
|
||||
const ordinary = quoteSpiceCollection(game);
|
||||
assert.deepEqual(quoteDiscoveryCollection(game, ordinary), {
|
||||
receipts: ordinary.receipts, effects: [],
|
||||
});
|
||||
Object.assign(game, {
|
||||
phase: 5, active: 'g', movementRemaining: ['g'], phaseOpening: null,
|
||||
});
|
||||
const collected = applyAction(game, 'g', { type: 'endMovement' });
|
||||
assert.equal(collected.phase, 7);
|
||||
assert.deepEqual(collected.players.map(player => player.spice), [0, 0, 2]);
|
||||
assert.equal(collected.spice['hagga_basin:12'], 0);
|
||||
});
|
||||
|
||||
void test('a contested Cistern retains the unresolved bonus without blocking collection', () => {
|
||||
const game = fixture(false, [
|
||||
['e', 'ecaz'],
|
||||
@@ -336,6 +409,50 @@ void test('the real collection phase credits Cistern once and preserves the rece
|
||||
);
|
||||
});
|
||||
|
||||
void test('a revealed sole Orgiz occupant steals one from each rival collected deposit in Basic and Advanced', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const game = fixture(advanced);
|
||||
game.discoveryEnabled = true;
|
||||
reveal(game, ORGIZ_PROCESSING_STATION);
|
||||
Object.assign(game, {
|
||||
phase: 5,
|
||||
active: 'g',
|
||||
movementRemaining: ['g'],
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const player of game.players)
|
||||
Object.assign(player, {
|
||||
hand: [], traitors: [], traitorChoices: [], shipped: true,
|
||||
moved: 1, reserves: 20,
|
||||
});
|
||||
game.players[0].forces = { [`${ORGIZ_PROCESSING_STATION}:0`]: 1, 'red_chasm:7': 1 };
|
||||
game.players[0].reserves = 18;
|
||||
game.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
game.players[1].reserves = 19;
|
||||
game.players[2].forces = { 'cielago_south:2': 1 };
|
||||
game.players[2].reserves = 19;
|
||||
game.spice = { 'red_chasm:7': 2, 'hagga_basin:12': 4, 'cielago_south:2': 3 };
|
||||
|
||||
const before = structuredClone(game);
|
||||
const collected = applyAction(game, 'g', { type: 'endMovement' });
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(collected.phase, 7);
|
||||
assert.deepEqual(collected.players.map(player => player.spice), [4, 1, 1]);
|
||||
assert.equal(collected.spice['red_chasm:7'], 0);
|
||||
assert.equal(collected.spice['hagga_basin:12'], 2);
|
||||
assert.equal(collected.spice['cielago_south:2'], 1);
|
||||
assert.equal(
|
||||
collected.players.reduce((total, player) => total + player.spice, 0) +
|
||||
Object.values(collected.spice).reduce((total, spice) => total + spice, 0),
|
||||
9,
|
||||
);
|
||||
assert.equal(collected.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 2);
|
||||
const restored = JSON.parse(JSON.stringify(collected)) as Game;
|
||||
assert.deepEqual(normalizeAutomaticGame(restored), restored);
|
||||
assert.deepEqual(restored.players.map(player => player.spice), [4, 1, 1]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a saved Ecaz response commits Cistern once only after the collection response resolves', () => {
|
||||
const game = createGame(
|
||||
'DISCOVERYECAZCOLLECTION',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
DISCOVERY_TOKENS, placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken,
|
||||
placeDiscovery, rememberDiscoveryFace, revealDiscoveryToken,
|
||||
type DiscoveryTokenFace,
|
||||
} from '../game/discoveries';
|
||||
import { discoveryAction, discoveryBotActions, discoveryDiscardAction } from '../game/discovery-options';
|
||||
@@ -94,20 +94,6 @@ void test('Fremen and Guild privileged faces remain distinct from another seat
|
||||
assert.deepEqual(discoveryBotActions(guild), []);
|
||||
});
|
||||
|
||||
void test('all eight authorized faces have readable rules while shared location rules preserve parent-territory meaning', () => {
|
||||
const game = fixture();
|
||||
for (const definition of DISCOVERY_TOKENS) place(game, definition.id, true);
|
||||
const markup = html(viewGame(game, 'p'));
|
||||
for (const definition of DISCOVERY_TOKENS) assert.ok(markup.includes(`Read ${definition.name} rules`), definition.name);
|
||||
for (const text of ['opposing undialed force', 'two spice from the bank', 'Weather Control',
|
||||
'use a Truthtrance card as Karama', 'each spice blow that is collected', 'including the new card',
|
||||
'Receive seven spice', 'On a later turn', 'separate territory inside', 'At most two factions',
|
||||
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
|
||||
assert.doesNotMatch(markup, /href="https?:/);
|
||||
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
|
||||
assert.ok(markup.includes('Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.'));
|
||||
});
|
||||
|
||||
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
let game = fixture();
|
||||
|
||||
@@ -4,8 +4,10 @@ import { unitStore } from './fixture-nexus-room-store';
|
||||
import {
|
||||
discoveryFixture,
|
||||
enterDiscoveryCollection,
|
||||
putDiscovery,
|
||||
} from './fixture-discovery';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { revealDiscoveryToken } from '../game/discoveries';
|
||||
import { discoveryStashSignature } from '../game/discovery-actions';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
@@ -180,3 +182,66 @@ void test('SQLite Discovery prototype preserves seats and competing stash reveal
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('competing saved Collection commits transfer Orgiz spice exactly once', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom('Orgiz SQL', 'atreides', false, []);
|
||||
const code = made.view.code;
|
||||
const tokens = [made.token];
|
||||
for (const faction of ['guild', 'fremen'] as const)
|
||||
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
|
||||
const auths = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auths.map(auth => auth.playerId) as [string, string, string];
|
||||
const g = discoveryFixture(false, ids);
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
const token = putDiscovery(g, 'orgiz-processing-station');
|
||||
g.discoveries = revealDiscoveryToken(g.discoveries!, token.id, g.turn).state;
|
||||
Object.assign(g, {
|
||||
phase: 5, active: ids[2], movementRemaining: [ids[2]],
|
||||
phaseOpening: null, response: null, decision: null,
|
||||
storm: 18, stormPending: null,
|
||||
spice: { 'hagga_basin:12': 4 },
|
||||
});
|
||||
for (const player of g.players)
|
||||
Object.assign(player, {
|
||||
forces: {}, reserves: 20, shipped: true, moved: 1,
|
||||
});
|
||||
g.players[0].forces = { 'orgiz-processing-station:0': 1 };
|
||||
g.players[0].reserves = 19;
|
||||
g.players[1].forces = { 'hagga_basin:12': 1 };
|
||||
g.players[1].reserves = 19;
|
||||
const beforeOwner = g.players[0].spice, beforeCollector = g.players[1].spice;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
|
||||
let arrivals = 0, release!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const timer = setTimeout(release, 2000);
|
||||
try {
|
||||
const results = await Promise.allSettled([0, 1].map(() =>
|
||||
store.restart().act(code, auths[2], g.version, { type: 'endMovement' }, clock)));
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
delete store.hooks.beforeWrite;
|
||||
}
|
||||
const done = await store.restart().readRoom(code);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
assert.equal(done.players[0].spice, beforeOwner + 1);
|
||||
assert.equal(done.players[1].spice, beforeCollector + 1);
|
||||
assert.equal(done.spice['hagga_basin:12'], 2);
|
||||
assert.equal(done.log.filter(entry => entry.automatic?.name === 'Orgiz Processing Station').length, 1);
|
||||
const settled = JSON.stringify(done);
|
||||
await store.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal(JSON.stringify(await store.restart().readRoom(code)), settled);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user