Files
game-forest/scripts/play.ts
T

291 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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<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).'));