/** 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 { 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 = 1200; const MAP_HEIGHT = 800; /** 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 = 400; const store = new GameStore(); 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, preRabbit: ReturnType): 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).'));