Fix Grumman stack placement cancellation and inventory validation
This commit is contained in:
1 parent
ba0b5647e1
commit
a6d9235099
9 files changed
+650
-7
No files matched your search
@@ -0,0 +1,256 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import {
|
||||
grummanCollectionFixture,
|
||||
grummanInventory,
|
||||
grummanToken,
|
||||
holdGrummanCard,
|
||||
} from './fixture-grumman-collection';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
const made = await store.rooms.createRoom(
|
||||
'Grumman cancellation SQL',
|
||||
'moritani',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const code = made.view.code,
|
||||
credentials = [made.token];
|
||||
for (const faction of ['atreides', 'harkonnen'] as const)
|
||||
credentials.push(
|
||||
(await store.rooms.joinRoom(code, faction, faction)).token!,
|
||||
);
|
||||
const auths = await Promise.all(
|
||||
credentials.map((token) => store.rooms.authenticate(code, token)),
|
||||
);
|
||||
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
|
||||
const g = grummanCollectionFixture({ seatIds: ids });
|
||||
const karama = holdGrummanCard(g, ids[1], 'karama').id;
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
const save = (state: Game) =>
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(state), state.version, code);
|
||||
save(g);
|
||||
store.writes.length = 0;
|
||||
return { ...store, code, credentials, auths, ids, initial: g, karama, save };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>> & { sqlite: DatabaseSync };
|
||||
const row = (f: Fixture) =>
|
||||
f.sqlite
|
||||
.prepare('SELECT state,version FROM rooms WHERE code = ?')
|
||||
.get(f.code);
|
||||
async function act(f: Fixture, g: Game, index: number, action: Action) {
|
||||
const rooms = f.restart();
|
||||
await rooms.act(f.code, f.auths[index], g.version, action, clock);
|
||||
return rooms.readRoom(f.code);
|
||||
}
|
||||
async function pending(f: Fixture) {
|
||||
let g = await act(f, f.initial, 1, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
g = await act(f, g, 0, {
|
||||
type: 'decision',
|
||||
event: g.grummanCollection!.event,
|
||||
mode: 'add',
|
||||
token: grummanToken(g, 'sabotage').id,
|
||||
territory: 'arrakeen',
|
||||
});
|
||||
for (let i = 0; i < f.ids.length && g.phase === 7; i++)
|
||||
g = await act(f, g, i, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
g = await act(f, g, 0, {
|
||||
type: 'decision',
|
||||
token: grummanToken(g, 'sneakAttack').id,
|
||||
territory: 'carthag',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(
|
||||
g.moritaniTerror!.tokens.filter((t) => t.location === 'arrakeen').length,
|
||||
2,
|
||||
);
|
||||
assert.equal(grummanToken(g, 'sneakAttack').status, 'available');
|
||||
assert.equal(g.players[0].spice, 24);
|
||||
assert.equal(g.moritaniTerror!.placementTurn, 1);
|
||||
grummanInventory(g);
|
||||
return g;
|
||||
}
|
||||
async function restored(f: Fixture, g: Game) {
|
||||
const before = row(f),
|
||||
rooms = f.restart();
|
||||
for (const [index, credential] of f.credentials.entries()) {
|
||||
const auth = await rooms.authenticate(f.code, credential);
|
||||
const view = await rooms.readSeatView(f.code, auth);
|
||||
assert.deepEqual(view, viewGame(g, f.ids[index]));
|
||||
for (const opponent of view.players.filter((p) => p.id !== auth.playerId)) {
|
||||
assert.equal(opponent.hand, undefined);
|
||||
assert.equal(opponent.spice, undefined);
|
||||
}
|
||||
if (index !== 0) {
|
||||
for (const token of view.moritaniTerror!.tokens.filter(
|
||||
(t) => t.status === 'placed',
|
||||
))
|
||||
assert.equal('kind' in token, false);
|
||||
assert.equal(
|
||||
view.moritaniTerror!.tokens.some(
|
||||
(t) => t.id === grummanToken(g, 'sneakAttack').id,
|
||||
),
|
||||
grummanToken(g, 'sneakAttack').status === 'placed',
|
||||
);
|
||||
}
|
||||
}
|
||||
await rooms.continueRoomAutomatic(f.code, clock);
|
||||
assert.deepEqual(row(f), before);
|
||||
grummanInventory(g);
|
||||
}
|
||||
const cancel = (f: Fixture): Action => ({
|
||||
type: 'card',
|
||||
card: f.karama,
|
||||
mode: 'cancel',
|
||||
});
|
||||
function outcome(f: Fixture, before: Game, done: Game, canceled: boolean) {
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.pendingMoritaniPlacement, null);
|
||||
assert.equal(done.moritaniTerror!.placementTurn, before.turn);
|
||||
assert.equal(done.players[0].spice, 24);
|
||||
assert.deepEqual(done.grummanCollection, before.grummanCollection);
|
||||
assert.deepEqual(
|
||||
done.moritaniTerror!.tokens.filter((t) => t.location === 'arrakeen'),
|
||||
before.moritaniTerror!.tokens.filter((t) => t.location === 'arrakeen'),
|
||||
);
|
||||
assert.equal(
|
||||
grummanToken(done, 'sneakAttack').status,
|
||||
canceled ? 'available' : 'placed',
|
||||
);
|
||||
assert.equal(
|
||||
grummanToken(done, 'sneakAttack').location,
|
||||
canceled ? null : 'carthag',
|
||||
);
|
||||
assert.equal(
|
||||
done.discard.filter((c) => c.id === f.karama).length,
|
||||
canceled ? 1 : 0,
|
||||
);
|
||||
assert.deepEqual(
|
||||
done.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
|
||||
before.players.map((p) => [p.spice, p.forces, p.reserves, p.tanks]),
|
||||
);
|
||||
grummanInventory(done);
|
||||
}
|
||||
|
||||
void test('SQLite reconnect cancels subsequent Mentat placement without disturbing the completed Grumman stack or income', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const g = await pending(f);
|
||||
await restored(f, g);
|
||||
const done = await act(f, g, 1, cancel(f));
|
||||
outcome(f, g, done, true);
|
||||
await restored(f, done);
|
||||
const before = row(f);
|
||||
for (const version of [g.version, done.version])
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[1], version, cancel(f), clock),
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite cancellation racing the last allowance commits one placement outcome and at most one Karama', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const g = await pending(f);
|
||||
f.writes.length = 0;
|
||||
let arrivals = 0,
|
||||
release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
// A failed preflight must fail this regression rather than strand the
|
||||
// competing valid request at the CAS barrier forever.
|
||||
const timeout = setTimeout(release, 1000);
|
||||
f.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await gate;
|
||||
};
|
||||
const results = await Promise.allSettled([
|
||||
f.restart().act(f.code, f.auths[1], g.version, cancel(f), clock),
|
||||
f
|
||||
.restart()
|
||||
.act(f.code, f.auths[1], g.version, { type: 'passResponse' }, clock),
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(
|
||||
f.writes.map((w) => w.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
const done = await f.restart().readRoom(f.code);
|
||||
assert.equal(done.version, g.version + 1);
|
||||
outcome(
|
||||
f,
|
||||
g,
|
||||
done,
|
||||
done.discard.some((c) => c.id === f.karama),
|
||||
);
|
||||
await restored(f, done);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQLite damaged pending token, destination or turn rejects cancellation and allowance before card cost or any write', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const good = await pending(f);
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.token = 'missing-physical-token';
|
||||
},
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.territory = 'not-a-territory';
|
||||
},
|
||||
(g) => {
|
||||
grummanToken(g, 'assassination').id = g.pendingMoritaniPlacement!.token;
|
||||
},
|
||||
(g) => {
|
||||
// Neither identity is the proposed source: allowance still must not
|
||||
// persist an invalid inventory merely because placeTerror can run.
|
||||
grummanToken(g, 'assassination').id = grummanToken(g, 'atomics').id;
|
||||
},
|
||||
(g) => {
|
||||
g.pendingMoritaniPlacement!.turn--;
|
||||
},
|
||||
];
|
||||
for (const [index, mutate] of mutations.entries()) {
|
||||
const bad = JSON.parse(JSON.stringify(good)) as Game;
|
||||
mutate(bad);
|
||||
f.save(bad);
|
||||
const before = row(f);
|
||||
f.writes.length = 0;
|
||||
for (const action of [cancel(f), { type: 'passResponse' }])
|
||||
await assert.rejects(
|
||||
f.restart().act(f.code, f.auths[1], bad.version, action, clock),
|
||||
`Mutation ${index}, action ${action.type} must reject`,
|
||||
);
|
||||
assert.deepEqual(row(f), before);
|
||||
assert.equal(f.writes.length, 0);
|
||||
const retained = await f.rooms.readRoom(f.code);
|
||||
assert.ok(retained.players[1].hand.some((c) => c.id === f.karama));
|
||||
assert.equal(
|
||||
retained.discard.some((c) => c.id === f.karama),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { quotePlacementCancellation } from '../game/placement-cancellation';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import {
|
||||
grummanCollectionFixture,
|
||||
enterGrummanCollection,
|
||||
addGrummanToken,
|
||||
holdGrummanCard,
|
||||
grummanToken,
|
||||
grummanPlayer,
|
||||
grummanReload,
|
||||
grummanInventory,
|
||||
} from './fixture-grumman-collection';
|
||||
|
||||
function pendingPlacement(advanced = false, low = false) {
|
||||
const initial = grummanCollectionFixture({ advanced });
|
||||
const karama = holdGrummanCard(initial, 'a', 'karama');
|
||||
let g = addGrummanToken(enterGrummanCollection(initial));
|
||||
if (low) {
|
||||
// Conserved later position: the existing legal stack outlives its creating
|
||||
// high-side population. Actual phase actions record this physical change.
|
||||
grummanPlayer(g, 'm').reserves--;
|
||||
grummanPlayer(g, 'm').forces['polar_sink:0']++;
|
||||
}
|
||||
for (const player of g.players)
|
||||
g = applyAction(g, player.id, { type: 'ready' });
|
||||
assert.equal(g.phase, 8);
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
g = applyAction(g, 'm', {
|
||||
type: 'decision',
|
||||
token: grummanToken(g, 'assassination').id,
|
||||
territory: 'carthag',
|
||||
});
|
||||
assert.equal(g.response?.kind, 'moritaniPlacement');
|
||||
assert.equal(
|
||||
g.moritaniTerror!.tokens.filter((token) => token.location === 'arrakeen')
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
return { g, action: { type: 'card', card: karama.id, mode: 'cancel' } };
|
||||
}
|
||||
|
||||
void test('real Basic and Advanced Grumman stacks survive printed Karama canceling the separate Mentat placement', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const { g, action } = pendingPlacement(advanced);
|
||||
const before = grummanReload(g);
|
||||
const result = applyAction(grummanReload(g), 'a', action);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(result.moritaniTerror!.tokens, g.moritaniTerror!.tokens);
|
||||
assert.equal(result.moritaniTerror!.placementTurn, g.turn);
|
||||
assert.equal(result.pendingMoritaniPlacement, null);
|
||||
assert.equal(
|
||||
result.discard.filter((card) => card.id === action.card).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(grummanPlayer(result, 'm').spice, 24);
|
||||
assert.equal(grummanToken(result, 'assassination').status, 'available');
|
||||
assert.throws(
|
||||
() =>
|
||||
placeTerror(
|
||||
result.moritaniTerror!,
|
||||
grummanToken(result, 'assassination').id,
|
||||
'carthag',
|
||||
g.turn,
|
||||
),
|
||||
/already been used/,
|
||||
);
|
||||
assert.throws(() => applyAction(result, 'a', action));
|
||||
assert.deepEqual(normalizeAutomaticGame(grummanReload(result)), result);
|
||||
for (const id of ['a', 'g']) {
|
||||
const projected = viewGame(result, id);
|
||||
assert.ok(
|
||||
projected.moritaniTerror!.tokens.every((token) => !('kind' in token)),
|
||||
);
|
||||
assert.equal(
|
||||
projected.players.find((player) => player.id === 'm')!.hand,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
grummanInventory(result);
|
||||
}
|
||||
});
|
||||
|
||||
void test('placement denial preserves an earlier stack at low population and the pure quote accepts a later turn without private hands', () => {
|
||||
const { g, action } = pendingPlacement(true, true);
|
||||
assert.equal(grummanPlayer(g, 'm').reserves, 7);
|
||||
const result = applyAction(grummanReload(g), 'a', action);
|
||||
assert.deepEqual(result.moritaniTerror!.tokens, g.moritaniTerror!.tokens);
|
||||
grummanInventory(result);
|
||||
// This is a pure future-turn eligibility probe, not a fabricated live phase
|
||||
// transition. Existing placement history remains unchanged.
|
||||
const later = grummanReload(g);
|
||||
later.turn++;
|
||||
later.pendingMoritaniPlacement!.turn = later.turn;
|
||||
for (const player of later.players)
|
||||
for (const field of ['hand', 'spice', 'traitors'])
|
||||
Object.defineProperty(player, field, {
|
||||
get() {
|
||||
throw new Error('Private field read: ' + field);
|
||||
},
|
||||
});
|
||||
const quote = quotePlacementCancellation(later, later.response!);
|
||||
assert.equal(quote?.kind, 'moritaniPlacement');
|
||||
if (quote?.kind !== 'moritaniPlacement')
|
||||
assert.fail('Missing placement denial');
|
||||
assert.deepEqual(quote.terror.tokens, g.moritaniTerror!.tokens);
|
||||
assert.equal(quote.terror.placementTurn, later.turn);
|
||||
});
|
||||
|
||||
void test('denial tolerates changed placement feasibility but rejects malformed custody and source records without cost', () => {
|
||||
const { g, action } = pendingPlacement();
|
||||
const changedPlacements: [string, (bad: Game) => void][] = [
|
||||
[
|
||||
'removed source',
|
||||
(bad) => {
|
||||
grummanToken(bad, 'assassination').status = 'removed';
|
||||
},
|
||||
],
|
||||
[
|
||||
'extortion source',
|
||||
(bad) => {
|
||||
const token = grummanToken(bad, 'extortion');
|
||||
token.status = 'extortion';
|
||||
bad.pendingMoritaniPlacement!.token = token.id;
|
||||
},
|
||||
],
|
||||
[
|
||||
'same source and target',
|
||||
(bad) => {
|
||||
bad.pendingMoritaniPlacement!.token = grummanToken(bad, 'robbery').id;
|
||||
bad.pendingMoritaniPlacement!.territory = 'arrakeen';
|
||||
},
|
||||
],
|
||||
[
|
||||
'occupied destination',
|
||||
(bad) => {
|
||||
bad.pendingMoritaniPlacement!.territory = 'arrakeen';
|
||||
},
|
||||
],
|
||||
];
|
||||
// Denying a declared placement does not attempt it again. Changed source
|
||||
// availability or an occupied target must not turn cancellation into a move.
|
||||
for (const [label, edit] of changedPlacements) {
|
||||
const changed = grummanReload(g);
|
||||
edit(changed);
|
||||
const before = grummanReload(changed);
|
||||
const quoted = quotePlacementCancellation(changed, changed.response!);
|
||||
assert.equal(quoted?.kind, 'moritaniPlacement', label);
|
||||
const canceled = applyAction(changed, 'a', action);
|
||||
assert.deepEqual(
|
||||
canceled.moritaniTerror!.tokens,
|
||||
changed.moritaniTerror!.tokens,
|
||||
label,
|
||||
);
|
||||
assert.equal(canceled.moritaniTerror!.placementTurn, changed.turn, label);
|
||||
assert.deepEqual(changed, before, label);
|
||||
grummanInventory(canceled);
|
||||
}
|
||||
const edits: [string, (bad: Game) => void][] = [
|
||||
[
|
||||
'non-stronghold target',
|
||||
(bad) => {
|
||||
bad.pendingMoritaniPlacement!.territory = 'polar_sink';
|
||||
},
|
||||
],
|
||||
[
|
||||
'missing token',
|
||||
(bad) => {
|
||||
bad.pendingMoritaniPlacement!.token = 'not-a-token';
|
||||
},
|
||||
],
|
||||
[
|
||||
'duplicate physical ID',
|
||||
(bad) => {
|
||||
bad.moritaniTerror!.tokens[0].id = bad.moritaniTerror!.tokens[1].id;
|
||||
},
|
||||
],
|
||||
[
|
||||
'duplicate physical face',
|
||||
(bad) => {
|
||||
bad.moritaniTerror!.tokens[0].kind = bad.moritaniTerror!.tokens[1].kind;
|
||||
},
|
||||
],
|
||||
[
|
||||
'malformed status',
|
||||
(bad) => {
|
||||
Object.assign(grummanToken(bad, 'assassination'), {
|
||||
status: 'invented',
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'stale declaration',
|
||||
(bad) => {
|
||||
bad.pendingMoritaniPlacement!.turn--;
|
||||
},
|
||||
],
|
||||
[
|
||||
'wrong faction owner',
|
||||
(bad) => {
|
||||
bad.response!.owner = 'a';
|
||||
},
|
||||
],
|
||||
[
|
||||
'duplicate response owner',
|
||||
(bad) => {
|
||||
bad.players.push(structuredClone(grummanPlayer(bad, 'm')));
|
||||
},
|
||||
],
|
||||
];
|
||||
for (const [label, edit] of edits) {
|
||||
const bad = grummanReload(g);
|
||||
edit(bad);
|
||||
const before = grummanReload(bad);
|
||||
assert.throws(
|
||||
() => quotePlacementCancellation(bad, bad.response!),
|
||||
/./,
|
||||
label,
|
||||
);
|
||||
assert.throws(() => applyAction(bad, 'a', action), /./, label);
|
||||
assert.deepEqual(bad, before, label);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user