Allow faction swaps with AI seats
This commit is contained in:
1 parent
c6b4a165bd
commit
910e1f9b40
11 files changed
+188
-28
No files matched your search
@@ -31,17 +31,23 @@ const click = (nodes: Element[], label: string) => {
|
||||
(button.props.onClick as () => void)();
|
||||
};
|
||||
|
||||
void test('offers target other human seats and require the recipient to accept', () => {
|
||||
void test('offers distinguish human consent from immediate AI acceptance', () => {
|
||||
const calls: Action[] = [];
|
||||
const nodes = controls(base, async (action) => { calls.push(action); });
|
||||
assert.deepEqual(nodes.map((node) => node.props.children), [
|
||||
'Offer swap to Basil (Harkonnen)', 'Offer swap to Chani (Fremen)',
|
||||
'Swap immediately with Computer (Spacing Guild AI)',
|
||||
]);
|
||||
assert.match(render(base), /other player must accept/);
|
||||
assert.match(render(base), /AI seats cannot receive swap offers/);
|
||||
assert.match(render(base), /human recipient must accept/);
|
||||
assert.match(render(base), /AI seats accept immediately/);
|
||||
assert.match(render(base), /exchange clears human readiness/);
|
||||
assert.doesNotMatch(render({ ...base, status: 'playing' }), /Swap factions/);
|
||||
click(nodes, 'Offer swap to Basil (Harkonnen)');
|
||||
assert.deepEqual(calls, [{ type: 'factionSwapOffer', target: 'b' }]);
|
||||
click(nodes, 'Swap immediately with Computer (Spacing Guild AI)');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'factionSwapOffer', target: 'b' },
|
||||
{ type: 'factionSwapOffer', target: 'd' },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('offers are unavailable during a draft or while busy', () => {
|
||||
@@ -49,9 +55,14 @@ void test('offers are unavailable during a draft or while busy', () => {
|
||||
const inDraft = { ...base, factionDraft: draft } as unknown as GameView;
|
||||
assert.match(render(inDraft), /Finish or cancel the faction draft/);
|
||||
for (const node of [...controls(inDraft), ...controls(base, send, true)]) assert.equal(node.props.disabled, true);
|
||||
const alone = { ...base, players: [base.players[0], base.players[3]] };
|
||||
assert.match(render(alone), /second human seat/);
|
||||
const withBot = { ...base, players: [base.players[0], base.players[3]] };
|
||||
assert.equal(controls(withBot).length, 1);
|
||||
const alone = { ...base, players: [base.players[0]] };
|
||||
assert.match(render(alone), /second seat/);
|
||||
assert.equal(controls(alone).length, 0);
|
||||
const asBot = { ...base, me: 'd' };
|
||||
assert.match(render(asBot), /Only human players can offer a swap/);
|
||||
assert.equal(controls(asBot).length, 0);
|
||||
});
|
||||
|
||||
void test('the pending offer is visible to everyone, with consent and cancellation limited to each party', () => {
|
||||
|
||||
@@ -85,14 +85,13 @@ void test('declining and cancelling preserve factions and existing readiness', (
|
||||
rejected(game, 'host', { type: 'factionSwapRespond', event: second, accept: true });
|
||||
});
|
||||
|
||||
void test('swap actions reject forged parties, payloads, stale events and AI seats without mutation', () => {
|
||||
void test('swap actions reject forged parties, payloads, stale events and AI proposers without mutation', () => {
|
||||
let game = lobby();
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'guild', difficulty: 'Easy' });
|
||||
const bot = game.players.find(player => player.bot)!;
|
||||
for (const [actor, action] of [
|
||||
['host', { type: 'factionSwapOffer', target: 'host' }],
|
||||
['host', { type: 'factionSwapOffer', target: 'missing' }],
|
||||
['host', { type: 'factionSwapOffer', target: bot.id }],
|
||||
[bot.id, { type: 'factionSwapOffer', target: 'host' }],
|
||||
['missing', { type: 'factionSwapOffer', target: 'host' }],
|
||||
['host', { type: 'factionSwapOffer', target: 'guest', from: 'third' }],
|
||||
@@ -115,6 +114,66 @@ void test('swap actions reject forged parties, payloads, stale events and AI sea
|
||||
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||
});
|
||||
|
||||
void test('a human offer to an AI seat is accepted immediately with faction assets and readiness updated', () => {
|
||||
let game = createGame('SWAPAI', newPlayer('host', 'Host', 'atreides'));
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'harkonnen'));
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'guild', difficulty: 'Hard' });
|
||||
const bot = game.players.find(player => player.bot)!;
|
||||
const positions = structuredClone(game.playerPositions);
|
||||
game = readyAll(game);
|
||||
const original = structuredClone(game);
|
||||
|
||||
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: bot.id });
|
||||
assert.equal(game.factionSwap, undefined, 'AI consent completes within the offer action');
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
assert.equal(game.host, 'host');
|
||||
assert.deepEqual(game.players.map(player => player.id), original.players.map(player => player.id));
|
||||
assert.equal(game.players.find(player => player.id === 'guest')!.faction, 'guild');
|
||||
assert.equal(game.players.find(player => player.id === 'guest')!.name, 'Guest');
|
||||
const swappedBot = game.players.find(player => player.id === bot.id)!;
|
||||
assert.equal(swappedBot.faction, 'harkonnen');
|
||||
assert.equal(swappedBot.name, 'Harkonnen AI');
|
||||
assert.equal(swappedBot.bot, 'Hard');
|
||||
assert.deepEqual(swappedBot.leaders, newPlayer('x', 'X', 'harkonnen').leaders);
|
||||
assert.deepEqual(game.players.find(player => player.id === 'guest')!.leaders, newPlayer('x', 'X', 'guild').leaders);
|
||||
assert.equal(swappedBot.ready, true);
|
||||
assert.ok(game.players.filter(player => !player.bot).every(player => !player.ready));
|
||||
assert.ok(game.log.some(entry => /accept/i.test(entry.text)), 'the automatic acceptance is recorded');
|
||||
rejected(game, bot.id, { type: 'factionSwapOffer', target: 'guest' });
|
||||
rejected(game, 'guest', { type: 'factionSwapRespond', event: 'missing', accept: true });
|
||||
});
|
||||
|
||||
void test('a two-faction AI controller keeps both seats and receives Ixian elites when swapped', () => {
|
||||
let game = createGame('SWAPAI2', newPlayer('human', 'Human', 'ixians'), false, ['ix']);
|
||||
game = applyAction(game, 'human', { type: 'twoPlayerMode', enabled: true });
|
||||
joinControlledFaction(game, newPlayer('second', 'Human second', 'harkonnen'), 'human');
|
||||
game = applyAction(game, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Medium' });
|
||||
const firstBot = game.players.find(player => player.bot)!;
|
||||
game = applyAction(game, 'human', { type: 'addControlledBot', controller: firstBot.id, faction: 'guild', difficulty: 'Easy' });
|
||||
const secondBot = game.players.find(player => player.faction === 'guild')!;
|
||||
const mapping = structuredClone(game.twoPlayerControllers);
|
||||
const positions = structuredClone(game.playerPositions);
|
||||
game = readyAll(game);
|
||||
|
||||
game = applyAction(game, 'human', { type: 'factionSwapOffer', target: secondBot.id });
|
||||
assert.equal(game.factionSwap, undefined);
|
||||
assert.deepEqual(game.twoPlayerControllers, mapping);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
const human = game.players.find(player => player.id === 'human')!;
|
||||
const bot = game.players.find(player => player.id === secondBot.id)!;
|
||||
assert.equal(human.faction, 'guild');
|
||||
assert.equal(human.elites, undefined);
|
||||
assert.equal(bot.faction, 'ixians');
|
||||
assert.equal(bot.name, 'Ixians AI');
|
||||
assert.equal(bot.bot, 'Easy');
|
||||
assert.deepEqual(bot.elites, { reserves: 7, tanks: 0, forces: {}, revived: 0 });
|
||||
assert.deepEqual(bot.leaders, newPlayer('x', 'X', 'ixians').leaders);
|
||||
assert.equal(game.players.find(player => player.id === firstBot.id)!.faction, 'emperor');
|
||||
assert.ok(game.players.filter(player => player.bot).every(player => player.ready));
|
||||
assert.ok(game.players.filter(player => !player.bot).every(player => !player.ready));
|
||||
assert.deepEqual(viewGame(game, 'human').controlledFactionSeats.map(seat => seat.id), ['human', 'second']);
|
||||
});
|
||||
|
||||
void test('an outstanding offer freezes conflicting lobby actions and joining', () => {
|
||||
let game = readyAll(lobby());
|
||||
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' });
|
||||
|
||||
@@ -9,7 +9,7 @@ type Wire = {
|
||||
code: string;
|
||||
me: string;
|
||||
version: number;
|
||||
players: { id: string; faction: string; ready: boolean }[];
|
||||
players: { id: string; name: string; faction: string; ready: boolean; bot?: string }[];
|
||||
factionSwap?: { event: string; from: string; to: string; fromFaction: string; toFaction: string } | null;
|
||||
controlledFactionSeats?: { id: string; faction: string }[];
|
||||
error?: string;
|
||||
@@ -114,3 +114,49 @@ void test('faction swaps require the recipient seat credential and preserve cont
|
||||
assert.ok(!declined.data.factionSwap);
|
||||
assert.deepEqual(declined.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), beforeDecline);
|
||||
});
|
||||
|
||||
void test('a human can exchange factions with an AI through their own authenticated seat', async () => {
|
||||
const host = await request('/api/rooms', {
|
||||
name: 'AI swap host', faction: 'atreides', advanced: false, expansions: [], entry: receipt(),
|
||||
});
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data));
|
||||
assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const added = await request(path, {
|
||||
version: host.data.version, action: { type: 'addBot', faction: 'harkonnen', difficulty: 'Hard' },
|
||||
}, host.cookie, host.data.me);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.data));
|
||||
const bot = added.data.players.find(player => player.faction === 'harkonnen' && player.bot === 'Hard');
|
||||
assert.ok(bot);
|
||||
const ready = await request(path, {
|
||||
version: added.data.version, action: { type: 'ready' },
|
||||
}, host.cookie, host.data.me);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
assert.ok(ready.data.players.every(player => player.ready));
|
||||
const originalIds = ready.data.players.map(player => player.id);
|
||||
|
||||
// Selecting the AI seat does not grant a credential to speak for it.
|
||||
const forged = await request(path, {
|
||||
version: ready.data.version, action: { type: 'factionSwapOffer', target: host.data.me },
|
||||
}, host.cookie, bot.id);
|
||||
assert.equal(forged.status, 409, JSON.stringify(forged.data));
|
||||
const accepted = await request(path, {
|
||||
version: ready.data.version, action: { type: 'factionSwapOffer', target: bot.id },
|
||||
}, host.cookie, host.data.me);
|
||||
assert.equal(accepted.status, 200, JSON.stringify(accepted.data));
|
||||
assert.ok(!accepted.data.factionSwap);
|
||||
assert.deepEqual(accepted.data.players.map(player => player.id), originalIds);
|
||||
const human = accepted.data.players.find(player => player.id === host.data.me);
|
||||
const swappedBot = accepted.data.players.find(player => player.id === bot.id);
|
||||
assert.equal(human?.faction, 'harkonnen');
|
||||
assert.equal(human?.ready, false);
|
||||
assert.equal(swappedBot?.faction, 'atreides');
|
||||
assert.equal(swappedBot?.bot, 'Hard');
|
||||
assert.equal(swappedBot?.name, 'Atreides AI');
|
||||
assert.equal(swappedBot?.ready, true);
|
||||
const refreshed = await request(path, undefined, host.cookie, host.data.me);
|
||||
assert.equal(refreshed.status, 200, JSON.stringify(refreshed.data));
|
||||
assert.equal(refreshed.data.me, host.data.me);
|
||||
assert.deepEqual(refreshed.data.players, accepted.data.players);
|
||||
assert.ok(!refreshed.data.factionSwap);
|
||||
});
|
||||
Reference in new issue
Block a user