Connect bounded Orgiz spice transfers to Discovery Collection

This commit is contained in:
Quintine committed 2026-09-28 09:49:14 +10:00
1 parent c9a3625ff2
commit 209fb18a27
11 files changed
+327 -123

No files matched your search

+140 -23
View File
@@ -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',
+1 -15
View File
@@ -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();
+65
View File
@@ -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();
}
});