diff --git a/package.json b/package.json index 22ce876..5bd405e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "oxlint", "preview": "vite preview", - "sim": "tsx scripts/simulate.ts" + "sim": "tsx scripts/simulate.ts", + "play": "tsx scripts/play.ts" }, "dependencies": { "break_eternity.js": "^2.1.3", diff --git a/scripts/play.ts b/scripts/play.ts new file mode 100644 index 0000000..b6e2b68 --- /dev/null +++ b/scripts/play.ts @@ -0,0 +1,290 @@ +/** 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).'));