Add prepayment Guild rate Karama response
This commit is contained in:
1 parent
745e4fa8ab
commit
8a23b5ccd2
17 files changed
+567
-21
No files matched your search
@@ -297,7 +297,14 @@ for (const cancel of [false, true])
|
||||
for (const route of ['reserve', 'cross'] as const) {
|
||||
const f = await fixture(route, true);
|
||||
try {
|
||||
await f.action(0, f.shipment);
|
||||
const announced = await f.action(0, f.shipment);
|
||||
if (route === 'reserve') {
|
||||
assert.equal(announced.response?.kind, 'guildRate');
|
||||
assert.equal(announced.players[0].spice, 20);
|
||||
assert.equal(announced.players[0].shipped, false);
|
||||
const allowed = await f.action(2, { type: 'passResponse' });
|
||||
assert.equal(allowed.response?.kind, 'guildIncome');
|
||||
}
|
||||
const pending = await restored(f);
|
||||
paidOnce(f, pending, 19);
|
||||
assert.equal(pending.response?.kind, 'guildIncome');
|
||||
|
||||
@@ -168,7 +168,12 @@ void test('Guild income from either contributor direction can be allowed or canc
|
||||
const { g, shipper, donor } = fixture(direction, route);
|
||||
const card = holdKarama(g, 'a');
|
||||
const beforeGuild = player(g, 'g').spice;
|
||||
const pending = applyAction(g, shipper, shipment(route, 1));
|
||||
let pending = applyAction(g, shipper, shipment(route, 1));
|
||||
if (route === 'reserve') {
|
||||
assert.equal(pending.response?.kind, 'guildRate');
|
||||
assert.equal(player(pending, shipper).shipped, false);
|
||||
pending = applyAction(pending, 'a', { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(pending.response?.kind, 'guildIncome');
|
||||
assert.equal(pending.response?.owner, 'g');
|
||||
assert.equal(pending.response?.amount, 1);
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Action,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const player = (g: Game, id: string) => g.players.find(seat => seat.id === id)!;
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
function fixture(shipper: 'g' | 'e', advanced = false) {
|
||||
const g = createGame('GUILDRATE', newPlayer('g', 'Guild', 'guild'), advanced);
|
||||
g.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'));
|
||||
g.status = 'playing';
|
||||
g.phase = 5;
|
||||
g.turn = 2;
|
||||
g.storm = 18;
|
||||
g.order = ['g', 'e', 'a'];
|
||||
g.movementRemaining = [...g.order];
|
||||
g.active = shipper;
|
||||
g.deck = baseDeck();
|
||||
for (const seat of g.players) {
|
||||
seat.forces = {};
|
||||
seat.reserves = 20;
|
||||
seat.spice = 20;
|
||||
seat.hand = [];
|
||||
}
|
||||
if (shipper === 'e') {
|
||||
player(g, 'e').ally = 'g';
|
||||
player(g, 'g').ally = 'e';
|
||||
}
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama');
|
||||
const karama = g.deck.splice(index, 1)[0];
|
||||
player(g, 'a').hand.push(karama);
|
||||
return { g, karama };
|
||||
}
|
||||
function declare(g: Game, id: 'g' | 'e', amount: number, destination = 'arrakeen'): Game {
|
||||
return applyAction(g, id, {
|
||||
type: 'ship', territory: destination, sector: destination === 'arrakeen' ? 10 : 7,
|
||||
amount, allyPayment: 0,
|
||||
});
|
||||
}
|
||||
function cancel(g: Game, card: string): Game {
|
||||
assert.equal(g.response?.kind, 'guildRate');
|
||||
return applyAction(reload(g), 'a', { type: 'card', mode: 'cancel', card });
|
||||
}
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('Karama removes Guild own discount before any forces or spice move, then charges full stronghold cost', () => {
|
||||
const { g: initial, karama } = fixture('g');
|
||||
const offered = declare(initial, 'g', 3);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.equal(offered.pendingShipment?.cost, 2);
|
||||
assert.equal(player(offered, 'g').spice, 20);
|
||||
assert.equal(player(offered, 'g').reserves, 20);
|
||||
assert.equal(player(offered, 'g').shipped, false);
|
||||
const ownerView = viewGame(offered, 'g');
|
||||
assert.equal(ownerView.response?.kind, 'guildRate');
|
||||
assert.equal('spice' in ownerView.players.find(seat => seat.id === 'a')!, false);
|
||||
assert.equal(viewGame(offered, 'a').responseControls?.cancelCards.includes(karama.id), true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(offered)), reload(offered));
|
||||
const done = cancel(offered, karama.id);
|
||||
assert.equal(done.pendingShipment, null);
|
||||
assert.equal(done.guildRateBlocked, undefined);
|
||||
assert.equal(player(done, 'g').spice, 17);
|
||||
assert.equal(player(done, 'g').reserves, 17);
|
||||
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
|
||||
assert.equal(player(done, 'g').shipped, true);
|
||||
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
|
||||
reject(done, 'a', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
});
|
||||
|
||||
void test('the Guild ally can be repriced to full desert tariff without changing the approved payment split', () => {
|
||||
const { g: initial, karama } = fixture('e');
|
||||
const offered = declare(initial, 'e', 3, 'red_chasm');
|
||||
assert.equal(offered.pendingShipment?.cost, 3);
|
||||
const done = cancel(offered, karama.id);
|
||||
assert.equal(player(done, 'e').spice, 14);
|
||||
assert.equal(player(done, 'e').reserves, 17);
|
||||
assert.equal(player(done, 'e').forces['red_chasm:7'], 3);
|
||||
assert.equal(player(done, 'g').spice, 26);
|
||||
assert.equal(done.guildRateBlocked, undefined);
|
||||
});
|
||||
|
||||
void test('unaffordable full price releases the physical declaration, spends Karama, and preserves one full-price replacement', () => {
|
||||
const { g: initial, karama } = fixture('e');
|
||||
player(initial, 'e').spice = 2;
|
||||
const offered = declare(initial, 'e', 4);
|
||||
assert.equal(offered.pendingShipment?.cost, 2);
|
||||
const withdrawn = cancel(offered, karama.id);
|
||||
assert.equal(player(withdrawn, 'e').spice, 2);
|
||||
assert.equal(player(withdrawn, 'e').reserves, 20);
|
||||
assert.equal(player(withdrawn, 'e').shipped, false);
|
||||
assert.equal(withdrawn.pendingShipment, null);
|
||||
assert.deepEqual(withdrawn.guildRateBlocked, { turn: 2, player: 'e' });
|
||||
assert.equal(viewGame(withdrawn, 'e').guildRateCanceled, true);
|
||||
assert.equal(viewGame(withdrawn, 'a').guildRateCanceled, false);
|
||||
const botState = reload(withdrawn);
|
||||
player(botState, 'e').bot = 'Easy';
|
||||
const botShipment = botActions(viewGame(botState, 'e')).find(action => action.type === 'ship');
|
||||
assert.ok(botShipment, 'The payer must retain a legal full-price AI shipment.');
|
||||
const botCompletion = applyAction(botState, 'e', botShipment);
|
||||
assert.equal(player(botCompletion, 'e').shipped, true);
|
||||
assert.ok(player(botCompletion, 'e').reserves < 20);
|
||||
assert.equal(withdrawn.discard.filter(card => card.id === karama.id).length, 1);
|
||||
reject(withdrawn, 'e', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 4 });
|
||||
const replacement = declare(reload(withdrawn), 'e', 2);
|
||||
assert.equal(replacement.response?.kind === 'guildRate', false);
|
||||
assert.equal(player(replacement, 'e').spice, 0);
|
||||
assert.equal(player(replacement, 'e').reserves, 18);
|
||||
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 2);
|
||||
assert.equal(replacement.guildRateBlocked?.player, 'e');
|
||||
});
|
||||
|
||||
void test('a canceled unspent Guild discount expires after a completed turn', () => {
|
||||
const { g: initial, karama } = fixture('e');
|
||||
player(initial, 'e').spice = 2;
|
||||
let g = cancel(declare(initial, 'e', 4), karama.id);
|
||||
assert.equal(viewGame(g, 'e').guildRateCanceled, true);
|
||||
for (let turn = 0; g.phase === 5 && turn < g.players.length; turn++)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
for (const phase of [7, 8]) {
|
||||
assert.equal(g.phase, phase);
|
||||
for (const seat of g.players) g = applyAction(g, seat.id, { type: 'ready' });
|
||||
}
|
||||
assert.equal(g.turn, 3);
|
||||
assert.equal(g.guildRateBlocked, undefined);
|
||||
assert.equal(viewGame(g, 'e').guildRateCanceled, false);
|
||||
});
|
||||
|
||||
void test('approved allied contribution stays fixed while full-price Guild shipment routes only that contribution back', () => {
|
||||
const { g: initial, karama } = fixture('g');
|
||||
player(initial, 'g').ally = 'e';
|
||||
player(initial, 'e').ally = 'g';
|
||||
const funded = applyAction(initial, 'e', { type: 'pledgeAid', amount: 2 });
|
||||
const offered = applyAction(funded, 'g', {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, allyPayment: 1,
|
||||
});
|
||||
assert.equal(offered.pendingShipment?.cost, 2);
|
||||
assert.equal(offered.pendingShipment?.allyPayment, 1);
|
||||
const done = cancel(offered, karama.id);
|
||||
assert.equal(player(done, 'g').spice, 19);
|
||||
assert.equal(player(done, 'e').spice, 18);
|
||||
assert.equal(done.aid.e.amount, 1);
|
||||
assert.equal(player(done, 'g').forces['arrakeen:10'], 3);
|
||||
});
|
||||
|
||||
void test('unused pledged aid is not silently added to a canceled split; the payer can authorize it on replacement', () => {
|
||||
const { g: initial, karama } = fixture('e');
|
||||
player(initial, 'e').spice = 2;
|
||||
const funded = applyAction(initial, 'g', { type: 'pledgeAid', amount: 2 });
|
||||
const offered = declare(funded, 'e', 4);
|
||||
assert.equal(offered.pendingShipment?.allyPayment, 0);
|
||||
const withdrawn = cancel(offered, karama.id);
|
||||
assert.equal(player(withdrawn, 'e').spice, 2);
|
||||
assert.equal(withdrawn.aid.g.amount, 2);
|
||||
assert.equal(player(withdrawn, 'e').shipped, false);
|
||||
const replacement = applyAction(reload(withdrawn), 'e', {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount: 4, allyPayment: 2,
|
||||
});
|
||||
assert.equal(player(replacement, 'e').spice, 0);
|
||||
assert.equal(replacement.aid.g.amount, 0);
|
||||
assert.equal(player(replacement, 'e').forces['arrakeen:10'], 4);
|
||||
});
|
||||
|
||||
void test('a tampered saved rate opportunity cannot spend Karama, forces or payment', () => {
|
||||
const { g: initial, karama } = fixture('e');
|
||||
const offered = declare(initial, 'e', 3);
|
||||
const corrupt = reload(offered);
|
||||
corrupt.response!.guildRateEvent = 'obsolete';
|
||||
assert.throws(() => viewGame(corrupt, 'a'));
|
||||
reject(corrupt, 'a', { type: 'card', mode: 'cancel', card: karama.id });
|
||||
});
|
||||
|
||||
void test('the Advanced Guild stop decision precedes the separate Karama rate response', () => {
|
||||
const { g: initial, karama } = fixture('e', true);
|
||||
const declared = declare(initial, 'e', 3);
|
||||
assert.equal(declared.decision?.kind, 'guildShipment');
|
||||
assert.equal(declared.response, null);
|
||||
const offered = applyAction(reload(declared), 'g', { type: 'decision', allow: true });
|
||||
assert.equal(offered.decision, null);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.equal(player(offered, 'e').spice, 20);
|
||||
const done = cancel(offered, karama.id);
|
||||
assert.equal(player(done, 'e').spice, 17);
|
||||
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
|
||||
});
|
||||
|
||||
void test('an unstamped saved Advanced declaration gains a turn stamp before its new rate response', () => {
|
||||
const { g: initial, karama } = fixture('e', true);
|
||||
const legacy = declare(initial, 'e', 3);
|
||||
assert.equal(legacy.decision?.kind, 'guildShipment');
|
||||
delete legacy.pendingShipment!.turn;
|
||||
const offered = applyAction(reload(legacy), 'g', { type: 'decision', allow: true });
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.equal(offered.pendingShipment?.turn, offered.turn);
|
||||
assert.equal(player(offered, 'e').spice, 20);
|
||||
const done = cancel(offered, karama.id);
|
||||
assert.equal(player(done, 'e').spice, 17);
|
||||
assert.equal(player(done, 'e').forces['arrakeen:10'], 3);
|
||||
});
|
||||
|
||||
void test('Advanced BG Worthless converts into a saved rate cancellation without losing shipment custody', () => {
|
||||
const { g: initial, karama } = fixture('g', true);
|
||||
initial.players.push(newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
initial.order.push('b');
|
||||
initial.movementRemaining!.push('b');
|
||||
player(initial, 'b').spice = 20;
|
||||
player(initial, 'b').forces = {};
|
||||
player(initial, 'b').reserves = 20;
|
||||
const index = initial.deck.findIndex(card => card.kind === 'worthless');
|
||||
const worthless = initial.deck.splice(index, 1)[0];
|
||||
player(initial, 'b').hand.push(worthless);
|
||||
let g = declare(initial, 'g', 3);
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
g = applyAction(g, 'g', { type: 'decision', allow: true });
|
||||
assert.equal(g.response?.kind, 'guildRate');
|
||||
g = applyAction(reload(g), 'b', { type: 'card', mode: 'cancel', card: worthless.id });
|
||||
assert.equal(g.response?.kind, 'worthlessKarama');
|
||||
assert.equal(g.pendingKarama?.use.kind, 'cancel');
|
||||
assert.equal(g.pendingShipment?.guildRateEvent, g.pendingKarama?.use.kind === 'cancel'
|
||||
? g.pendingKarama.use.response.guildRateEvent : null);
|
||||
assert.equal(viewGame(reload(g), 'a').response?.kind, 'worthlessKarama');
|
||||
for (let turn = 0; g.response && turn < 12; turn++) {
|
||||
const responder = g.players.find(seat => !g.response!.passed.includes(seat.id))!;
|
||||
g = applyAction(reload(g), responder.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
assert.equal(player(g, 'g').spice, 17);
|
||||
assert.equal(player(g, 'g').forces['arrakeen:10'], 3);
|
||||
assert.equal(g.discard.filter(card => card.id === worthless.id).length, 1);
|
||||
assert.equal(player(g, 'a').hand.some(card => card.id === karama.id), true);
|
||||
});
|
||||
|
||||
void test('all four AI profiles can pass the physical Guild rate response and finish a legal shipment', () => {
|
||||
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const { g: initial } = fixture('g');
|
||||
player(initial, 'a').bot = level;
|
||||
let g = declare(initial, 'g', 3);
|
||||
const action = botActions(viewGame(g, 'a'))[0];
|
||||
assert.ok(action, `${level} must answer the Guild rate response`);
|
||||
g = applyAction(reload(g), 'a', action);
|
||||
assert.equal(player(g, 'g').forces['arrakeen:10'], 3);
|
||||
assert.equal(player(g, 'g').spice, 18);
|
||||
assert.equal(g.pendingShipment, null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { newPlayer, type Game } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: Rooms.RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
void test('restarted rooms expose the unpaid Guild rate choice and commit one Karama cancellation under a CAS race', async () => {
|
||||
const store = unitStore();
|
||||
try {
|
||||
const made = await store.rooms.createRoom('Guild rate CAS', 'guild', false, []);
|
||||
const code = made.view.code;
|
||||
const joinedEmperor = await store.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||
const joinedAtreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const tokens = [made.token, joinedEmperor.token!, joinedAtreides.token!];
|
||||
const seats = await Promise.all(tokens.map(token => store.restart().authenticate(code, token)));
|
||||
const ids = seats.map(seat => seat.playerId);
|
||||
const initial = await store.restart().readRoom(code);
|
||||
const g: Game = {
|
||||
...initial,
|
||||
players: [
|
||||
newPlayer(ids[0], 'Guild', 'guild'),
|
||||
newPlayer(ids[1], 'Emperor', 'emperor'),
|
||||
newPlayer(ids[2], 'Atreides', 'atreides'),
|
||||
],
|
||||
status: 'playing', phase: 5, turn: 2, storm: 18,
|
||||
order: ids, movementRemaining: ids, active: ids[0],
|
||||
ready: [], decision: null, response: null, phaseOpening: null,
|
||||
deck: baseDeck(), discard: [],
|
||||
};
|
||||
for (const player of g.players) {
|
||||
player.spice = 20;
|
||||
player.reserves = 20;
|
||||
player.forces = {};
|
||||
player.hand = [];
|
||||
}
|
||||
const index = g.deck.findIndex(card => card.effect === 'karama');
|
||||
const karama = g.deck.splice(index, 1)[0];
|
||||
g.players[2].hand.push(karama);
|
||||
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
|
||||
await store.restart().act(code, seats[0], g.version, {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, allyPayment: 0,
|
||||
}, clock);
|
||||
const offered = await store.restart().readRoom(code);
|
||||
assert.equal(offered.version, g.version + 1);
|
||||
assert.equal(offered.response?.kind, 'guildRate');
|
||||
assert.equal(offered.pendingShipment?.cost, 2);
|
||||
assert.equal(offered.players[0].spice, 20);
|
||||
assert.equal(offered.players[0].reserves, 20);
|
||||
const responderView = await store.restart().readSeatView(code, seats[2]);
|
||||
assert.equal(responderView.responseControls?.cancelCards.includes(karama.id), true);
|
||||
assert.equal(responderView.players.find(player => player.id === ids[0])?.spice, undefined);
|
||||
assert.equal((await store.restart().readSeatView(code, seats[1])).response?.kind, 'guildRate');
|
||||
const before = JSON.parse(JSON.stringify(offered));
|
||||
await assert.rejects(store.restart().act(code, seats[0], offered.version,
|
||||
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
|
||||
assert.deepEqual(await store.restart().readRoom(code), before);
|
||||
|
||||
let arrivals = 0;
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>(resolve => { release = resolve; });
|
||||
store.hooks.beforeWrite = async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await waiting;
|
||||
};
|
||||
let results: PromiseSettledResult<unknown>[];
|
||||
try {
|
||||
results = await Promise.allSettled([0, 1].map(() => store.restart().act(
|
||||
code, seats[2], offered.version,
|
||||
{ type: 'card', mode: 'cancel', card: karama.id }, clock,
|
||||
)));
|
||||
} finally {
|
||||
delete store.hooks.beforeWrite;
|
||||
}
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.deepEqual(store.writes.slice(-2).map(write => write.changes).sort((a, b) => a - b), [0, 1]);
|
||||
const done = await store.restart().readRoom(code);
|
||||
assert.equal(done.version, offered.version + 1);
|
||||
assert.equal(done.pendingShipment, null);
|
||||
assert.equal(done.response, null);
|
||||
assert.equal(done.players[0].spice, 17);
|
||||
assert.equal(done.players[0].reserves, 17);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 3);
|
||||
assert.equal(done.discard.filter(card => card.id === karama.id).length, 1);
|
||||
await assert.rejects(store.restart().act(code, seats[2], offered.version,
|
||||
{ type: 'card', mode: 'cancel', card: karama.id }, clock));
|
||||
assert.deepEqual(await store.restart().readRoom(code), done);
|
||||
} finally {
|
||||
store.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -274,7 +274,7 @@ void test('a paid Nullentropy search suspends and restores the payer without los
|
||||
g = applyAction(g, 'e', { type: 'card', card: box.id });
|
||||
assert.equal(g.decision?.kind, 'nullentropy');
|
||||
assert.equal(g.pendingNullentropy?.resume.decision?.kind, 'moritaniExtortion');
|
||||
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
|
||||
assert.equal(viewGame(reload(g), 'e').decision?.kind, 'nullentropy');
|
||||
const selected = g.discard[0].id;
|
||||
g = applyAction(reload(g), 'e', { type: 'decision', event: g.pendingNullentropy!.event, card: selected });
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
@@ -307,7 +307,7 @@ void test('a Richese gift response suspends its payer and restores the same save
|
||||
g = applyAction(g, 'b', { type: 'richeseGift', card: gift.id });
|
||||
assert.equal(g.response?.kind, 'richeseGift');
|
||||
assert.equal(g.pendingRicheseGift?.resume.decision?.kind, 'moritaniExtortion');
|
||||
assert.doesNotThrow(() => viewGame(reload(g), 'e'));
|
||||
assert.equal(viewGame(reload(g), 'e').response?.kind, 'richeseGift');
|
||||
g = applyAction(reload(g), 'e', { type: 'passResponse' });
|
||||
assert.equal(g.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(g.decision.event, event);
|
||||
|
||||
Reference in new issue
Block a user