298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/** Headless test bot: plays the game continuously and prints every action it takes, timestamped
|
||
* with the in-game clock. Runs until interrupted with Ctrl+C.
|
||
*
|
||
* Policy: at night, flee the nearest rabbit (survival comes first, regardless of what else is
|
||
* available). By day, chop the nearest node (tree or bush, whichever is closer) if one exists;
|
||
* once none are left, hunt the nearest rabbit instead. Whenever coins can afford it, buy the
|
||
* cheapest currently-affordable upgrade (repeating until nothing more is affordable), printing
|
||
* every upgrade's level after each purchase.
|
||
*
|
||
* Ticks are not paced to real time — each tick always advances the simulated clock by a fixed
|
||
* TICK_SECONDS, and ticks run back-to-back as fast as the CPU allows (yielding briefly between
|
||
* batches only so Ctrl+C and console output stay responsive).
|
||
*
|
||
* Controls (when stdin is a terminal): Space pauses/resumes, Escape (or Ctrl+C) stops.
|
||
*
|
||
* Run with: npx tsx scripts/play.ts [tickSeconds]
|
||
*/
|
||
import readline from 'node:readline';
|
||
import { Analytics, makeAnalyticsGameInfo } from '../src/analytics';
|
||
import { ConsoleAnalyticsTransport } from '../src/analytics/ConsoleAnalyticsTransport';
|
||
import { bushesForDay, treesForDay } from '../src/game/constants';
|
||
import { formatClock } from '../src/game/daynight';
|
||
import { Rabbit } from '../src/game/entities/Rabbit';
|
||
import { ResourceNode } from '../src/game/entities/ResourceNode';
|
||
import { GameStore } from '../src/game/GameStore';
|
||
import { costForNextLevel, UPGRADE_LIST, type UpgradeId } from '../src/game/upgrades';
|
||
|
||
const MAP_WIDTH = 400;
|
||
const MAP_HEIGHT = 260;
|
||
/** How many simulated seconds pass per tick — fixed, independent of how fast ticks actually run. */
|
||
const TICK_SECONDS = Number(process.argv[2] ?? 0.1);
|
||
/** Ticks per event-loop turn between yields — batching amortizes the yield's overhead while
|
||
* still keeping Ctrl+C/output responsive within about one batch's worth of latency. */
|
||
const TICKS_PER_BATCH = 1000;
|
||
/** How far past the nearest rabbit to aim while fleeing it. */
|
||
const FLEE_DISTANCE = 133;
|
||
|
||
// Logs every analytics call to the console (see ConsoleAnalyticsTransport) instead of sending it
|
||
// anywhere — this is what actually lets `npm run play` be used to verify analytics behavior.
|
||
let store: GameStore;
|
||
const analytics = new Analytics(new ConsoleAnalyticsTransport(), () => makeAnalyticsGameInfo(store));
|
||
analytics.start();
|
||
store = new GameStore(analytics);
|
||
store.initialize(MAP_WIDTH, MAP_HEIGHT);
|
||
|
||
const startedAt = Date.now();
|
||
|
||
/** Total simulated seconds ticked so far — a monotonic counter independent of the in-game
|
||
* day/hour clock (which resets on death and runs at a variable, upgradable pace). */
|
||
let totalSimulatedSeconds = 0;
|
||
let lastPurchaseSimulatedSeconds = 0;
|
||
|
||
/** Real time spent paused so far, excluded from formatElapsed() so the displayed elapsed real
|
||
* time freezes while paused instead of continuing to tick. */
|
||
let pausedAccumulatedMs = 0;
|
||
let pauseStartedAt: number | null = null;
|
||
|
||
/** A duration (seconds) as HH:MM:SS. */
|
||
function formatDuration(totalSecondsRaw: number): string {
|
||
const totalSeconds = Math.floor(totalSecondsRaw);
|
||
const h = Math.floor(totalSeconds / 3600);
|
||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||
const s = totalSeconds % 60;
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
/** Real (wall-clock) time elapsed since the simulation started, as HH:MM:SS — excludes time spent
|
||
* paused, so it freezes instead of continuing to advance while paused. */
|
||
function formatElapsed(): string {
|
||
const now = Date.now();
|
||
const ongoingPauseMs = pauseStartedAt !== null ? now - pauseStartedAt : 0;
|
||
return formatDuration((now - startedAt - pausedAccumulatedMs - ongoingPauseMs) / 1000);
|
||
}
|
||
|
||
/** ANSI color codes — only applied when stdout is an actual terminal, so piped/redirected output
|
||
* (e.g. into a log file) stays plain text instead of full of escape sequences. */
|
||
const RED = '\x1b[31m';
|
||
const GOLD = '\x1b[93m';
|
||
const GRAY = '\x1b[90m';
|
||
const RESET = '\x1b[0m';
|
||
|
||
function colorize(text: string, color: string): string {
|
||
return process.stdout.isTTY ? `${color}${text}${RESET}` : text;
|
||
}
|
||
|
||
/** Inventory contents, resource names shortened to one emoji each. */
|
||
function formatInventory(): string {
|
||
const inv = store.inventory;
|
||
return `🪵${inv.logs} 🌿${inv.twigs} 🍖${inv.meat} 🪙${inv.coins.toFixed(1)}`;
|
||
}
|
||
|
||
function log(message: string, color?: string): void {
|
||
const line = `[+${formatElapsed()} | сим ${formatDuration(totalSimulatedSeconds)} | ${store.day}:${formatClock(store.hour)}] ${formatInventory()} ${message}`;
|
||
console.log(color ? colorize(line, color) : line);
|
||
}
|
||
|
||
function logUpgradeLevels(): void {
|
||
const summary = UPGRADE_LIST.map((def) => `${def.id}=${store.upgradeLevels[def.id]}`).join(' ');
|
||
console.log(` Апгрейды: ${summary}`);
|
||
}
|
||
|
||
/** Buys every upgrade the player can currently afford, cheapest first, until none are — costs
|
||
* rise after each purchase, so this re-checks from scratch rather than assuming one is enough. */
|
||
function buyAffordableUpgrades(): void {
|
||
for (;;) {
|
||
let cheapestId: UpgradeId | null = null;
|
||
let cheapestCost = Infinity;
|
||
for (const def of UPGRADE_LIST) {
|
||
const cost = costForNextLevel(def, store.upgradeLevels[def.id]);
|
||
if (store.inventory.coins >= cost && cost < cheapestCost) {
|
||
cheapestId = def.id;
|
||
cheapestCost = cost;
|
||
}
|
||
}
|
||
if (!cheapestId)
|
||
return;
|
||
|
||
store.buyUpgrade(cheapestId);
|
||
const def = UPGRADE_LIST.find((d) => d.id === cheapestId)!;
|
||
const sincePrevious = totalSimulatedSeconds - lastPurchaseSimulatedSeconds;
|
||
log(`Куплен апгрейд «${def.name}» за ${cheapestCost.toFixed(1)} — уровень ${store.upgradeLevels[cheapestId]}, с последней покупки ${sincePrevious.toFixed(1)}с`, GOLD);
|
||
lastPurchaseSimulatedSeconds = totalSimulatedSeconds;
|
||
logUpgradeLevels();
|
||
}
|
||
}
|
||
|
||
type Behavior = 'fleeing' | 'chopping' | 'hunting' | 'idle';
|
||
let behavior: Behavior = 'idle';
|
||
let targetId: string | null = null;
|
||
|
||
/** Picks what to do this tick and drives the character toward it — only logs when the decision
|
||
* actually changes (a new target, or switching behavior), not every tick it's re-affirmed. */
|
||
function decideAction(): void {
|
||
if (store.isNight) {
|
||
const rabbit = Rabbit.findNearestToPosition(store.rabbits, store.character);
|
||
if (rabbit) {
|
||
// Keyed on the behavior alone, not which rabbit happens to be nearest right now — while
|
||
// fleeing, the nearest rabbit's identity can flip from tick to tick as the character moves,
|
||
// which isn't a new action worth a fresh log line the way switching chop targets is.
|
||
if (behavior !== 'fleeing') {
|
||
log(`Ночь — убегает от кролика (${Math.round(rabbit.x)}, ${Math.round(rabbit.y)})`);
|
||
behavior = 'fleeing';
|
||
}
|
||
targetId = rabbit.id;
|
||
const dx = store.character.x - rabbit.x;
|
||
const dy = store.character.y - rabbit.y;
|
||
const distance = Math.hypot(dx, dy) || 1;
|
||
store.moveTo(
|
||
Math.min(Math.max(store.character.x + (dx / distance) * FLEE_DISTANCE, 0), MAP_WIDTH),
|
||
Math.min(Math.max(store.character.y + (dy / distance) * FLEE_DISTANCE, 0), MAP_HEIGHT),
|
||
);
|
||
return;
|
||
}
|
||
// No rabbits to flee from — fall through to the normal day-shaped behavior below.
|
||
}
|
||
|
||
const node = ResourceNode.findNearestToPosition(store.nodes, store.character);
|
||
if (node) {
|
||
if (behavior !== 'chopping' || targetId !== node.id) {
|
||
log(`Бежит к ${nodeEmoji(node.kind)} (${Math.round(node.x)}, ${Math.round(node.y)})`);
|
||
behavior = 'chopping';
|
||
targetId = node.id;
|
||
}
|
||
store.holdNode(node.id);
|
||
return;
|
||
}
|
||
|
||
const rabbit = Rabbit.findNearestToPosition(store.rabbits, store.character);
|
||
if (rabbit) {
|
||
if (behavior !== 'hunting' || targetId !== rabbit.id) {
|
||
log(`🌳🌱 нет — охотится на кролика (${Math.round(rabbit.x)}, ${Math.round(rabbit.y)})`);
|
||
behavior = 'hunting';
|
||
targetId = rabbit.id;
|
||
}
|
||
store.holdRabbit(rabbit.id);
|
||
return;
|
||
}
|
||
|
||
if (behavior !== 'idle') {
|
||
log('Нечего делать — ждёт.');
|
||
behavior = 'idle';
|
||
targetId = null;
|
||
}
|
||
store.release();
|
||
}
|
||
|
||
log(`Игра началась. Тик = ${TICK_SECONDS}с симуляции, без привязки к реальному времени.`);
|
||
|
||
function nodeEmoji(kind: 'tree' | 'bush'): string {
|
||
return kind === 'tree' ? '🌳' : '🌱';
|
||
}
|
||
|
||
/** Logs a node's depletion or a rabbit's death — not each individual chop/hit along the way, only
|
||
* this one-off outcome — snapshotting what's about to be chopped/attacked before the tick, since a
|
||
* depleted node or killed rabbit is removed from the store's arrays by the time the tick returns. */
|
||
function logCombatResults(preNode: ReturnType<typeof store.heldNode>, preRabbit: ReturnType<typeof store.heldRabbit>): void {
|
||
if (preNode && !store.nodes.some((n) => n.id === preNode.id))
|
||
log(`Срубил ${nodeEmoji(preNode.kind)} полностью`);
|
||
|
||
if (preRabbit && !store.rabbits.some((r) => r.id === preRabbit.id))
|
||
log('Убил кролика');
|
||
}
|
||
|
||
function runOneTick(): void {
|
||
if (store.isDead) {
|
||
log('Погиб! Начинается новая игра.', RED);
|
||
store.initialize(MAP_WIDTH, MAP_HEIGHT);
|
||
behavior = 'idle';
|
||
targetId = null;
|
||
}
|
||
|
||
decideAction();
|
||
|
||
const preNode = store.heldNode;
|
||
const preRabbit = store.heldRabbit;
|
||
|
||
store.tick(TICK_SECONDS);
|
||
totalSimulatedSeconds += TICK_SECONDS;
|
||
|
||
logCombatResults(preNode, preRabbit);
|
||
buyAffordableUpgrades();
|
||
}
|
||
|
||
let running = true;
|
||
let paused = false;
|
||
|
||
function runBatch(): void {
|
||
if (!running)
|
||
return;
|
||
if (!paused) {
|
||
for (let i = 0; i < TICKS_PER_BATCH; i++)
|
||
runOneTick();
|
||
}
|
||
// Rescheduled even while paused, so a paused run still notices Escape/Ctrl+C/Space promptly
|
||
// instead of sitting blocked on nothing at all.
|
||
setImmediate(runBatch);
|
||
}
|
||
|
||
setImmediate(runBatch);
|
||
|
||
/** Every 5 real seconds (not simulated — this fires on a wall-clock timer, independent of how
|
||
* fast ticks are actually running): time since the last upgrade purchase, this in-game day's
|
||
* resource cap (how many trees/bushes it allows), and what's actually still on the map. */
|
||
function printStats(): void {
|
||
const sinceLastPurchase = totalSimulatedSeconds - lastPurchaseSimulatedSeconds;
|
||
const treeCap = treesForDay(store.day);
|
||
const bushCap = bushesForDay(store.day);
|
||
const treesLeft = store.nodes.filter((n) => n.kind === 'tree').length;
|
||
const bushesLeft = store.nodes.filter((n) => n.kind === 'bush').length;
|
||
const logsLeft = store.nodes.reduce((sum, n) => sum + n.remaining.log, 0);
|
||
const twigsLeft = store.nodes.reduce((sum, n) => sum + n.remaining.twig, 0);
|
||
|
||
log(
|
||
`Последняя покупка ${sinceLastPurchase.toFixed(1)}с назад; `
|
||
+ `Ресурсы: 🌳${treesLeft}/${treeCap} 🌱${bushesLeft}/${bushCap} 🪵${logsLeft} 🌿${twigsLeft}`,
|
||
GRAY,
|
||
);
|
||
}
|
||
|
||
setInterval(printStats, 5000);
|
||
|
||
function stop(reason: string): void {
|
||
running = false;
|
||
console.log(`\n${reason}`);
|
||
process.exit(0);
|
||
}
|
||
|
||
function togglePause(): void {
|
||
paused = !paused;
|
||
if (paused) {
|
||
pauseStartedAt = Date.now();
|
||
} else if (pauseStartedAt !== null) {
|
||
pausedAccumulatedMs += Date.now() - pauseStartedAt;
|
||
pauseStartedAt = null;
|
||
}
|
||
log(paused ? 'Пауза (пробел — продолжить, Escape — остановить).' : 'Продолжение.');
|
||
}
|
||
|
||
if (process.stdin.isTTY) {
|
||
readline.emitKeypressEvents(process.stdin);
|
||
process.stdin.setRawMode(true);
|
||
process.stdin.resume();
|
||
process.stdin.on('keypress', (_str, key) => {
|
||
if (!key)
|
||
return;
|
||
if (key.name === 'space')
|
||
togglePause();
|
||
else if (key.name === 'escape')
|
||
stop('Остановлено пользователем (Escape).');
|
||
else if (key.ctrl && key.name === 'c')
|
||
stop('Остановлено пользователем (Ctrl+C).');
|
||
});
|
||
} else {
|
||
log('stdin не терминал — пробел/Escape недоступны, используйте Ctrl+C для остановки.');
|
||
}
|
||
|
||
process.on('SIGINT', () => stop('Остановлено пользователем (Ctrl+C).'));
|