From b997129b00704b5c7b6cf79d7a27418bdfa353e5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 3 Mar 2026 20:08:01 -0500 Subject: [PATCH] feat: DragonSlayer autonomous end-game runner, launcher, and tasks - DragonSlayer-Launcher.ps1 / DragonSlayer.bat: one-click launcher scripts - LAUNCHER_README.md: setup and usage documentation - docs/DRAGON_SLAYER_RC29.md: RC29 changelog and release notes - profiles/dragon-slayer.json: bot profile for dragon-killing run - src/agent/library/dragon_runner.js: autonomous ender dragon strategy - src/agent/library/dragon_progress.js: phase progress tracking - src/agent/library/progress_reporter.js: real-time milestone reporting - tasks/dragon/*.json: blaze rods, diamond pickaxe, ender pearls, nether portal, stronghold, full-run task definitions - tasks/human_evaluation.js, running_human_ai.md: evaluation harness Closes #dragon-slayer-feature --- DragonSlayer-Launcher.ps1 | 1122 +++++++++++++++++ DragonSlayer.bat | 24 + LAUNCHER_README.md | 239 ++++ docs/DRAGON_SLAYER_RC29.md | 173 +++ profiles/dragon-slayer.json | 124 ++ src/agent/library/dragon_progress.js | 358 ++++++ src/agent/library/dragon_runner.js | 1593 ++++++++++++++++++++++++ src/agent/library/progress_reporter.js | 231 ++++ tasks/dragon/blaze_rods.json | 15 + tasks/dragon/diamond_pickaxe.json | 17 + tasks/dragon/ender_dragon.json | 22 + tasks/dragon/ender_pearls.json | 15 + tasks/dragon/full_run.json | 13 + tasks/dragon/nether_portal.json | 13 + tasks/dragon/stronghold.json | 13 + tasks/human_evaluation.js | 8 +- tasks/running_human_ai.md | 2 +- 17 files changed, 3977 insertions(+), 5 deletions(-) create mode 100644 DragonSlayer-Launcher.ps1 create mode 100644 DragonSlayer.bat create mode 100644 LAUNCHER_README.md create mode 100644 docs/DRAGON_SLAYER_RC29.md create mode 100644 profiles/dragon-slayer.json create mode 100644 src/agent/library/dragon_progress.js create mode 100644 src/agent/library/dragon_runner.js create mode 100644 src/agent/library/progress_reporter.js create mode 100644 tasks/dragon/blaze_rods.json create mode 100644 tasks/dragon/diamond_pickaxe.json create mode 100644 tasks/dragon/ender_dragon.json create mode 100644 tasks/dragon/ender_pearls.json create mode 100644 tasks/dragon/full_run.json create mode 100644 tasks/dragon/nether_portal.json create mode 100644 tasks/dragon/stronghold.json diff --git a/DragonSlayer-Launcher.ps1 b/DragonSlayer-Launcher.ps1 new file mode 100644 index 000000000..b995352c5 --- /dev/null +++ b/DragonSlayer-Launcher.ps1 @@ -0,0 +1,1122 @@ +<# +.SYNOPSIS + DragonSlayer One-Click Launcher for Mindcraft v4.0 + RTX 3090 / CUDA Ollama / Windows 10-11 +.DESCRIPTION + Production-ready launcher that: + 0. Validates every prerequisite (Node 20, npm, Ollama, CUDA GPU, profile) + 1. Starts Ollama daemon if not already running + 2. Pulls required models (andy-4 q8_0 + nomic-embed-text + llava) + 3. (Optional) Starts a local Paper 1.21.x MC server with EULA prompt + 4. Launches DragonSlayer bot with live timestamped colorized log output + 5. Opens the MindServer HUD in the default browser + 6. (Optional) Sends !beatMinecraft via Socket.IO to start the dragon run + 7. Monitors for crash/keypress, then tears down everything gracefully + 8. (Optional) GitHub PR workflow: commit, push to fork, create/update PR + - Derives fork owner dynamically from git remote URL + - Feature branch creation when on a protected branch + - Staging confirmation before git add -A + - Safe merge (skips --delete-branch for protected branches) +.NOTES + Author : Mindcraft Research Rig + Version : 4.0.0 + Date : 2025-07-18 + License : MIT +#> + +#Requires -Version 5.1 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ═══════════════════════════════════════════════════════════════════════════════ +# CONFIG — Edit these values to match your environment +# ═══════════════════════════════════════════════════════════════════════════════ +$SCRIPT_DIR = $PSScriptRoot # Folder this .ps1 lives in +$MINDCRAFT_DIR = $SCRIPT_DIR # Project root (same folder) +$PROFILE_PATH = "./profiles/dragon-slayer.json" # Bot profile (relative) +$BOT_NAME = "DragonSlayer" # Must match profile "name" +$OLLAMA_MODELS = @( # Models to ensure are pulled + "sweaterdog/andy-4:q8_0" + "nomic-embed-text" + "llava" +) +$OLLAMA_PORT = 11434 # Ollama API port +$MINDSERVER_PORT = 8080 # MindServer HUD port +$MINDSERVER_URL = "http://localhost:$MINDSERVER_PORT" +$MC_HOST = "localhost" # Minecraft server host +$MC_PORT = 42069 # Minecraft server port + +# ── Paper server (optional — leave blank to skip) ── +$PAPER_JAR = "" # Full path to paper-*.jar +$PAPER_DIR = "" # Server working directory +$PAPER_PORT = 25565 # Server port +$PAPER_RAM = "4G" # Max heap (-Xmx) + +# ── Auto-!beatMinecraft ── +$AUTO_BEAT_MC = $false # $true = send without prompting +$BEAT_MC_DELAY = 15 # Seconds to wait after bot start + +# ── Cosmetics ── +$HUD_OPEN_DELAY = 8 # Seconds to poll before opening HUD +$LOG_TIMESTAMP = $true # Prefix bot output with HH:mm:ss + +# ── GitHub PR workflow (requires `gh` CLI authenticated) ── +$ENABLE_PR_WORKFLOW = $true # Offer PR submission on shutdown +$PR_FORK_REMOTE = "fork" # Git remote pointing to your public fork +$PR_TARGET_REPO = "mindcraft-bots/mindcraft" # Upstream repo (owner/name) +$PR_BASE_BRANCH = "develop" # Target branch for PRs +$PR_PROTECTED = @('main', 'master', 'develop') # Branches that --delete-branch skips + +# ═══════════════════════════════════════════════════════════════════════════════ +# STATE VARIABLES (do not edit) +# ═══════════════════════════════════════════════════════════════════════════════ +$script:OllamaStartedByUs = $false +$script:BotProcess = $null +$script:PaperProcess = $null +$script:LaunchTime = $null +$script:StepCounter = 0 + +# ═══════════════════════════════════════════════════════════════════════════════ +# HELPERS +# ═══════════════════════════════════════════════════════════════════════════════ + +function Write-Banner { + $dragon = @" + + ___ ____ __ + / _ \ _ __ __ _ __ _ ___ _ __ / ___|| | __ _ _ _ ___ _ __ + / / \ \ '__/ _` |/ _` |/ _ \| '_ \\___ \| |/ _` | | | |/ _ \ '__| + / /_/ / | | (_| | (_| | (_) | | | |___) | | (_| | |_| | __/ | + /_____/|_| \__,_|\__, |\___/|_| |_|____/|_|\__,_|\__, |\___|_| + |___/ |___/ +"@ + Write-Host "" + Write-Host $dragon -ForegroundColor Red + + $sub = @" + +===============================================================+ + | Mindcraft Autonomous Ender-Dragon Speedrun - Launcher v4 | + | GPU: NVIDIA GeForce RTX 3090 - Model: andy-4 q8_0 | + +===============================================================+ +"@ + Write-Host $sub -ForegroundColor DarkRed + Write-Host "" +} + +function Write-Section ([string]$title) { + $script:StepCounter++ + $n = $script:StepCounter + Write-Host "" + Write-Host " -- Step $n : $title -----------------------------------------------" -ForegroundColor White +} + +function Write-Step ([string]$msg) { Write-Host " [*] $msg" -ForegroundColor Cyan } +function Write-Ok ([string]$msg) { Write-Host " [+] $msg" -ForegroundColor Green } +function Write-Warn ([string]$msg) { Write-Host " [!] $msg" -ForegroundColor Yellow } +function Write-Err ([string]$msg) { Write-Host " [-] $msg" -ForegroundColor Red } +function Write-Info ([string]$msg) { Write-Host " $msg" -ForegroundColor DarkGray } + +function Write-ProgressBar ([string]$label, [int]$pct) { + $pct = [math]::Max(0, [math]::Min(100, $pct)) + $filled = [math]::Floor($pct / 2) + $empty = 50 - $filled + $bar = ([char]0x2588).ToString() * $filled + ([char]0x2591).ToString() * $empty + Write-Host "`r [$bar] $($pct.ToString().PadLeft(3))% $label " -NoNewline -ForegroundColor Magenta +} + +function Write-ProgressDone { Write-Host "" } # newline after a progress bar + +function Write-Box ([string[]]$lines, [ConsoleColor]$color = 'Yellow') { + $maxLen = ($lines | Measure-Object -Property Length -Maximum).Maximum + $w = $maxLen + 4 + $top = "+" + ("-" * $w) + "+" + $bot = "+" + ("-" * $w) + "+" + Write-Host " $top" -ForegroundColor $color + foreach ($l in $lines) { + $pad = ' ' * ($maxLen - $l.Length) + Write-Host " | $l$pad |" -ForegroundColor $color + } + Write-Host " $bot" -ForegroundColor $color +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 0 — PRE-FLIGHT CHECKS +# ═══════════════════════════════════════════════════════════════════════════════ +function Test-Prerequisites { + Write-Section "Pre-Flight Checks" + $pass = $true + + # ── Node.js ── + $nodeVer = $null + try { $nodeVer = (node --version 2>$null) } catch {} + if (-not $nodeVer) { + Write-Err "Node.js not found!" + Write-Info "Install Node.js v20 LTS -> https://nodejs.org/" + $pass = $false + } else { + $major = [int]($nodeVer -replace '^v','').Split('.')[0] + if ($major -lt 18) { + Write-Err "Node.js $nodeVer is too old (need v18+). Get v20 LTS: https://nodejs.org/" + $pass = $false + } elseif ($major -ge 24) { + Write-Warn "Node.js $nodeVer (v24+ may have compatibility issues; v20 LTS recommended)" + } else { + Write-Ok "Node.js $nodeVer" + } + } + + # ── npm ── + $npmVer = $null + try { $npmVer = (npm --version 2>$null) } catch {} + if (-not $npmVer) { + Write-Err "npm not found! Reinstall Node.js: https://nodejs.org/" + $pass = $false + } else { + Write-Ok "npm v$npmVer" + } + + # ── Ollama ── + $ollamaCmd = $null + try { $ollamaCmd = Get-Command ollama -ErrorAction SilentlyContinue } catch {} + if (-not $ollamaCmd) { + Write-Err "Ollama not installed!" + Write-Info "Download -> https://ollama.com/download/windows" + $pass = $false + } else { + Write-Ok "Ollama: $($ollamaCmd.Source)" + } + + # ── NVIDIA GPU ── + try { + $gpuInfo = nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>$null + if ($gpuInfo) { + Write-Ok "GPU: $($gpuInfo.Trim())" + } else { throw "no output" } + } catch { + Write-Warn "nvidia-smi not found -- CUDA acceleration may be unavailable" + Write-Info "Get drivers -> https://www.nvidia.com/drivers" + } + + # ── CUDA version ── + try { + $cudaLine = (nvidia-smi 2>$null) | Select-String "CUDA Version" + if ($cudaLine) { + $cudaVer = ($cudaLine.ToString() -replace '.*CUDA Version:\s*','').Trim(' |') + Write-Ok "CUDA $cudaVer" + } + } catch {} + + # ── Mindcraft project ── + $pkgJson = Join-Path $MINDCRAFT_DIR "package.json" + if (-not (Test-Path $pkgJson)) { + Write-Err "Mindcraft project not found at: $MINDCRAFT_DIR" + Write-Info "Place this script inside the mindcraft-0.1.3/ folder." + $pass = $false + } else { + Write-Ok "Mindcraft project root OK" + } + + # ── .env / keys ── + $envFile = Join-Path $MINDCRAFT_DIR ".env" + $keysFile = Join-Path $MINDCRAFT_DIR "keys.json" + if (Test-Path $envFile) { + Write-Ok ".env present" + } elseif (Test-Path $keysFile) { + Write-Ok "keys.json present (no .env)" + } else { + Write-Info "No .env or keys.json -- API keys must come from environment variables" + } + + # ── node_modules ── + $nmDir = Join-Path $MINDCRAFT_DIR "node_modules" + if (-not (Test-Path $nmDir)) { + Write-Warn "node_modules/ missing -- installing dependencies..." + Push-Location $MINDCRAFT_DIR + try { + $out = npm install 2>&1 + $out | ForEach-Object { Write-Info $_ } + if ($LASTEXITCODE -ne 0) { throw "npm install exited with code $LASTEXITCODE" } + Write-Ok "npm install succeeded" + } catch { + Write-Err "npm install failed: $_" + Write-Info "Try deleting node_modules/ and package-lock.json, then run: npm install" + Pop-Location + $pass = $false + } + Pop-Location + } else { + Write-Ok "node_modules/ present" + } + + # ── Bot profile ── + $profileFull = Join-Path $MINDCRAFT_DIR ($PROFILE_PATH -replace '^\.\/', '') + if (-not (Test-Path $profileFull)) { + Write-Err "Profile not found: $profileFull" + Write-Info "Expected: $PROFILE_PATH" + $pass = $false + } else { + Write-Ok "Profile: $PROFILE_PATH" + } + + if ($pass) { + Write-Host "" + Write-Ok "All pre-flight checks passed!" + } + return $pass +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 1 — START OLLAMA +# ═══════════════════════════════════════════════════════════════════════════════ +function Start-OllamaServer { + Write-Section "Ollama Server" + $baseUrl = "http://localhost:$OLLAMA_PORT" + + # Already running? + $running = $false + try { + $r = Invoke-WebRequest -Uri "$baseUrl/api/tags" -TimeoutSec 3 -ErrorAction SilentlyContinue + if ($r.StatusCode -eq 200) { $running = $true } + } catch {} + + if ($running) { + Write-Ok "Ollama already running on :$OLLAMA_PORT" + return $true + } + + Write-Step "Starting Ollama daemon..." + $ollamaExe = (Get-Command ollama).Source + $si = New-Object System.Diagnostics.ProcessStartInfo + $si.FileName = $ollamaExe + $si.Arguments = "serve" + $si.UseShellExecute = $false + $si.CreateNoWindow = $true + $si.RedirectStandardOutput = $true + $si.RedirectStandardError = $true + $proc = [System.Diagnostics.Process]::Start($si) + $script:OllamaStartedByUs = $true + + # Poll until healthy + $ready = $false + for ($i = 0; $i -lt 40; $i++) { + Start-Sleep -Milliseconds 500 + try { + $r = Invoke-WebRequest -Uri "$baseUrl/api/tags" -TimeoutSec 2 -ErrorAction SilentlyContinue + if ($r.StatusCode -eq 200) { $ready = $true; break } + } catch {} + Write-ProgressBar "Ollama starting..." ([math]::Min(95, $i * 2 + 5)) + } + Write-ProgressDone + + if ($ready) { + Write-Ok "Ollama started (PID $($proc.Id))" + return $true + } + + Write-Err "Ollama did not become healthy within 20 s" + Write-Info "Try running 'ollama serve' in another terminal, then re-launch." + return $false +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 2 — PULL MODELS +# ═══════════════════════════════════════════════════════════════════════════════ +function Install-OllamaModels { + Write-Section "Ollama Models" + $baseUrl = "http://localhost:$OLLAMA_PORT" + + # Fetch installed list once + $installed = @() + try { + $json = (Invoke-WebRequest -Uri "$baseUrl/api/tags" -TimeoutSec 10).Content | + ConvertFrom-Json + $installed = $json.models | ForEach-Object { $_.name } + } catch { + Write-Warn "Could not query installed models: $_" + } + + $total = $OLLAMA_MODELS.Count + $idx = 0 + foreach ($model in $OLLAMA_MODELS) { + $idx++ + $tag = "[$idx/$total]" + + # Match: exact, with :latest suffix, or prefix match + $found = $false + foreach ($inst in $installed) { + if ($inst -eq $model -or $inst -eq "${model}:latest" -or $inst.StartsWith("${model}:")) { + $found = $true; break + } + } + + if ($found) { + Write-Ok "$tag $model (ready)" + continue + } + + Write-Step "$tag Pulling $model -- first run downloads the full model..." + try { + $p = Start-Process -FilePath "ollama" -ArgumentList "pull $model" ` + -NoNewWindow -PassThru -Wait + if ($p.ExitCode -eq 0) { + Write-Ok "$tag $model (pulled)" + } else { + Write-Err "$tag Pull failed (exit $($p.ExitCode)). Try: ollama pull $model" + return $false + } + } catch { + Write-Err "$tag Error pulling ${model}: $_" + return $false + } + } + return $true +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 3 (OPTIONAL) — LOCAL PAPER MC SERVER +# ═══════════════════════════════════════════════════════════════════════════════ +function Start-LocalPaperServer { + # Skip entirely when no jar configured + if (-not $PAPER_JAR -or -not (Test-Path $PAPER_JAR)) { return } + + Write-Section "Local Paper MC Server" + Write-Box @( + "Paper 1.21.x server JAR detected!" + "Start it for local testing? (Y/N)" + ) Yellow + + $choice = Read-Host " Start local server? [y/N]" + if ($choice -notmatch '^[Yy]') { + Write-Info "Skipped local Paper server." + return + } + + # Java check + Write-Step "Checking Java..." + try { + $javaOut = java -version 2>&1 + $javaVer = ($javaOut | Select-Object -First 1).ToString() + Write-Ok "Java: $javaVer" + } catch { + Write-Err "Java not found! Paper MC requires Java 21+." + Write-Info "Download -> https://adoptium.net/" + return + } + + $serverDir = if ($PAPER_DIR) { $PAPER_DIR } else { Split-Path $PAPER_JAR -Parent } + + # EULA + $eulaFile = Join-Path $serverDir "eula.txt" + $eulaOk = $false + if ((Test-Path $eulaFile) -and ((Get-Content $eulaFile -Raw) -match 'eula=true')) { + $eulaOk = $true + } + if (-not $eulaOk) { + Write-Warn "Minecraft EULA has not been accepted." + Write-Info "Review: https://aka.ms/MinecraftEULA" + $accept = Read-Host " Accept EULA? [y/N]" + if ($accept -match '^[Yy]') { + "eula=true" | Set-Content -Path $eulaFile -Encoding UTF8 + Write-Ok "EULA accepted" + } else { + Write-Warn "Cannot start server without accepting the EULA." + return + } + } + + Write-Step "Starting Paper on port $PAPER_PORT (Xmx=$PAPER_RAM)..." + $si = New-Object System.Diagnostics.ProcessStartInfo + $si.FileName = "java" + $si.Arguments = "-Xms1G -Xmx$PAPER_RAM -jar `"$PAPER_JAR`" --port $PAPER_PORT --nogui" + $si.WorkingDirectory = $serverDir + $si.UseShellExecute = $false + $si.CreateNoWindow = $true + $si.RedirectStandardOutput = $true + $si.RedirectStandardError = $true + + try { + $proc = [System.Diagnostics.Process]::Start($si) + $script:PaperProcess = $proc + Write-Ok "Paper starting (PID $($proc.Id)) on :$PAPER_PORT" + Write-Info "Server needs ~30 s to fully load. The bot auto-retries connections." + Start-Sleep -Seconds 3 + } catch { + Write-Warn "Failed to start Paper: $_" + Write-Warn "Continuing without local server." + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 4 — START THE BOT +# ═══════════════════════════════════════════════════════════════════════════════ +function Start-MindcraftBot { + Write-Section "DragonSlayer Bot" + Write-Step "Launching: node main.js --profiles $PROFILE_PATH" + + Push-Location $MINDCRAFT_DIR + $nodeExe = (Get-Command node).Source + + $si = New-Object System.Diagnostics.ProcessStartInfo + $si.FileName = $nodeExe + $si.Arguments = "main.js --profiles $PROFILE_PATH" + $si.WorkingDirectory = $MINDCRAFT_DIR + $si.UseShellExecute = $false + $si.CreateNoWindow = $false + $si.RedirectStandardOutput = $true + $si.RedirectStandardError = $true + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $si + $proc.EnableRaisingEvents = $true + + # ── Colorized async log handlers ── + $useTs = $LOG_TIMESTAMP + $outputAction = { + if ([string]::IsNullOrEmpty($EventArgs.Data)) { return } + $line = $EventArgs.Data + $prefix = if ($Event.MessageData) { (Get-Date).ToString("HH:mm:ss ") } else { "" } + $tag = " ${prefix}BOT | " + + $color = switch -Regex ($line) { + 'error|Error|ERROR|exception|Exception|FATAL|ECONNREFUSED' { 'Red'; break } + 'warn|Warn|WARN|deprecated' { 'Yellow'; break } + 'dragon|Dragon|DRAGON|ender|Ender|beat\s*minecraft' { 'Magenta'; break } + 'victory|Victory|VICTORY|completed|progression' { 'Magenta'; break } + 'connected|ready|started|logged.in|spawned|Logged in' { 'Green'; break } + 'nether|blaze|stronghold|portal|end.portal|fortress' { 'Blue'; break } + 'diamond|iron|craft|smelt|enchant|bucket' { 'DarkCyan'; break } + default { 'Gray' } + } + Write-Host "$tag$line" -ForegroundColor $color + } + + $errorAction = { + if ([string]::IsNullOrEmpty($EventArgs.Data)) { return } + $prefix = if ($Event.MessageData) { (Get-Date).ToString("HH:mm:ss ") } else { "" } + Write-Host " ${prefix}BOT | $($EventArgs.Data)" -ForegroundColor DarkYellow + } + + Register-ObjectEvent -InputObject $proc -EventName OutputDataReceived ` + -Action $outputAction -MessageData $useTs | Out-Null + Register-ObjectEvent -InputObject $proc -EventName ErrorDataReceived ` + -Action $errorAction -MessageData $useTs | Out-Null + + $proc.Start() | Out-Null + $proc.BeginOutputReadLine() + $proc.BeginErrorReadLine() + + $script:BotProcess = $proc + $script:LaunchTime = Get-Date + Pop-Location + + Write-Ok "Bot launched (PID $($proc.Id))" + return $true +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 5 — OPEN HUD IN BROWSER +# ═══════════════════════════════════════════════════════════════════════════════ +function Open-MindServerHUD { + Write-Section "MindServer HUD" + Write-Step "Waiting for $MINDSERVER_URL ..." + + $ready = $false + $maxPolls = $HUD_OPEN_DELAY * 2 # poll every 500ms + for ($i = 0; $i -lt $maxPolls; $i++) { + Start-Sleep -Milliseconds 500 + try { + $r = Invoke-WebRequest -Uri $MINDSERVER_URL -TimeoutSec 2 -ErrorAction SilentlyContinue + if ($r.StatusCode -eq 200) { $ready = $true; break } + } catch {} + Write-ProgressBar "MindServer..." ([math]::Min(95, [math]::Floor(($i+1) / $maxPolls * 100))) + } + Write-ProgressDone + + if ($ready) { + Start-Process $MINDSERVER_URL + Write-Ok "HUD opened in browser: $MINDSERVER_URL" + } else { + Write-Warn "MindServer not responding yet." + Write-Info "Open manually when ready: $MINDSERVER_URL" + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# STEP 6 — SEND !beatMinecraft VIA SOCKET.IO +# ═══════════════════════════════════════════════════════════════════════════════ +function Send-BeatMinecraftCommand { + param([bool]$SkipPrompt = $false) + + Write-Section "Dragon Run Command" + + if (-not $SkipPrompt) { + Write-Box @( + "Send !beatMinecraft to start the dragon run?" + "(The bot must be connected to the MC server)" + ) Magenta + + $choice = Read-Host " Send !beatMinecraft? [Y/n]" + if ($choice -match '^[Nn]') { + Write-Info "Skipped. Send manually from the HUD or in-game chat." + return + } + } else { + Write-Step "AUTO_BEAT_MC is enabled -- will send automatically." + } + + # Wait for bot to finish joining the server + Write-Step "Waiting $BEAT_MC_DELAY s for bot to connect to Minecraft..." + for ($i = 0; $i -lt $BEAT_MC_DELAY; $i++) { + Start-Sleep -Seconds 1 + Write-ProgressBar "Bot connecting..." ([math]::Floor(($i + 1) / $BEAT_MC_DELAY * 100)) + } + Write-ProgressDone + + # Verify socket.io-client is available in node_modules + $nmSocket = Join-Path $MINDCRAFT_DIR "node_modules" "socket.io-client" + if (-not (Test-Path $nmSocket)) { + Write-Warn "socket.io-client not found in node_modules/." + Write-Info "Run 'npm install' then retry, or send the command from the HUD." + return + } + + # Write a temp .mjs file to avoid shell-quoting issues + $nodeExe = (Get-Command node).Source + $tempJs = Join-Path $env:TEMP "ds_beat_mc_$PID.mjs" + $jsCode = @" +import { io } from 'socket.io-client'; +const url = 'http://localhost:$MINDSERVER_PORT'; +const bot = '$BOT_NAME'; +const cmd = '!beatMinecraft'; +const sock = io(url, { reconnection: false, timeout: 5000 }); +sock.on('connect', () => { + sock.emit('send-message', bot, { from: 'ADMIN', message: cmd }); + console.log('Sent ' + cmd + ' to ' + bot + ' via ' + url); + setTimeout(() => process.exit(0), 600); +}); +sock.on('connect_error', (e) => { + console.error('Connection failed: ' + e.message); + process.exit(1); +}); +setTimeout(() => { console.error('Timeout connecting to MindServer'); process.exit(1); }, 8000); +"@ + # Save any pre-existing NODE_PATH so we can restore it after + $origNodePath = $env:NODE_PATH + try { + $jsCode | Set-Content -Path $tempJs -Encoding UTF8 -Force + + # Run with the project's node_modules on the resolve path + $env:NODE_PATH = Join-Path $MINDCRAFT_DIR "node_modules" + $result = & $nodeExe $tempJs 2>&1 + $code = $LASTEXITCODE + + if ($code -eq 0) { + Write-Ok "!beatMinecraft sent to $BOT_NAME -- dragon run initiated!" + $msg = ($result | Out-String).Trim() + if ($msg) { Write-Info $msg } + } else { + Write-Warn "Could not send !beatMinecraft automatically (exit $code)." + $msg = ($result | Out-String).Trim() + if ($msg) { Write-Info $msg } + Write-Info "Send it manually from the HUD chat box or type in Minecraft." + } + } catch { + Write-Warn "Failed to send command: $_" + Write-Info "Send !beatMinecraft manually from the HUD or in-game chat." + } finally { + Remove-Item $tempJs -Force -ErrorAction SilentlyContinue + # Restore original NODE_PATH (or remove if there wasn't one) + if ($origNodePath) { $env:NODE_PATH = $origNodePath } + else { Remove-Item Env:\NODE_PATH -ErrorAction SilentlyContinue } + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# GITHUB PR WORKFLOW — commit, push to fork, create/update PR to upstream +# ═══════════════════════════════════════════════════════════════════════════════ +function Submit-PullRequest { + if (-not $ENABLE_PR_WORKFLOW) { return } + + Write-Host "" + Write-Host " ===========================================================" -ForegroundColor Cyan + Write-Host " GITHUB PR WORKFLOW (v4.0)" -ForegroundColor Cyan + Write-Host " ===========================================================" -ForegroundColor Cyan + Write-Host "" + + # ── 0. Check for gh CLI ── + if (-not (Get-Command "gh" -ErrorAction SilentlyContinue)) { + Write-Warn "gh CLI not found -- skipping PR workflow." + Write-Info "Install: https://cli.github.com/" + return + } + + # ── 1. Check gh authentication (capture exit code before piping) ── + $authRaw = & gh auth status 2>&1 + $authCode = $LASTEXITCODE + if ($authCode -ne 0) { + Write-Warn "gh is not authenticated -- skipping PR workflow." + Write-Info "Run: gh auth login" + return + } + + # ── 2. Check for git ── + if (-not (Get-Command "git" -ErrorAction SilentlyContinue)) { + Write-Warn "git not found -- skipping PR workflow." + return + } + + # ── 3. Check we're inside a git repo ── + Push-Location $MINDCRAFT_DIR + try { + $null = & git rev-parse --git-dir 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warn "Not a git repository -- skipping PR workflow." + return + } + + # ── 4. Derive fork owner from remote URL (replaces hardcoded username) ── + $forkUrl = (& git remote get-url $PR_FORK_REMOTE 2>&1).Trim() + $forkOwner = $null + # Matches: git@github.com:Owner/repo or https://github.com/Owner/repo + if ($forkUrl -match 'github\.com[:/]([^/]+)/') { + $forkOwner = $Matches[1] + Write-Ok "Fork owner: $forkOwner (from '$PR_FORK_REMOTE' remote)" + } else { + Write-Warn "Could not parse fork owner from remote '$PR_FORK_REMOTE'." + Write-Info "Remote URL: $forkUrl" + $forkOwner = Read-Host " Enter your GitHub username" + if ([string]::IsNullOrWhiteSpace($forkOwner)) { + Write-Err "Cannot create cross-repo PR without fork owner. Aborting." + return + } + } + + # ── 5. Check for uncommitted changes ── + $status = & git status --porcelain 2>&1 + $branch = (& git branch --show-current 2>&1).Trim() + $changedCount = ($status | Where-Object { $_ -match '\S' }).Count + + if ($changedCount -eq 0) { + Write-Info "Working tree is clean -- nothing to commit." + Write-Host "" + + # Still offer to push existing commits / create PR + $aheadCount = 0 + try { + $aheadRaw = & git rev-list --count "${PR_FORK_REMOTE}/${branch}..HEAD" 2>&1 + $aheadCode = $LASTEXITCODE + if ($aheadCode -eq 0) { $aheadCount = [int]$aheadRaw } + } catch {} + + if ($aheadCount -eq 0) { + Write-Ok "Branch '$branch' is up to date with $PR_FORK_REMOTE. No PR needed." + return + } + Write-Info "$aheadCount unpushed commit(s) on '$branch'." + } else { + Write-Info "$changedCount file(s) with uncommitted changes on branch '$branch':" + Write-Host "" + # Show a compact status summary (max 15 lines) + $statusLines = $status | Select-Object -First 15 + foreach ($line in $statusLines) { + $code = $line.Substring(0, 2) + $file = $line.Substring(3) + $color = switch -Regex ($code) { + '^\?\?' { 'DarkYellow' } # untracked + '^M' { 'Yellow' } # modified + '^A' { 'Green' } # added + '^D' { 'Red' } # deleted + '^R' { 'Cyan' } # renamed + default { 'Gray' } + } + Write-Host " $code $file" -ForegroundColor $color + } + if ($changedCount -gt 15) { + Write-Host " ... and $($changedCount - 15) more" -ForegroundColor DarkGray + } + Write-Host "" + } + + # ── 6. Feature branch option (when on a protected branch with changes) ── + if ($branch -in $PR_PROTECTED -and $changedCount -gt 0) { + Write-Warn "You are on '$branch' (a protected branch)." + Write-Info "Creating a feature branch is recommended for clean PRs." + Write-Host "" + Write-Host " Enter a feature branch name, or press Enter to stay on '$branch':" -ForegroundColor Cyan + $featureName = Read-Host " Branch" + if (-not [string]::IsNullOrWhiteSpace($featureName)) { + $featureName = $featureName.Trim() -replace '\s+', '-' + $branchRaw = & git checkout -b $featureName 2>&1 + $branchCode = $LASTEXITCODE + if ($branchCode -ne 0) { + Write-Err "Failed to create branch '$featureName': $($branchRaw | Out-String)" + return + } + $branch = $featureName + Write-Ok "Switched to new branch: $branch" + } + } + + # ── 7. Prompt: submit PR? ── + Write-Host " Submit these changes as a Pull Request to $PR_TARGET_REPO?" -ForegroundColor Cyan + Write-Host " [Y] Yes [N] No (default: N)" -ForegroundColor DarkGray + Write-Host "" + $prChoice = Read-Host " PR workflow" + if ($prChoice -notmatch '^[Yy]') { + Write-Info "PR workflow skipped." + return + } + + # ── 8. Commit if there are uncommitted changes ── + if ($changedCount -gt 0) { + Write-Host "" + Write-Host " Enter commit message (or press Enter for default):" -ForegroundColor Cyan + $defaultMsg = "DragonSlayer session update $(Get-Date -Format 'yyyy-MM-dd HH:mm')" + Write-Host " Default: $defaultMsg" -ForegroundColor DarkGray + $commitMsg = Read-Host " Message" + if ([string]::IsNullOrWhiteSpace($commitMsg)) { $commitMsg = $defaultMsg } + + # Warn before git add -A — user should verify .gitignore covers secrets + Write-Warn "'git add -A' will stage ALL changes (including untracked files)." + Write-Info "Ensure .env, keys.json, etc. are in .gitignore before proceeding." + Write-Host " Proceed with staging? [Y/n]" -ForegroundColor Cyan + $stageChoice = Read-Host " Stage" + if ($stageChoice -match '^[Nn]') { + Write-Info "Staging cancelled. Stage files manually with 'git add', then re-run." + return + } + + Write-Step "Staging all changes..." + & git add -A 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Err "git add failed." + return + } + Write-Ok "Staged" + + Write-Step "Committing: $commitMsg" + & git commit -m $commitMsg 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Err "git commit failed." + return + } + Write-Ok "Committed" + + # Re-capture file diff after commit so PR body is accurate (not stale) + $status = & git diff --stat HEAD~1 2>&1 + } + + # ── 9. Push to fork remote (capture exit code before piping) ── + Write-Step "Pushing '$branch' to remote '$PR_FORK_REMOTE'..." + $pushRaw = & git push $PR_FORK_REMOTE $branch 2>&1 + $pushCode = $LASTEXITCODE + $pushOutput = $pushRaw | Out-String + if ($pushCode -ne 0) { + Write-Err "git push failed:" + Write-Host " $pushOutput" -ForegroundColor Red + return + } + Write-Ok "Pushed to $PR_FORK_REMOTE/$branch" + + # ── 10. Check for existing PR (owner-qualified --head ref) ── + Write-Step "Checking for existing PR..." + $headRef = "${forkOwner}:${branch}" + $existingRaw = & gh pr list --repo $PR_TARGET_REPO --head $headRef --state open --json number,title,url 2>&1 + $existingCode = $LASTEXITCODE + $existingStr = $existingRaw | Out-String + $prList = @() + try { $prList = $existingStr | ConvertFrom-Json } catch {} + + if ($prList.Count -gt 0) { + $pr = $prList[0] + Write-Ok "Existing PR #$($pr.number): $($pr.title)" + Write-Info "URL: $($pr.url)" + Write-Info "Push updated the PR automatically (commits added to existing branch)." + } else { + # ── 11. Create new PR ── + Write-Step "Creating new Pull Request..." + Write-Host "" + Write-Host " Enter PR title (or press Enter for default):" -ForegroundColor Cyan + $defaultTitle = "DragonSlayer: $branch updates" + Write-Host " Default: $defaultTitle" -ForegroundColor DarkGray + $prTitle = Read-Host " Title" + if ([string]::IsNullOrWhiteSpace($prTitle)) { $prTitle = $defaultTitle } + + # Build PR body with post-commit diff stats (not stale pre-commit status) + $statusText = $status | Out-String + $prBody = @" +## Changes + +Pushed from DragonSlayer Launcher v4.0 PR workflow. + +**Branch:** ``$branch`` +**Date:** $(Get-Date -Format 'yyyy-MM-dd HH:mm UTC') +**Machine:** $env:COMPUTERNAME + +### Files changed +`````` +$statusText +`````` + +--- +*Auto-generated by DragonSlayer-Launcher.ps1 v4.0 PR workflow* +"@ + + $createArgs = @( + "pr", "create", + "--repo", $PR_TARGET_REPO, + "--base", $PR_BASE_BRANCH, + "--head", $headRef, + "--title", $prTitle, + "--body", $prBody + ) + $createRaw = & gh @createArgs 2>&1 + $createCode = $LASTEXITCODE + $createOutput = $createRaw | Out-String + if ($createCode -ne 0) { + Write-Err "PR creation failed:" + Write-Host " $createOutput" -ForegroundColor Red + return + } + # gh pr create returns the URL + $prUrl = $createOutput.Trim() + Write-Ok "PR created: $prUrl" + } + + # ── 12. Optional: merge the PR (safe: skip --delete-branch for protected) ── + Write-Host "" + Write-Host " Merge the PR now? (requires write access to $PR_TARGET_REPO)" -ForegroundColor Cyan + Write-Host " [Y] Yes [N] No (default: N)" -ForegroundColor DarkGray + $mergeChoice = Read-Host " Merge" + if ($mergeChoice -match '^[Yy]') { + Write-Step "Merging PR..." + $mergeArgs = @("pr", "merge", "--repo", $PR_TARGET_REPO, "--squash") + if ($branch -notin $PR_PROTECTED) { + $mergeArgs += "--delete-branch" + } else { + Write-Info "Skipping --delete-branch (protected branch: $branch)" + } + $mergeRaw = & gh @mergeArgs 2>&1 + $mergeCode = $LASTEXITCODE + $mergeOutput = $mergeRaw | Out-String + if ($mergeCode -eq 0) { + Write-Ok "PR merged successfully!" + } else { + Write-Warn "Merge failed (you may not have write access):" + Write-Host " $mergeOutput" -ForegroundColor Yellow + } + } else { + Write-Info "PR left open for review." + } + + } finally { + Pop-Location + } +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# GRACEFUL SHUTDOWN +# ═══════════════════════════════════════════════════════════════════════════════ +function Stop-Everything { + Write-Host "" + Write-Host " ===========================================================" -ForegroundColor Yellow + Write-Host " SHUTTING DOWN DRAGONSLAYER..." -ForegroundColor Yellow + Write-Host " ===========================================================" -ForegroundColor Yellow + + # Session duration + if ($script:LaunchTime) { + $dur = (Get-Date) - $script:LaunchTime + Write-Info ("Session duration: {0:hh\:mm\:ss}" -f $dur) + } + + # 1) Kill bot process tree (taskkill /T works on PS 5.1; .Kill($true) needs .NET 5+) + if ($script:BotProcess -and -not $script:BotProcess.HasExited) { + $bPid = $script:BotProcess.Id + Write-Step "Stopping bot (PID $bPid)..." + try { + # taskkill /T = kill entire process tree (works on all Windows PS versions) + $null = & taskkill /F /T /PID $bPid 2>&1 + } catch { + try { $script:BotProcess.Kill() } catch {} + } + try { $script:BotProcess.WaitForExit(5000) } catch {} + Write-Ok "Bot stopped" + } + + # 2) Kill Paper server + if ($script:PaperProcess -and -not $script:PaperProcess.HasExited) { + $pPid = $script:PaperProcess.Id + Write-Step "Stopping Paper server (PID $pPid)..." + try { + $null = & taskkill /F /T /PID $pPid 2>&1 + } catch { + try { $script:PaperProcess.Kill() } catch {} + } + try { $script:PaperProcess.WaitForExit(5000) } catch {} + Write-Ok "Paper stopped" + } + + # 3) Stop Ollama only if we started it + if ($script:OllamaStartedByUs) { + Write-Step "Stopping Ollama (we started it)..." + try { + Get-Process -Name "ollama*" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + Get-Process -Name "ollama_llama_server" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + Write-Ok "Ollama stopped" + } catch { + Write-Warn "Ollama cleanup: $_" + } + } else { + Write-Info "Ollama was already running -- leaving it alone." + } + + # 4) Unregister all process events + Get-EventSubscriber -ErrorAction SilentlyContinue | + Where-Object { $_.SourceObject -is [System.Diagnostics.Process] } | + Unregister-Event -ErrorAction SilentlyContinue + + # 5) Orphan sweep -- kill stray node processes tied to mindcraft + $orphans = Get-Process -Name "node" -ErrorAction SilentlyContinue | + Where-Object { + try { + $cmd = (Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)" ` + -ErrorAction SilentlyContinue).CommandLine + $cmd -and ($cmd -match 'mindcraft|main\.js') + } catch { $false } + } + if ($orphans) { + Write-Step "Cleaning $($orphans.Count) orphaned node process(es)..." + $orphans | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Ok "Orphans cleaned" + } + + Write-Host "" + Write-Ok "All services stopped. Session ended." + Write-Host "" +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# MAIN ENTRY POINT +# ═══════════════════════════════════════════════════════════════════════════════ +try { + $Host.UI.RawUI.WindowTitle = "DragonSlayer v4.0 -- Mindcraft Launcher" + Clear-Host + Write-Banner + + # -- 0. Pre-flight -- + if (-not (Test-Prerequisites)) { + Write-Host "" + Write-Err "Pre-flight failed. Fix the errors above and try again." + Write-Host "" + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + exit 1 + } + + # -- 1. Ollama -- + if (-not (Start-OllamaServer)) { + Write-Err "Cannot continue without Ollama." + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + exit 1 + } + + # -- 2. Models -- + if (-not (Install-OllamaModels)) { + Write-Err "Cannot continue without required models." + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + exit 1 + } + + # -- 3. Optional Paper server -- + Start-LocalPaperServer + + # -- 4. Bot -- + if (-not (Start-MindcraftBot)) { + Write-Err "Bot launch failed." + Stop-Everything + Write-Host " Press any key to exit..." -ForegroundColor DarkGray + $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + exit 1 + } + + # -- 5. HUD -- + Open-MindServerHUD + + # -- 6. !beatMinecraft -- + Send-BeatMinecraftCommand -SkipPrompt $AUTO_BEAT_MC + + # ── STATUS DASHBOARD (pad + truncate to exactly 45 chars for alignment) ── + function Fit([string]$s, [int]$w = 45) { + if ($s.Length -gt $w) { return $s.Substring(0, $w - 1) + [char]0x2026 } # ellipsis + return $s.PadRight($w) + } + $hudStr = "http://localhost:$MINDSERVER_PORT" + $serverStr = "${MC_HOST}:$MC_PORT" + $pidStr = "$($script:BotProcess.Id)" + $modelStr = "sweaterdog/andy-4:q8_0 (RTX 3090 CUDA)" + + Write-Host "" + Write-Host " +==============================================================+" -ForegroundColor Green + Write-Host " | |" -ForegroundColor Green + Write-Host " | DRAGONSLAYER IS RUNNING |" -ForegroundColor Green + Write-Host " | |" -ForegroundColor Green + Write-Host " | HUD $(Fit $hudStr)|" -ForegroundColor Green + Write-Host " | Server $(Fit $serverStr)|" -ForegroundColor Green + Write-Host " | Model $(Fit $modelStr)|" -ForegroundColor Green + Write-Host " | Profile $(Fit $PROFILE_PATH)|" -ForegroundColor Green + Write-Host " | PID $(Fit $pidStr)|" -ForegroundColor Green + Write-Host " | |" -ForegroundColor Green + Write-Host " | >>> PRESS ANY KEY TO SHUT DOWN <<< |" -ForegroundColor Green + Write-Host " | |" -ForegroundColor Green + Write-Host " +==============================================================+" -ForegroundColor Green + Write-Host "" + + # ── Event loop: watch for keypress or crash ── + while ($true) { + # Bot crashed? + if ($script:BotProcess -and $script:BotProcess.HasExited) { + $ec = $script:BotProcess.ExitCode + Write-Host "" + if ($ec -eq 0) { + Write-Ok "Bot exited normally (code 0)." + } else { + Write-Err "Bot crashed! Exit code: $ec" + Write-Info "Check logs: bots/$BOT_NAME/" + } + break + } + + # Keypress? + if ([Console]::KeyAvailable) { + $null = [Console]::ReadKey($true) + Write-Host "" + Write-Info "Keypress detected -- shutting down..." + break + } + + Start-Sleep -Milliseconds 250 + } + +} catch { + Write-Host "" + Write-Err "Fatal: $_" + Write-Err $_.ScriptStackTrace +} finally { + Stop-Everything + + # ── Offer GitHub PR workflow after shutdown (only if bot actually launched) ── + if ($script:LaunchTime) { + try { Submit-PullRequest } catch { + Write-Warn "PR workflow error: $_" + } + } else { + Write-Info "Bot never launched -- skipping PR workflow." + } + + Write-Host " Press any key to close..." -ForegroundColor DarkGray + try { $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") } catch {} +} diff --git a/DragonSlayer.bat b/DragonSlayer.bat new file mode 100644 index 000000000..a2216c617 --- /dev/null +++ b/DragonSlayer.bat @@ -0,0 +1,24 @@ +@echo off +title DragonSlayer v4.0 — Mindcraft Launcher +color 0C +echo. +echo ======================================== +echo DragonSlayer Launcher v4.0 +echo ======================================== +echo. + +:: Run the PowerShell script from the same directory as this .bat +:: -ExecutionPolicy Bypass = no admin required, no policy changes +:: -NoProfile = skip user PS profile for clean startup +:: -File = run the .ps1 script + +powershell.exe -ExecutionPolicy Bypass -NoProfile -File "%~dp0DragonSlayer-Launcher.ps1" + +:: If PowerShell isn't available (very old Win), show error +if errorlevel 9009 ( + echo. + echo ERROR: PowerShell not found! + echo Install PowerShell: https://aka.ms/powershell + echo. + pause +) diff --git a/LAUNCHER_README.md b/LAUNCHER_README.md new file mode 100644 index 000000000..a1de306eb --- /dev/null +++ b/LAUNCHER_README.md @@ -0,0 +1,239 @@ +# DragonSlayer Single-Click Launcher v4.0 + +One double-click to launch the entire DragonSlayer autonomous Ender Dragon speedrun bot on your RTX 3090. + +## What It Does (Automatically) + +| Step | Action | +|------|--------| +| 0 | Pre-flight: validates Node 20, npm, Ollama, NVIDIA GPU, CUDA, profile, `.env` | +| 1 | Starts Ollama server if not already running (with health-poll progress bar) | +| 2 | Pulls `sweaterdog/andy-4:q8_0` + `nomic-embed-text` + `llava` if missing | +| 3 | (Optional) Starts a local Paper 1.21.x MC server with EULA prompt | +| 4 | Launches DragonSlayer bot with **timestamped, colorized** live log output | +| 5 | Opens MindServer HUD in your default browser (`http://localhost:8080`) | +| 6 | **Sends `!beatMinecraft`** to start the dragon run (prompt or auto) | +| 7 | Shows a status dashboard with PID, server, model at a glance | +| 8 | Event loop: crash detection + press any key → graceful shutdown with session duration | +| 9 | **GitHub PR workflow**: commit changes, push to fork, create/update PR to upstream | + +## Quick Start + +### Option A: Double-Click (Easiest) + +1. Double-click **`DragonSlayer.bat`** in the `mindcraft-0.1.3` folder +2. That's it. Everything happens automatically. + +### Option B: PowerShell Direct + +```powershell +cd "C:\Users\Name\Mindcraft\mindcraft-0.1.3" +.\DragonSlayer-Launcher.ps1 +``` + +If you get an execution policy error: +```powershell +powershell -ExecutionPolicy Bypass -File .\DragonSlayer-Launcher.ps1 +``` + +### Option C: Convert to .EXE (PS2EXE) + +Turn the script into a standalone `.exe` you can pin to your taskbar: + +```powershell +# 1. Install PS2EXE (one-time, from an elevated or user-scope shell) +Install-Module -Name ps2exe -Scope CurrentUser -Force + +# 2. Convert to .exe (run from the mindcraft-0.1.3 folder) +cd "C:\Users\\path\to\mindcraft-0.1.3" +Invoke-PS2EXE -InputFile .\DragonSlayer-Launcher.ps1 ` + -OutputFile .\DragonSlayer.exe ` + -Title "DragonSlayer Launcher" ` + -Description "Mindcraft DragonSlayer Autonomous Bot Launcher" ` + -Company "Mindcraft Research" ` + -Version "4.0.0" ` + -NoConsole:$false ` + -RequireAdmin:$false + +# Optional: add a custom icon (.ico file in the same folder) +# -IconFile .\dragon.ico + +# 3. Done! Double-click DragonSlayer.exe or pin it to your taskbar. +# Copy the whole folder to another machine and it just works. +``` + +The resulting `DragonSlayer.exe`: +- **No external dependencies** — PowerShell is built into Windows 10/11 +- **No admin rights** required +- Double-click from anywhere, pin to Start Menu or Taskbar +- Portable: copy the whole folder to another machine and it just works + +> **Tray icon / minimize-to-tray**: PS2EXE does not support tray icons natively. +> Options for tray-icon support: +> - [AutoHotkey](https://www.autohotkey.com/) — wrap the .exe in an AHK script with `Menu, Tray` commands +> - WPF wrapper — create a lightweight C# WPF app that hosts the launcher process +> - [Winsw](https://github.com/winsw/winsw) — run as a Windows Service (headless, no tray icon needed) + +## Prerequisites + +| Requirement | Version | Link | +|------------|---------|------| +| Windows | 10 or 11 | — | +| Node.js | v18+ (v20 LTS recommended; v24+ may cause issues) | https://nodejs.org/ | +| Ollama | Latest (with CUDA support) | https://ollama.com/download/windows | +| NVIDIA Driver | Latest Game Ready | https://www.nvidia.com/drivers | +| RTX 3090 | With working CUDA | — | +| Minecraft Server | Running on target host:port | Your EC2 or local server | +| GitHub CLI (`gh`) | Latest (for PR workflow) | https://cli.github.com/ | + +## Configuration + +Edit the `CONFIG` block at the top of `DragonSlayer-Launcher.ps1`: + +```powershell +# ── Core ── +$PROFILE_PATH = "./profiles/dragon-slayer.json" +$BOT_NAME = "DragonSlayer" # must match profile "name" field +$OLLAMA_MODELS = @("sweaterdog/andy-4:q8_0", "nomic-embed-text", "llava") +$OLLAMA_PORT = 11434 # Ollama API port +$MINDSERVER_PORT = 8080 # MindServer HUD port +$MC_HOST = "localhost" # MC server host +$MC_PORT = 42069 # MC server port + +# ── Paper server (optional) ── +$PAPER_JAR = "" # Full path to paper-*.jar (blank = skip) +$PAPER_DIR = "" # Server working directory +$PAPER_PORT = 25565 # Server port +$PAPER_RAM = "4G" # Max heap (-Xmx) + +# ── Auto-!beatMinecraft ── +$AUTO_BEAT_MC = $false # $true = send without prompting +$BEAT_MC_DELAY = 15 # Seconds to wait for bot to connect + +# ── Cosmetics ── +$HUD_OPEN_DELAY = 8 # Seconds to poll before opening HUD +$LOG_TIMESTAMP = $true # Prefix bot output with HH:mm:ss +``` + +### `!beatMinecraft` Auto-Send + +After the bot starts, the launcher sends `!beatMinecraft` via Socket.IO to the MindServer. By default it prompts you — press Enter or type `Y` to confirm. To skip the prompt entirely: + +```powershell +$AUTO_BEAT_MC = $true +``` + +The command is delivered via a temp `.mjs` file using the project's `socket.io-client` (no global installs needed). If MindServer isn't ready, it retries for 8 seconds then falls back gracefully with instructions to send manually. + +### GitHub PR Workflow + +After shutdown (only if the bot was actually launched), the launcher offers to commit your changes and create a Pull Request to upstream. Powered by the [`gh` CLI](https://cli.github.com/). + +**Prerequisites:** +- `gh` CLI installed and authenticated (`gh auth login`) +- Git remotes configured: `fork` → your public fork, `upstream` → `mindcraft-bots/mindcraft` + +**What it does (step by step):** +1. Checks `gh auth status` and `git status` +2. **Derives your GitHub username** from the `fork` remote URL (no hardcoded usernames) +3. Shows changed files with color-coded status +4. **Offers to create a feature branch** if you're on a protected branch (main/master/develop) +5. Prompts: "Submit as PR?" (Y/N, default N) +6. If yes: prompts for commit message, **confirms before `git add -A`** (warns about staging scope) +7. Commits, then **re-captures diff stats** so the PR body is accurate (not stale) +8. Pushes to your `fork` remote +9. Creates a new PR to upstream (or detects existing open PR via owner-qualified `--head` ref) +10. Optionally offers to merge (squash) — **skips `--delete-branch` for protected branches** + +**Configuration:** +```powershell +$ENABLE_PR_WORKFLOW = $true # Set $false to disable entirely +$PR_FORK_REMOTE = "fork" # Git remote name for your public fork +$PR_TARGET_REPO = "mindcraft-bots/mindcraft" # Upstream owner/repo +$PR_BASE_BRANCH = "develop" # Target branch for PRs +$PR_PROTECTED = @('main', 'master', 'develop') # Branches safe from --delete-branch +``` + +**To disable:** Set `$ENABLE_PR_WORKFLOW = $false` in the config block. + +**v4.0 improvements over v3.1:** +- Fork owner derived dynamically from git remote (no hardcoded GitHub username) +- Feature branch creation when working on main/master/develop +- Staging confirmation before `git add -A` (prevents accidental secret exposure) +- PR body uses post-commit diff stats (not stale pre-commit status) +- `$LASTEXITCODE` captured before piping (PS 5.1 compatibility) +- `--delete-branch` skipped for protected branches (won't delete main) +- Owner-qualified `--head` ref for reliable cross-repo PR detection +- PR workflow only runs if bot was actually launched (skips on pre-flight failure) + +### Enable Local Paper Server + +1. Download Paper from https://papermc.io/downloads +2. Put the jar in a folder (e.g., `C:\MCServer\paper-1.21.11.jar`) +3. Edit the launcher config: + ```powershell + $PAPER_JAR = "C:\MCServer\paper-1.21.11.jar" + $PAPER_DIR = "C:\MCServer" + $PAPER_RAM = "4G" + ``` +4. On first run, the launcher prompts you to accept the Minecraft EULA +5. Update `settings.js` to point to your local server: + ```js + "host": "localhost", + "port": 25565, + ``` + +## Log Color Coding + +The live bot output is colorized by keyword for quick scanning: + +| Color | Keywords | +|-------|----------| +| **Red** | error, exception, FATAL, ECONNREFUSED | +| **Yellow** | warn, deprecated | +| **Magenta** | dragon, ender, beat minecraft, victory, progression | +| **Green** | connected, ready, started, logged in, spawned | +| **Blue** | nether, blaze, stronghold, portal, fortress | +| **Teal** | diamond, iron, craft, smelt, enchant, bucket | +| **Gray** | Everything else | + +Timestamps (when `$LOG_TIMESTAMP = $true`): ` 14:32:07 BOT | Crafting iron pickaxe...` + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| "Ollama not installed" | Install from https://ollama.com/download/windows | +| "Node.js not found" | Install from https://nodejs.org/ (v20 LTS) | +| "Node.js v24+" warning | Downgrade to v20 LTS — v24+ may cause compatibility issues | +| Model pull fails | Run manually: `ollama pull sweaterdog/andy-4:q8_0` | +| Bot can't connect to MC | Ensure server is running on the configured host:port | +| "npm install failed" | Delete `node_modules/` + `package-lock.json`, re-run launcher | +| Script won't run | Use the `.bat` file or: `powershell -ExecutionPolicy Bypass -File .\DragonSlayer-Launcher.ps1` | +| CUDA not detected | Update NVIDIA drivers, restart, verify with `nvidia-smi` | +| Bot crashes immediately | Check logs in `bots/DragonSlayer/` | +| `!beatMinecraft` not sent | MindServer may not be ready; send from HUD chat or in-game | +| "socket.io-client not found" | Run `npm install` to restore dependencies | +| Paper EULA rejected | Re-run launcher and accept, or manually create `eula.txt` with `eula=true` | +| PR workflow skipped | Install `gh` CLI and run `gh auth login` | +| PR creation fails | Ensure `fork` remote points to your public fork on GitHub | +| "Not a git repository" | Ensure the launcher is inside the cloned mindcraft repo | + +## File Layout + +``` +mindcraft-0.1.3/ +├── DragonSlayer.bat ← Double-click this +├── DragonSlayer-Launcher.ps1 ← The engine (PowerShell, v4.0) +├── DragonSlayer.exe ← (after PS2EXE conversion) +├── LAUNCHER_README.md ← This file +├── docs/ ← Research docs, Notebook LLM exports, mod pack notes +│ └── index.md ← Docs table of contents +├── main.js +├── settings.js +├── profiles/ +│ └── dragon-slayer.json +├── bots/ +│ └── DragonSlayer/ ← Bot state & logs +└── ... +``` diff --git a/docs/DRAGON_SLAYER_RC29.md b/docs/DRAGON_SLAYER_RC29.md new file mode 100644 index 000000000..41c2718cf --- /dev/null +++ b/docs/DRAGON_SLAYER_RC29.md @@ -0,0 +1,173 @@ +# Dragon Slayer RC29 — Autonomous Ender Dragon System + +> **Status: Live** — DragonSlayer is running on local Windows PC (RTX 3090, `sweaterdog/andy-4:q8_0` via Ollama) connected to Paper 1.21.11 on AWS EC2. RC29 persistent state saving active. MindServer HUD: `http://localhost:8080`. + +## Executive Summary + +RC29 upgrades Mindcraft's dragon progression system from a fragile single-run pipeline into a **persistent, death-surviving, restart-resilient autonomous Ender Dragon slayer**. + +Key improvements: +- **Persistent state** (`dragon_progress.json`) survives crashes, restarts, and deaths via atomic JSON writes +- **Smart orchestrator** with exponential backoff (5 retries per chunk), death recovery, gear re-acquisition +- **Dimension awareness** — tracks overworld/nether/end transitions +- **Pre-chunk preparation** — proactive food stockpiling, gear checks, inventory management +- **`!beatMinecraft` command** — single-command alias for the full autonomous run +- **Milestone tracking** — records highest resource counts ever achieved (not just current inventory) + +--- + +## New Files + +### `src/agent/library/dragon_progress.js` (~360 lines) +Persistent Dragon Progression state machine. + +| Feature | Detail | +|---------|--------| +| **State schema** | version, chunks status map (6 chunks), coords (7 named positions), milestones (7 items), stats (deaths, retries, dimension), dragonFight state | +| **Persistence** | Atomic `.tmp` + `renameSync` pattern (same as `history.js` RC27) | +| **Corruption recovery** | Renames corrupted save to `.corrupted.`, starts fresh | +| **API** | `load()`, `save()`, `currentChunk()`, `markChunkActive/Done/Failed()`, `setCoord()`, `updateMilestones()`, `recordDeath()`, `getSummary()` | +| **LLM integration** | `getSummary()` returns compact text for prompt injection | + +### `docs/DRAGON_SLAYER_RC29.md` (this file) +Documentation, testing plan, and quick-start guide. + +--- + +## Modified Files + +### `src/agent/library/dragon_runner.js` +**Header/imports:** Added `import { DragonProgress, CHUNKS }` from `dragon_progress.js`. Added `getDimension()` helper. + +**New functions:** +- `prepareForChunk(bot, chunkName, progress)` — adapts gear/food prep to target chunk +- `recoverFromDeath(bot, progress)` — goes to death location, picks up items, re-crafts lost tools + +**Orchestrator rewrite** (`runDragonProgression`): +- Loads `DragonProgress` on entry, saves after each chunk transition +- Registers `bot.on('death')` handler to record death position + save state +- 5 retries per chunk (up from 3) with exponential backoff (1s → 2s → 4s → 8s → 16s, max 30s) +- `runner.check()` consults both inventory AND persistent state (e.g., milestones) +- `runner.onSuccess()` hooks save key coordinates (portal, fortress, stronghold, end portal positions) +- Death recovery between retries: respawn wait → go to death pos → pickup items → re-craft tools +- Explore to fresh area on retry (100 + 50*retryCount blocks) +- `finally` block always removes death listener + +**Chunk functions:** Unchanged (proven gameplay logic preserved). + +### `src/agent/commands/actions.js` +- Updated `!dragonProgression` timeout from 120min to 180min, description updated +- Added `!beatMinecraft` command (alias for `runDragonProgression`, 180min timeout) + +--- + +## Updated Profiles + +### `profiles/dragon-slayer.json` +- System prompt mentions `!beatMinecraft` and persistent progress +- Death recovery example updated: "Died! Progress is saved. !beatMinecraft" +- Self-prompt updated to lead with `!beatMinecraft` +- All conversation examples using `!dragonProgression` → `!beatMinecraft` + +### `profiles/local-research.json` +- System prompt rule 19 updated to mention `!beatMinecraft` + persistent progress +- Added rule 22: "After dying, progress is saved — just re-run !beatMinecraft to resume." +- Conversation example for "defeat the ender dragon" → `!beatMinecraft` +- Self-prompt updated to lead with `!beatMinecraft` + +--- + +## Testing Plan + +### Unit-level Verification + +| Test | How | Expected | +|------|-----|----------| +| **JSON parse** | `node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json'))"` | No error | +| **Import chain** | `node -e "import('./src/agent/library/dragon_runner.js').then(m => console.log(Object.keys(m)))"` | Exports: `buildNetherPortal`, `collectBlazeRods`, `collectEnderPearls`, `locateStronghold`, `defeatEnderDragon`, `runDragonProgression` | +| **Progress persistence** | Create DragonProgress, save, reload, verify state matches | State round-trips correctly | +| **Lint** | `npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js` | 0 errors, 0 warnings | +| **Command registration** | Start bot, check `!help` output includes `!beatMinecraft` | Listed with description | + +### Integration Tests (Manual) + +1. **Fresh start**: New world → `!beatMinecraft` → observe Chunk 1 (diamond pickaxe) begins +2. **Persistence**: Kill bot process mid-chunk → restart → `!beatMinecraft` → resumes from last incomplete chunk (not from scratch) +3. **Death recovery**: Let bot die during Chunk 3 (blaze rods) → observe death handler fires → on retry, bot goes to death pos, recovers items +4. **Exponential backoff**: Make chunk fail (e.g., block all iron spawns) → observe increasing backoff delays in logs +5. **Full run**: Fresh world → `!beatMinecraft` → dragon defeated (target: < 3 hours game time) +6. **Individual chunks**: `!getDiamondPickaxe` → `!buildNetherPortal` → etc. still work independently +7. **Interrupt**: Mid-run `!stop` → bot stops → `!beatMinecraft` → resumes from saved state + +### Smoke Test Script + +```bash +# 1. Validate all files +npx eslint src/agent/library/dragon_progress.js src/agent/library/dragon_runner.js src/agent/commands/actions.js + +# 2. Validate profiles +node -e "JSON.parse(require('fs').readFileSync('profiles/dragon-slayer.json','utf8')); console.log('OK')" +node -e "JSON.parse(require('fs').readFileSync('profiles/local-research.json','utf8')); console.log('OK')" + +# 3. Validate imports +node --input-type=module -e "import { runDragonProgression, buildNetherPortal, collectBlazeRods, collectEnderPearls, locateStronghold, defeatEnderDragon } from './src/agent/library/dragon_runner.js'; console.log('All exports OK')" + +# 4. Run bot with dragon-slayer profile +node main.js --profiles ./profiles/dragon-slayer.json +``` + +--- + +## Quick-Start Guide + +### Prerequisites +- Node.js v18+ (v20 LTS recommended) +- Minecraft server running — Paper 1.21.x server with `host` and `port` configured in `settings.js` +- Ollama running locally with `sweaterdog/andy-4:q8_0`, `nomic-embed-text`, and `llava` pulled: `ollama pull sweaterdog/andy-4:q8_0 && ollama pull nomic-embed-text && ollama pull llava` +- `npm install` completed + +### Option A: DragonSlayer Bot (dedicated profile) +```bash +node main.js --profiles ./profiles/dragon-slayer.json +``` +The bot will self-prompt and begin `!beatMinecraft` automatically. + +### Option B: Any Bot, Manual Trigger +```bash +# Start your preferred bot +node main.js --profiles ./profiles/local-research.json + +# In Minecraft chat: +DragonSlayer, !beatMinecraft +``` + +### Option C: Individual Chunks +``` +!getDiamondPickaxe # Chunk 1 +!buildNetherPortal # Chunk 2 +!collectBlazeRods(12) # Chunk 3 +!collectEnderPearls(12) # Chunk 4 +!locateStronghold # Chunk 5 +!defeatEnderDragon # Chunk 6 +``` + +### Monitoring Progress +The persistent state is saved at `bots//dragon_progress.json`. You can inspect it: +```bash +cat bots/DragonSlayer/dragon_progress.json | python -m json.tool +``` + +### Resetting Progress +Delete the state file to start fresh: +```bash +rm bots/DragonSlayer/dragon_progress.json +``` + +### Troubleshooting +| Issue | Fix | +|-------|-----| +| Bot stuck in a loop | `!stop` then `!beatMinecraft` to resume from saved state | +| Bot keeps dying | Check food supply; modes `auto_eat` and `panic_defense` must be `true` | +| "Chunk X failed after 5 attempts" | Manual intervention needed: explore to better biome, ensure pickaxe/food, then `!beatMinecraft` | +| Bot won't enter Nether | Ensure `flint_and_steel` + obsidian portal exists; try `!buildNetherPortal` individually | +| State file corrupted | Delete `dragon_progress.json` and restart | diff --git a/profiles/dragon-slayer.json b/profiles/dragon-slayer.json new file mode 100644 index 000000000..92676905c --- /dev/null +++ b/profiles/dragon-slayer.json @@ -0,0 +1,124 @@ +{ + "_comment": "Dragon Slayer profile — optimized for autonomous fresh-world-to-Ender-Dragon runs (RC29 persistent). Works with any model. Use: node main.js --profiles ./profiles/dragon-slayer.json", + + "name": "DragonSlayer", + "model": "ollama/sweaterdog/andy-4:q8_0", + "embedding": "ollama/nomic-embed-text", + "vision_model": "ollama/llava", + "cooldown": 3000, + + "modes": { + "self_preservation": true, + "unstuck": true, + "cowardice": false, + "self_defense": true, + "hunting": true, + "item_collecting": true, + "torch_placing": true, + "elbow_room": false, + "idle_staring": false, + "night_bed": true, + "auto_eat": true, + "panic_defense": true, + "cheat": false + }, + + "conversing": "You are $NAME, a Minecraft speedrun bot. Your ONLY goal: defeat the Ender Dragon autonomously. Give 1 command per response. No narration.\n\nCRITICAL RULE: ALWAYS call !dragonProgression. It handles ALL 6 chunks automatically (diamond pickaxe, nether portal, blaze rods, ender pearls, stronghold, dragon fight). Progress is persistent — survives deaths and restarts. NEVER call !craftRecipe, !collectBlocks, or other manual commands.\n\nANTI-STUCK:\n- ANY failure or error → !dragonProgression (it resumes from where it left off).\n- \"requires a crafting table\" → !dragonProgression.\n- \"collected 0\" or \"not found\" → !dragonProgression.\n- \"Navigation timed out\" → !dragonProgression.\n- NEVER use !moveAway (BLOCKED), !searchForBlock, or !craftRecipe.\n\nSURVIVAL:\n- Hungry → !ensureFed. No food → !stockpileFood(16).\n- Health <6 and under attack → !buildPanicRoom.\n\nDEATH RECOVERY:\n- After dying → !dragonProgression (it resumes automatically).\n\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$WIKI\n$EXAMPLES\nConversation Begin:", + + "coding": "You are $NAME playing minecraft by writing javascript codeblocks. Write a js codeblock ``` // like this ```. Use await for all async calls. Vec3, skills, world are imported and bot is given. Do not import libraries. Do not use !commands, only codeblocks.\n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$LEARNINGS\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + + "saving_memory": "You are $NAME, a dragon-slaying bot. Summarize into new memory. Keep: (1) which chunk you're on (1-6), (2) key coordinates (portals, fortress, stronghold, base), (3) what worked/failed, (4) items needed for next chunk. Max 500 chars.\nOld Memory: '$MEMORY'\nRecent:\n$TO_SUMMARIZE\nRespond with new memory only:", + + "bot_responder": "You are $NAME, busy with: '$ACTION'. You are on a solo dragon-slaying mission. Ignore ALL messages from other bots unless a human player is speaking. Output ONLY 'respond' or 'ignore'.\nConversation: $TO_SUMMARIZE\nYour decision:", + + "conversation_examples": [ + [ + {"role": "system", "content": "You are self-prompting with the goal: 'Defeat the Ender Dragon'. Respond:"}, + {"role": "assistant", "content": "!dragonProgression"} + ], + [ + {"role": "system", "content": "Action output:\nCrafting wooden_pickaxe requires a crafting table."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nYou do not have the resources to craft a oak_planks. It requires: oak_log: 1."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 oak_log. There are no oak_log blocks in this area."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 0 oak_log. There are no oak_log blocks in this area.\n[ANTI-LOOP] You have failed to collect oak_log repeatedly. Do NOT call !collectBlocks again."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nPathfinding stopped: Navigation timed out."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nDiamond pickaxe obtained!"}, + {"role": "assistant", "content": "Chunk 1 done! !buildNetherPortal"} + ], + [ + {"role": "system", "content": "Action output:\nNether portal built and activated!"}, + {"role": "assistant", "content": "Chunk 2 done! !collectBlazeRods(12)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 12 blaze rods!"}, + {"role": "assistant", "content": "Chunk 3 done! !collectEnderPearls(12)"} + ], + [ + {"role": "system", "content": "Action output:\nCollected 12 ender pearls."}, + {"role": "assistant", "content": "Chunk 4 done! !locateStronghold"} + ], + [ + {"role": "system", "content": "Action output:\nEnd portal is ACTIVE! Ready to enter."}, + {"role": "assistant", "content": "Chunk 5 done! !defeatEnderDragon"} + ], + [ + {"role": "system", "content": "Action output:\nENDER DRAGON DEFEATED! VICTORY!"}, + {"role": "assistant", "content": "GG! !endGoal"} + ], + [ + {"role": "system", "content": "Action output:\nNot enough blocks to build panic room."}, + {"role": "assistant", "content": "!stockpileFood(16)"} + ], + [ + {"role": "system", "content": "You died at position x: 100, y: 30, z: -50. Your place of death is saved as 'last_death_position'."}, + {"role": "assistant", "content": "Died! Progress is saved. !dragonProgression"} + ], + [ + {"role": "system", "content": "Action output:\nCommand !moveAway is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Command !craftRecipe is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Command !collectBlocks is disabled in your profile's blocked_actions."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nExploring 200 blocks (4 hops)...\nPathfinding stopped: Navigation timed out after 64s.\nPathfinding stopped: Navigation timed out after 64s.\nExplored 46 blocks to (6, 72, -105)."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "system", "content": "Action output:\nFound oak_log at (21, 64, -66). Navigating...\nPathfinding stopped: Navigation timed out after 64s."}, + {"role": "assistant", "content": "!getDiamondPickaxe"} + ], + [ + {"role": "user", "content": "player1: stop"}, + {"role": "assistant", "content": "!stop"} + ], + [ + {"role": "user", "content": "player1: beat the dragon"}, + {"role": "assistant", "content": "!beatMinecraft"} + ] + ], + + "blocked_actions": ["!startConversation", "!moveAway", "!craftRecipe", "!collectBlocks", "!searchForBlock", "!getCraftingPlan", "!newAction"], + + "self_prompt": "Defeat the Ender Dragon. Call !dragonProgression now. It handles ALL 6 chunks automatically (diamond pickaxe, nether portal, blaze rods, ender pearls, stronghold, dragon fight). Progress survives deaths and restarts. Do NOT manually craft, collect, or call individual chunk commands." +} diff --git a/src/agent/library/dragon_progress.js b/src/agent/library/dragon_progress.js new file mode 100644 index 000000000..6e9d2a91d --- /dev/null +++ b/src/agent/library/dragon_progress.js @@ -0,0 +1,358 @@ +/** + * dragon_progress.js — Persistent Dragon Progression State + * + * Survives restarts, deaths, and crashes via atomic JSON writes. + * Tracks: completed chunks, key coordinates, inventory milestones, + * death count, current dimension, retry counts, and timestamps. + * + * Uses the same safeWriteFile pattern as history.js (RC27). + */ + +import { readFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs'; +import { writeFile } from 'fs/promises'; + +// ── RC27: Atomic write — .tmp + rename ───────────────────────────────── +async function safeWriteFile(filepath, content, retries = 3, delay = 100) { + const tmpPath = filepath + '.tmp'; + for (let i = 0; i < retries; i++) { + try { + await writeFile(tmpPath, content, 'utf8'); + try { + renameSync(tmpPath, filepath); + } catch (renameErr) { + console.warn(`[DragonProgress] Atomic rename failed for ${filepath}, falling back:`, renameErr.message); + await writeFile(filepath, content, 'utf8'); + try { unlinkSync(tmpPath); } catch (_e) { /* ignore */ } + } + return; + } catch (error) { + if (error.code === 'EBADF' && i < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + continue; + } + throw error; + } + } +} + +// ── Chunk definitions ────────────────────────────────────────────────── +export const CHUNKS = Object.freeze({ + DIAMOND_PICKAXE: 'diamond_pickaxe', + NETHER_PORTAL: 'nether_portal', + BLAZE_RODS: 'blaze_rods', + ENDER_PEARLS: 'ender_pearls', + STRONGHOLD: 'stronghold', + DRAGON_FIGHT: 'dragon_fight', +}); + +const CHUNK_ORDER = [ + CHUNKS.DIAMOND_PICKAXE, + CHUNKS.NETHER_PORTAL, + CHUNKS.BLAZE_RODS, + CHUNKS.ENDER_PEARLS, + CHUNKS.STRONGHOLD, + CHUNKS.DRAGON_FIGHT, +]; + +function defaultState() { + return { + version: 2, + startedAt: new Date().toISOString(), + lastUpdated: null, + + // Which chunks are done / in-progress / failed + chunks: Object.fromEntries(CHUNK_ORDER.map(c => [c, { + status: 'pending', // pending | active | done | failed + attempts: 0, + lastAttempt: null, + completedAt: null, + }])), + + // Key coordinates discovered during the run + coords: { + overworldPortal: null, // [x, y, z] + netherPortal: null, + netherFortress: null, + stronghold: null, + endPortal: null, + lastDeathPos: null, + basecamp: null, // safe surface base + }, + + // Inventory milestones — tracks highest counts ever achieved + milestones: { + hasDiamondPick: false, + hasIronArmor: false, + hasDiamondSword: false, + hasBow: false, + blazeRods: 0, + enderPearls: 0, + eyesOfEnder: 0, + }, + + // Run statistics + stats: { + deaths: 0, + totalRetries: 0, + currentChunkIndex: 0, // index into CHUNK_ORDER + dimension: 'overworld', // overworld | the_nether | the_end + }, + + // Dragon fight specific + dragonFight: { + crystalsDestroyed: 0, + dragonHitsLanded: 0, + enteredEnd: false, + }, + }; +} + +export class DragonProgress { + /** + * @param {string} botName — used to derive file path under bots/ + */ + constructor(botName) { + this.botName = botName; + this.filePath = `./bots/${botName}/dragon_progress.json`; + this.state = defaultState(); + this._dirty = false; + } + + // ── Persistence ──────────────────────────────────────────────────── + + load() { + try { + if (!existsSync(this.filePath)) { + console.log(`[DragonProgress] No save file for ${this.botName}, starting fresh.`); + return this.state; + } + const raw = readFileSync(this.filePath, 'utf8'); + if (!raw || !raw.trim()) { + console.warn(`[DragonProgress] Empty save file, starting fresh.`); + return this.state; + } + const loaded = JSON.parse(raw); + // Merge with defaults to handle schema upgrades + this.state = { ...defaultState(), ...loaded }; + // Ensure all chunks exist (in case new ones were added) + for (const c of CHUNK_ORDER) { + if (!this.state.chunks[c]) { + this.state.chunks[c] = { status: 'pending', attempts: 0, lastAttempt: null, completedAt: null }; + } + } + console.log(`[DragonProgress] Loaded state for ${this.botName}: chunk ${this.currentChunkIndex()}/${CHUNK_ORDER.length}`); + return this.state; + } catch (err) { + console.error(`[DragonProgress] Failed to load for ${this.botName}:`, err.message); + // Rename corrupted file + if (existsSync(this.filePath)) { + const backup = this.filePath + '.corrupted.' + Date.now(); + try { renameSync(this.filePath, backup); } catch (_e) { /* ignore */ } + } + return this.state; + } + } + + async save() { + try { + const dir = `./bots/${this.botName}`; + mkdirSync(dir, { recursive: true }); + this.state.lastUpdated = new Date().toISOString(); + await safeWriteFile(this.filePath, JSON.stringify(this.state, null, 2)); + this._dirty = false; + } catch (err) { + console.error(`[DragonProgress] Failed to save:`, err.message); + } + } + + // ── Chunk State ──────────────────────────────────────────────────── + + /** Get the current chunk name (the first non-done chunk) */ + currentChunk() { + for (const c of CHUNK_ORDER) { + if (this.state.chunks[c].status !== 'done') return c; + } + return null; // all done! + } + + /** Get the 0-based index of current chunk */ + currentChunkIndex() { + const current = this.currentChunk(); + return current ? CHUNK_ORDER.indexOf(current) : CHUNK_ORDER.length; + } + + /** Is a specific chunk complete? */ + isChunkDone(chunkName) { + return this.state.chunks[chunkName]?.status === 'done'; + } + + /** Mark a chunk as started */ + markChunkActive(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'active'; + chunk.attempts++; + chunk.lastAttempt = new Date().toISOString(); + this.state.stats.totalRetries++; + this.state.stats.currentChunkIndex = CHUNK_ORDER.indexOf(chunkName); + this._dirty = true; + } + + /** Mark a chunk as successfully completed */ + markChunkDone(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'done'; + chunk.completedAt = new Date().toISOString(); + this._dirty = true; + } + + /** Mark a chunk as failed (for this attempt) */ + markChunkFailed(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'failed'; + this._dirty = true; + } + + /** Get how many attempts a chunk has had */ + getChunkAttempts(chunkName) { + return this.state.chunks[chunkName]?.attempts || 0; + } + + /** Is everything complete? */ + isComplete() { + return CHUNK_ORDER.every(c => this.state.chunks[c].status === 'done'); + } + + /** Reset a specific chunk back to pending (for retry after recovery) */ + resetChunk(chunkName) { + const chunk = this.state.chunks[chunkName]; + if (!chunk) return; + chunk.status = 'pending'; + this._dirty = true; + } + + // ── Coordinates ──────────────────────────────────────────────────── + + setCoord(name, x, y, z) { + if (this.state.coords[name] !== undefined) { + this.state.coords[name] = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this._dirty = true; + } + } + + getCoord(name) { + return this.state.coords[name]; + } + + // ── Milestones ───────────────────────────────────────────────────── + + updateMilestones(bot) { + const inv = {}; + if (bot.inventory) { + for (const item of bot.inventory.items()) { + inv[item.name] = (inv[item.name] || 0) + item.count; + } + } + const m = this.state.milestones; + m.hasDiamondPick = m.hasDiamondPick || !!(inv['diamond_pickaxe']); + m.hasIronArmor = m.hasIronArmor || !!(inv['iron_chestplate']); + m.hasDiamondSword = m.hasDiamondSword || !!(inv['diamond_sword']); + m.hasBow = m.hasBow || !!(inv['bow']); + m.blazeRods = Math.max(m.blazeRods, inv['blaze_rod'] || 0); + m.enderPearls = Math.max(m.enderPearls, inv['ender_pearl'] || 0); + m.eyesOfEnder = Math.max(m.eyesOfEnder, inv['ender_eye'] || 0); + this._dirty = true; + } + + // ── Death tracking ───────────────────────────────────────────────── + + recordDeath(x, y, z, dimension) { + this.state.stats.deaths++; + this.state.coords.lastDeathPos = [Math.floor(x), Math.floor(y), Math.floor(z)]; + this.state.stats.dimension = dimension || 'overworld'; + this._dirty = true; + } + + // ── Dimension ────────────────────────────────────────────────────── + + setDimension(dim) { + this.state.stats.dimension = dim; + this._dirty = true; + } + + getDimension() { + return this.state.stats.dimension; + } + + // ── Dragon fight state ───────────────────────────────────────────── + + recordCrystalDestroyed() { + this.state.dragonFight.crystalsDestroyed++; + this._dirty = true; + } + + recordDragonHit() { + this.state.dragonFight.dragonHitsLanded++; + this._dirty = true; + } + + setEnteredEnd(val = true) { + this.state.dragonFight.enteredEnd = val; + this._dirty = true; + } + + // ── Summary for LLM prompt injection ─────────────────────────────── + + getSummary() { + const s = this.state; + const idx = this.currentChunkIndex(); + const current = this.currentChunk(); + const parts = []; + + parts.push(`[DRAGON PROGRESS ${idx}/${CHUNK_ORDER.length}]`); + + // Completed chunks + const done = CHUNK_ORDER.filter(c => s.chunks[c].status === 'done'); + if (done.length > 0) { + parts.push(`Done: ${done.join(', ')}`); + } + + // Current chunk + attempts + if (current) { + const attempts = s.chunks[current].attempts; + parts.push(`Current: ${current} (attempt ${attempts + 1})`); + } else { + parts.push('ALL CHUNKS COMPLETE — dragon defeated!'); + } + + // Key coords + const coordEntries = Object.entries(s.coords) + .filter(([, v]) => v !== null) + .map(([k, v]) => `${k}: ${v.join(',')}`); + if (coordEntries.length > 0) { + parts.push(`Coords: ${coordEntries.join(' | ')}`); + } + + // Stats + parts.push(`Deaths: ${s.stats.deaths} | Dim: ${s.stats.dimension}`); + + // Dragon fight + if (s.dragonFight.enteredEnd) { + parts.push(`End fight: ${s.dragonFight.crystalsDestroyed} crystals, ${s.dragonFight.dragonHitsLanded} hits`); + } + + return parts.join('\n'); + } + + // ── Static helpers ───────────────────────────────────────────────── + + static get CHUNK_ORDER() { + return CHUNK_ORDER; + } + + static get CHUNKS() { + return CHUNKS; + } +} diff --git a/src/agent/library/dragon_runner.js b/src/agent/library/dragon_runner.js new file mode 100644 index 000000000..4cdf89178 --- /dev/null +++ b/src/agent/library/dragon_runner.js @@ -0,0 +1,1593 @@ +/** + * dragon_runner.js — Autonomous Ender Dragon progression system (RC29). + * + * Six modular gameplay chunks that chain together for a full + * fresh-world → Ender Dragon defeat run: + * Chunk 1: getDiamondPickaxe (already in skills.js) + * Chunk 2: buildNetherPortal + * Chunk 3: collectBlazeRods + * Chunk 4: collectEnderPearls + * Chunk 5: locateStronghold + * Chunk 6: defeatEnderDragon + * + * Plus the meta-orchestrator: runDragonProgression() + * + * RC29 upgrades: + * - Persistent state via DragonProgress (survives restarts/deaths) + * - Smart orchestrator with exponential backoff + * - Death recovery with gear re-acquisition + * - Dimension-aware navigation + * - Proactive food/gear management between chunks + * + * All functions use existing skill primitives from skills.js and world.js. + * Each is idempotent — safe to call multiple times (skips completed steps). + */ + +import * as skills from './skills.js'; +import * as world from './world.js'; +import { DragonProgress, CHUNKS } from './dragon_progress.js'; +import { ProgressReporter } from './progress_reporter.js'; +import Vec3 from 'vec3'; + +function log(bot, msg) { + skills.log(bot, msg); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** Count a specific item in inventory */ +function countItem(bot, name) { + return (world.getInventoryCounts(bot)[name] || 0); +} + +/** Check if we have at least `n` of item */ +function hasItem(bot, name, n = 1) { + return countItem(bot, name) >= n; +} + +/** Ensure the bot has food and eats if hungry */ +async function eatIfNeeded(bot) { + if (bot.food < 14) { + await skills.ensureFed(bot); + } +} + +/** Get the bot's current dimension */ +function getDimension(bot) { + const dim = bot.game?.dimension || 'overworld'; + if (dim.includes('nether')) return 'the_nether'; + if (dim.includes('end')) return 'the_end'; + return 'overworld'; +} + +/** Ensure we have enough of an item, trying to craft then collect */ +async function _ensureItem(bot, itemName, count, craftFrom = null) { + let have = countItem(bot, itemName); + if (have >= count) return true; + + if (craftFrom) { + const needed = count - have; + for (let i = 0; i < needed; i++) { + if (bot.interrupt_code) return false; + if (!await skills.craftRecipe(bot, itemName, 1)) break; + } + have = countItem(bot, itemName); + if (have >= count) return true; + } + + const needed = count - countItem(bot, itemName); + if (needed > 0) { + await skills.collectBlock(bot, itemName, needed); + } + return countItem(bot, itemName) >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// PRE-CHUNK PREPARATION & DEATH RECOVERY +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Ensure minimum gear and food before starting a chunk. + * Adapts requirements based on which chunk is next. + */ +async function prepareForChunk(bot, chunkName, progress) { + console.log(`[RC31] prepareForChunk: ${chunkName}`); + log(bot, `Preparing for chunk: ${chunkName}`); + if (bot.interrupt_code) return; + + // Always eat first + console.log('[RC31] prepareForChunk: eatIfNeeded'); + await eatIfNeeded(bot); + + // Ensure food stockpile (min 12 cooked meat) + const foodItems = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'cooked_salmon', 'cooked_cod', 'apple', 'carrot']; + let totalFood = 0; + const inv = world.getInventoryCounts(bot); + for (const f of foodItems) totalFood += (inv[f] || 0); + + if (totalFood < 12) { + console.log(`[RC31] prepareForChunk: stockpileFood (totalFood=${totalFood})`); + log(bot, `Food low (${totalFood}). Stockpiling...`); + await skills.stockpileFood(bot, 20); + } + + // RC30: Proactive inventory overflow — place a chest and store junk if nearly full + console.log('[RC31] prepareForChunk: checking inventory'); + const emptySlots = bot.inventory.emptySlotCount(); + if (emptySlots < 6 && getDimension(bot) === 'overworld') { + log(bot, `Inventory nearly full (${emptySlots} empty). Placing chest for overflow...`); + const nearbyChest = world.getNearestBlock(bot, 'chest', 32); + if (!nearbyChest && hasItem(bot, 'chest')) { + // Place a chest at current position + const pos = bot.entity.position; + try { + await skills.placeBlock(bot, 'chest', + Math.floor(pos.x) + 1, Math.floor(pos.y), Math.floor(pos.z), 'side'); + } catch (_e) { + log(bot, 'Could not place overflow chest.'); + } + } + } + + // Manage inventory (stores in nearby chests or discards junk) + console.log('[RC31] prepareForChunk: autoManageInventory'); + await skills.autoManageInventory(bot); + + // Chunk-specific prep + console.log(`[RC31] prepareForChunk: chunk-specific prep for ${chunkName}`); + switch (chunkName) { + case CHUNKS.NETHER_PORTAL: + // RC31: Must be on the surface for portal building (need iron, gravel, lava) + if (bot.entity.position.y < 50 && getDimension(bot) === 'overworld') { + log(bot, 'Underground — going to surface for nether portal building...'); + try { + await skills.goToSurface(bot); + } catch (_e) { + log(bot, 'goToSurface failed, trying pillarUp...'); + try { + await skills.pillarUp(bot, Math.min(30, 70 - Math.floor(bot.entity.position.y))); + } catch (_pe) { + log(bot, 'pillarUp also failed. Will try portal building from current position.'); + } + } + } + break; + + case CHUNKS.BLAZE_RODS: + case CHUNKS.ENDER_PEARLS: + // Need a sword for combat chunks + if (!hasItem(bot, 'diamond_sword') && !hasItem(bot, 'iron_sword')) { + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + break; + + case CHUNKS.DRAGON_FIGHT: + // Max out gear before the End + if (!hasItem(bot, 'diamond_sword') && hasItem(bot, 'diamond', 2)) { + await skills.craftRecipe(bot, 'diamond_sword', 1); + } + // Collect cobblestone for pillaring + if (countItem(bot, 'cobblestone') < 64 && getDimension(bot) === 'overworld') { + await skills.collectBlock(bot, 'cobblestone', 64); + } + break; + } + + // Update milestones + progress.updateMilestones(bot); + await progress.save(); +} + +/** + * After death: try to recover items by going to death location. + * Then re-acquire minimum gear if recovery failed. + * RC30: Hardened recovery — full tool chain re-crafting, dimension-aware + * portal linking safety, armor re-equip. + */ +async function recoverFromDeath(bot, progress) { + log(bot, 'Death recovery initiated...'); + + const deathPos = progress.getCoord('lastDeathPos'); + const deathDim = progress.getDimension(); + const currentDim = getDimension(bot); + + // RC30: Portal linking safety — if we died in the Nether but respawned in Overworld, + // and we have a saved portal coord, navigate to portal first before attempting recovery + if (deathDim === 'the_nether' && currentDim === 'overworld') { + const portalCoord = progress.getCoord('overworldPortal'); + if (portalCoord) { + log(bot, `Died in Nether, respawned in Overworld. Heading to saved portal: ${portalCoord.join(', ')}`); + try { + await skills.goToPosition(bot, portalCoord[0], portalCoord[1], portalCoord[2], 3); + // Wait for portal transition + await skills.wait(bot, 5000); + } catch (_e) { + log(bot, 'Could not reach Overworld portal. Will re-acquire gear in Overworld.'); + } + } + } + + // Attempt item recovery only if in the same dimension as death + if (deathPos && getDimension(bot) === deathDim) { + log(bot, `Heading to death location: ${deathPos.join(', ')}`); + try { + await skills.goToPosition(bot, deathPos[0], deathPos[1], deathPos[2], 3); + await skills.pickupNearbyItems(bot); + log(bot, 'Picked up items near death location.'); + } catch (_e) { + log(bot, 'Could not reach death location.'); + } + } else if (deathPos) { + log(bot, `Death was in ${deathDim} but currently in ${getDimension(bot)}. Skipping item recovery.`); + } + + // RC30: Full inventory check and re-acquisition chain + const inv = world.getInventoryCounts(bot); + const hasPickaxe = inv['diamond_pickaxe'] || inv['iron_pickaxe'] || inv['stone_pickaxe']; + const hasSword = inv['diamond_sword'] || inv['iron_sword'] || inv['stone_sword']; + + if (!hasPickaxe) { + log(bot, 'Lost pickaxe! Full tool chain re-crafting...'); + // Step 1: Get wood (try multiple tree types) + // Ocean escape: if no log exists within 64 blocks, explore until we find land + const LOG_TYPES = ['oak_log', 'birch_log', 'spruce_log', 'dark_oak_log', 'acacia_log', 'jungle_log']; + const hasAnyLogInRange = () => world.getNearestBlocks(bot, LOG_TYPES, 64, 1).length > 0; + if (!hasAnyLogInRange()) { + log(bot, 'No trees within 64 blocks (ocean/void spawn?). Exploring up to 600 blocks to find land with trees...'); + for (let attempt = 0; attempt < 3 && !hasAnyLogInRange(); attempt++) { + if (bot.interrupt_code) break; + await skills.explore(bot, 200); + } + if (!hasAnyLogInRange()) { + log(bot, 'Still no trees after 3 explore attempts. Running getDiamondPickaxe full chain as fallback.'); + await skills.getDiamondPickaxe(bot); + return; + } + } + let gotWood = false; + for (const logType of LOG_TYPES) { + if (bot.interrupt_code) return; + if (hasItem(bot, logType, 1)) { gotWood = true; break; } + try { + await skills.collectBlock(bot, logType, 4); + if (countItem(bot, logType) > 0) { gotWood = true; break; } + } catch (_e) { /* try next type */ } + } + if (gotWood) { + // Step 2: Craft basic tools + const planksType = Object.keys(world.getInventoryCounts(bot)) + .find(k => k.endsWith('_log')); + if (planksType) { + const planksName = planksType.replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'crafting_table', 1); + await skills.craftRecipe(bot, 'wooden_pickaxe', 1); + // Step 3: Upgrade to stone + try { + await skills.collectBlock(bot, 'cobblestone', 8); + await skills.craftRecipe(bot, 'stone_pickaxe', 1); + await skills.craftRecipe(bot, 'stone_sword', 1); + } catch (_e) { + log(bot, 'Could not gather cobblestone for stone tools.'); + } + // Step 4: Try for iron if we have time + if (!bot.interrupt_code && getDimension(bot) === 'overworld') { + try { + await skills.collectBlock(bot, 'iron_ore', 3); + if (countItem(bot, 'raw_iron') >= 3 || countItem(bot, 'iron_ore') >= 3) { + await skills.smeltItem(bot, 'raw_iron', 3); + await skills.craftRecipe(bot, 'iron_pickaxe', 1); + } + } catch (_e) { + log(bot, 'Could not upgrade to iron. Stone tools will suffice.'); + } + } + } + } + } + + if (!hasSword) { + log(bot, 'Lost sword! Crafting replacement...'); + if (hasItem(bot, 'iron_ingot', 2)) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (hasItem(bot, 'cobblestone', 2) || hasItem(bot, 'cobbled_deepslate', 2)) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } else { + // Desperate: craft wooden sword + const logTypes = Object.keys(world.getInventoryCounts(bot)).filter(k => k.endsWith('_log')); + if (logTypes.length > 0) { + const planksName = logTypes[0].replace('_log', '_planks'); + await skills.craftRecipe(bot, planksName, 1); + await skills.craftRecipe(bot, 'stick', 1); + await skills.craftRecipe(bot, 'wooden_sword', 1); + } + } + } + + // RC30: Re-equip armor if we have any + const armorSlots = ['head', 'torso', 'legs', 'feet']; + const armorPriority = { + head: ['diamond_helmet', 'iron_helmet', 'chainmail_helmet', 'leather_helmet'], + torso: ['diamond_chestplate', 'iron_chestplate', 'chainmail_chestplate', 'leather_chestplate'], + legs: ['diamond_leggings', 'iron_leggings', 'chainmail_leggings', 'leather_leggings'], + feet: ['diamond_boots', 'iron_boots', 'chainmail_boots', 'leather_boots'], + }; + for (const slot of armorSlots) { + for (const armorName of armorPriority[slot]) { + const armorItem = bot.inventory.items().find(i => i.name === armorName); + if (armorItem) { + try { await bot.equip(armorItem, slot); } catch (_e) { /* best effort */ } + break; + } + } + } + + // Stock up food + await skills.stockpileFood(bot, 16); + await eatIfNeeded(bot); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 2: Build a Nether Portal +// ═══════════════════════════════════════════════════════════════════════════ + +export async function buildNetherPortal(bot) { + console.log('[RC31] buildNetherPortal: starting'); + /** + * Build a nether portal using one of two methods: + * (A) If the bot already has 10+ obsidian and flint_and_steel, build directly. + * (B) Otherwise, use water bucket + lava source to cast obsidian in place. + * Requires: iron_pickaxe or diamond_pickaxe, bucket, flint_and_steel. + * @param {MinecraftBot} bot + * @returns {Promise} true if nether portal is built and lit. + **/ + log(bot, '=== CHUNK 2: Build Nether Portal ==='); + + // Check prerequisites + const inv = world.getInventoryCounts(bot); + const hasPickaxe = (inv['diamond_pickaxe'] || 0) > 0 || (inv['iron_pickaxe'] || 0) > 0; + if (!hasPickaxe) { + log(bot, 'Need at least an iron pickaxe first. Running getDiamondPickaxe...'); + if (!await skills.getDiamondPickaxe(bot)) { + log(bot, 'Cannot get a pickaxe. Aborting nether portal.'); + return false; + } + } + + await eatIfNeeded(bot); + + // Ensure we have flint and steel + if (!hasItem(bot, 'flint_and_steel')) { + // Need iron ingot + flint + if (!hasItem(bot, 'iron_ingot')) { + // Mine and smelt iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 1); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 1); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 1); + } + if (!hasItem(bot, 'flint')) { + await skills.collectBlock(bot, 'gravel', 5); // flint drops from gravel + // Check if we got flint from mining gravel + if (!hasItem(bot, 'flint')) { + // Mine more gravel + for (let i = 0; i < 10 && !hasItem(bot, 'flint'); i++) { + if (bot.interrupt_code) return false; + await skills.collectBlock(bot, 'gravel', 3); + } + } + } + if (hasItem(bot, 'iron_ingot') && hasItem(bot, 'flint')) { + await skills.craftRecipe(bot, 'flint_and_steel', 1); + } + if (!hasItem(bot, 'flint_and_steel')) { + log(bot, 'Cannot craft flint_and_steel. Need iron_ingot + flint.'); + return false; + } + } + + // Ensure we have a bucket + if (!hasItem(bot, 'bucket') && !hasItem(bot, 'water_bucket') && !hasItem(bot, 'lava_bucket')) { + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } else { + // Need more iron + let gotIron = await skills.collectBlock(bot, 'iron_ore', 3); + if (!gotIron) gotIron = await skills.collectBlock(bot, 'deepslate_iron_ore', 3); + if (gotIron) await skills.smeltItem(bot, 'raw_iron', 3); + if (hasItem(bot, 'iron_ingot', 3)) { + await skills.craftRecipe(bot, 'bucket', 1); + } + } + } + + // Method A: If we already have 10 obsidian, build directly + if (hasItem(bot, 'obsidian', 10)) { + return await buildPortalFromObsidian(bot); + } + + // Method B: Cast obsidian portal using water + lava + log(bot, 'Casting obsidian portal with water + lava method...'); + + // Get water bucket + if (!hasItem(bot, 'water_bucket')) { + const waterBlock = world.getNearestBlock(bot, 'water', 64); + if (waterBlock) { + await skills.goToPosition(bot, waterBlock.position.x, waterBlock.position.y, waterBlock.position.z, 2); + // Equip bucket and right-click water + const bucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (bucket) { + await bot.equip(bucket, 'hand'); + try { + const wBlock = bot.blockAt(waterBlock.position); + if (wBlock) await bot.activateBlock(wBlock); + } catch (_e) { /* try useOn fallback */ } + } + } + if (!hasItem(bot, 'water_bucket')) { + log(bot, 'Cannot find water source for bucket. Attempting direct mining of obsidian...'); + return await mineObsidianDirect(bot); + } + } + + // Find or create a lava source underground + log(bot, 'Finding lava source for portal casting...'); + + // Dig down to find lava (common near Y=10) + const currentY = Math.floor(bot.entity.position.y); + if (currentY > 15) { + const digDist = currentY - 11; + await skills.digDown(bot, digDist); + } + + // Find lava nearby + let lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) { + log(bot, 'No lava found nearby. Exploring at depth...'); + await skills.explore(bot, 40); + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + } + if (!lavaBlock) { + log(bot, 'Could not find lava. Try a different location.'); + return false; + } + + // Cast obsidian: pour water on lava source blocks + log(bot, 'Found lava! Casting obsidian...'); + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + + // Mine the obsidian we create — need at least 10 blocks + let obsidianCount = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidianCount < 10 && attempts < 25) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + // Pour water on lava + lavaBlock = world.getNearestBlock(bot, 'lava', 8); + if (!lavaBlock) { + lavaBlock = world.getNearestBlock(bot, 'lava', 32); + if (!lavaBlock) break; + await skills.goToPosition(bot, lavaBlock.position.x, lavaBlock.position.y, lavaBlock.position.z, 3); + } + + // Place water near lava + const waterBucket = bot.inventory.items().find(i => i.name === 'water_bucket'); + if (waterBucket) { + await bot.equip(waterBucket, 'hand'); + try { + const aboveLava = bot.blockAt(lavaBlock.position.offset(0, 1, 0)); + if (aboveLava) await bot.activateBlock(aboveLava); + } catch (_e) { /* best effort */ } + await new Promise(r => setTimeout(r, 1000)); + + // Pick water back up + const waterBlock = world.getNearestBlock(bot, 'water', 5); + if (waterBlock) { + const emptyBucket = bot.inventory.items().find(i => i.name === 'bucket'); + if (emptyBucket) { + await bot.equip(emptyBucket, 'hand'); + try { + const wb = bot.blockAt(waterBlock.position); + if (wb) await bot.activateBlock(wb); + } catch (_e) { /* best effort */ } + } + } + } + + // Mine newly created obsidian + const obsidian = world.getNearestBlock(bot, 'obsidian', 8); + if (obsidian) { + // Need diamond pickaxe to mine obsidian + const diamPick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (diamPick) { + await bot.equip(diamPick, 'hand'); + await skills.breakBlockAt(bot, obsidian.position.x, obsidian.position.y, obsidian.position.z); + await skills.pickupNearbyItems(bot); + } else { + log(bot, 'Need diamond pickaxe to mine obsidian!'); + return false; + } + } + + obsidianCount = countItem(bot, 'obsidian'); + log(bot, `Obsidian progress: ${obsidianCount}/10`); + } + + if (obsidianCount < 10) { + log(bot, `Only got ${obsidianCount} obsidian. Need 10. Try again.`); + return false; + } + + // Go to surface and build the portal + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function mineObsidianDirect(bot) { + /** Mine 10 obsidian directly (slow — need diamond pickaxe) */ + if (!hasItem(bot, 'diamond_pickaxe')) { + log(bot, 'Need diamond pickaxe to mine obsidian.'); + return false; + } + + log(bot, 'Mining obsidian directly...'); + let obsidian = countItem(bot, 'obsidian'); + let attempts = 0; + while (obsidian < 10 && attempts < 30) { + if (bot.interrupt_code) return false; + attempts++; + const block = world.getNearestBlock(bot, 'obsidian', 32); + if (!block) { + await skills.explore(bot, 40); + continue; + } + const pick = bot.inventory.items().find(i => i.name === 'diamond_pickaxe'); + if (pick) await bot.equip(pick, 'hand'); + await skills.breakBlockAt(bot, block.position.x, block.position.y, block.position.z); + await skills.pickupNearbyItems(bot); + obsidian = countItem(bot, 'obsidian'); + } + + if (obsidian < 10) return false; + + await skills.goToSurface(bot); + return await buildPortalFromObsidian(bot); +} + +async function buildPortalFromObsidian(bot) { + /** Build a standard 4x5 nether portal frame and light it */ + log(bot, 'Building nether portal frame...'); + const pos = bot.entity.position; + const bx = Math.floor(pos.x) + 2; + const by = Math.floor(pos.y); + const bz = Math.floor(pos.z); + + // Standard portal frame: 4 wide x 5 tall, only the frame blocks + // Bottom row (2 blocks) + const portalBlocks = [ + // Bottom + [bx + 1, by, bz], [bx + 2, by, bz], + // Left column + [bx, by + 1, bz], [bx, by + 2, bz], [bx, by + 3, bz], + // Right column + [bx + 3, by + 1, bz], [bx + 3, by + 2, bz], [bx + 3, by + 3, bz], + // Top row + [bx + 1, by + 4, bz], [bx + 2, by + 4, bz], + ]; + + for (const [px, py, pz] of portalBlocks) { + if (bot.interrupt_code) return false; + const block = bot.blockAt(new Vec3(px, py, pz)); + if (block && block.name !== 'obsidian') { + // Clear the block first if not air + if (block.name !== 'air') { + await skills.breakBlockAt(bot, px, py, pz); + } + await skills.placeBlock(bot, 'obsidian', px, py, pz, 'bottom', true); + } + } + + // Clear the portal interior (2x3) + for (let dx = 1; dx <= 2; dx++) { + for (let dy = 1; dy <= 3; dy++) { + const block = bot.blockAt(new Vec3(bx + dx, by + dy, bz)); + if (block && block.name !== 'air') { + await skills.breakBlockAt(bot, bx + dx, by + dy, bz); + } + } + } + + // Light the portal with flint and steel + log(bot, 'Lighting nether portal...'); + const flintSteel = bot.inventory.items().find(i => i.name === 'flint_and_steel'); + if (flintSteel) { + await bot.equip(flintSteel, 'hand'); + const insideBlock = bot.blockAt(new Vec3(bx + 1, by + 1, bz)); + if (insideBlock) { + try { + await bot.activateBlock(insideBlock); + } catch (_e) { + // Try activating the bottom obsidian + const bottomBlock = bot.blockAt(new Vec3(bx + 1, by, bz)); + if (bottomBlock) await bot.activateBlock(bottomBlock); + } + } + } + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const portalBlock = world.getNearestBlock(bot, 'nether_portal', 8); + if (portalBlock) { + log(bot, 'Nether portal built and activated!'); + // Remember portal location + bot.memory_bank?.rememberPlace?.('overworld_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frame built but not activated. May need to manually light it.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 3: Collect Blaze Rods +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectBlazeRods(bot, count = 12) { + /** + * Travel to the Nether, find a Nether Fortress, and kill Blazes for rods. + * Prerequisites: nether portal exists, good gear + food. + * @param {MinecraftBot} bot + * @param {number} count - number of blaze rods to collect. Default 12. + * @returns {Promise} true if enough blaze rods collected. + **/ + log(bot, `=== CHUNK 3: Collect ${count} Blaze Rods ===`); + + const currentRods = countItem(bot, 'blaze_rod'); + if (currentRods >= count) { + log(bot, `Already have ${currentRods} blaze rods!`); + return true; + } + + // Ensure we have gear + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Check we're prepared + const inv = world.getInventoryCounts(bot); + if (!inv['iron_sword'] && !inv['diamond_sword'] && !inv['stone_sword']) { + log(bot, 'Need a sword before entering the Nether.'); + // Try to craft one + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } else if (inv['cobblestone'] >= 2) { + await skills.craftRecipe(bot, 'stone_sword', 1); + } + } + + // Enter nether portal + const portal = world.getNearestBlock(bot, 'nether_portal', 64); + if (!portal) { + log(bot, 'No nether portal found! Build one first with !buildNetherPortal.'); + return false; + } + + log(bot, 'Entering nether portal...'); + await skills.goToPosition(bot, portal.position.x, portal.position.y, portal.position.z, 0); + + // Wait for dimension change + await new Promise(r => setTimeout(r, 8000)); + + // Check if we're in the nether + const dimension = bot.game?.dimension || 'overworld'; + if (!dimension.includes('nether') && !dimension.includes('the_nether')) { + log(bot, 'Failed to enter the Nether. Standing on portal...'); + // Try stepping into the portal + await new Promise(r => setTimeout(r, 5000)); + } + + // Search for nether fortress (nether_bricks) + log(bot, 'Searching for Nether Fortress...'); + let fortressFound = false; + let searchAttempts = 0; + + while (!fortressFound && searchAttempts < 15) { + if (bot.interrupt_code) return false; + searchAttempts++; + await eatIfNeeded(bot); + + // Look for nether_bricks which indicate a fortress + const bricks = world.getNearestBlock(bot, 'nether_bricks', 64); + if (bricks) { + log(bot, 'Found Nether Fortress!'); + await skills.goToPosition(bot, bricks.position.x, bricks.position.y, bricks.position.z, 3); + fortressFound = true; + } else { + log(bot, `Fortress search attempt ${searchAttempts}/15...`); + // Travel in a consistent direction through the nether + const pos = bot.entity.position; + const angle = (searchAttempts * 0.6) * Math.PI; // spiral pattern + const dist = 50 + searchAttempts * 20; + const targetX = pos.x + Math.cos(angle) * dist; + const targetZ = pos.z + Math.sin(angle) * dist; + await skills.goToPosition(bot, targetX, pos.y, targetZ, 5); + } + } + + if (!fortressFound) { + log(bot, 'Could not find a Nether Fortress after extensive search.'); + return false; + } + + // Hunt blazes + log(bot, 'Hunting blazes for blaze rods...'); + let rods = countItem(bot, 'blaze_rod'); + let huntAttempts = 0; + + while (rods < count && huntAttempts < 40) { + if (bot.interrupt_code) return false; + huntAttempts++; + await eatIfNeeded(bot); + + // Check health — retreat if low + if (bot.health < 8) { + log(bot, 'Low health! Building emergency shelter...'); + await skills.buildPanicRoom(bot); + } + + const blaze = world.getNearestEntityWhere(bot, e => e.name === 'blaze', 48); + if (blaze) { + // Prefer ranged attack for blazes + const hasBow = hasItem(bot, 'bow') && hasItem(bot, 'arrow'); + if (hasBow) { + await skills.rangedAttack(bot, 'blaze'); + } else { + log(bot, 'Fighting blaze in melee...'); + await skills.attackEntity(bot, blaze, true); + } + await skills.pickupNearbyItems(bot); + } else { + // Explore fortress to find blaze spawners + log(bot, 'No blazes visible. Searching fortress...'); + const spawner = world.getNearestBlock(bot, 'spawner', 32); + if (spawner) { + await skills.goToPosition(bot, spawner.position.x, spawner.position.y, spawner.position.z, 5); + await skills.wait(bot, 5000); // Wait for blazes to spawn + } else { + await skills.explore(bot, 30); + } + } + + rods = countItem(bot, 'blaze_rod'); + log(bot, `Blaze rods: ${rods}/${count}`); + } + + if (rods >= count) { + log(bot, `Collected ${rods} blaze rods! Heading back to portal...`); + } else { + log(bot, `Only got ${rods}/${count} blaze rods. May need to retry.`); + } + + return rods >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 4: Collect Ender Pearls +// ═══════════════════════════════════════════════════════════════════════════ + +export async function collectEnderPearls(bot, count = 12) { + /** + * Collect ender pearls by hunting Endermen. Works in both Overworld and Nether. + * Endermen are taller, so look at their feet to aggro them safely. + * @param {MinecraftBot} bot + * @param {number} count - target ender pearls. Default 12. + * @returns {Promise} true if enough ender pearls collected. + **/ + log(bot, `=== CHUNK 4: Collect ${count} Ender Pearls ===`); + + let pearls = countItem(bot, 'ender_pearl'); + if (pearls >= count) { + log(bot, `Already have ${pearls} ender pearls!`); + return true; + } + + await eatIfNeeded(bot); + await skills.autoManageInventory(bot); + + // Ensure we have a good sword + const inv = world.getInventoryCounts(bot); + if (!inv['diamond_sword'] && !inv['iron_sword']) { + if (inv['iron_ingot'] >= 2) { + await skills.craftRecipe(bot, 'iron_sword', 1); + } + } + + log(bot, 'Hunting Endermen for ender pearls...'); + let attempts = 0; + + while (pearls < count && attempts < 50) { + if (bot.interrupt_code) return false; + attempts++; + await eatIfNeeded(bot); + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // Find an enderman + const enderman = world.getNearestEntityWhere(bot, e => e.name === 'enderman', 48); + + if (enderman) { + log(bot, `Found Enderman! Distance: ${Math.floor(bot.entity.position.distanceTo(enderman.position))}`); + + // Get close enough + if (bot.entity.position.distanceTo(enderman.position) > 5) { + await skills.goToPosition(bot, + enderman.position.x, enderman.position.y, enderman.position.z, 4); + } + + // Look at its feet to aggro it (looking at head triggers teleportation aggro) + await bot.lookAt(enderman.position.offset(0, 0.5, 0)); + await new Promise(r => setTimeout(r, 500)); + + // Attack + await skills.attackEntity(bot, enderman, true); + await skills.pickupNearbyItems(bot); + } else { + // Endermen spawn more at night and in specific biomes + const timeOfDay = bot.time?.timeOfDay || 0; + if (timeOfDay < 13000) { + log(bot, 'Waiting for night (endermen spawn more at night)...'); + await skills.wait(bot, 5000); + } else { + // Explore to find endermen + await skills.explore(bot, 80); + } + } + + pearls = countItem(bot, 'ender_pearl'); + if (attempts % 5 === 0) { + log(bot, `Ender pearls: ${pearls}/${count} (attempt ${attempts})`); + } + } + + log(bot, `Collected ${pearls} ender pearls.`); + return pearls >= count; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 5: Locate Stronghold & Enter the End +// ═══════════════════════════════════════════════════════════════════════════ + +export async function locateStronghold(bot) { + /** + * Craft eyes of ender, throw them to triangulate the stronghold, + * dig down to find it, locate the end portal, and activate it. + * Prerequisites: blaze rods + ender pearls. + * @param {MinecraftBot} bot + * @returns {Promise} true if end portal found and activated. + **/ + log(bot, '=== CHUNK 5: Locate Stronghold & Enter the End ==='); + + // Craft blaze powder from blaze rods + const blazeRods = countItem(bot, 'blaze_rod'); + const blazePowder = countItem(bot, 'blaze_powder'); + const enderPearls = countItem(bot, 'ender_pearl'); + const eyesOfEnder = countItem(bot, 'ender_eye'); + + const totalEyes = eyesOfEnder; + const canCraftEyes = Math.min( + blazeRods * 2 + blazePowder, + enderPearls + ); + + if (totalEyes + canCraftEyes < 12) { + log(bot, `Not enough materials for 12 eyes of ender. Have: ${eyesOfEnder} eyes, ${blazeRods} rods, ${enderPearls} pearls.`); + return false; + } + + // Craft blaze powder + if (blazeRods > 0 && countItem(bot, 'blaze_powder') < enderPearls) { + const rodsToCraft = Math.min(blazeRods, Math.ceil((enderPearls - blazePowder) / 2)); + await skills.craftRecipe(bot, 'blaze_powder', rodsToCraft); + } + + // Craft eyes of ender + const currentEyes = countItem(bot, 'ender_eye'); + if (currentEyes < 12) { + const toCraft = Math.min( + countItem(bot, 'blaze_powder'), + countItem(bot, 'ender_pearl'), + 12 - currentEyes + ); + for (let i = 0; i < toCraft; i++) { + if (bot.interrupt_code) return false; + await skills.craftRecipe(bot, 'ender_eye', 1); + } + } + + const finalEyes = countItem(bot, 'ender_eye'); + if (finalEyes < 12) { + log(bot, `Only crafted ${finalEyes} eyes of ender. Need 12.`); + return false; + } + + log(bot, `Crafted ${finalEyes} eyes of ender. Triangulating stronghold...`); + + // Throw eyes of ender to find stronghold direction + // The eye floats toward the stronghold then drops + // We need 2 throws from different positions to triangulate + + const throw1Pos = bot.entity.position.clone(); + let throw1Dir = null; + let throw2Dir = null; + + // First throw + log(bot, 'Throwing first eye of ender...'); + const eye1 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye1) { + await bot.equip(eye1, 'hand'); + await bot.look(0, 0); // look forward + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + // The eye entity should appear and float in a direction + // Watch for thrown ender eye entity + const eyeEntity = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity) { + const eyePos = eyeEntity.position; + throw1Dir = { + x: eyePos.x - throw1Pos.x, + z: eyePos.z - throw1Pos.z + }; + log(bot, `Eye flew toward (${Math.floor(eyePos.x)}, ${Math.floor(eyePos.z)})`); + } + } + + // Move perpendicular for second throw + if (throw1Dir) { + const perpX = throw1Pos.x + (-throw1Dir.z > 0 ? 200 : -200); + const perpZ = throw1Pos.z + (throw1Dir.x > 0 ? 200 : -200); + log(bot, 'Moving for second triangulation throw...'); + await skills.goToPosition(bot, perpX, bot.entity.position.y, perpZ, 10); + } else { + // First throw failed, just move and try again + await skills.explore(bot, 200); + } + + const throw2Pos = bot.entity.position.clone(); + + // Second throw + log(bot, 'Throwing second eye of ender...'); + const eye2 = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (eye2) { + await bot.equip(eye2, 'hand'); + await bot.look(0, 0); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const eyeEntity2 = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (eyeEntity2) { + throw2Dir = { + x: eyeEntity2.position.x - throw2Pos.x, + z: eyeEntity2.position.z - throw2Pos.z + }; + } + } + + // Estimate stronghold position from two throws + let targetX, targetZ; + if (throw1Dir && throw2Dir) { + // Line intersection to find stronghold + const det = throw1Dir.x * throw2Dir.z - throw1Dir.z * throw2Dir.x; + if (Math.abs(det) > 0.01) { + const t = ((throw2Pos.x - throw1Pos.x) * throw2Dir.z - (throw2Pos.z - throw1Pos.z) * throw2Dir.x) / det; + targetX = throw1Pos.x + throw1Dir.x * t; + targetZ = throw1Pos.z + throw1Dir.z * t; + log(bot, `Stronghold estimated at (${Math.floor(targetX)}, ${Math.floor(targetZ)})`); + } else { + // Lines are parallel, just follow the first direction + targetX = throw1Pos.x + throw1Dir.x * 100; + targetZ = throw1Pos.z + throw1Dir.z * 100; + } + } else { + // Fallback: strongholds typically generate 1000-3000 blocks from origin + // in ring patterns. Head toward origin at ~1500 block radius + const pos = bot.entity.position; + const distFromOrigin = Math.sqrt(pos.x * pos.x + pos.z * pos.z); + if (distFromOrigin > 2000) { + targetX = pos.x * 0.6; // Move toward origin + targetZ = pos.z * 0.6; + } else { + targetX = pos.x + 500; + targetZ = pos.z + 500; + } + log(bot, `Eye tracking failed. Heading toward estimated stronghold area...`); + } + + // Navigate to estimated position + log(bot, 'Traveling to stronghold area...'); + await skills.goToPosition(bot, targetX, bot.entity.position.y, targetZ, 20); + + // Keep throwing eyes to refine position until they go DOWN + log(bot, 'Refining position with more eye throws...'); + let goingDown = false; + let refineAttempts = 0; + while (!goingDown && refineAttempts < 10) { + if (bot.interrupt_code) return false; + refineAttempts++; + await eatIfNeeded(bot); + + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + bot.activateItem(); + await new Promise(r => setTimeout(r, 3000)); + + const flyingEye = world.getNearestEntityWhere(bot, e => + e.name === 'eye_of_ender' || e.name === 'ender_eye', 32); + if (flyingEye) { + const eyeY = flyingEye.position.y; + const botY = bot.entity.position.y; + if (eyeY < botY) { + // Eye went DOWN — stronghold is below us! + goingDown = true; + log(bot, 'Eye went underground — stronghold is directly below!'); + } else { + // Still need to follow + await skills.goToPosition(bot, + flyingEye.position.x, bot.entity.position.y, flyingEye.position.z, 10); + } + } + await skills.pickupNearbyItems(bot); // Recover dropped eye + } + + // Dig down to find the stronghold + log(bot, 'Digging down to stronghold...'); + await skills.digDown(bot, 40); + + // Search for end portal frame blocks + let portalFrame = null; + let searchAttempts = 0; + while (!portalFrame && searchAttempts < 20) { + if (bot.interrupt_code) return false; + searchAttempts++; + + portalFrame = world.getNearestBlock(bot, 'end_portal_frame', 32); + if (!portalFrame) { + // Look for stone_bricks (stronghold material) + const stoneBricks = world.getNearestBlock(bot, 'stone_bricks', 32); + if (stoneBricks) { + log(bot, 'Found stronghold stonework! Searching for portal room...'); + await skills.goToPosition(bot, + stoneBricks.position.x, stoneBricks.position.y, stoneBricks.position.z, 2); + } + await skills.explore(bot, 20); + } + } + + if (!portalFrame) { + log(bot, 'Could not find end portal frame. Dig around in the stronghold to find it.'); + return false; + } + + log(bot, 'Found end portal frame! Filling with eyes of ender...'); + await skills.goToPosition(bot, + portalFrame.position.x, portalFrame.position.y, portalFrame.position.z, 3); + + // Fill all portal frames with eyes of ender + const frames = bot.findBlocks({ + matching: block => block && block.name === 'end_portal_frame', + maxDistance: 16, + count: 12 + }); + + let filled = 0; + for (const framePos of frames) { + if (bot.interrupt_code) return false; + const frameBlock = bot.blockAt(framePos); + if (!frameBlock) continue; + + // Check if frame already has an eye (metadata check) + // end_portal_frame has property 'eye' which is true/false + const hasEye = frameBlock.getProperties?.()?.eye === 'true' || + frameBlock.getProperties?.()?.eye === true; + if (hasEye) { + filled++; + continue; + } + + // Place eye of ender in frame + const eyeItem = bot.inventory.items().find(i => i.name === 'ender_eye'); + if (!eyeItem) { + log(bot, 'Ran out of eyes of ender!'); + return false; + } + + await bot.equip(eyeItem, 'hand'); + try { + await bot.activateBlock(frameBlock); + filled++; + await new Promise(r => setTimeout(r, 500)); + } catch (_e) { + log(bot, 'Failed to place eye in frame.'); + } + } + + log(bot, `Filled ${filled}/${frames.length} portal frames.`); + + // Check if portal is active + await new Promise(r => setTimeout(r, 2000)); + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (endPortal) { + log(bot, 'End portal is ACTIVE! Ready to enter.'); + // Remember location + const pos = bot.entity.position; + bot.memory_bank?.rememberPlace?.('end_portal', + Math.floor(pos.x), Math.floor(pos.y), Math.floor(pos.z)); + return true; + } + + log(bot, 'Portal frames placed but portal not active. May need more eyes.'); + return false; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CHUNK 6: Defeat the Ender Dragon +// ═══════════════════════════════════════════════════════════════════════════ + +export async function defeatEnderDragon(bot) { + /** + * Enter The End and defeat the Ender Dragon. + * Strategy: destroy end crystals first, then attack dragon during perching. + * Requires: strong weapon, bow + arrows, blocks for pillaring, food. + * @param {MinecraftBot} bot + * @returns {Promise} true if dragon defeated. + **/ + log(bot, '=== CHUNK 6: Defeat the Ender Dragon ==='); + + await eatIfNeeded(bot); + + // Ensure we have needed supplies + const inv = world.getInventoryCounts(bot); + const hasSword = inv['diamond_sword'] || inv['iron_sword']; + const hasBow = inv['bow'] && (inv['arrow'] || 0) >= 32; + const hasBlocks = (inv['cobblestone'] || 0) >= 64; + + if (!hasSword) { + log(bot, 'Need a sword for dragon fight.'); + if (inv['diamond'] >= 2) await skills.craftRecipe(bot, 'diamond_sword', 1); + else if (inv['iron_ingot'] >= 2) await skills.craftRecipe(bot, 'iron_sword', 1); + } + + if (!hasBow) { + log(bot, 'Bow + arrows strongly recommended for end crystals.'); + } + + // Ensure we have blocks for pillaring up to crystals + if (!hasBlocks) { + await skills.collectBlock(bot, 'cobblestone', 64); + } + + // Enter the end portal + const endPortal = world.getNearestBlock(bot, 'end_portal', 16); + if (!endPortal) { + log(bot, 'No end portal found! Run !locateStronghold first.'); + return false; + } + + log(bot, 'Jumping into the End...'); + await skills.goToPosition(bot, endPortal.position.x, endPortal.position.y, endPortal.position.z, 0); + await new Promise(r => setTimeout(r, 10000)); // Wait for dimension transfer + + // In The End now + log(bot, 'Arrived in The End. Beginning dragon fight!'); + + // Phase 1: Destroy end crystals on obsidian pillars + log(bot, 'Phase 1: Destroying end crystals...'); + let crystalsDestroyed = 0; + let crystalAttempts = 0; + + while (crystalAttempts < 30) { + if (bot.interrupt_code) return false; + crystalAttempts++; + await eatIfNeeded(bot); + + // Health check + if (bot.health < 8) { + log(bot, 'Low health! Eating and hiding...'); + await skills.buildPanicRoom(bot); + } + + // Find end crystals + const crystal = world.getNearestEntityWhere(bot, e => + e.name === 'end_crystal' || e.name === 'ender_crystal', 64); + + if (!crystal) { + log(bot, `All visible crystals destroyed (${crystalsDestroyed} confirmed).`); + break; + } + + const dist = bot.entity.position.distanceTo(crystal.position); + log(bot, `End crystal found at distance ${Math.floor(dist)}`); + + if (hasBow && dist > 8) { + // Shoot the crystal with bow + await skills.rangedAttack(bot, crystal.name); + crystalsDestroyed++; + } else { + // Pillar up and melee the crystal + // Get close first + await skills.goToPosition(bot, + crystal.position.x, bot.entity.position.y, crystal.position.z, 4); + + // If crystal is high up, pillar + const heightDiff = crystal.position.y - bot.entity.position.y; + if (heightDiff > 3) { + log(bot, `Pillaring up ${Math.floor(heightDiff)} blocks...`); + const pos = bot.entity.position; + for (let i = 0; i < Math.floor(heightDiff); i++) { + if (bot.interrupt_code) return false; + await skills.placeBlock(bot, 'cobblestone', + Math.floor(pos.x), Math.floor(pos.y) + i, Math.floor(pos.z), 'bottom', true); + bot.setControlState('jump', true); + await new Promise(r => setTimeout(r, 400)); + bot.setControlState('jump', false); + } + } + + // Attack the crystal (causes explosion — back away!) + try { + await bot.attack(crystal); + crystalsDestroyed++; + log(bot, 'Crystal destroyed! (watch for explosion damage)'); + } catch (_e) { + log(bot, 'Failed to attack crystal directly.'); + } + + // Move away from explosion + await skills.moveAway(bot, 5); + } + } + + // Phase 2: Fight the dragon + log(bot, 'Phase 2: Fighting the Ender Dragon!'); + let dragonAlive = true; + let fightAttempts = 0; + + while (dragonAlive && fightAttempts < 100) { + if (bot.interrupt_code) return false; + fightAttempts++; + await eatIfNeeded(bot); + + // RC30: Golden apple priority when health is critical during dragon fight + if (bot.health < 10) { + const inv = world.getInventoryCounts(bot); + const gapple = (inv['golden_apple'] || 0) > 0 ? 'golden_apple' + : (inv['enchanted_golden_apple'] || 0) > 0 ? 'enchanted_golden_apple' : null; + if (gapple) { + log(bot, `Critical health (${bot.health.toFixed(1)})! Eating ${gapple}!`); + await skills.consume(bot, gapple); + } + } + + if (bot.health < 8) { + await skills.buildPanicRoom(bot); + } + + // RC30: Void edge avoidance — check before we get too close + const pos = bot.entity.position; + if (pos.y < 5 || (Math.abs(pos.x) > 40 && pos.y < 55) || (Math.abs(pos.z) > 40 && pos.y < 55)) { + log(bot, 'DANGER: Near void edge! Moving to center...'); + await skills.goToPosition(bot, 0, 64, 0, 10); // Center of End island + continue; + } + + // Find the dragon + const dragon = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 128); + + if (!dragon) { + // Dragon might be dead or far away + const dragonEntity = world.getNearestEntityWhere(bot, e => + e.name === 'ender_dragon' || e.name === 'enderdragon', 256); + if (!dragonEntity) { + log(bot, 'Dragon not found. It might be defeated!'); + dragonAlive = false; + break; + } + // Move toward center where dragon perches + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 3000); + continue; + } + + const dist = bot.entity.position.distanceTo(dragon.position); + + // When dragon is perching on the fountain (near 0,64,0), it's vulnerable + if (dragon.position.y < 70 && dist < 20) { + log(bot, 'Dragon is perching! Attacking!'); + // Equip best sword + await equipBestSword(bot); + try { + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + await new Promise(r => setTimeout(r, 500)); + await bot.attack(dragon); + } catch (_e) { + // Dragon may have moved + } + } else if (hasBow && dist < 64) { + // Shoot with bow when dragon is flying + log(bot, 'Shooting dragon with bow...'); + const bow = bot.inventory.items().find(i => i.name === 'bow'); + if (bow) { + await bot.equip(bow, 'hand'); + const predictedPos = dragon.position.offset( + (dragon.velocity?.x || 0) * 2, + (dragon.velocity?.y || 0) * 2 + 2, + (dragon.velocity?.z || 0) * 2 + ); + await bot.lookAt(predictedPos); + bot.activateItem(); + await new Promise(r => setTimeout(r, 1200)); + bot.deactivateItem(); + } + } else { + // Move toward center and wait for dragon to perch + await skills.goToPosition(bot, 0, 64, 0, 10); + await skills.wait(bot, 2000); + } + + // Check for experience orbs (dragon death indicator) + const xpOrb = world.getNearestEntityWhere(bot, e => + e.name === 'experience_orb' || e.name === 'xp_orb', 32); + if (xpOrb) { + log(bot, 'Experience orbs detected — Dragon might be dead!'); + dragonAlive = false; + } + } + + if (!dragonAlive) { + log(bot, '🐉 ENDER DRAGON DEFEATED! VICTORY!'); + await skills.pickupNearbyItems(bot); + return true; + } + + log(bot, 'Dragon fight timed out. May need to retry.'); + return false; +} + +async function equipBestSword(bot) { + const swords = bot.inventory.items().filter(i => i.name.includes('sword')); + if (swords.length === 0) return; + // Sort by attack damage (diamond > iron > stone > wooden) + const priority = { 'netherite_sword': 5, 'diamond_sword': 4, 'iron_sword': 3, 'stone_sword': 2, 'golden_sword': 1, 'wooden_sword': 0 }; + swords.sort((a, b) => (priority[b.name] || 0) - (priority[a.name] || 0)); + await bot.equip(swords[0], 'hand'); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// META ORCHESTRATOR: Full Dragon Progression (RC29 — persistent + smart) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Complete autonomous run from fresh world to defeating the Ender Dragon. + * Uses persistent DragonProgress to survive restarts and deaths. + * Smart retry with exponential backoff, death recovery, dimension awareness. + * @param {MinecraftBot} bot + * @returns {Promise} true if Ender Dragon defeated. + */ +export async function runDragonProgression(bot) { + log(bot, '╔══════════════════════════════════════════════════╗'); + log(bot, '║ DRAGON PROGRESSION v2 (RC29): Smart Orchestrator║'); + log(bot, '╚══════════════════════════════════════════════════╝'); + + // ── Load or initialize persistent state ──────────────────────────── + const botName = bot.username || bot.entity?.username || 'UnknownBot'; + const progress = new DragonProgress(botName); + progress.load(); + + // Log current state + log(bot, progress.getSummary()); + + // ── Register death handler for this run ──────────────────────────── + let deathOccurred = false; + const deathHandler = () => { + deathOccurred = true; + const pos = bot.entity?.position; + if (pos) { + progress.recordDeath(pos.x, pos.y, pos.z, getDimension(bot)); + } + progress.save().catch(err => console.error('[DragonProgress] Save on death failed:', err)); + }; + bot.on('death', deathHandler); + + // ── RC30: Start progress reporter ────────────────────────────────── + const reporter = new ProgressReporter(bot, progress); + reporter.start(); + + // ── Chunk definitions ────────────────────────────────────────────── + const chunkRunners = { + [CHUNKS.DIAMOND_PICKAXE]: { + name: 'Diamond Pickaxe', + check: () => hasItem(bot, 'diamond_pickaxe') || progress.isChunkDone(CHUNKS.DIAMOND_PICKAXE), + run: () => skills.getDiamondPickaxe(bot), + }, + [CHUNKS.NETHER_PORTAL]: { + name: 'Nether Portal', + check: () => { + if (progress.isChunkDone(CHUNKS.NETHER_PORTAL)) return true; + return world.getNearestBlock(bot, 'nether_portal', 128) !== null; + }, + run: () => buildNetherPortal(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('overworldPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.BLAZE_RODS]: { + name: 'Blaze Rods', + check: () => hasItem(bot, 'blaze_rod', 7) || progress.state.milestones.blazeRods >= 7, + run: () => collectBlazeRods(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.ENDER_PEARLS]: { + name: 'Ender Pearls', + check: () => { + const totalEyeMaterial = countItem(bot, 'ender_pearl') + countItem(bot, 'ender_eye'); + return totalEyeMaterial >= 12 || progress.state.milestones.eyesOfEnder >= 12; + }, + run: () => collectEnderPearls(bot, 12), + onSuccess: () => { + progress.updateMilestones(bot); + }, + }, + [CHUNKS.STRONGHOLD]: { + name: 'Stronghold', + check: () => { + if (progress.isChunkDone(CHUNKS.STRONGHOLD)) return true; + return world.getNearestBlock(bot, 'end_portal', 16) !== null; + }, + run: () => locateStronghold(bot), + onSuccess: () => { + const p = bot.entity.position; + progress.setCoord('stronghold', p.x, p.y, p.z); + progress.setCoord('endPortal', p.x, p.y, p.z); + }, + }, + [CHUNKS.DRAGON_FIGHT]: { + name: 'Ender Dragon Fight', + check: () => false, // Always attempt + run: () => defeatEnderDragon(bot), + onSuccess: () => { + progress.setEnteredEnd(true); + }, + }, + }; + + // ── Main orchestration loop ──────────────────────────────────────── + const MAX_RETRIES_PER_CHUNK = 5; + + try { + for (const chunkKey of DragonProgress.CHUNK_ORDER) { + console.log(`[RC31] orchestrator: processing chunk ${chunkKey}`); + if (bot.interrupt_code) { + log(bot, 'Dragon progression interrupted.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + + const runner = chunkRunners[chunkKey]; + const chunkIdx = DragonProgress.CHUNK_ORDER.indexOf(chunkKey) + 1; + const totalChunks = DragonProgress.CHUNK_ORDER.length; + + // Skip completed chunks + if (runner.check()) { + console.log(`[RC31] orchestrator: chunk ${chunkKey} check=true, skipping`); + if (!progress.isChunkDone(chunkKey)) { + progress.markChunkDone(chunkKey); + await progress.save(); + } + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- already complete, skipping.`); + continue; + } + + log(bot, `\n>> Chunk ${chunkIdx}/${totalChunks}: ${runner.name}`); + + // Pre-chunk preparation + await prepareForChunk(bot, chunkKey, progress); + + let success = false; + let retries = 0; + + while (!success && retries < MAX_RETRIES_PER_CHUNK) { + if (bot.interrupt_code) break; + retries++; + + // Handle death recovery between retries + if (deathOccurred) { + deathOccurred = false; + log(bot, `Died during ${runner.name}. Recovering...`); + await new Promise(r => setTimeout(r, 3000)); // Wait for respawn + await recoverFromDeath(bot, progress); + } + + progress.markChunkActive(chunkKey); + await progress.save(); + + const backoffMs = Math.min(1000 * Math.pow(2, retries - 1), 30000); + if (retries > 1) { + log(bot, `Retry ${retries}/${MAX_RETRIES_PER_CHUNK} for ${runner.name} (backoff ${Math.round(backoffMs / 1000)}s)...`); + await new Promise(r => setTimeout(r, backoffMs)); + await eatIfNeeded(bot); + // Explore to fresh area before retrying + if (getDimension(bot) === 'overworld') { + await skills.explore(bot, 100 + retries * 50); + } + } + + try { + console.log(`[RC31] orchestrator: running chunk ${runner.name} (retry ${retries})`); + success = await runner.run(); + console.log(`[RC31] orchestrator: chunk ${runner.name} returned success=${success}`); + } catch (err) { + console.error(`[RC31] orchestrator: chunk ${runner.name} threw: ${err.message}`); + log(bot, `Chunk ${runner.name} error: ${err.message}`); + success = false; + } + + if (success) { + // Run onSuccess hook + if (runner.onSuccess) { + try { runner.onSuccess(); } catch (_e) { /* best effort */ } + } + progress.markChunkDone(chunkKey); + progress.updateMilestones(bot); + await progress.save(); + log(bot, `[${chunkIdx}/${totalChunks}] ${runner.name} -- COMPLETE!`); + reporter.onChunkChange(); // RC30: trigger progress report on chunk transition + } else if (!bot.interrupt_code) { + progress.markChunkFailed(chunkKey); + await progress.save(); + } + } + + if (!success) { + log(bot, `Chunk ${runner.name} failed after ${MAX_RETRIES_PER_CHUNK} attempts.`); + log(bot, 'Dragon progression paused. Run !beatMinecraft or !dragonProgression to resume.'); + await progress.save(); + bot.removeListener('death', deathHandler); + return false; + } + } + } finally { + reporter.stop(); // RC30: stop progress reporter + bot.removeListener('death', deathHandler); + } + + // ── Victory! ─────────────────────────────────────────────────────── + log(bot, '\n== ENDER DRAGON DEFEATED! GG! =='); + log(bot, progress.getSummary()); + await progress.save(); + return true; +} diff --git a/src/agent/library/progress_reporter.js b/src/agent/library/progress_reporter.js new file mode 100644 index 000000000..2c97fa018 --- /dev/null +++ b/src/agent/library/progress_reporter.js @@ -0,0 +1,231 @@ +/** + * progress_reporter.js — Periodic Dragon Progression Status Reporter (RC30). + * + * Reports bot status every 5 minutes (or on chunk change) to: + * 1. Console/log output + * 2. Optional Discord webhook (if DISCORD_PROGRESS_WEBHOOK env var is set) + * + * Status includes: current chunk, health/hunger, dimension, location, + * elapsed time, estimated time to next stage, next goal, and optionally + * a screenshot if vision is enabled. + * + * Uses the same safeWriteFile and logging patterns as dragon_progress.js. + */ + +import * as world from './world.js'; +import * as skills from './skills.js'; + +// ── Estimated durations per chunk (minutes), for ETA calculation ──────── +const CHUNK_ESTIMATES = { + diamond_pickaxe: 15, + nether_portal: 12, + blaze_rods: 20, + ender_pearls: 25, + stronghold: 15, + dragon_fight: 20, +}; + +const CHUNK_GOALS = { + diamond_pickaxe: 'Mine diamonds and craft a diamond pickaxe', + nether_portal: 'Collect obsidian and build a Nether portal', + blaze_rods: 'Find a Nether fortress and collect 7+ blaze rods', + ender_pearls: 'Hunt endermen for 12+ ender pearls, craft eyes of ender', + stronghold: 'Triangulate and locate the stronghold / End portal', + dragon_fight: 'Enter the End, destroy crystals, defeat the Ender Dragon', +}; + +/** + * ProgressReporter — attaches to a bot + DragonProgress instance. + * Call start() to begin periodic reporting, stop() to end. + */ +export class ProgressReporter { + /** + * @param {object} bot — mineflayer bot instance + * @param {import('./dragon_progress.js').DragonProgress} progress — dragon state tracker + * @param {object} [options] + * @param {number} [options.intervalMs=300000] — report interval (default 5 min) + * @param {string} [options.webhookUrl] — Discord webhook URL (or set DISCORD_PROGRESS_WEBHOOK env) + * @param {object} [options.visionInterpreter] — VisionInterpreter instance for screenshots + */ + constructor(bot, progress, options = {}) { + this.bot = bot; + this.progress = progress; + this.intervalMs = options.intervalMs || 300_000; // 5 minutes + this.webhookUrl = options.webhookUrl || process.env.DISCORD_PROGRESS_WEBHOOK || null; + this.visionInterpreter = options.visionInterpreter || null; + this._timer = null; + this._startTime = null; + this._lastChunk = null; + this._reportCount = 0; + } + + /** Start the periodic reporter. Safe to call multiple times (idempotent). */ + start() { + if (this._timer) return; // already running + this._startTime = Date.now(); + this._lastChunk = this.progress.currentChunk(); + + // Immediate first report + this._report().catch(err => console.error('[ProgressReporter] First report error:', err.message)); + + this._timer = setInterval(() => { + this._report().catch(err => console.error('[ProgressReporter] Report error:', err.message)); + }, this.intervalMs); + + console.log(`[ProgressReporter] Started — reporting every ${Math.round(this.intervalMs / 60_000)}min`); + } + + /** Stop the reporter. */ + stop() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + // Send final report + this._report().catch(() => {}); + console.log('[ProgressReporter] Stopped.'); + } + + /** + * Check if chunk changed and trigger an off-cycle report. + * Call this from the orchestrator after each chunk transition. + */ + onChunkChange() { + const current = this.progress.currentChunk(); + if (current !== this._lastChunk) { + this._lastChunk = current; + this._report().catch(err => + console.error('[ProgressReporter] Chunk-change report error:', err.message)); + } + } + + // ── Internal ─────────────────────────────────────────────────────── + + async _report() { + this._reportCount++; + const status = this._buildStatus(); + const text = this._formatConsole(status); + + // Always log to console + skills.log(this.bot, `\n${text}`); + console.log(text); + + // Send to Discord webhook if configured + if (this.webhookUrl) { + await this._sendWebhook(status); + } + } + + _buildStatus() { + const bot = this.bot; + const progress = this.progress; + const pos = bot.entity?.position; + const currentChunk = progress.currentChunk(); + const chunkIndex = progress.currentChunkIndex(); + const totalChunks = progress.constructor.CHUNK_ORDER.length; + + // Elapsed time + const elapsedMs = Date.now() - (this._startTime || Date.now()); + const elapsedMin = Math.round(elapsedMs / 60_000); + + // ETA for current chunk + const chunkAttempts = currentChunk ? progress.getChunkAttempts(currentChunk) : 0; + const estimatedMin = currentChunk ? (CHUNK_ESTIMATES[currentChunk] || 15) : 0; + // Rough ETA: base estimate × (1 + 0.5 * retries) — retries take longer + const etaMin = Math.round(estimatedMin * (1 + 0.3 * chunkAttempts)); + + // Inventory summary + const inv = world.getInventoryCounts(bot); + const keyItems = []; + for (const item of ['diamond_pickaxe', 'diamond_sword', 'iron_sword', 'bow', + 'blaze_rod', 'ender_pearl', 'ender_eye', 'obsidian']) { + const count = inv[item] || 0; + if (count > 0) keyItems.push(`${item}×${count}`); + } + + // Food count + const foodNames = ['cooked_beef', 'cooked_porkchop', 'cooked_mutton', 'cooked_chicken', + 'bread', 'baked_potato', 'apple', 'carrot', 'golden_apple']; + let foodCount = 0; + for (const f of foodNames) foodCount += (inv[f] || 0); + + return { + botName: bot.username || 'Bot', + chunk: currentChunk || 'COMPLETE', + chunkIndex: chunkIndex + 1, + totalChunks, + chunkName: currentChunk ? (CHUNK_GOALS[currentChunk] || currentChunk) : 'Dragon defeated!', + health: bot.health?.toFixed(1) || '?', + hunger: bot.food ?? '?', + dimension: bot.game?.dimension || 'unknown', + position: pos ? `${Math.floor(pos.x)}, ${Math.floor(pos.y)}, ${Math.floor(pos.z)}` : 'unknown', + elapsedMin, + etaMin, + deaths: progress.state.stats.deaths, + totalRetries: progress.state.stats.totalRetries, + keyItems, + foodCount, + reportNumber: this._reportCount, + }; + } + + _formatConsole(s) { + const bar = '═'.repeat(50); + return [ + `╔${bar}╗`, + `║ PROGRESS REPORT #${s.reportNumber}`, + `╠${bar}╣`, + `║ Bot: ${s.botName}`, + `║ Chunk: ${s.chunkIndex}/${s.totalChunks} — ${s.chunk}`, + `║ Goal: ${s.chunkName}`, + `║ Health: ${s.health}/20 Hunger: ${s.hunger}/20 Food: ${s.foodCount}`, + `║ Dimension: ${s.dimension}`, + `║ Position: ${s.position}`, + `║ Elapsed: ${s.elapsedMin}min ETA chunk: ~${s.etaMin}min`, + `║ Deaths: ${s.deaths} Retries: ${s.totalRetries}`, + s.keyItems.length > 0 ? `║ Key items: ${s.keyItems.join(', ')}` : null, + `╚${bar}╝`, + ].filter(Boolean).join('\n'); + } + + async _sendWebhook(status) { + if (!this.webhookUrl) return; + try { + const embed = { + title: `🐉 Progress Report #${status.reportNumber}`, + color: status.chunk === 'COMPLETE' ? 0x00ff00 : 0x7289da, + fields: [ + { name: 'Chunk', value: `${status.chunkIndex}/${status.totalChunks} — ${status.chunk}`, inline: true }, + { name: 'Goal', value: status.chunkName, inline: false }, + { name: 'Health', value: `${status.health}/20`, inline: true }, + { name: 'Hunger', value: `${status.hunger}/20`, inline: true }, + { name: 'Food', value: `${status.foodCount}`, inline: true }, + { name: 'Dimension', value: status.dimension, inline: true }, + { name: 'Position', value: status.position, inline: true }, + { name: 'Elapsed', value: `${status.elapsedMin}min`, inline: true }, + { name: 'ETA', value: `~${status.etaMin}min`, inline: true }, + { name: 'Deaths', value: `${status.deaths}`, inline: true }, + { name: 'Retries', value: `${status.totalRetries}`, inline: true }, + ], + timestamp: new Date().toISOString(), + }; + + if (status.keyItems.length > 0) { + embed.fields.push({ name: 'Key Items', value: status.keyItems.join(', '), inline: false }); + } + + const payload = { + username: `${status.botName} Progress`, + embeds: [embed], + }; + + await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } catch (err) { + console.warn('[ProgressReporter] Webhook send failed:', err.message); + } + } +} diff --git a/tasks/dragon/blaze_rods.json b/tasks/dragon/blaze_rods.json new file mode 100644 index 000000000..7a149d5d8 --- /dev/null +++ b/tasks/dragon/blaze_rods.json @@ -0,0 +1,15 @@ +{ + "_comment": "Chunk 3: Collect blaze rods from a Nether Fortress.", + "blaze_rods": { + "goal": "Collect 12 blaze rods in the Nether. Use !collectBlazeRods(12). Requires: nether portal, sword, food, and ideally a bow with arrows.", + "initial_inventory": { "0": { "diamond_pickaxe": 1, "iron_sword": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "target": "blaze_rod", + "number_of_target": 12, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/diamond_pickaxe.json b/tasks/dragon/diamond_pickaxe.json new file mode 100644 index 000000000..c42d3f883 --- /dev/null +++ b/tasks/dragon/diamond_pickaxe.json @@ -0,0 +1,17 @@ +{ + "_comment": "Chunk 1: Get a diamond pickaxe from scratch.", + "diamond_pickaxe": { + "goal": "Obtain a diamond pickaxe. Use !getDiamondPickaxe for automated progression, or manually: collect logs → craft wooden pickaxe → mine stone → craft stone pickaxe → mine iron → smelt → craft iron pickaxe → dig to Y=-11 → mine diamonds → craft diamond pickaxe.", + "initial_inventory": {}, + "agent_count": 1, + "target": "diamond_pickaxe", + "number_of_target": 1, + "type": "techtree", + "max_depth": 4, + "depth": 0, + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": true + } +} diff --git a/tasks/dragon/ender_dragon.json b/tasks/dragon/ender_dragon.json new file mode 100644 index 000000000..9cf832729 --- /dev/null +++ b/tasks/dragon/ender_dragon.json @@ -0,0 +1,22 @@ +{ + "_comment": "Chunk 6: Enter The End and defeat the Ender Dragon.", + "ender_dragon": { + "goal": "Jump into the end portal and defeat the Ender Dragon. Use !defeatEnderDragon. Strategy: destroy end crystals with bow, then melee dragon during perching phase. Bring: sword, bow+arrows, blocks, food.", + "initial_inventory": { + "0": { + "diamond_sword": 1, + "bow": 1, + "arrow": 64, + "cobblestone": 64, + "cooked_beef": 64, + "diamond_pickaxe": 1 + } + }, + "agent_count": 1, + "type": "techtree", + "timeout": 3600, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/ender_pearls.json b/tasks/dragon/ender_pearls.json new file mode 100644 index 000000000..0fb259e3d --- /dev/null +++ b/tasks/dragon/ender_pearls.json @@ -0,0 +1,15 @@ +{ + "_comment": "Chunk 4: Collect ender pearls from Endermen.", + "ender_pearls": { + "goal": "Collect 12 ender pearls by hunting Endermen. Use !collectEnderPearls(12). Works best at night or in the Nether warped forest.", + "initial_inventory": { "0": { "diamond_sword": 1, "iron_armor_set": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "target": "ender_pearl", + "number_of_target": 12, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/full_run.json b/tasks/dragon/full_run.json new file mode 100644 index 000000000..60d609266 --- /dev/null +++ b/tasks/dragon/full_run.json @@ -0,0 +1,13 @@ +{ + "_comment": "Full dragon progression: fresh world to Ender Dragon defeat. Single agent.", + "dragon_full_run": { + "goal": "Defeat the Ender Dragon. Use !dragonProgression to run the full automated sequence, or manually chain: !getDiamondPickaxe → !buildNetherPortal → !collectBlazeRods(12) → !collectEnderPearls(12) → !locateStronghold → !defeatEnderDragon.", + "initial_inventory": {}, + "agent_count": 1, + "type": "techtree", + "timeout": 7200, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/dragon/nether_portal.json b/tasks/dragon/nether_portal.json new file mode 100644 index 000000000..66a4d64cb --- /dev/null +++ b/tasks/dragon/nether_portal.json @@ -0,0 +1,13 @@ +{ + "_comment": "Chunk 2: Build and light a nether portal.", + "nether_portal": { + "goal": "Build and activate a nether portal. Use !buildNetherPortal for automated construction. Requires diamond pickaxe for mining obsidian. Will use water+lava casting method or direct obsidian mining.", + "initial_inventory": { "0": { "diamond_pickaxe": 1, "iron_ingot": 5 } }, + "agent_count": 1, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": true + } +} diff --git a/tasks/dragon/stronghold.json b/tasks/dragon/stronghold.json new file mode 100644 index 000000000..2c8d64427 --- /dev/null +++ b/tasks/dragon/stronghold.json @@ -0,0 +1,13 @@ +{ + "_comment": "Chunk 5: Locate the stronghold and activate the end portal.", + "stronghold": { + "goal": "Find the stronghold using eyes of ender and activate the end portal. Use !locateStronghold. Requires: 12+ eyes of ender (crafted from blaze powder + ender pearls).", + "initial_inventory": { "0": { "ender_eye": 12, "diamond_pickaxe": 1, "cooked_beef": 32 } }, + "agent_count": 1, + "type": "techtree", + "timeout": 1800, + "blocked_actions": { "0": ["!startConversation"] }, + "missing_items": [], + "requires_ctable": false + } +} diff --git a/tasks/human_evaluation.js b/tasks/human_evaluation.js index dbefe4404..4336d3c8d 100644 --- a/tasks/human_evaluation.js +++ b/tasks/human_evaluation.js @@ -5,7 +5,7 @@ import { Blueprint, ConstructionTaskValidator } from '../src/agent/tasks/constru import { CookingTaskInitiator } from '../src/agent/tasks/cooking_tasks.js'; import fs from 'fs'; -import { start } from 'repl'; +import { start as _start } from 'repl'; // add a mineflayer bot the world named Andy const bot = mineflayer.createBot({ @@ -61,7 +61,7 @@ bot.on('spawn', async () => { bot.chat(`/tp andy ${usernames[0]}`); await new Promise(resolve => setTimeout(resolve, 5000)); // console.log(taskData); - console.log(`Task id is ${task_id}`) + console.log(`Task id is ${task_id}`); console.log(task_id); const task = taskData[task_id]; console.log(task); @@ -83,7 +83,7 @@ bot.on('spawn', async () => { } else { console.log(`No inventory found for user: ${user}`); } - let validator = null; + let _validator = null; if (task.type === "techtree" ) { bot.chat(`/tell ${user} You have the goal to ${task.goal}`); @@ -94,7 +94,7 @@ bot.on('spawn', async () => { const blueprint = new Blueprint(task.blueprint); console.log(blueprint); const result = blueprint.autoDelete(); - const commands = result.commands; + const _commands = result.commands; // for (const command of commands) { // bot.chat(command); // } diff --git a/tasks/running_human_ai.md b/tasks/running_human_ai.md index 1dcb8ece1..0e9be0658 100644 --- a/tasks/running_human_ai.md +++ b/tasks/running_human_ai.md @@ -15,7 +15,7 @@ pip install -r requirements.txt Setting up the world! Make sure your world has cheats enabled! You can do this on creation of your Minecraft world in the Minecraft console, or you can type ```/op @a``` in the chat or in the console of the world launched from the jar file. ## Construction -Press F3 to view the coordinates of the game. And pull up the file tasks/construction_tasks/church_blueprint.pdf +Press F3 to view the coordinates of the game. Download and open the [church blueprint PDF](https://github.com/Z0mb13V1/mindcraft/releases/download/v0.1.3-assets/church_blueprint.pdf). Run ``` python tasks/evaluation_script.py --no_launch_world --template_profile profiles/tasks/construction_profile.json --task_path tasks/construction_tasks/human_ai/1_agent_1_human.json --usernames YOUR_USERNAME --num_agents 1 --insecure_coding