- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
332 lines
17 KiB
JavaScript
332 lines
17 KiB
JavaScript
// AI player (server/match/bot.js): field model, layout planner, rehearsal, buying / leveling / bench management.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
|
||
import { FIELD, canPlace, positionClass, parseKey } from '../../server/match/board.js';
|
||
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
|
||
|
||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||
|
||
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
|
||
const h = soloBot({ seed: 2 }).start();
|
||
const m = h.m;
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||
const model = fieldModel(m);
|
||
assert.ok(model.routes.length > 0);
|
||
const enemies = m.wave.spawns.reduce((s, x) => s + Math.max(1, x.count || 1), 0);
|
||
assert.equal(model.routes.reduce((s, r) => s + r.n, 0), enemies, 'every spawned enemy is on one traced route');
|
||
for (const rt of model.routes) {
|
||
assert.ok(rt.tiles.length > 0 && rt.tileTime > 0 && rt.hp > 0);
|
||
for (const k of rt.tiles) {
|
||
const [r, c] = parseKey(k);
|
||
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `route tile ${k} inside the own board`);
|
||
}
|
||
}
|
||
// ground routes follow the stage's device-aware ground paths
|
||
const st = m.stage;
|
||
const paths = st.groundPathsWithDevices || st.groundPaths;
|
||
const ground = m.wave.routes.filter((r) => r.motion !== 'FLY');
|
||
for (const rt of ground) {
|
||
const path = paths[`${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`];
|
||
if (!path) continue;
|
||
for (const [r, c] of path.slice(0, -1)) if (r >= 9 && r <= 12 && c <= 10) assert.ok(model.ground.has(`${r},${c}`), `ground path tile ${r},${c}`);
|
||
}
|
||
assert.equal(fieldModel(m), model, 'cached per round');
|
||
m.dispose();
|
||
});
|
||
|
||
test('field model: 近地悬浮 enemies (掠海漂移体) walk the ground path but count as air units (no blocker credit, anti-air needed)', () => {
|
||
const h = soloBot({ seed: 2 }).start();
|
||
const m = h.m;
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||
const groundRi = new Set(m.wave.routes.map((r, i) => (r.motion !== 'FLY' ? i : -1)).filter((i) => i >= 0));
|
||
const before = fieldModel(m);
|
||
assert.ok(before.ground.size > 0);
|
||
m.wave = { ...m.wave, spawns: m.wave.spawns.map((s) => (groundRi.has(s.routeIndex) && s.tag !== 'boss' ? { ...s, enemyKey: 'enemy_2025_syufo' } : s)) };
|
||
m._botPath = null;
|
||
const model = fieldModel(m);
|
||
const hover = m.wave.spawns.filter((s) => s.enemyKey === 'enemy_2025_syufo').reduce((a, s) => a + Math.max(1, s.count || 1), 0);
|
||
assert.ok(hover > 0);
|
||
assert.ok(model.flyTotal >= hover, `${model.flyTotal} ≥ ${hover}`);
|
||
const hov = model.routes.filter((r) => r.fly && r.tiles.some((k) => before.ground.has(k)));
|
||
assert.ok(hov.length > 0, 'an air route on the ground path tiles');
|
||
m.dispose();
|
||
});
|
||
|
||
test('layout planner: legal distinct tiles; blockers on the enemy road, ranged units cover it without standing on it', () => {
|
||
const h = soloBot({ seed: 3 }).start();
|
||
const m = h.m;
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 2);
|
||
const ps = m.order[0];
|
||
// a controlled lineup: 2 blockers, 3 ranged
|
||
for (const p of [...ps.board.values()]) ps.board.delete([...ps.board.entries()].find(([, x]) => x === p)[0]);
|
||
ps.hand.fill(null);
|
||
ps.recompute();
|
||
const melee = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'MELEE' && c.attackKind === 'melee' && c.stats.blockCnt >= 1).slice(0, 2);
|
||
const ranged = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'RANGED' && c.attackKind === 'ranged').slice(0, 3);
|
||
const pieces = [...melee, ...ranged].map((c) => give(m, ps, c.chessId, 'hand'));
|
||
const plan = planLayout(m, ps, pieces);
|
||
assert.equal(plan.size, pieces.length);
|
||
assert.equal(new Set(plan.values()).size, pieces.length, 'distinct tiles');
|
||
const map = ps.deployMap();
|
||
const model = fieldModel(m);
|
||
for (const p of pieces) {
|
||
const [r, c] = parseKey(plan.get(p.uid));
|
||
assert.ok(canPlace(map, positionClass(m.gd.chess(p.id)), r, c), `${p.id} legal at ${r},${c}`);
|
||
}
|
||
for (const p of pieces.slice(0, 2)) assert.ok(model.ground.has(plan.get(p.uid)), 'a blocker stands on the enemy road');
|
||
for (const p of pieces.slice(2)) {
|
||
const rec = m.gd.chess(p.id);
|
||
const [r, c] = parseKey(plan.get(p.uid));
|
||
// the planned direction rotates the range (DESIGN §3)
|
||
const dir = plan.dirs.get(p.uid);
|
||
assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(dir), `${rec.name} has a planned direction`);
|
||
const covered = rangeTiles(rec, r, c, dir).some((k) => model.ground.has(k) || model.routes.some((rt) => rt.fly && rt.tiles.includes(k)));
|
||
assert.ok(covered, `${rec.name} covers the enemy route from ${r},${c} facing ${dir}`);
|
||
}
|
||
m.dispose();
|
||
});
|
||
|
||
test('rehearsal: candidate layouts are simulated without side effects; the chosen plan is one of them', () => {
|
||
const h = soloBot({ seed: 4, botRehearsal: 3 }).start();
|
||
const m = h.m;
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 3);
|
||
const ps = m.order[0];
|
||
const chosen = ps.allChess().slice(0, ps.deployCap);
|
||
assert.ok(chosen.length >= 3);
|
||
const snap = () => JSON.stringify({ board: [...ps.board.entries()].map(([k, p]) => [k, p.uid, p.dir]), hand: ps.hand.map((p) => p && p.uid), bonds: ps.bonds, pool: m.pool.snapshot(), lp: ps.lp, funds: ps.funds, layers: ps.layers, round: m.round, phase: m.phase, uid: m.uidSeq });
|
||
const before = snap();
|
||
const plans = REHEARSAL_VARIANTS.map((v) => planLayout(m, ps, chosen, { ...LAYOUT_PARAMS, ...v }));
|
||
const best = rehearse(m, ps, chosen, plans);
|
||
assert.ok(plans.includes(best));
|
||
assert.equal(snap(), before, 'board, hand, bonds, pool, economy untouched');
|
||
assert.equal(m.errorCount, 0);
|
||
checkInvariants(m);
|
||
// the whole match still runs deterministically with rehearsal on
|
||
const run = () => { const x = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY' }).start(); return JSON.stringify({ ...x.runToEnd(), durationMs: 0 }); };
|
||
assert.equal(run(), run());
|
||
m.dispose();
|
||
});
|
||
|
||
test('rehearsal runs in bounded slices between scheduler callbacks and plays exactly like the one-shot run', () => {
|
||
const run = (botSliceMs) => {
|
||
const h = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY', botSliceMs }).start();
|
||
const m = h.m;
|
||
/** scheduler callback → rehearsal ticks stepped in it */
|
||
const perCallback = new Map();
|
||
const newBattle = m.newBattle.bind(m);
|
||
m.newBattle = (opts) => {
|
||
const b = newBattle(opts);
|
||
if (String(opts.fieldId).startsWith('r:')) {
|
||
const step = b.step.bind(b);
|
||
b.step = () => { perCallback.set(h.sched.executed, (perCallback.get(h.sched.executed) || 0) + 1); step(); };
|
||
}
|
||
return b;
|
||
};
|
||
const end = h.runToEnd();
|
||
assert.equal(m.errorCount, 0);
|
||
m.dispose();
|
||
return { result: JSON.stringify({ ...end, durationMs: 0 }), perCallback };
|
||
};
|
||
const whole = run(undefined);
|
||
const sliced = run(1e-6);
|
||
assert.ok(whole.perCallback.size > 0, 'sanity: the bot rehearsed');
|
||
assert.ok(Math.max(...whole.perCallback.values()) > 500, 'unbounded: a whole rehearsal in one callback (virtual time)');
|
||
assert.ok(Math.max(...sliced.perCallback.values()) <= 4, `sliced: ≤ 4 ticks per callback (got ${Math.max(...sliced.perCallback.values())})`);
|
||
assert.ok(sliced.perCallback.size > whole.perCallback.size * 10);
|
||
assert.equal(sliced.result, whole.result, 'same decisions, same match');
|
||
});
|
||
|
||
test('the prep routine itself (shop, lineup, layout planning) runs in bounded slices and plays exactly like the one-shot run', () => {
|
||
const seats = [0, 1].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
|
||
const run = (botSliceMs) => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true, botRehearsal: 3, botSliceMs }).start();
|
||
const m = h.m;
|
||
/** `${bot}:${round}` → the scheduler callbacks in which the bot acted */
|
||
const cbs = new Map();
|
||
for (const ps of m.players.values()) {
|
||
for (const k of ['buy', 'move', 'sell', 'levelUp', 'refresh', 'equip', 'pickReward']) {
|
||
const f = ps[k].bind(ps);
|
||
ps[k] = (...a) => {
|
||
const key = `${ps.playerId}:${m.round}`;
|
||
if (!cbs.has(key)) cbs.set(key, new Set());
|
||
cbs.get(key).add(h.sched.executed);
|
||
return f(...a);
|
||
};
|
||
}
|
||
}
|
||
h.run(() => h.ended != null || (m.phase === PHASE.SETTLE && m.round === 6), { maxSteps: 5e6 });
|
||
assert.equal(m.errorCount, 0);
|
||
checkInvariants(m);
|
||
const state = JSON.stringify([...m.players.values()].map((ps) => ({
|
||
board: [...ps.board.entries()].map(([k, p]) => [k, p.id, p.dir ?? null, (p.items || []).map((x) => x.id)]),
|
||
hand: ps.hand.map((p) => p && p.id), funds: ps.funds, level: ps.shop.level, lp: ps.lp, bonds: ps.bonds,
|
||
})));
|
||
m.dispose();
|
||
return { state, perPrep: [...cbs.values()].map((s) => s.size) };
|
||
};
|
||
const whole = run(undefined);
|
||
const sliced = run(1e-6);
|
||
assert.ok(Math.max(...whole.perPrep) <= 1, `unbounded (virtual time): a whole prep in one callback (${whole.perPrep})`);
|
||
assert.ok(Math.min(...sliced.perPrep) >= 3 && Math.max(...sliced.perPrep) >= 8, `sliced: every prep spread over many callbacks (${sliced.perPrep})`);
|
||
assert.equal(sliced.state, whole.state, 'same decisions, same boards');
|
||
});
|
||
|
||
test('a bot prep dropped mid-planning (the prep ended) leaves a legal board and no rehearsal layout behind', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 14, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
|
||
const m = h.m;
|
||
h.toPrep(4);
|
||
const bot = h.ps('ai_0');
|
||
let moves = 0;
|
||
const move = bot.move.bind(bot);
|
||
bot.move = (...a) => { moves++; return move(...a); };
|
||
// stop right after the bot started placing (mid-way through its sliced prep)
|
||
h.run(() => moves > 0);
|
||
assert.equal(bot.ready, false, 'mid-prep');
|
||
m.prepDeadline();
|
||
assert.equal(m.phase, PHASE.COMBAT);
|
||
const at = moves;
|
||
h.run(() => m.phase === PHASE.SETTLE);
|
||
assert.equal(moves, at, 'the pending steps were dropped');
|
||
assert.ok(bot.deployCount <= bot.deployCap);
|
||
assert.equal(m.errorCount, 0);
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('a sliced rehearsal is dropped when the prep ends first; the default layout is already on the board', () => {
|
||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 12, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
|
||
const m = h.m;
|
||
h.toPrep(3);
|
||
const bot = h.ps('ai_0');
|
||
let ticks = 0;
|
||
const newBattle = m.newBattle.bind(m);
|
||
m.newBattle = (opts) => {
|
||
const b = newBattle(opts);
|
||
if (String(opts.fieldId).startsWith('r:')) { const step = b.step.bind(b); b.step = () => { ticks++; step(); }; }
|
||
return b;
|
||
};
|
||
h.run(() => ticks > 0);
|
||
assert.equal(bot.ready, false, 'mid-rehearsal');
|
||
const layout = JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid]));
|
||
assert.ok(bot.deployCount > 0, 'the default layout already stands');
|
||
m.prepDeadline();
|
||
assert.equal(m.phase, PHASE.COMBAT);
|
||
const at = ticks;
|
||
h.run(() => m.phase === PHASE.SETTLE);
|
||
assert.equal(ticks, at, 'the pending slices dropped the job');
|
||
assert.equal(JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid])), layout, 'the board was left alone');
|
||
assert.equal(m.errorCount, 0);
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
|
||
for (const seed of [5, 8]) {
|
||
const h = soloBot({ seed }).start();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||
ps.lp = 999;
|
||
const funds1 = ps.funds; // 老鲤 (band_lmlee) withholds R1–R2 income until R3
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 1);
|
||
assert.equal(ps.shop.level, 1, 'no level-up in round 1 (units first)');
|
||
assert.equal(ps.deployCount, Math.floor(funds1 / 2), `R1: ${funds1} funds → tier-I operators deployed`);
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
|
||
assert.equal(ps.deployCount, ps.deployCap, 'full board by round 4');
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 6);
|
||
assert.ok(ps.shop.level >= 2, `level ${ps.shop.level} by round 6`);
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 10);
|
||
assert.ok(ps.shop.level >= 4, `level ${ps.shop.level} by round 10`);
|
||
assert.ok(ps.hand.some((x) => x == null), 'a hand slot is kept free for merges');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
}
|
||
});
|
||
|
||
test('bench management: stale low-tier singles are sold at a high shop level; pairs that can still merge are kept', () => {
|
||
const h = soloBot({ seed: 6 }).start();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
ps.lp = 999;
|
||
h.run(() => m.phase === PHASE.ROUND_START && m.round === 7);
|
||
// a crowded bench of tier-I singles at shop level 5, plus a live pair of a tier-V chess
|
||
ps.shop.level = 5;
|
||
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
|
||
const t5 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 5 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId));
|
||
const freeIdx = () => ps.hand.findIndex((x) => x == null);
|
||
assert.ok(freeIdx() >= 0);
|
||
const pairA = give(m, ps, t5.chessId, 'hand', freeIdx());
|
||
assert.ok(freeIdx() >= 0);
|
||
const pairB = give(m, ps, t5.chessId, 'hand', freeIdx());
|
||
const t1 = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !owned.has(c.chessId));
|
||
const junk = [];
|
||
for (const c of t1) if (freeIdx() >= 0) junk.push(give(m, ps, c.chessId, 'hand', freeIdx()));
|
||
assert.ok(junk.length >= 2, 'the bench is crowded with tier-I singles');
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
|
||
const bench = ps.hand.filter((p) => p && p.kind === 'chess');
|
||
assert.ok(bench.length <= 6, `bench trimmed to ${bench.length}`);
|
||
const stillOwned = junk.filter((p) => ps.find(p.uid)).length;
|
||
assert.ok(stillOwned < junk.length, `sold stale singles (${junk.length - stillOwned}/${junk.length})`);
|
||
const kept = ps.allChess().filter((p) => m.gd.baseIdOf(p.id) === t5.chessId);
|
||
assert.ok(kept.length >= 2 || kept.some((p) => m.gd.isGolden(p.id)) || (ps.find(pairA.uid) && ps.find(pairB.uid)), 'the tier-V pair is kept (or merged)');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('机变 pick: items and team buffs before bounties (bounties add enemies to the bot\'s own battle)', () => {
|
||
const h = soloBot({ seed: 7 }).start();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
const cards = [
|
||
{ idx: 0, kind: 'bounty', payout: 'kill', coin: 3, tier: 2, count: 3, rounds: 1 },
|
||
{ idx: 1, kind: 'item', id: 'x', tier: 2 },
|
||
{ idx: 2, kind: 'tactic', team: true, tacticKind: 'ally' },
|
||
];
|
||
assert.equal(botPickCard(m, ps, cards, [0, 1, 2]), 2);
|
||
assert.equal(botPickCard(m, ps, cards, [0, 1]), 1);
|
||
assert.equal(botPickCard(m, ps, cards, [0]), 0);
|
||
m.dispose();
|
||
});
|
||
|
||
test('merge rewards: a merge completed while buying is followed by taking its free reward offer the same prep', () => {
|
||
const h = soloBot({ seed: 9 }).start();
|
||
const m = h.m;
|
||
const ps = m.order[0];
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 1);
|
||
ps.lp = 999;
|
||
h.run(() => m.phase === PHASE.ROUND_START && m.round === 3);
|
||
// two copies owned, the third in the shop: the bot buys it (merge) and must pick the reward offer it queues
|
||
const id = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && ps.countCopies(c.chessId) === 0).chessId;
|
||
const freeIdx = () => ps.hand.findIndex((x) => x == null);
|
||
give(m, ps, id, 'hand', freeIdx());
|
||
give(m, ps, id, 'hand', freeIdx());
|
||
ps.funds = 20;
|
||
h.run(() => m.phase === PHASE.PREP && m.round === 3);
|
||
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), price: m.gd.chessPrice(id), sold: false, frozen: false };
|
||
const picks = [];
|
||
const orig = ps.pickReward.bind(ps);
|
||
ps.pickReward = (i) => { const r = orig(i); picks.push(r); return r; };
|
||
const merges = ps.stats.merges;
|
||
h.run(() => m.phase === PHASE.COMBAT && m.round === 3);
|
||
assert.ok(ps.stats.merges > merges, 'the third copy was bought (merge)');
|
||
assert.ok(picks.some((r) => r && r.ok), 'the merge reward was taken in the same prep');
|
||
assert.ok(ps.allChess().some((p) => m.gd.goldenIdOf(id) === p.id), 'the elite is owned');
|
||
checkInvariants(m);
|
||
m.dispose();
|
||
});
|
||
|
||
test('band pick: alone, the bot avoids a band that withholds the first rounds\' funds (老鲤); in co-op it may take it', () => {
|
||
const counts = { solo: 0, coop: 0 };
|
||
for (let seed = 1; seed <= 60; seed++) {
|
||
for (const mode of ['solo', 'coop']) {
|
||
const h = makeMatch({ mode, difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed, fake: true });
|
||
if (botPickBand(h.m, h.m.order[0]) === 'band_lmlee') counts[mode]++;
|
||
h.m.dispose();
|
||
}
|
||
}
|
||
assert.ok(counts.solo <= 1, `solo picks 老鲤 ${counts.solo}/60`);
|
||
assert.ok(counts.coop >= 1, `co-op may pick it (${counts.coop}/60)`);
|
||
});
|