633 lines
19 KiB
TypeScript
633 lines
19 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
newPlayer,
|
|
normalizeAutomaticGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { baseDeck } from '../game/cards';
|
|
import {
|
|
TERRITORIES,
|
|
distance,
|
|
location,
|
|
splitLocation,
|
|
mobileRoutes,
|
|
MOBILE_LOCATION,
|
|
} from '../game/board';
|
|
import {
|
|
quoteMovementCancellation,
|
|
MovementCancellationError,
|
|
} from '../game/karama-movement-cancellation';
|
|
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
|
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
|
function fixture(faction: 'guild' | 'ixians' | 'fremen' = 'ixians') {
|
|
const g = createGame(
|
|
'MOVECANCEL',
|
|
newPlayer('b', 'Sisterhood', 'beneGesserit'),
|
|
true,
|
|
faction === 'ixians' ? ['ix'] : [],
|
|
);
|
|
g.players.push(
|
|
newPlayer('e', 'Emperor', 'emperor'),
|
|
newPlayer('p', 'Mover', faction),
|
|
);
|
|
Object.assign(g, {
|
|
status: 'playing',
|
|
phase: 5,
|
|
turn: 2,
|
|
storm: 18,
|
|
active: 'p',
|
|
order: ['e', 'p', 'b'],
|
|
movementRemaining: ['e', 'p', 'b'],
|
|
deck: baseDeck(),
|
|
lastBattle: ['e', 'p'],
|
|
phaseOpening: null,
|
|
});
|
|
for (const p of g.players)
|
|
Object.assign(p, {
|
|
hand: [],
|
|
forces: {},
|
|
reserves: 20,
|
|
tanks: 0,
|
|
spice: 20,
|
|
traitors: [],
|
|
traitorChoices: [],
|
|
});
|
|
for (const [id, kind] of [
|
|
['b', 'worthless'],
|
|
['e', 'karama'],
|
|
] as const) {
|
|
const at = g.deck.findIndex((c) => c.kind === kind || c.effect === kind);
|
|
assert.ok(at >= 0);
|
|
player(g, id).hand.push(g.deck.splice(at, 1)[0]);
|
|
}
|
|
return g;
|
|
}
|
|
function ready(g: Game) {
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
return g;
|
|
}
|
|
function enterMovement(g: Game) {
|
|
g.phase = 4;
|
|
g.active = null;
|
|
g.ready = [];
|
|
g = ready(g);
|
|
if (g.phaseOpening) g = ready(g);
|
|
assert.equal(g.phase, 5);
|
|
return g;
|
|
}
|
|
function destination() {
|
|
const to = TERRITORIES.filter((t) => t.type === 'sand')
|
|
.flatMap((t) => t.sectors.map((s) => location(t.id, s)))
|
|
.find(
|
|
(key) =>
|
|
distance('red_chasm:7', key, (k) => splitLocation(k).sector === 18) ===
|
|
2,
|
|
);
|
|
assert.ok(to);
|
|
return splitLocation(to);
|
|
}
|
|
type Case =
|
|
| 'guildFirst'
|
|
| 'guildLast'
|
|
| 'ix'
|
|
| 'fremen'
|
|
| 'mobile'
|
|
| 'advisorFollowup'
|
|
| 'advisorDeclaration'
|
|
| 'advisorRemaining';
|
|
function opportunity(kind: Case) {
|
|
if (kind === 'guildFirst' || kind === 'guildLast') {
|
|
let g = fixture('guild');
|
|
if (kind === 'guildLast') g.order = ['p', 'e', 'b'];
|
|
g = enterMovement(g);
|
|
assert.equal(g.decision?.kind, 'guildTiming');
|
|
return applyAction(g, 'p', {
|
|
type: 'decision',
|
|
take: kind === 'guildFirst',
|
|
});
|
|
}
|
|
if (kind === 'ix' || kind === 'fremen') {
|
|
let g = fixture(kind === 'ix' ? 'ixians' : 'fremen');
|
|
player(g, 'p').forces = { 'red_chasm:7': 4 };
|
|
player(g, 'p').reserves = 16;
|
|
player(g, 'p').elites = {
|
|
forces: { 'red_chasm:7': 1 },
|
|
tanks: 0,
|
|
reserves: kind === 'ix' ? 6 : 2,
|
|
revived: 0,
|
|
};
|
|
const to = destination();
|
|
g = applyAction(g, 'p', {
|
|
type: 'move',
|
|
from: 'red_chasm:7',
|
|
amount: 4,
|
|
elite: 1,
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
});
|
|
assert.equal(
|
|
g.response?.kind,
|
|
kind === 'ix' ? 'ixMovement' : 'fremenMovement',
|
|
);
|
|
return g;
|
|
}
|
|
if (kind === 'mobile') {
|
|
let g = fixture();
|
|
g.phase = 8;
|
|
g.turn = 1;
|
|
g.active = null;
|
|
g.mobileStronghold = { location: 'polar_sink:0' };
|
|
player(g, 'p').forces = { [MOBILE_LOCATION]: 6 };
|
|
player(g, 'p').reserves = 14;
|
|
player(g, 'p').elites = {
|
|
forces: { [MOBILE_LOCATION]: 3 },
|
|
reserves: 4,
|
|
tanks: 0,
|
|
revived: 1,
|
|
};
|
|
g = ready(g);
|
|
if (g.phaseOpening) g = ready(g);
|
|
assert.equal(g.decision?.kind, 'mobileStronghold');
|
|
const route = mobileRoutes(g, 3)[0];
|
|
assert.ok(route);
|
|
return applyAction(g, 'p', { type: 'decision', route, collect: true });
|
|
}
|
|
let g = fixture('fremen');
|
|
player(g, 'b').forces = { 'arrakeen:10': 2 };
|
|
player(g, 'b').reserves = 18;
|
|
if (kind === 'advisorFollowup') {
|
|
g.active = 'e';
|
|
g = applyAction(g, 'e', {
|
|
type: 'ship',
|
|
territory: 'arrakeen',
|
|
sector: 10,
|
|
amount: 2,
|
|
});
|
|
assert.equal(g.decision?.kind, 'intrusion');
|
|
return applyAction(g, 'b', { type: 'decision', accept: true });
|
|
}
|
|
player(g, 'b').advisors = { arrakeen: {} };
|
|
player(g, 'e').forces = { 'arrakeen:10': 2 };
|
|
player(g, 'e').reserves = 18;
|
|
if (kind === 'advisorRemaining') {
|
|
player(g, 'b').forces['carthag:11'] = 1;
|
|
player(g, 'b').reserves--;
|
|
player(g, 'b').advisors!.carthag = {};
|
|
player(g, 'e').forces['carthag:11'] = 1;
|
|
player(g, 'e').reserves--;
|
|
}
|
|
g = enterMovement(g);
|
|
assert.equal(g.decision?.kind, 'advisorBattle');
|
|
return applyAction(g, 'b', {
|
|
type: 'decision',
|
|
accept: true,
|
|
territory: 'arrakeen',
|
|
});
|
|
}
|
|
function quote(g: Game) {
|
|
assert.ok(g.response);
|
|
return quoteMovementCancellation(g, g.response);
|
|
}
|
|
function cancel(g: Game, bg = false) {
|
|
const id = bg ? 'b' : 'e',
|
|
card = player(g, id).hand[0].id;
|
|
let done = applyAction(g, id, { type: 'card', card, mode: 'cancel' });
|
|
if (bg) {
|
|
assert.equal(done.response?.kind, 'worthlessKarama');
|
|
while (done.response?.kind === 'worthlessKarama') {
|
|
const next = done.players.find(
|
|
(p) => !done.response!.passed.includes(p.id),
|
|
)!;
|
|
done = applyAction(done, next.id, { type: 'passResponse' });
|
|
}
|
|
}
|
|
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
|
|
return done;
|
|
}
|
|
for (const kind of [
|
|
'guildFirst',
|
|
'guildLast',
|
|
'ix',
|
|
'fremen',
|
|
'mobile',
|
|
'advisorFollowup',
|
|
'advisorDeclaration',
|
|
'advisorRemaining',
|
|
] as const)
|
|
void test(`pure ${kind} cancellation quote follows genuine declaration without mutation, hands or random effects`, (t) => {
|
|
const g = opportunity(kind),
|
|
before = structuredClone(g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Pure quote sampled randomness');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Pure quote created UUID');
|
|
});
|
|
const result = quote(g);
|
|
assert.ok(result);
|
|
assert.deepEqual(quote(g), result);
|
|
assert.deepEqual(g, before);
|
|
for (const p of g.players)
|
|
Object.defineProperty(p, 'hand', {
|
|
get() {
|
|
throw Error('Read private hand');
|
|
},
|
|
});
|
|
assert.deepEqual(quote(g), result);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
for (const bg of [false, true])
|
|
for (const kind of [
|
|
'guildFirst',
|
|
'guildLast',
|
|
'ix',
|
|
'fremen',
|
|
'mobile',
|
|
] as const)
|
|
void test(`${bg ? 'BG' : 'printed'} cancellation commits only the quoted ${kind} flag or successor`, () => {
|
|
const g = opportunity(kind),
|
|
result = quote(g)!,
|
|
before = structuredClone(g);
|
|
const done = cancel(reload(g), bg);
|
|
assert.deepEqual(g, before);
|
|
assert.deepEqual(
|
|
done.players.map((p) => ({
|
|
forces: p.forces,
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
spice: p.spice,
|
|
moved: p.moved,
|
|
})),
|
|
g.players.map((p) => ({
|
|
forces: p.forces,
|
|
reserves: p.reserves,
|
|
tanks: p.tanks,
|
|
spice: p.spice,
|
|
moved: p.moved,
|
|
})),
|
|
);
|
|
if (result.kind === 'guildTiming') {
|
|
assert.equal(done.active, result.active);
|
|
assert.equal(done.guildTimingLocked, true);
|
|
assert.deepEqual(done.movementRemaining, g.movementRemaining);
|
|
} else if (
|
|
result.kind === 'ixMovement' ||
|
|
result.kind === 'fremenMovement'
|
|
) {
|
|
assert.deepEqual(
|
|
result.kind === 'ixMovement'
|
|
? player(done, 'p').ixMovementBlocked
|
|
: player(done, 'p').fremenMovementBlocked,
|
|
{ turn: g.turn, move: player(g, 'p').moved },
|
|
);
|
|
assert.equal(
|
|
result.kind === 'ixMovement'
|
|
? done.pendingIxMove
|
|
: done.pendingFremenMove,
|
|
null,
|
|
);
|
|
} else {
|
|
assert.equal(done.pendingMobileMove, null);
|
|
assert.deepEqual(done.mobileStronghold, g.mobileStronghold);
|
|
assert.equal(done.turn, g.turn);
|
|
assert.equal(done.phase, 0);
|
|
assert.ok(
|
|
Number.isInteger(done.stormPending) &&
|
|
done.stormPending! >= 1 &&
|
|
done.stormPending! <= 6,
|
|
);
|
|
assert.equal(done.stormMovementSource?.kind, 'card');
|
|
assert.equal(done.stormMovementSource?.turn, done.turn);
|
|
assert.equal(done.stormMovementSource?.distance, done.stormPending);
|
|
for (const dialer of g.lastBattle)
|
|
assert.throws(
|
|
() => applyAction(done, dialer, { type: 'stormDial', amount: 1 }),
|
|
/not dialing/,
|
|
);
|
|
assert.equal(player(done, 'p').elites!.revived, 0);
|
|
assert.equal(
|
|
done.log.filter((l) => l.text === `Turn ${g.turn} begins.`).length,
|
|
1,
|
|
);
|
|
}
|
|
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
|
|
});
|
|
for (const kind of [
|
|
'advisorFollowup',
|
|
'advisorDeclaration',
|
|
'advisorRemaining',
|
|
] as const)
|
|
void test(`printed ${kind} cancellation preserves stance and opens only its quoted next choice`, () => {
|
|
const g = opportunity(kind),
|
|
result = quote(g)!;
|
|
const done = cancel(g);
|
|
assert.deepEqual(player(done, 'b').advisors, player(g, 'b').advisors);
|
|
assert.equal(result.kind, 'advisorFlip');
|
|
if (result.kind !== 'advisorFlip') throw Error('Wrong quote');
|
|
if (result.successor === 'advisor' || result.successor === 'advisorBattle')
|
|
assert.equal(done.decision?.kind, result.successor);
|
|
else {
|
|
assert.equal(done.decision, null);
|
|
assert.equal(done.active, g.movementRemaining![0]);
|
|
}
|
|
});
|
|
void test('canceled Ix move remains accepted when its uncommitted source or route is no longer usable', () => {
|
|
const g = opportunity('ix');
|
|
player(g, 'p').reserves += 4;
|
|
player(g, 'p').forces = {};
|
|
player(g, 'p').elites!.reserves++;
|
|
player(g, 'p').elites!.forces = {};
|
|
g.storm = splitLocation(g.response!.location!).sector;
|
|
assert.equal(quote(g)?.kind, 'ixMovement');
|
|
const done = cancel(g, true);
|
|
assert.equal(player(done, 'p').moved, 0);
|
|
assert.deepEqual(player(done, 'p').forces, {});
|
|
});
|
|
void test('future advisor shipment and remaining declaration choices are not pre-accepted by cancellation', () => {
|
|
const g = opportunity('advisorFollowup');
|
|
g.storm = 10;
|
|
assert.deepEqual(quote(g), { kind: 'advisorFlip', successor: 'advisor' });
|
|
const done = cancel(g);
|
|
assert.equal(done.decision?.kind, 'advisor');
|
|
const remaining = opportunity('advisorRemaining');
|
|
player(remaining, 'b').advisors!.carthag.lockedTurn = remaining.turn;
|
|
assert.deepEqual(quote(remaining), {
|
|
kind: 'advisorFlip',
|
|
successor: 'advisorBattle',
|
|
});
|
|
assert.equal(cancel(remaining).decision?.kind, 'advisorBattle');
|
|
});
|
|
void test('mobile cancellation never quotes a sampled replacement for a missing Fremen storm card', (t) => {
|
|
const g = opportunity('mobile');
|
|
g.players.push(newPlayer('f', 'Fremen', 'fremen'));
|
|
g.stormCard = null;
|
|
const before = structuredClone(g);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Sampled future storm card');
|
|
});
|
|
assert.deepEqual(quote(g), {
|
|
kind: 'mobileStronghold',
|
|
successor: 'beginStormTurn',
|
|
});
|
|
assert.deepEqual(g, before);
|
|
assert.equal(random.mock.callCount(), 0);
|
|
});
|
|
void test('worm-ride and empty-queue phase-completion advisor chains are explicitly outside this bounded quote', () => {
|
|
const worm = opportunity('advisorFollowup');
|
|
worm.phase = 1;
|
|
worm.response!.advisorResume = 'wormRide';
|
|
delete worm.response!.advisorFollowup;
|
|
assert.equal(quote(worm), null);
|
|
const last = opportunity('advisorDeclaration');
|
|
last.movementRemaining = [];
|
|
assert.equal(quote(last), null);
|
|
});
|
|
for (const kind of [
|
|
'guildFirst',
|
|
'ix',
|
|
'fremen',
|
|
'mobile',
|
|
'advisorFollowup',
|
|
'advisorDeclaration',
|
|
] as const)
|
|
void test(`malformed ${kind} owner, phase and family-specific bindings reject the pure quote atomically`, () => {
|
|
const original = opportunity(kind);
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.response!.owner = 'missing';
|
|
},
|
|
(g) => {
|
|
g.phase = 7;
|
|
},
|
|
];
|
|
if (kind === 'guildFirst')
|
|
mutations.push(
|
|
(g) => {
|
|
g.movementRemaining = ['unseated'];
|
|
},
|
|
(g) => {
|
|
g.response!.take = false;
|
|
},
|
|
);
|
|
else if (kind === 'ix')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingIxMove!.total++;
|
|
},
|
|
(g) => {
|
|
g.response!.elite = 99;
|
|
},
|
|
);
|
|
else if (kind === 'fremen')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingFremenMove!.turn++;
|
|
},
|
|
(g) => {
|
|
g.pendingFremenMove!.move++;
|
|
},
|
|
);
|
|
else if (kind === 'mobile')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingMobileMove!.route = ['polar_sink:0', 'red_chasm:7'];
|
|
},
|
|
(g) => {
|
|
g.stormCard = 99;
|
|
},
|
|
);
|
|
else if (kind === 'advisorFollowup')
|
|
mutations.push(
|
|
(g) => {
|
|
g.response!.advisorFollowup!.shipment = 'missing';
|
|
},
|
|
(g) => {
|
|
g.response!.advisorFollowup!.destination = 'arrakeen:00';
|
|
},
|
|
);
|
|
else
|
|
mutations.push((g) => {
|
|
g.response!.advisorRemaining = ['missing'];
|
|
});
|
|
for (const mutate of mutations) {
|
|
const g = reload(original);
|
|
mutate(g);
|
|
const before = structuredClone(g);
|
|
assert.throws(() => quote(g), MovementCancellationError);
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
|
|
void test('Basic Fremen and a second Hajr Ix movement bind the current unspent move index', () => {
|
|
const basic = fixture('fremen');
|
|
basic.advanced = false;
|
|
player(basic, 'p').forces = { 'red_chasm:7': 4 };
|
|
player(basic, 'p').reserves = 16;
|
|
const to = destination();
|
|
const response = applyAction(basic, 'p', {
|
|
type: 'move',
|
|
from: 'red_chasm:7',
|
|
amount: 4,
|
|
territory: to.territory,
|
|
sector: to.sector,
|
|
});
|
|
assert.deepEqual(quote(response), {
|
|
kind: 'fremenMovement',
|
|
player: 'p',
|
|
turn: 2,
|
|
move: 0,
|
|
});
|
|
assert.equal(player(cancel(response), 'p').moved, 0);
|
|
const second = opportunity('ix');
|
|
second.hajr = ['p'];
|
|
player(second, 'p').moved = 1;
|
|
assert.deepEqual(quote(second), {
|
|
kind: 'ixMovement',
|
|
player: 'p',
|
|
turn: 2,
|
|
move: 1,
|
|
});
|
|
assert.deepEqual(player(cancel(second), 'p').ixMovementBlocked, {
|
|
turn: 2,
|
|
move: 1,
|
|
});
|
|
});
|
|
void test('Fremen canonical order validation still rejects lost custody before either cancellation cost', () => {
|
|
for (const bg of [false, true]) {
|
|
const g = opportunity('fremen');
|
|
player(g, 'p').forces = {};
|
|
player(g, 'p').elites!.forces = {};
|
|
const before = structuredClone(g);
|
|
// This helper validates declaration shape; the existing engine additionally
|
|
// validates actual Fremen order custody/routes, unlike canceled Ix movement.
|
|
assert.equal(quote(g)?.kind, 'fremenMovement');
|
|
assert.throws(() => cancel(g, bg));
|
|
assert.deepEqual(g, before);
|
|
}
|
|
});
|
|
void test('canceled Guild timing retains the protected Sapho-last queue verbatim', () => {
|
|
const g = opportunity('guildFirst');
|
|
// Current public queue protection established before the Guild response;
|
|
// exercise the adapter without guessing a new first-versus-Guild ruling.
|
|
g.saphoMovementLast = {
|
|
turn: g.turn,
|
|
event: `movement:${g.turn}`,
|
|
player: 'b',
|
|
};
|
|
assert.deepEqual(quote(g), { kind: 'guildTiming', active: 'e' });
|
|
const done = cancel(g);
|
|
assert.deepEqual(done.saphoMovementLast, g.saphoMovementLast);
|
|
assert.deepEqual(done.movementRemaining, g.movementRemaining);
|
|
assert.equal(done.active, 'e');
|
|
});
|
|
|
|
// Invalid saved opportunities are constructed from genuine declarations. No
|
|
// signature is edited: these exercise the semantic check before the first cost.
|
|
for (const kind of [
|
|
'guildFirst',
|
|
'ix',
|
|
'fremen',
|
|
'mobile',
|
|
'advisorFollowup',
|
|
] as const)
|
|
for (const bg of [false, true]) {
|
|
if (kind === 'advisorFollowup' && bg) continue; // The owner cannot cancel itself.
|
|
void test(`${bg ? 'BG' : 'printed'} ${kind} rejects invalid live cancellation context before consuming its card`, (t) => {
|
|
const original = opportunity(kind);
|
|
const mutations: ((g: Game) => void)[] = [
|
|
(g) => {
|
|
g.phase = 7;
|
|
},
|
|
(g) => {
|
|
g.response!.owner = 'missing';
|
|
},
|
|
];
|
|
if (kind === 'guildFirst')
|
|
mutations.push(
|
|
(g) => {
|
|
g.movementRemaining = [];
|
|
},
|
|
(g) => {
|
|
g.movementRemaining = ['e', 'e', 'p'];
|
|
},
|
|
(g) => {
|
|
g.response!.take = false;
|
|
},
|
|
);
|
|
else if (kind === 'ix')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingIxMove = null;
|
|
},
|
|
(g) => {
|
|
g.pendingIxMove!.total++;
|
|
},
|
|
(g) => {
|
|
g.response!.elite = 99;
|
|
},
|
|
);
|
|
else if (kind === 'fremen')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingFremenMove = null;
|
|
},
|
|
(g) => {
|
|
g.pendingFremenMove!.turn++;
|
|
},
|
|
(g) => {
|
|
g.pendingFremenMove!.order.eliteGroup = {};
|
|
},
|
|
);
|
|
else if (kind === 'mobile')
|
|
mutations.push(
|
|
(g) => {
|
|
g.pendingMobileMove = null;
|
|
},
|
|
(g) => {
|
|
g.pendingMobileMove!.collect = 'yes' as unknown as boolean;
|
|
},
|
|
(g) => {
|
|
g.stormCard = 99;
|
|
},
|
|
);
|
|
else
|
|
mutations.push(
|
|
(g) => {
|
|
g.response!.advisorFollowup!.shipment = 'missing';
|
|
},
|
|
(g) => {
|
|
g.response!.advisorFollowup!.destination = 'arrakeen:00';
|
|
},
|
|
);
|
|
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
|
throw Error('Invalid cancellation sampled randomness');
|
|
});
|
|
const uuid = t.mock.method(crypto, 'randomUUID', () => {
|
|
throw Error('Invalid cancellation generated UUID');
|
|
});
|
|
for (const mutate of mutations) {
|
|
const g = reload(original);
|
|
mutate(g);
|
|
const before = structuredClone(g),
|
|
id = bg ? 'b' : 'e',
|
|
card = player(g, id).hand[0].id;
|
|
assert.throws(() =>
|
|
applyAction(g, id, { type: 'card', card, mode: 'cancel' }),
|
|
);
|
|
assert.deepEqual(g, before);
|
|
assert.ok(player(g, id).hand.some((c) => c.id === card));
|
|
assert.equal(
|
|
g.discard.some((c) => c.id === card),
|
|
false,
|
|
);
|
|
assert.equal(g.pendingKarama, before.pendingKarama);
|
|
}
|
|
assert.equal(random.mock.callCount(), 0);
|
|
assert.equal(uuid.mock.callCount(), 0);
|
|
});
|
|
}
|