From ec4798dae3ecc865a249021492f660ef0bf764a3 Mon Sep 17 00:00:00 2001 From: jaenster Date: Thu, 7 May 2026 15:06:42 +0200 Subject: [PATCH] Add e2e test runner + fix NPC walking, charname, screenshot OOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E: - Test runner uses the full Game proxy (items/npcs/move/sendPacket); previously ran with a hand-built minimal game stub that couldn't drive shopping flows. - Runner has its own __onOogTick that drives splash → SP → char select with oogSelectChar("EpicSorc"), so e2e is fully autonomous. - pnpm test:e2e: spawns daemon --tests + run.sh, scrapes aether_log.txt for the runner's "Results: X passed" line, exits with status. - New scripts/tests/shopping.test.ts: 7 in-game tests covering shouldKeep, trader visibility, real openTrade (interact + walk + menu) and short-circuit. NPC walking (Game.moveTo + unit.ts moveToward): - A single move click is clipped to the viewport, so the player only walked a few tiles before stopping. Now we get a path via findPath and walk node by node with the kolbot pattern: one click → wait for walk to start → wait for walk to finish → re-click if not close enough. Always closes in on the actual target. - NPC.interact() walks before sending the interact packet. Fixes openTrade, heal and repair when the NPC is more than ~10 tiles away. charname binding: - jsUnitGetName now reads pUnitData->szName for player units (UNIT_PLAYER, dwType=0) — D2CLIENT_GetUnitName doesn't return the char name for players. Mirrors the d2bs pattern. Monsters/items unchanged. takeScreenshot: - Optional name argument (".bmp" appended if missing). Each call overwrites the same name; multiple names = multiple files. - Wired into hookOogDraw so screenshots work on splash, char select, etc. - .d.ts return type corrected (void, not string). Carry-over from earlier in the session (referenced for context): - 8 files used game.player.maxHp (undefined) — replaced with hpmax. This was the root cause of PotionDrinker/heal/overlay never firing. - itemGetLocation no longer flags Inventory items as Cube (removed grid==3 false-positive — only the page field is authoritative). - jsNpcMenuByMenuId binding + npcMenuByMenuId(MenuOption.Trade/TradeRepair) in NPC.openTrade/repair/openGamble. Replaces fragile menu-index lookup. - shopping.ts openTradeUI uses game.waitUntil; npc.ts healAtNPC retries up to 3 times and verifies hp >= hpmax before declaring success. Test scaffolding: - packages/sdk/test/native-stub.ts — all 125 native exports as stubs + a __mockNative control surface (setUIFlag, setUIFlagAfter, setMenuIdResult with side-effect callback, getSentPackets, getMenuIdCalls, getInteracts, getCloseCount, reset). - packages/sdk/test/mock-game.ts — makeMockGame factory + runGenerator driver. - 21 new fast unit tests across NPC/shopping/potions. Verified: 61/61 SDK + 17/17 scripts unit tests pass; 29/29 in-game e2e tests pass against a live wine/D2 1.14d session. --- package.json | 5 +- packages/native/src/d2/functions.zig | 65 ++++-- packages/native/src/hook/game_hooks.zig | 1 + packages/native/src/sm/bindings.zig | 80 ++++++- packages/sdk/game/game.ts | 66 ++++++ packages/sdk/game/npc.test.ts | 87 ++++++++ packages/sdk/game/unit.ts | 105 +++++++-- packages/sdk/native/index.d.ts | 14 +- packages/sdk/test-loader.mjs | 1 + packages/sdk/test/mock-game.ts | 139 ++++++++++++ packages/sdk/test/native-stub.ts | 284 ++++++++++++++++++++++++ packages/sdk/test/runner.ts | 104 +++++---- scripts/e2e/run.mjs | 122 ++++++++++ scripts/lib/npc.ts | 40 ++-- scripts/lib/potions.test.ts | 71 ++++++ scripts/lib/potions.ts | 12 +- scripts/lib/shopping.test.ts | 131 +++++++++++ scripts/lib/shopping.ts | 49 ++-- scripts/lib/town-visit.ts | 2 +- scripts/lib/walk-clear.ts | 2 +- scripts/sequences/act2-leveling.ts | 2 +- scripts/sequences/act3-leveling.ts | 2 +- scripts/sequences/act4-leveling.ts | 2 +- scripts/sequences/act5-leveling.ts | 2 +- scripts/services/attack.ts | 2 +- scripts/services/pickit.ts | 8 +- scripts/tests/shopping.test.ts | 73 ++++++ scripts/threads/overlay.ts | 2 +- 28 files changed, 1329 insertions(+), 144 deletions(-) create mode 100644 packages/sdk/game/npc.test.ts create mode 100644 packages/sdk/test/mock-game.ts create mode 100644 packages/sdk/test/native-stub.ts create mode 100644 scripts/e2e/run.mjs create mode 100644 scripts/lib/potions.test.ts create mode 100644 scripts/lib/shopping.test.ts create mode 100644 scripts/tests/shopping.test.ts diff --git a/package.json b/package.json index d7a656e..4c8a36c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "packageManager": "pnpm@10.6.2", "scripts": { "compile:pickit": "node scripts/build/compile-pickit.mjs", - "test:sdk": "cd packages/sdk && node --import tsx --loader ./test-loader.mjs --test 'game/*.test.ts'" + "test:sdk": "cd packages/sdk && node --import tsx --loader ./test-loader.mjs --test 'game/*.test.ts'", + "test:scripts": "node --import tsx --loader ./packages/sdk/test-loader.mjs --test 'scripts/lib/*.test.ts'", + "test": "pnpm test:sdk && pnpm test:scripts", + "test:e2e": "node scripts/e2e/run.mjs" }, "devDependencies": { "@aether/sdk": "workspace:*", diff --git a/packages/native/src/d2/functions.zig b/packages/native/src/d2/functions.zig index 430340e..9d8a485 100644 --- a/packages/native/src/d2/functions.zig +++ b/packages/native/src/d2/functions.zig @@ -585,29 +585,56 @@ pub const GetInteractedUnit = struct { pub const NPCMenuArray: [*]const u8 = @ptrFromInt(0x00726C48); pub const NPCMenuCount: *const u32 = @ptrFromInt(0x00725A74); -/// Call an NPC menu option by scanning the menu array for the given NPC classId -/// and invoking the callback at the given option index. -pub fn callNpcMenuOption(npc_class_id: u32, option_index: u32) bool { +/// NPCMenu entry layout (39 bytes): +/// +0 (u32) classId +/// +4 (u32) optionsCount +/// +8 (i16[5]) stringIds (these are menu IDs — 0x0D44=Trade, 0x0D06=Trade/Repair, etc.) +/// +18 (u32[5]) callbacks +/// +38 (u8) enabled +const NPC_MENU_ENTRY_SIZE: u32 = 39; +const NPC_MENU_STRING_IDS_OFFSET: u32 = 8; +const NPC_MENU_CALLBACKS_OFFSET: u32 = 18; + +/// Find the menu entry for an NPC classId, or null if absent. +fn findNpcMenuEntry(npc_class_id: u32) ?[*]const u8 { const count = NPCMenuCount.*; - const entry_size: u32 = 39; - var i: u32 = 0; while (i < count) : (i += 1) { - const entry = NPCMenuArray + i * entry_size; + const entry = NPCMenuArray + i * NPC_MENU_ENTRY_SIZE; const entry_npc_id: u32 = @as(*align(1) const u32, @ptrCast(entry)).*; - if (entry_npc_id == npc_class_id) { - const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*; - if (option_index < options_count) { - // Callback pointers start at offset 0x12 (18), each is 4 bytes - const cb_ptr: u32 = @as(*align(1) const u32, @ptrCast(entry + 18 + option_index * 4)).*; - if (cb_ptr != 0) { - const callback: *const fn () callconv(.winapi) void = @ptrFromInt(cb_ptr); - callback(); - return true; - } - } - break; - } + if (entry_npc_id == npc_class_id) return entry; + } + return null; +} + +/// Invoke `entry`'s callback at the given option index. Returns false if the +/// index is out of range or the callback slot is null. +fn invokeNpcMenuCallback(entry: [*]const u8, option_index: u32) bool { + const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*; + if (option_index >= options_count) return false; + const cb_ptr: u32 = @as(*align(1) const u32, @ptrCast(entry + NPC_MENU_CALLBACKS_OFFSET + option_index * 4)).*; + if (cb_ptr == 0) return false; + const callback: *const fn () callconv(.winapi) void = @ptrFromInt(cb_ptr); + callback(); + return true; +} + +/// Call an NPC menu option by index. +pub fn callNpcMenuOption(npc_class_id: u32, option_index: u32) bool { + const entry = findNpcMenuEntry(npc_class_id) orelse return false; + return invokeNpcMenuCallback(entry, option_index); +} + +/// Call an NPC menu option by menu ID (e.g. 0x0D44 = Trade). Mirrors kolbot's +/// `Misc.useMenu(menuId)` — the canonical way to pick a menu option, since +/// option order varies by NPC (Akara has Talk first; Charsi has Trade first). +pub fn callNpcMenuByMenuId(npc_class_id: u32, menu_id: u16) bool { + const entry = findNpcMenuEntry(npc_class_id) orelse return false; + const options_count: u32 = @as(*align(1) const u32, @ptrCast(entry + 4)).*; + var j: u32 = 0; + while (j < options_count and j < 5) : (j += 1) { + const sid: u16 = @bitCast(@as(*align(1) const i16, @ptrCast(entry + NPC_MENU_STRING_IDS_OFFSET + j * 2)).*); + if (sid == menu_id) return invokeNpcMenuCallback(entry, j); } return false; } diff --git a/packages/native/src/hook/game_hooks.zig b/packages/native/src/hook/game_hooks.zig index e20dac2..0bb90ed 100644 --- a/packages/native/src/hook/game_hooks.zig +++ b/packages/native/src/hook/game_hooks.zig @@ -101,6 +101,7 @@ fn hookOogDraw() callconv(.c) void { _ = d2.functions.SetFont.call(.{old}); const drawCursorOog: *const fn () callconv(.c) void = @ptrFromInt(ADDR_DRAW_CURSOR_OOG); drawCursorOog(); + @import("../sm/bindings.zig").flushScreenshot(); } // --- Drawing: cursor (congrats/disc/unknown screens) --- diff --git a/packages/native/src/sm/bindings.zig b/packages/native/src/sm/bindings.zig index dc2c892..bc65d81 100644 --- a/packages/native/src/sm/bindings.zig +++ b/packages/native/src/sm/bindings.zig @@ -349,7 +349,21 @@ fn jsUnitGetName(cx: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_ const unit_type: u32 = @bitCast(argInt32(argc, vp, 0)); const unit_id: u32 = @bitCast(argInt32(argc, vp, 1)); const unit = units.findUnit(unit_type, unit_id) orelse { retString(cx, argc, vp, ""); return 1; }; - // GetUnitName returns wide string — convert to ascii + + // For player units, D2CLIENT_GetUnitName doesn't return the char name — read + // pUnitData->szName directly (the d2bs pattern). + if (unit.dwType == 0) { + if (unit.pUnitData) |raw| { + const data: *types.PlayerData = @ptrCast(@alignCast(raw)); + var len: usize = 0; + while (len < 16 and data.szName[len] != 0) len += 1; + retString(cx, argc, vp, data.szName[0..len]); + return 1; + } + retString(cx, argc, vp, ""); + return 1; + } + const name_w = d2.GetUnitName.call(.{unit}) orelse { retString(cx, argc, vp, ""); return 1; }; var buf: [64]u8 = undefined; var i: usize = 0; @@ -493,18 +507,18 @@ fn jsItemGetLocation(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) // body_location (0x44): non-zero = equipped body slot // node_page (0x69): 0=none, 1=inv grid, 2=belt, 3=bodyloc, 4=swapped const page = data.item_location; - const grid = data.game_location; const body = data.body_location; const npage = data.node_page; // First: check explicit container pages + // NOTE: only `page` (item_location) is authoritative for storage page. + // `grid` (game_location) is overloaded and frequently has small values + // (3 etc.) for plain inventory items — using it caused 23 inv items to + // misclassify as Cube. const result: i32 = blk: { - // item_location or game_location == 3 → cube - if (page == 3 or grid == 3) break :blk 3; - // item_location or game_location == 4 → stash - if (page == 4 or grid == 4) break :blk 4; - // vendor pages - if (page == 6 or page == 7 or grid == 6 or grid == 7) break :blk 6; + if (page == 3) break :blk 3; // cube + if (page == 4) break :blk 4; // stash + if (page == 6 or page == 7) break :blk 6; // vendor // Ground items: not in any inventory if (data.pOwnerInventory == null) break :blk @as(i32, 5); // ground @@ -825,6 +839,19 @@ fn jsNpcMenuSelect(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c return 1; } +fn jsNpcMenuByMenuId(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int { + const menu_id: u16 = @truncate(@as(u32, @bitCast(argInt32(argc, vp, 0)))); + + const npc = d2.GetInteractedUnit.call() orelse { + retBool(argc, vp, false); + return 1; + }; + + const ok = d2.callNpcMenuByMenuId(npc.dwTxtFileNo, menu_id); + retBool(argc, vp, ok); + return 1; +} + fn jsRunToEntity(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int { const unit_type = argInt32(argc, vp, 0); const unit_id = argInt32(argc, vp, 1); @@ -2354,14 +2381,42 @@ const screenshot_mem = struct { extern "kernel32" fn VirtualFree(lpAddress: [*]u8, dwSize: usize, dwFreeType: u32) callconv(.winapi) i32; }; -/// takeScreenshot() — requests a screenshot on the next draw hook (where overlay is visible). -fn jsScreenshot(_: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int { +/// Static filename buffer used between request and flush. +/// Defaults to "aether_screenshot.bmp" if takeScreenshot() called with no arg. +var screenshot_filename: [260]u8 = .{0} ** 260; + +/// takeScreenshot(name?: string) — requests a screenshot on the next draw hook +/// (where overlay is visible). If `name` is given, ".bmp" is appended if missing. +/// Works in-game and OOG. The file is written to the game directory. +fn jsScreenshot(cx: ?*anyopaque, argc: c_uint, vp: ?*anyopaque) callconv(.c) c_int { + const default_name = "aether_screenshot.bmp"; + var len: usize = 0; + if (argc >= 1) { + var nbuf: [256]u8 = undefined; + const nlen = c.sm_arg_string(cx, argc, vp, 0, &nbuf, nbuf.len); + if (nlen > 0) { + const ulen: usize = @intCast(nlen); + const has_ext = ulen >= 4 and std.mem.eql(u8, nbuf[ulen - 4 .. ulen], ".bmp"); + const total = if (has_ext) ulen else ulen + 4; + if (total < screenshot_filename.len) { + @memcpy(screenshot_filename[0..ulen], nbuf[0..ulen]); + if (!has_ext) @memcpy(screenshot_filename[ulen .. ulen + 4], ".bmp"); + screenshot_filename[total] = 0; + len = total; + } + } + } + if (len == 0) { + @memcpy(screenshot_filename[0..default_name.len], default_name); + screenshot_filename[default_name.len] = 0; + } shared_state.set(.screenshot_pending, 1); retUndefined(argc, vp); return 1; } /// Called from the draw hook dispatch (game_hooks.zig) — captures if pending. +/// Wired into both in-game and OOG post-draw hooks. pub fn flushScreenshot() void { if (shared_state.get(.screenshot_pending) == 0) return; shared_state.set(.screenshot_pending, 0); @@ -2380,7 +2435,7 @@ pub fn flushScreenshot() void { if (d2.GetBackBuffer.call(buf) == 0) return; - const fp: [*:0]const u8 = "aether_screenshot.bmp"; + const fp: [*:0]const u8 = @ptrCast(&screenshot_filename); const hFile = win32.CreateFileA(fp, 0x40000000, 0, null, 2, 0x80, null) orelse return; defer _ = win32.CloseHandle(hFile); @@ -2420,7 +2475,7 @@ pub fn flushScreenshot() void { _ = win32.WriteFile(hFile, &row_buf, row_stride, &written, null); } _ = win32.FlushFileBuffers(hFile); - log.print("screenshot saved"); + log.printStr("screenshot saved: ", std.mem.sliceTo(&screenshot_filename, 0)); } const bindings = [_]Binding{ @@ -2505,6 +2560,7 @@ const bindings = [_]Binding{ // Process control .{ .name = "closeNPCInteract", .func = &jsCloseNPCInteract, .nargs = 0 }, .{ .name = "npcMenuSelect", .func = &jsNpcMenuSelect, .nargs = 1 }, + .{ .name = "npcMenuByMenuId", .func = &jsNpcMenuByMenuId, .nargs = 1 }, .{ .name = "exitGame", .func = &jsExitGame, .nargs = 0 }, .{ .name = "exitClient", .func = &jsExitClient, .nargs = 0 }, .{ .name = "takeWaypoint", .func = &jsTakeWaypoint, .nargs = 2 }, diff --git a/packages/sdk/game/game.ts b/packages/sdk/game/game.ts index 7de6edc..99a83c4 100644 --- a/packages/sdk/game/game.ts +++ b/packages/sdk/game/game.ts @@ -215,6 +215,72 @@ export class Game { return false } + /** + * Walk to a world position. Uses findPath to break long walks into nodes + * (D2 clips clicks to viewport; you can't reach a target a screen away in + * one click). Each node is walked with the kolbot single-click pattern: + * one click → wait for walk to start → wait for walk to finish → re-click + * if not close enough. + * + * Returns true on arrival, false if dead or stuck. + */ + *moveTo(tx: number, ty: number, minDist = 4) { + const minDist2 = minDist * minDist + + const path = this.findPath(tx, ty) + const nodes = path.slice() + // Ensure the actual target is always the final node so we close in fully. + const last = nodes[nodes.length - 1] + if (!last || last.x !== tx || last.y !== ty) nodes.push({ x: tx, y: ty }) + + for (const node of nodes) { + const arrived = yield* this.walkToNode(node.x, node.y, minDist2) + if (arrived === "dead") return false + // Even if we didn't reach this intermediate node, try the next one. + } + + // Final check against the actual target. + const dx = this.player.x - tx, dy = this.player.y - ty + return dx * dx + dy * dy <= minDist2 + } + + /** Single-node walk — kolbot pattern. */ + private *walkToNode(tx: number, ty: number, minDist2: number): Generator { + let consecutiveFailedStarts = 0 + for (let attempt = 0; attempt < 6; attempt++) { + const p = this.player + const dx = p.x - tx, dy = p.y - ty + if (dx * dx + dy * dy <= minDist2) return "ok" + const m0 = p.mode + if (m0 === 0 || m0 === 17) return "dead" + + nativeMove(tx, ty) + + let walking = false + for (let t = 0; t < 20; t++) { + yield + const m = p.mode + if (m === 0 || m === 17) return "dead" + if (m === 2 || m === 3 || m === 6) { walking = true; break } + } + if (!walking) { + if (++consecutiveFailedStarts >= 3) return "stuck" + continue + } + consecutiveFailedStarts = 0 + + for (let t = 0; t < 120; t++) { + yield + const m = p.mode + if (m === 0 || m === 17) return "dead" + const dx2 = p.x - tx, dy2 = p.y - ty + if (dx2 * dx2 + dy2 * dy2 <= minDist2) return "ok" + if (m === 1 || m === 5) break + } + } + return "stuck" + } + // ── Packet hooks (S2C interception) ────────────────────────────── private _packetHandlers = new Map boolean | void>>() diff --git a/packages/sdk/game/npc.test.ts b/packages/sdk/game/npc.test.ts new file mode 100644 index 0000000..95f6d4f --- /dev/null +++ b/packages/sdk/game/npc.test.ts @@ -0,0 +1,87 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { UiFlags, MenuOption } from "diablo:constants" +import { __mockNative } from "../test/native-stub.js" +import { NPC } from "./unit.js" +import { runGenerator } from "../test/mock-game.js" + +test("NPC.openTrade: picks Trade by menu ID, returns true when Shop opens", () => { + __mockNative.reset() + const npc = new NPC(100) + + // interact() polls NPCMenu/Shop. We trigger NPCMenu after interact fires. + __mockNative.setMenuIdResult(MenuOption.Trade, true, + () => __mockNative.setUIFlag(UiFlags.Shop, true)) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* npc.openTrade() })() + + // Step gen until interact is captured, then unblock NPCMenu + for (let i = 0; i < 200; i++) { + if (wrapped.next().done) break + if (__mockNative.getInteracts().length === 1 && !__mockNative.getMenuIdCalls().length) { + __mockNative.setUIFlag(UiFlags.NPCMenu, true) + } + } + + assert.equal(result, true) + assert.equal(__mockNative.getInteracts().length, 1) + assert.deepEqual(__mockNative.getInteracts()[0], { type: 1, unitId: 100 }) + assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade]) +}) + +test("NPC.openTrade: falls back to TradeRepair when Trade unavailable", () => { + __mockNative.reset() + const npc = new NPC(154) // Charsi-like + + __mockNative.setMenuIdResult(MenuOption.Trade, false) + __mockNative.setMenuIdResult(MenuOption.TradeRepair, true, + () => __mockNative.setUIFlag(UiFlags.Shop, true)) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* npc.openTrade() })() + + for (let i = 0; i < 200; i++) { + if (wrapped.next().done) break + if (__mockNative.getInteracts().length === 1 && !__mockNative.getMenuIdCalls().length) { + __mockNative.setUIFlag(UiFlags.NPCMenu, true) + } + } + + assert.equal(result, true) + assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade, MenuOption.TradeRepair]) +}) + +test("NPC.openTrade: returns false if interact never sees menu/shop", () => { + __mockNative.reset() + const npc = new NPC(200) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* npc.openTrade() })() + runGenerator(wrapped) + + assert.equal(result, false) + assert.equal(__mockNative.getMenuIdCalls().length, 0, "no menu select on failed interact") +}) + +test("NPC.repair: interacts, picks TradeRepair, sends repair packet", () => { + __mockNative.reset() + const npc = new NPC(154) + + __mockNative.setMenuIdResult(MenuOption.TradeRepair, true) + + const wrapped = npc.repair() + + for (let i = 0; i < 200; i++) { + if (wrapped.next().done) break + // Unblock interact's waitUntil + if (__mockNative.getInteracts().length === 1) { + __mockNative.setUIFlag(UiFlags.NPCMenu, true) + } + } + + assert.equal(__mockNative.getInteracts().length, 1) + assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.TradeRepair]) + assert.equal(__mockNative.getSentPackets().length, 1, "should have sent NpcRepair packet") + assert.equal(__mockNative.getCloseCount(), 1, "should have closed dialog") +}) diff --git a/packages/sdk/game/unit.ts b/packages/sdk/game/unit.ts index 36903c2..3f12557 100644 --- a/packages/sdk/game/unit.ts +++ b/packages/sdk/game/unit.ts @@ -8,11 +8,14 @@ import { tileGetDestArea, sendPacket as nativeSendPacket, interact as nativeInteract, + move as nativeMove, + findPath as nativeFindPath, getUIFlag as nativeGetUIFlag, closeNPCInteract as nativeCloseNPCInteract, npcMenuSelect as nativeNpcMenuSelect, + npcMenuByMenuId, } from "diablo:native" -import { UnitType, PlayerMode, MonsterMode, UiFlags, MonsterSpecType, MonsterClassId, ItemFlags, C2SPacket, REPAIR_ALL_FLAG } from "diablo:constants"; +import { UnitType, PlayerMode, MonsterMode, UiFlags, MonsterSpecType, MonsterClassId, ItemFlags, C2SPacket, REPAIR_ALL_FLAG, MenuOption } from "diablo:constants"; import { computeStatEx } from "./stat-ex.js"; export abstract class Unit { @@ -170,6 +173,82 @@ function* waitUntil(pred: () => boolean, maxFrames = 150) { return false } +/** + * Walk toward a world point. + * + * D2 clips long-distance clicks (you can't path beyond visible viewport in + * one click), so we get a path via findPath and walk node-by-node. Each + * node uses the kolbot single-click pattern: click → wait for walk to start + * → wait for walk to finish → re-click if not close enough. + * + * Modes: 0=Death, 1=Neutral, 2=Walk, 3=Run, 4=GetHit, + * 5=TownNeutral, 6=TownWalk, 17=Dead. + */ +function* moveToward(tx: number, ty: number, minDist = 4): Generator { + const minDist2 = minDist * minDist + const pid = meGetUnitId() + + const raw = nativeFindPath(tx, ty) + const nodes: Array<{ x: number; y: number }> = [] + if (raw) { + try { + const arr = JSON.parse(raw) as number[][] + for (const p of arr) nodes.push({ x: p[0]!, y: p[1]! }) + } catch { /* fallback handled below */ } + } + // Ensure the actual target is always the final node so we close in fully. + const last = nodes[nodes.length - 1] + if (!last || last.x !== tx || last.y !== ty) nodes.push({ x: tx, y: ty }) + + for (const node of nodes) { + const result = yield* walkToNode(pid, node.x, node.y, minDist2) + if (result === "dead") return false + // continue to next node even if this one was stuck — pathing may resume + } + + const px = unitGetX(0, pid), py = unitGetY(0, pid) + const dx = px - tx, dy = py - ty + return dx * dx + dy * dy <= minDist2 +} + +function* walkToNode(pid: number, tx: number, ty: number, minDist2: number): Generator { + let consecutiveFailedStarts = 0 + + for (let attempt = 0; attempt < 6; attempt++) { + const px = unitGetX(0, pid), py = unitGetY(0, pid) + const dx = px - tx, dy = py - ty + if (dx * dx + dy * dy <= minDist2) return "ok" + const m0 = unitGetMode(0, pid) + if (m0 === 0 || m0 === 17) return "dead" + + nativeMove(tx, ty) + + let walking = false + for (let t = 0; t < 20; t++) { + yield + const m = unitGetMode(0, pid) + if (m === 0 || m === 17) return "dead" + if (m === 2 || m === 3 || m === 6) { walking = true; break } + } + if (!walking) { + if (++consecutiveFailedStarts >= 3) return "stuck" + continue + } + consecutiveFailedStarts = 0 + + for (let t = 0; t < 120; t++) { + yield + const m = unitGetMode(0, pid) + if (m === 0 || m === 17) return "dead" + const px2 = unitGetX(0, pid), py2 = unitGetY(0, pid) + const dx2 = px2 - tx, dy2 = py2 - ty + if (dx2 * dx2 + dy2 * dy2 <= minDist2) return "ok" + if (m === 1 || m === 5) break + } + } + return "stuck" +} + export class NPC extends Monster { static readonly npcClassIds = new Set([ ...healClassIds, ...repairClassIds, ...tradeClassIds, @@ -183,12 +262,16 @@ export class NPC extends Monster { get canIdentify(): boolean { return identifyClassIds.has(this.classid) } get canResurrect(): boolean { return resurrectClassIds.has(this.classid) } - /** Open interaction with this NPC (client-side walk + menu). */ + /** + * Walk to this NPC, then send the interact packet and wait for NPCMenu/Shop + * to open. Returns true if the dialog opened. + */ *interact() { + if (this.distance > 4) yield* moveToward(this.x, this.y, 4) nativeInteract(this.type, this.unitId) const ok: unknown = yield* waitUntil(() => nativeGetUIFlag(UiFlags.NPCMenu) || nativeGetUIFlag(UiFlags.Shop) - ) + , 200) return !!ok } @@ -206,10 +289,7 @@ export class NPC extends Monster { /** Heal at this NPC — the game auto-heals on NPC interaction (HealByPlayerByNPC). */ *heal() { - nativeInteract(this.type, this.unitId) - yield* waitUntil(() => - nativeGetUIFlag(UiFlags.NPCMenu) || nativeGetUIFlag(UiFlags.Shop) - ) + yield* this.interact() yield* delay(200) yield* this.close() } @@ -218,8 +298,7 @@ export class NPC extends Monster { *repair() { yield* this.interact() yield* delay(200) - // Use NPC menu callback for repair (typically option index 1) - nativeNpcMenuSelect(1) + npcMenuByMenuId(MenuOption.TradeRepair) yield* delay(300) nativeSendPacket(buildPacket(C2SPacket.NpcRepair, this.unitId, 0, 0, REPAIR_ALL_FLAG)) yield* delay(200) @@ -231,9 +310,8 @@ export class NPC extends Monster { const interacted = yield* this.interact() if (!interacted) return false yield* delay(200) - // Use NPC menu callback for trade (option index 0) - const menuOk = nativeNpcMenuSelect(0) - if (!menuOk) return false + // Trade vs Trade/Repair varies per NPC (Akara: Trade; Charsi: TradeRepair). + if (!npcMenuByMenuId(MenuOption.Trade) && !npcMenuByMenuId(MenuOption.TradeRepair)) return false return yield* waitUntil(() => nativeGetUIFlag(UiFlags.Shop)) } @@ -241,8 +319,7 @@ export class NPC extends Monster { *openGamble() { yield* this.interact() yield* delay(200) - // Use NPC menu callback for gamble (typically option index 2) - nativeNpcMenuSelect(2) + npcMenuByMenuId(MenuOption.Gamble) return yield* waitUntil(() => nativeGetUIFlag(UiFlags.Shop)) } } diff --git a/packages/sdk/native/index.d.ts b/packages/sdk/native/index.d.ts index 702f201..f681ec6 100644 --- a/packages/sdk/native/index.d.ts +++ b/packages/sdk/native/index.d.ts @@ -99,6 +99,9 @@ // NPC interaction export function closeNPCInteract(): void; export function npcMenuSelect(menuIndex: number): boolean; + /** Pick an NPC menu option by its menu ID (e.g. NpcMenuId.Trade=0x0D44). + * Mirrors kolbot's Misc.useMenu — order-independent. */ + export function npcMenuByMenuId(menuId: number): boolean; // Merc /** Returns merc state: -1 = no merc, 0 = dead, 1+ = HP percent */ @@ -222,5 +225,12 @@ export function drawSetText(slot: number, text: string): void; // Screenshot - /** Capture framebuffer to aether_screenshot.bmp in game dir. Returns filename or "" on failure. */ - export function takeScreenshot(): string; + /** + * Request a BMP screenshot on the next draw frame. Works in-game and OOG + * (splash, char select). The file is written to the game directory. + * + * @param name Optional filename (".bmp" appended if missing). + * Defaults to "aether_screenshot.bmp". Each call overwrites + * the file with the same name. + */ + export function takeScreenshot(name?: string): void; diff --git a/packages/sdk/test-loader.mjs b/packages/sdk/test-loader.mjs index d33651a..42b9cc7 100644 --- a/packages/sdk/test-loader.mjs +++ b/packages/sdk/test-loader.mjs @@ -9,6 +9,7 @@ const SDK_ROOT = dirname(fileURLToPath(import.meta.url)) const ALIASES = { "diablo:constants": pathResolve(SDK_ROOT, "constants/index.ts"), "diablo:game": pathResolve(SDK_ROOT, "game/index.d.ts"), + "diablo:native": pathResolve(SDK_ROOT, "test/native-stub.ts"), } export async function resolve(specifier, context, nextResolve) { diff --git a/packages/sdk/test/mock-game.ts b/packages/sdk/test/mock-game.ts new file mode 100644 index 0000000..11d1b63 --- /dev/null +++ b/packages/sdk/test/mock-game.ts @@ -0,0 +1,139 @@ +/** + * Test helper for orchestration-level (generator) tests. Builds a minimal + * `Game`-shaped object with the surface area used by `scripts/lib/*` flows + * (shopping, npc, potions). Tests configure starting state and assert on + * captured side-effects. + * + * Drive a generator with `runGenerator(gen)` — it advances frame-by-frame, + * incrementing `_frame`, until the generator finishes or hits the safety cap. + */ + +import type { Game } from "../game/index.d.js" +import { __mockNative } from "./native-stub.js" + +/** Minimal item shape — matches what shopping/potions code reads. */ +export interface MockItem { + unitId: number + code: string + name?: string + location: number + quality?: number + durability?: number + maxdurability?: number +} + +/** Minimal NPC shape used by interactNPC. */ +export interface MockNpc { + type: number + unitId: number + classid: number + x: number + y: number + distance: number + name?: string + canTrade?: boolean + canRepair?: boolean +} + +export interface MockGameInit { + area?: number + gold?: number + hp?: number + hpmax?: number + mp?: number + mpmax?: number + charLevel?: number + items?: MockItem[] + npcs?: MockNpc[] +} + +export interface MockGameAPI { + game: Game + /** Captured `game.log` calls. */ + logs: string[] + /** Set HP at runtime (e.g. simulate heal). */ + setHp(hp: number): void + /** Push an item into game.items. */ + addItem(item: MockItem): void + /** Step a generator one frame. Returns done. */ + step(gen: Generator): boolean +} + +/** Run a generator to completion. Caps at 1000 frames to avoid hangs. */ +export function runGenerator(gen: Generator, maxFrames = 1000): number { + for (let i = 0; i < maxFrames; i++) { + if (gen.next().done) return i + } + throw new Error(`runGenerator: hit ${maxFrames}-frame safety cap`) +} + +export function makeMockGame(init: MockGameInit = {}): MockGameAPI { + __mockNative.reset() + + const state = { + area: init.area ?? 1, + gold: init.gold ?? 0, + hp: init.hp ?? 100, + hpmax: init.hpmax ?? 100, + mp: init.mp ?? 100, + mpmax: init.mpmax ?? 100, + charLevel: init.charLevel ?? 1, + items: init.items ?? [], + npcs: init.npcs ?? [], + frame: 0, + } + const logs: string[] = [] + + const player = { + get hp() { return state.hp }, + get hpmax() { return state.hpmax }, + get mp() { return state.mp }, + get mpmax() { return state.mpmax }, + get x() { return 0 }, + get y() { return 0 }, + get area() { return state.area }, + get mode() { return 1 }, + get classid() { return 0 }, + get charname() { return "TestChar" }, + get charlvl() { return state.charLevel }, + } + + const game: Partial = { + get area() { return state.area }, + get inGame() { return true }, + get charLevel() { return state.charLevel }, + get gold() { return state.gold }, + get _frame() { return state.frame }, + get player() { return player as Game["player"] }, + get items() { return state.items as unknown as Game["items"] }, + get npcs() { return state.npcs as unknown as Game["npcs"] }, + log(msg: string) { logs.push(msg) }, + sendPacket(_data: Uint8Array): void { + // delegate to native stub so tests can inspect via __mockNative + __mockNative.getSentPackets().push(new Uint8Array(_data)) + }, + *delay(ms: number): Generator { + const ticks = Math.ceil(ms / 40) + for (let i = 0; i < ticks; i++) yield + }, + *waitUntil(predicate: () => boolean, maxFrames = 150): Generator { + for (let i = 0; i < maxFrames; i++) { + if (predicate()) return true + yield + } + return false + }, + } + + return { + game: game as Game, + logs, + setHp(hp: number) { state.hp = hp }, + addItem(item: MockItem) { state.items.push(item) }, + step(gen: Generator) { + const r = gen.next() + state.frame++ + return Boolean(r.done) + }, + } +} diff --git a/packages/sdk/test/native-stub.ts b/packages/sdk/test/native-stub.ts new file mode 100644 index 0000000..5fd08f4 --- /dev/null +++ b/packages/sdk/test/native-stub.ts @@ -0,0 +1,284 @@ +/** + * Test stub for `diablo:native`. Implements all 125+ exports as no-ops with + * sensible defaults, plus a `__mockNative` control surface so tests can: + * - flip UI flags (`setUIFlag(flag, true)`) + * - capture sent packets (`getSentPackets()`) + * - script `npcMenuByMenuId` results per menu ID + * - reset between tests (`reset()`) + * + * The real implementation lives in Zig and is bound by SpiderMonkey at runtime; + * this module only exists for Node-side unit/orchestration tests. + */ + +// ── Mock state ────────────────────────────────────────────────────── + +interface MockState { + uiFlags: Map + sentPackets: Uint8Array[] + menuIdResults: Map + menuIdSideEffects: Map void> + menuIdCalls: number[] + menuSelectCalls: number[] + interacts: Array<{ type: number; unitId: number }> + closeCount: number + /** When set, getInteractedNPC returns this id. */ + interactedNpcId: number + /** Predicate-driven UI flag setter — fires after N getUIFlag reads. */ + flagAfter: Array<{ flag: number; value: boolean; readsLeft: number }> +} + +const state: MockState = { + uiFlags: new Map(), + sentPackets: [], + menuIdResults: new Map(), + menuIdSideEffects: new Map(), + menuIdCalls: [], + menuSelectCalls: [], + interacts: [], + closeCount: 0, + interactedNpcId: 0, + flagAfter: [], +} + +export const __mockNative = { + reset(): void { + state.uiFlags.clear() + state.sentPackets.length = 0 + state.menuIdResults.clear() + state.menuIdSideEffects.clear() + state.menuIdCalls.length = 0 + state.menuSelectCalls.length = 0 + state.interacts.length = 0 + state.closeCount = 0 + state.interactedNpcId = 0 + state.flagAfter.length = 0 + }, + + /** Set a UI flag value seen by `getUIFlag(flag)`. */ + setUIFlag(flag: number, value: boolean): void { + state.uiFlags.set(flag, value) + }, + + /** Schedule a UI flag flip after the next `readsLeft` calls to `getUIFlag(flag)`. */ + setUIFlagAfter(flag: number, value: boolean, readsLeft: number): void { + state.flagAfter.push({ flag, value, readsLeft }) + }, + + /** Configure return value of `npcMenuByMenuId(menuId)`. Default is false. + * Optional onCall side-effect (e.g. set Shop=true to simulate UI opening). */ + setMenuIdResult(menuId: number, result: boolean, onCall?: () => void): void { + state.menuIdResults.set(menuId, result) + if (onCall) state.menuIdSideEffects.set(menuId, onCall) + }, + + /** Set the result of `getInteractedNPC()`. */ + setInteractedNpc(unitId: number): void { + state.interactedNpcId = unitId + }, + + getSentPackets(): Uint8Array[] { + return state.sentPackets + }, + + getMenuIdCalls(): number[] { + return state.menuIdCalls + }, + + getInteracts(): Array<{ type: number; unitId: number }> { + return state.interacts + }, + + getCloseCount(): number { + return state.closeCount + }, +} + +// ── State ── +export function getArea(): number { return 1 } +export function getAct(): number { return 1 } +export function getUnitX(): number { return 0 } +export function getUnitY(): number { return 0 } +export function getUnitHP(): number { return 100 } +export function getUnitMaxHP(): number { return 100 } +export function getUnitMP(): number { return 100 } +export function getUnitMaxMP(): number { return 100 } +export function getUnitStat(_stat: number, _layer: number): number { return 0 } +export function inGame(): boolean { return true } +export function getDifficulty(): number { return 0 } +export function getTickCount(): number { return 0 } +export function log(_message: string): void {} +export function logVerbose(_message: string): void {} + +// ── Unit iteration / properties ── +export function unitCount(_type: number): number { return 0 } +export function unitAtIndex(_index: number): number { return 0 } +export function unitValid(_type: number, _unitId: number): boolean { return true } +export function unitGetX(_t: number, _u: number): number { return 0 } +export function unitGetY(_t: number, _u: number): number { return 0 } +export function unitGetMode(_t: number, _u: number): number { return 0 } +export function unitGetClassId(_t: number, _u: number): number { return 0 } +export function unitGetStat(_t: number, _u: number, _s: number, _l: number): number { return 0 } +export function unitGetState(_t: number, _u: number, _s: number): boolean { return false } +export function unitGetName(_t: number, _u: number): string { return "" } +export function unitGetArea(_t: number, _u: number): number { return 0 } +export function unitGetFlags(_t: number, _u: number): number { return 0 } +export function unitGetOwnerId(_t: number, _u: number): number { return -1 } +export function unitGetOwnerType(_t: number, _u: number): number { return -1 } + +// ── Monster ── +export function monGetSpecType(_unitId: number): number { return 0 } +export function monGetEnchants(_unitId: number): number[] { return [] } +export function monGetMaxHP(_classId: number): number { return 100 } + +// ── Item ── +export function itemGetQuality(_unitId: number): number { return 0 } +export function itemGetFlags(_unitId: number): number { return 0 } +export function itemGetLocation(_unitId: number): number { return 0 } +export function itemGetLocationRaw(_unitId: number): number { return 0 } +export function itemGetCode(_unitId: number): string { return "" } +export function itemGetRunewordIndex(_unitId: number): number { return 0 } +export function itemGetItemType(_unitId: number): number { return 0 } +export function itemGetLevel(_unitId: number): number { return 1 } +export function itemGetStatList(_unitId: number): string { return "[]" } +export function itemGetPrefixes(_unitId: number): string { return "" } +export function itemGetSuffixes(_unitId: number): string { return "" } + +// ── Tile ── +export function tileGetDestArea(_unitId: number): number { return 0 } + +// ── Player ── +export function meGetCharName(): string { return "TestChar" } +export function meGetUnitId(): number { return 1 } + +// ── Actions ── +export function clickMap(_t: number, _s: number, _x: number, _y: number): void {} +export function move(_x: number, _y: number): void {} +export function selectSkill(_s: number, _h: number): void {} +export function castSkillAt(_x: number, _y: number): void {} +export function castSkillPacket(_x: number, _y: number): void {} +export function getRightSkill(): number { return 0 } + +export function getUIFlag(flag: number): boolean { + // Apply scheduled deferred flips + for (const e of state.flagAfter) { + if (e.flag !== flag) continue + if (e.readsLeft <= 0) { + state.uiFlags.set(flag, e.value) + e.readsLeft = -1 + } else { + e.readsLeft-- + } + } + return state.uiFlags.get(flag) ?? false +} +export function setUIFlag(flag: number, mode?: number): void { + const cur = state.uiFlags.get(flag) ?? false + if (mode === 1) state.uiFlags.set(flag, false) + else if (mode === 2) state.uiFlags.set(flag, !cur) + else state.uiFlags.set(flag, true) +} + +export function say(_message: string): void {} +export function interact(type: number, unitId: number): void { + state.interacts.push({ type, unitId }) +} +export function runToEntity(_t: number, _u: number): void {} + +// ── Map / pathfinding ── +export function getExits(): string | null { return null } +export function findPath(_x: number, _y: number): string | null { return null } +export function findTelePath(_x: number, _y: number): string | null { return null } +export function findPreset(_t: number, _c: number): string | undefined { return undefined } + +// ── Skills / locale / txt ── +export function getSkillLevel(_s: number, _i: number): number { return 0 } +export function getLocaleString(_i: number): string { return "" } +export function txtReadField(_t: number, _r: number, _c: number, _s: number): number { return 0 } +export function txtReadFieldU(_t: number, _r: number, _c: number, _s: number): number { return 0 } + +// ── NPC interaction ── +export function closeNPCInteract(): void { state.closeCount++ } +export function npcMenuSelect(menuIndex: number): boolean { + state.menuSelectCalls.push(menuIndex) + return true +} +export function npcMenuByMenuId(menuId: number): boolean { + state.menuIdCalls.push(menuId) + const sideEffect = state.menuIdSideEffects.get(menuId) + if (sideEffect) sideEffect() + return state.menuIdResults.get(menuId) ?? false +} + +// ── Misc ── +export function getMercState(): number { return -1 } +export function exitGame(): void {} +export function exitClient(): void {} +export function takeWaypoint(_w: number, _d: number): void {} +export function sendPacket(data: Uint8Array): void { + state.sentPackets.push(new Uint8Array(data)) +} +export function registerPacketHook(_o: number): void {} +export function getPacketData(): Uint8Array { return new Uint8Array() } +export function getPacketSize(): number { return 0 } +export function injectPacket(_d: Uint8Array): void {} + +// ── Collision / spatial ── +export function getCollision(_x: number, _y: number): number { return 0 } +export function getCollisionRect(_x: number, _y: number, _w: number, _h: number): string { return "" } +export function getRooms(): string { return "" } +export function hasLineOfSight(_a: number, _b: number, _c: number, _d: number): number { return 1 } +export function getMapSeed(): number { return 0 } +export function getRoomSeed(_x: number, _y: number): string { return "" } + +// ── Screen / quest / player ── +export function printScreen(_m: string, _c: number): void {} +export function getQuest(_q: number, _s: number): number { return 0 } +export function hasWaypoint(_w: number): boolean { return false } +export function meGetClassId(): number { return 0 } +export function meGetGameType(): number { return 0 } +export function meGetPlayerType(): number { return 0 } +export function meGetLevel(): number { return 1 } +export function meGetGold(): number { return 0 } +export function meGetGoldStash(): number { return 0 } +export function clickItem(_m: number, _u: number): void {} +export function getInteractedNPC(): number { return state.interactedNpcId } + +// ── OOG ── +export function oogControlCount(): number { return 0 } +export function oogControlGetInfo(_i: number): string { return "" } +export function oogControlGetText(_i: number): string { return "" } +export function oogControlSetText(_i: number, _t: string): boolean { return true } +export function oogControlClick(_i: number): boolean { return true } +export function oogClickScreen(_x: number, _y: number): void {} +export function oogControlFind(_t: number, _x: number, _y: number, _w: number, _h: number): number { return -1 } +export function oogControlGetAll(): string { return "[]" } +export function oogSelectClass(_c: number): boolean { return true } +export function oogSelectChar(_n: string): boolean { return true } + +// ── File I/O ── +export function readFile(_f: string): string { return "" } +export function writeFile(_f: string, _c: string): boolean { return true } + +// ── Drawing (no-ops) ── +export function drawLine(_a: number, _b: number, _c: number, _d: number, _co: number, _al?: number): void {} +export function drawSolidRect(_a: number, _b: number, _c: number, _d: number, _co: number, _al?: number): void {} +export function drawText(_t: string, _x: number, _y: number, _c?: number, _f?: number): void {} +export function setFont(_f: number): number { return 0 } +export function getTextWidth(_t: string): number { return 0 } +export function getTextHeight(_t: string): number { return 0 } +export function worldToScreenX(_x: number, _y: number): number { return 0 } +export function worldToScreenY(_x: number, _y: number): number { return 0 } +export function worldToAutomapX(_x: number, _y: number): number { return 0 } +export function worldToAutomapY(_x: number, _y: number): number { return 0 } +export function drawAutomapLine(_a: number, _b: number, _c: number, _d: number, _co: number, _al?: number): void {} + +// ── Screen / shared / input / native draw / screenshot ── +export function getScreenWidth(): number { return 800 } +export function getScreenHeight(): number { return 600 } +export function getSharedState(): Int32Array { return new Int32Array(16) } +export function getKeyState(_v: number): boolean { return false } +export function drawAlloc(_t: number, _ta: number): number { return -1 } +export function drawFree(_s: number): void {} +export function drawUpdate(_s: number, _x: number, _y: number, _x2: number, _y2: number, _c: number, _a: number, _v: number): void {} +export function drawSetText(_s: number, _t: string): void {} +export function takeScreenshot(_name?: string): void {} diff --git a/packages/sdk/test/runner.ts b/packages/sdk/test/runner.ts index 8ba6235..e84b7ad 100644 --- a/packages/sdk/test/runner.ts +++ b/packages/sdk/test/runner.ts @@ -1,49 +1,10 @@ -import { - log, exitGame, exitClient, inGame, getArea, getAct, getDifficulty, getTickCount, - meGetUnitId, meGetCharName, - unitGetX, unitGetY, unitGetStat, unitGetState, - getExits as nativeGetExits, -} from "diablo:native" +import { log, exitClient, inGame } from "diablo:native" import { __getTests } from "diablo:test" +import { Game, FormType } from "../game/game.js" -// Minimal Game object for test runner — avoids importing diablo:game barrel -// which pulls in constants and exceeds the WS buffer. -function makePlayer() { - const id = meGetUnitId() - return { - get charname() { return meGetCharName() }, - get name() { return meGetCharName() }, - get x() { return unitGetX(0, id) }, - get y() { return unitGetY(0, id) }, - get hp() { return unitGetStat(0, id, 6, 0) >> 8 }, - get hpmax() { return unitGetStat(0, id, 7, 0) >> 8 }, - get mp() { return unitGetStat(0, id, 8, 0) >> 8 }, - get mpmax() { return unitGetStat(0, id, 9, 0) >> 8 }, - getStat(stat: number, layer: number = 0) { return unitGetStat(0, id, stat, layer) }, - } -} +const game = new Game() -const game = { - get inGame() { return inGame() }, - get area() { return getArea() }, - get act() { return getAct() }, - get difficulty() { return getDifficulty() }, - get tickCount() { return getTickCount() }, - get player() { return makePlayer() }, - log(...args: any[]) { log(args.map((a: any) => String(a)).join(' ')) }, - getExits() { - const raw = nativeGetExits() - if (!raw) return [] - return raw.split(',').map(function(entry: string) { - const parts = entry.split(':') - return { area: parseInt(parts[0]!, 10), x: parseInt(parts[1]!, 10), y: parseInt(parts[2]!, 10) } - }) - }, - *delay(ms: number) { - const ticks = Math.ceil(ms / 40) - for (let i = 0; i < ticks; i++) yield - }, -} +const CHAR_NAME = "EpicSorc" let started = false let finished = false @@ -53,6 +14,57 @@ let passed = 0 let failed = 0 let failedNames: string[] = [] +// OOG flow — drives splash → main menu → char select → join. +// Mirrors main.ts's existing-char path (no create-char, since e2e expects +// the EpicSorc save to exist). +let oogGen: Generator | null = null + +function* oogFlow(): Generator { + while (!game.inGame) { + yield + const controls = game.getControls() + const buttons = controls.filter(c => c.type === FormType.Button) + + // Splash → click any text/image to dismiss + if (buttons.length === 0 && controls.length > 0) { + const c = controls.find(c => c.type === FormType.TextBox || c.type === FormType.Image) + if (c) game.clickControl(c.i) + yield* game.delay(500) + continue + } + + // Main menu → SINGLE PLAYER + const sp = buttons.find(b => b.text?.includes("SINGLE")) + if (sp) { game.clickControl(sp.i); yield* game.delay(1000); continue } + + // Char select → pick existing + if (game.oogSelectChar(CHAR_NAME)) { + yield* game.delay(3000) + continue + } + + yield* game.delay(500) + } +} + +function stepOog() { + if (finished) return + if (game.inGame) return + if (!oogGen) { + oogGen = oogFlow() + log("=== Aether Test Runner: OOG flow ===") + } + try { + const r = oogGen.next() + if (r.done) oogGen = null + } catch (e: any) { + log("OOG error: " + (e.message || String(e))) + oogGen = null + } +} + +;(globalThis as any).__onOogTick = stepOog + ;(globalThis as any).__onTick = function onTick() { if (finished) return if (!inGame()) return @@ -72,12 +84,10 @@ let failedNames: string[] = [] const tests = __getTests() - // Advance current generator if (currentGen) { try { const result = currentGen.next() - if (!result.done) return // yield — wait for next tick - // Test passed + if (!result.done) return passed++ log(" PASS: " + tests[currentTest]!.name) } catch (e: any) { @@ -90,7 +100,6 @@ let failedNames: string[] = [] currentTest++ } - // Start next test if (currentTest < tests.length) { const entry = tests[currentTest]! try { @@ -105,7 +114,6 @@ let failedNames: string[] = [] return } - // All tests done log("") log("Results: " + passed + " passed, " + failed + " failed") if (failedNames.length > 0) { diff --git a/scripts/e2e/run.mjs b/scripts/e2e/run.mjs new file mode 100644 index 0000000..09989c1 --- /dev/null +++ b/scripts/e2e/run.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * E2E test harness: launches the daemon in --tests mode + Game.exe under Wine, + * tails aether_log.txt for the test runner's `Results:` line, exits with a + * status code reflecting pass/fail. + * + * Auto-enter (native feature) handles splash → char select → join with the + * "EpicSorc" character — no manual interaction required. The character must + * already exist as a local SP save and be in a town suitable for the tests. + * + * Requires: + * GAME_DIR — path to the D2 install + * + * Usage: + * node scripts/e2e/run.mjs [--timeout 180] + */ +import { spawn, spawnSync } from "node:child_process" +import { existsSync, openSync, readSync, closeSync, unlinkSync } from "node:fs" +import { resolve, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const REPO = resolve(__dirname, "../..") +const DAEMON_PKG = join(REPO, "packages/daemon") +const NATIVE_PKG = join(REPO, "packages/native") + +const args = process.argv.slice(2) +let timeoutSec = 180 +for (let i = 0; i < args.length; i++) { + if (args[i] === "--timeout") timeoutSec = parseInt(args[++i], 10) +} + +const GAME_DIR = process.env.GAME_DIR +if (!GAME_DIR) { + console.error("E2E: GAME_DIR is not set. Export it to your D2 install path.") + process.exit(2) +} +const LOG_PATH = join(GAME_DIR, "aether_log.txt") + +// 1. Spawn daemon in --tests mode +console.log("[e2e] starting daemon (--tests)...") +const daemon = spawn("node", [join(DAEMON_PKG, "dist/index.js"), "--tests"], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, AETHER_PORT: "13119" }, + detached: false, +}) +daemon.stdout.on("data", b => process.stdout.write("[daemon] " + b)) +daemon.stderr.on("data", b => process.stderr.write("[daemon!] " + b)) +daemon.on("exit", code => console.log(`[e2e] daemon exited (code=${code})`)) + +await new Promise(r => setTimeout(r, 1500)) + +// 2. Spawn run.sh — builds DLL fresh, copies, launches Game.exe +console.log("[e2e] launching game (this builds DLL + launches Wine)...") +if (existsSync(LOG_PATH)) { + try { unlinkSync(LOG_PATH) } catch {} +} +const game = spawn("bash", [join(NATIVE_PKG, "run.sh")], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, AETHER_DAEMON: "127.0.0.1:13119", AETHER_ENTRY: "main.ts" }, + detached: false, +}) +game.stdout.on("data", b => process.stdout.write("[game] " + b)) +game.stderr.on("data", b => process.stderr.write("[game!] " + b)) + +// 3. Tail aether_log.txt +let exitCode = 1 +const deadline = Date.now() + timeoutSec * 1000 +let resultsSeen = false +let pos = 0 + +function pollLog() { + if (Date.now() > deadline) { + console.error(`[e2e] TIMEOUT after ${timeoutSec}s waiting for "Results:" line`) + cleanup(124) + return + } + if (!existsSync(LOG_PATH)) { + setTimeout(pollLog, 500) + return + } + try { + const fd = openSync(LOG_PATH, "r") + const buf = Buffer.alloc(64 * 1024) + let read + do { + read = readSync(fd, buf, 0, buf.length, pos) + pos += read + const chunk = buf.subarray(0, read).toString("utf8") + if (chunk) process.stdout.write(chunk.replace(/^/gm, "[log] ")) + + const m = chunk.match(/Results: (\d+) passed, (\d+) failed/) + if (m) { + const passed = parseInt(m[1], 10) + const failed = parseInt(m[2], 10) + console.log(`\n[e2e] DONE: ${passed} passed, ${failed} failed`) + exitCode = failed === 0 ? 0 : 1 + resultsSeen = true + } + } while (read > 0) + closeSync(fd) + } catch (e) { + console.error("[e2e] log read error:", e.message) + } + if (resultsSeen) { + setTimeout(() => cleanup(exitCode), 1000) + return + } + setTimeout(pollLog, 500) +} + +function cleanup(code) { + console.log(`[e2e] cleanup → exiting ${code}`) + try { spawnSync("pkill", ["-9", "-f", "Game.exe"], { stdio: "ignore" }) } catch {} + try { daemon.kill("SIGTERM") } catch {} + setTimeout(() => process.exit(code), 500).unref() +} + +process.on("SIGINT", () => cleanup(130)) +process.on("SIGTERM", () => cleanup(143)) + +setTimeout(pollLog, 2000) diff --git a/scripts/lib/npc.ts b/scripts/lib/npc.ts index 3dffe5d..8643b55 100644 --- a/scripts/lib/npc.ts +++ b/scripts/lib/npc.ts @@ -6,7 +6,8 @@ */ import { type Game, type NPC, UiFlags } from "diablo:game" -import { closeNPCInteract, getUIFlag, npcMenuSelect as nativeNpcMenuSelect } from "diablo:native" +import { closeNPCInteract, getUIFlag, npcMenuByMenuId } from "diablo:native" +import { MenuOption } from "diablo:game" import { walkTo } from "./walk-clear.js" /** Walk to an NPC and interact. Returns the NPC unit or null. */ @@ -60,18 +61,31 @@ export function dismissNPC() { closeNPCInteract() } -/** Talk to NPC and heal (free — just interacting heals you) */ +/** Talk to NPC and heal (free — interacting auto-heals). + * Retries until HP is full or attempts exhausted. */ export function* healAtNPC(game: Game, classid: number): Generator { - const npc = yield* interactNPC(game, classid) - if (!npc) { - game.log('[npc] healer classid=' + classid + ' not found') - return + const hpmax = game.player.hpmax + for (let attempt = 0; attempt < 3; attempt++) { + if (game.player.hp >= hpmax) return // already full + + const npc = yield* interactNPC(game, classid) + if (!npc) { + game.log('[npc] healer classid=' + classid + ' not found (attempt ' + (attempt + 1) + ')') + yield* game.delay(300) + continue + } + + const healed = yield* game.waitUntil(() => game.player.hp >= hpmax, 25) + dismissNPC() + yield* game.delay(200) + + if (healed) { + game.log('[npc] healed at ' + (npc.name ?? 'NPC') + ' hp=' + game.player.hp + '/' + hpmax) + return + } + game.log('[npc] heal failed at ' + (npc.name ?? 'NPC') + ' hp=' + game.player.hp + '/' + hpmax + ' (retry)') } - // Interacting with a healer auto-heals. Just close the dialog. - yield* game.delay(300) - dismissNPC() - yield* game.delay(200) - game.log('[npc] healed at ' + (npc.name ?? 'NPC') + ' hp=' + game.player.hp + '/' + game.player.hpmax) + game.log('[npc] heal gave up hp=' + game.player.hp + '/' + hpmax) } /** Talk to NPC and open trade. Caller handles buying/selling. Close with dismissNPC(). */ @@ -81,7 +95,7 @@ export function* openTrade(game: Game, classid: number): Generator { if (!npc) return if (getUIFlag(UiFlags.NPCMenu)) { - nativeNpcMenuSelect(0) // Trade/Repair + npcMenuByMenuId(MenuOption.TradeRepair) yield* game.delay(500) } diff --git a/scripts/lib/potions.test.ts b/scripts/lib/potions.test.ts new file mode 100644 index 0000000..f0594ef --- /dev/null +++ b/scripts/lib/potions.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { ItemContainer } from "diablo:game" +import { makeMockGame } from "../../packages/sdk/test/mock-game.js" +import { countBeltPots, getBeltCapacity, needsHpPots, needsMpPots } from "./potions.js" + +test("countBeltPots: empty belt", () => { + const { game } = makeMockGame() + const counts = countBeltPots(game) + assert.deepEqual(counts, { hp: 0, mp: 0, rv: 0, stamina: 0, total: 0 }) +}) + +test("countBeltPots: mixed belt is counted by code prefix", () => { + const { game } = makeMockGame({ + items: [ + { unitId: 1, code: "hp1", location: ItemContainer.Belt }, + { unitId: 2, code: "hp3", location: ItemContainer.Belt }, + { unitId: 3, code: "mp1", location: ItemContainer.Belt }, + { unitId: 4, code: "rvs", location: ItemContainer.Belt }, + { unitId: 5, code: "vps", location: ItemContainer.Belt }, + // not in belt — should be ignored + { unitId: 6, code: "hp1", location: ItemContainer.Inventory }, + ], + }) + const counts = countBeltPots(game) + assert.equal(counts.hp, 2) + assert.equal(counts.mp, 1) + assert.equal(counts.rv, 1) + assert.equal(counts.stamina, 1) + assert.equal(counts.total, 5) +}) + +test("getBeltCapacity: scales with charLevel proxy", () => { + // <10 → sash (8 slots) + assert.equal(getBeltCapacity(makeMockGame({ charLevel: 5 }).game), 8) + // 10–19 → light/regular belt (12) + assert.equal(getBeltCapacity(makeMockGame({ charLevel: 12 }).game), 12) + // 20+ → heavy/plated belt (16) + assert.equal(getBeltCapacity(makeMockGame({ charLevel: 25 }).game), 16) +}) + +test("needsHpPots: true when belt has fewer than half-capacity HP+rejuv", () => { + const setup = (hpCount: number, rvCount: number) => makeMockGame({ + charLevel: 5, // capacity 8 → half = 4 + items: Array.from({ length: hpCount }, (_, i) => ({ + unitId: i, code: "hp1", location: ItemContainer.Belt, + })).concat(Array.from({ length: rvCount }, (_, i) => ({ + unitId: 100 + i, code: "rvs", location: ItemContainer.Belt, + }))), + }) + + assert.equal(needsHpPots(setup(0, 0).game), true, "0 hp = needs") + assert.equal(needsHpPots(setup(3, 0).game), true, "3 hp = needs (< 4)") + assert.equal(needsHpPots(setup(2, 1).game), true, "2 hp + 1 rv = needs (rv counts)") + assert.equal(needsHpPots(setup(2, 2).game), false, "2 hp + 2 rv = enough") + assert.equal(needsHpPots(setup(4, 0).game), false, "4 hp = enough") +}) + +test("needsMpPots: true when belt has fewer than quarter-capacity MP", () => { + const setup = (mpCount: number) => makeMockGame({ + charLevel: 5, // capacity 8 → quarter = 2 + items: Array.from({ length: mpCount }, (_, i) => ({ + unitId: i, code: "mp1", location: ItemContainer.Belt, + })), + }) + + assert.equal(needsMpPots(setup(0).game), true) + assert.equal(needsMpPots(setup(1).game), true) + assert.equal(needsMpPots(setup(2).game), false) + assert.equal(needsMpPots(setup(3).game), false) +}) diff --git a/scripts/lib/potions.ts b/scripts/lib/potions.ts index 39e55a2..f4784c2 100644 --- a/scripts/lib/potions.ts +++ b/scripts/lib/potions.ts @@ -100,16 +100,16 @@ export const PotionDrinker = createScript(function*(game, _svc) { if (area === 1 || area === 40 || area === 75 || area === 103 || area === 109) continue // town // HP pot at 50% HP (1s cooldown) — aggressive for low-level survival - if (game.player.hp > 0 && game.player.maxHp > 0 && game.player.hp < game.player.maxHp * 0.5 && game._frame - lastHpDrink > 25) { - const drank = drinkHpPot(game) - game.log(`[pot] HP ${game.player.hp}/${game.player.maxHp} (${(game.player.hp/game.player.maxHp*100)|0}%) → ${drank ? 'DRANK' : 'NO POTS'}`) - if (drank) lastHpDrink = game._frame + if (game.player.hp > 0 && game.player.hpmax > 0 && game.player.hp < game.player.hpmax * 0.5 && game._frame - lastHpDrink > 25) { + if (drinkHpPot(game)) { + game.log(`[pot] HP ${game.player.hp}/${game.player.hpmax} (${(game.player.hp/game.player.hpmax*100)|0}%) → DRANK`) + lastHpDrink = game._frame + } } // MP pot at 15% MP (2s cooldown) if (game.player.mp > 0 && game.player.mpmax > 0 && game.player.mp < game.player.mpmax * 0.15 && game._frame - lastMpDrink > 50) { - const drank = drinkMpPot(game) - if (drank) { + if (drinkMpPot(game)) { game.log(`[pot] MP ${game.player.mp}/${game.player.mpmax} → drank`) lastMpDrink = game._frame } diff --git a/scripts/lib/shopping.test.ts b/scripts/lib/shopping.test.ts new file mode 100644 index 0000000..fbe89ff --- /dev/null +++ b/scripts/lib/shopping.test.ts @@ -0,0 +1,131 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { UiFlags, MenuOption } from "diablo:game" +import { __mockNative } from "../../packages/sdk/test/native-stub.js" +import { makeMockGame, runGenerator } from "../../packages/sdk/test/mock-game.js" +import { shouldKeep, openTradeUI } from "./shopping.js" + +// ── shouldKeep (pure) ───────────────────────────────────────────────── + +test("shouldKeep: keep TP/ID scrolls", () => { + assert.equal(shouldKeep("tsc"), true) + assert.equal(shouldKeep("isc"), true) +}) + +test("shouldKeep: keep tomes + keys", () => { + assert.equal(shouldKeep("tbk"), true) + assert.equal(shouldKeep("ibk"), true) + assert.equal(shouldKeep("key"), true) +}) + +test("shouldKeep: keep utility pots", () => { + assert.equal(shouldKeep("vps"), true) // stamina + assert.equal(shouldKeep("yps"), true) // antidote + assert.equal(shouldKeep("wms"), true) // thawing +}) + +test("shouldKeep: keep rejuvs", () => { + assert.equal(shouldKeep("rvs"), true) + assert.equal(shouldKeep("rvl"), true) +}) + +test("shouldKeep: keep all rune codes (r01-r39 by current regex)", () => { + assert.equal(shouldKeep("r01"), true) // El + assert.equal(shouldKeep("r15"), true) // Hel + assert.equal(shouldKeep("r33"), true) // Zod + assert.equal(shouldKeep("r40"), false) // out of [0-3][0-9] range + assert.equal(shouldKeep("rxx"), false) // non-numeric +}) + +test("shouldKeep: keep gems (gld, gpv, etc.)", () => { + assert.equal(shouldKeep("gld"), true) + assert.equal(shouldKeep("gpv"), true) + assert.equal(shouldKeep("gza"), true) +}) + +test("shouldKeep: don't keep junk weapons / pots / armor", () => { + assert.equal(shouldKeep("hax"), false) // hand axe + assert.equal(shouldKeep("hp1"), false) // minor healing pot + assert.equal(shouldKeep("mp1"), false) // minor mana pot + assert.equal(shouldKeep("lbt"), false) // light boots + assert.equal(shouldKeep("aqv"), false) // arrows +}) + +// ── openTradeUI ─────────────────────────────────────────────────────── + +test("openTradeUI: returns true immediately if Shop UI already up", () => { + const { game, logs } = makeMockGame() + __mockNative.setUIFlag(UiFlags.Shop, true) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* openTradeUI(game, 1) })() + runGenerator(wrapped) + + assert.equal(result, true) + assert.equal(__mockNative.getMenuIdCalls().length, 0, "should not need to open menu") + assert.equal(__mockNative.getSentPackets().length, 0, "should not need to send session packet") + assert.deepEqual(logs, []) +}) + +test("openTradeUI: NPCMenu appears, picks Trade, Shop opens", () => { + const { game } = makeMockGame() + __mockNative.setUIFlag(UiFlags.NPCMenu, true) + // When Trade is invoked, Shop opens + __mockNative.setMenuIdResult(MenuOption.Trade, true, + () => __mockNative.setUIFlag(UiFlags.Shop, true)) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* openTradeUI(game, 42) })() + runGenerator(wrapped) + + assert.equal(result, true) + assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade]) + assert.equal(__mockNative.getSentPackets().length, 0, "should not need fallback packet") +}) + +test("openTradeUI: Trade menu missing → falls back to TradeRepair", () => { + const { game } = makeMockGame() + __mockNative.setUIFlag(UiFlags.NPCMenu, true) + __mockNative.setMenuIdResult(MenuOption.Trade, false) + __mockNative.setMenuIdResult(MenuOption.TradeRepair, true, + () => __mockNative.setUIFlag(UiFlags.Shop, true)) + + let result: boolean | undefined + const wrapped = (function* () { result = yield* openTradeUI(game, 42) })() + runGenerator(wrapped) + + assert.equal(result, true) + assert.deepEqual(__mockNative.getMenuIdCalls(), [MenuOption.Trade, MenuOption.TradeRepair]) +}) + +test("openTradeUI: no menu, falls back to npcSession packet, Shop opens", () => { + const { game } = makeMockGame() + // Neither Shop nor NPCMenu appear in the first waitUntil window — it + // times out, then sendPacket is called. We watch for the packet, then + // simulate Shop opening so the second waitUntil resolves. + let result: boolean | undefined + const wrapped = (function* () { result = yield* openTradeUI(game, 42) })() + + for (let i = 0; i < 200; i++) { + if (wrapped.next().done) break + if (__mockNative.getSentPackets().length === 1 && !__mockNative.getMenuIdCalls().length) { + __mockNative.setUIFlag(UiFlags.Shop, true) + } + } + + assert.equal(result, true) + assert.equal(__mockNative.getMenuIdCalls().length, 0, "should not have invoked menu") + assert.equal(__mockNative.getSentPackets().length, 1, "should have sent session packet") +}) + +test("openTradeUI: total failure → returns false", () => { + const { game } = makeMockGame() + // No flags, no menu, packet fallback also fails + + let result: boolean | undefined + const wrapped = (function* () { result = yield* openTradeUI(game, 42) })() + runGenerator(wrapped, 200) + + assert.equal(result, false) + assert.equal(__mockNative.getSentPackets().length, 1, "should have tried session packet") +}) diff --git a/scripts/lib/shopping.ts b/scripts/lib/shopping.ts index 153a0cf..43580c8 100644 --- a/scripts/lib/shopping.ts +++ b/scripts/lib/shopping.ts @@ -2,14 +2,11 @@ * Shopping: sell inventory junk + buy potions at NPC. */ -import { type Game } from "diablo:game" +import { type Game, ItemContainer, UiFlags, MenuOption } from "diablo:game" +import { getUIFlag, npcMenuByMenuId } from "diablo:native" import { npcBuy, npcSell, npcSession } from "./packets.js" import { interactNPC, dismissNPC, getAct } from "./npc.js" -const INVENTORY = 0 -const BELT = 2 -const VENDOR = 6 // items in vendor's shop window - // Items to KEEP (don't sell) const KEEP_CODES = new Set([ 'tsc', 'isc', 'tbk', 'ibk', 'key', // scrolls, tomes, keys @@ -18,12 +15,30 @@ const KEEP_CODES = new Set([ ]) const KEEP_PATTERNS = [/^r[0-3][0-9]$/, /^g[a-z][a-z]$/] // runes, gems -function shouldKeep(code: string): boolean { +export function shouldKeep(code: string): boolean { if (KEEP_CODES.has(code)) return true for (const p of KEEP_PATTERNS) { if (p.test(code)) return true } return false } +/** + * Open the trade UI on the currently-interacted NPC. Picks "Trade" from the + * NPC menu by menu ID (Trade vs Trade/Repair varies per NPC); falls back to + * an explicit npcSession packet if the menu never appears. + */ +export function* openTradeUI(game: Game, npcId: number): Generator { + if (getUIFlag(UiFlags.Shop)) return true + + if (yield* game.waitUntil(() => getUIFlag(UiFlags.Shop) || getUIFlag(UiFlags.NPCMenu), 30)) { + if (getUIFlag(UiFlags.Shop)) return true + if (!npcMenuByMenuId(MenuOption.Trade)) npcMenuByMenuId(MenuOption.TradeRepair) + if (yield* game.waitUntil(() => getUIFlag(UiFlags.Shop), 30)) return true + } + + game.sendPacket(npcSession(0, npcId)) + return yield* game.waitUntil(() => getUIFlag(UiFlags.Shop), 30) +} + // NPC classids for selling const sellVendors: Record = { 1: 154, 2: 178, 3: 253, 4: 405, 5: 511, @@ -43,7 +58,7 @@ export function* sellJunk(game: Game): Generator { // Collect items to sell const toSell: any[] = [] for (const item of game.items) { - if (item.location !== INVENTORY) continue + if (item.location !== ItemContainer.Inventory) continue if (shouldKeep(item.code)) continue // Sell everything else (equipment, junk) toSell.push(item) @@ -56,9 +71,10 @@ export function* sellJunk(game: Game): Generator { const npc = yield* interactNPC(game, vendorId) if (!npc) { game.log('[shop] vendor not found'); return } - // Open trade session - game.sendPacket(npcSession(0, npc.unitId)) - yield* game.delay(500) + if (!(yield* openTradeUI(game, npc.unitId))) { + game.log('[shop] trade UI failed to open for sell') + return + } for (const item of toSell) { game.log('[shop] sell ' + (item.name ?? item.code)) @@ -82,7 +98,7 @@ export function* buyPotions(game: Game): Generator { // Count belt pots let beltCount = 0 for (const item of game.items) { - if (item.location === BELT) beltCount++ + if (item.location === ItemContainer.Belt) beltCount++ } if (beltCount >= 8) return // belt full enough @@ -91,14 +107,15 @@ export function* buyPotions(game: Game): Generator { const npc = yield* interactNPC(game, vendorId) if (!npc) return - // Open trade - game.sendPacket(npcSession(0, npc.unitId)) - yield* game.delay(500) + if (!(yield* openTradeUI(game, npc.unitId))) { + game.log('[shop] trade UI failed to open for buy') + return + } - // Look for HP pots in vendor's inventory (location = VENDOR = 6) + // Look for HP pots in vendor's inventory const vendorPots: any[] = [] for (const item of game.items) { - if (item.location === VENDOR && item.code.startsWith('hp')) { + if (item.location === ItemContainer.Vendor && item.code.startsWith('hp')) { vendorPots.push(item) } } diff --git a/scripts/lib/town-visit.ts b/scripts/lib/town-visit.ts index 2160cba..adff90f 100644 --- a/scripts/lib/town-visit.ts +++ b/scripts/lib/town-visit.ts @@ -10,7 +10,7 @@ import { shop } from "./shopping.js" /** Full town visit: heal → sell junk → buy pots → equip upgrades */ export function* townVisit(game: Game): Generator { // 1. Heal (always free) - if (game.player.hp < game.player.maxHp || game.player.mp < game.player.mpmax) { + if (game.player.hp < game.player.hpmax || game.player.mp < game.player.mpmax) { yield* healInTown(game) } diff --git a/scripts/lib/walk-clear.ts b/scripts/lib/walk-clear.ts index df97caa..75ceeac 100644 --- a/scripts/lib/walk-clear.ts +++ b/scripts/lib/walk-clear.ts @@ -95,7 +95,7 @@ function sortMonsters(monsters: Monster[], px: number, py: number): void { /** Should we backtrack? Ryuk: pressure >= floor(4 * hp% + 1) */ function shouldBacktrack(game: Game): boolean { - const hpPct = game.player.hp / game.player.maxHp + const hpPct = game.player.hp / game.player.hpmax const maxPressure = Math.floor(4 * hpPct) + 1 let pressure = 0 diff --git a/scripts/sequences/act2-leveling.ts b/scripts/sequences/act2-leveling.ts index 6de94d8..a455c15 100644 --- a/scripts/sequences/act2-leveling.ts +++ b/scripts/sequences/act2-leveling.ts @@ -19,7 +19,7 @@ export function* act2Leveling(game: Game, svc: any) { const atk = svc.get(Attack) const pickit = svc.get(Pickit) - if (townAreas.has(game.area) && game.player.hp < game.player.maxHp) { + if (townAreas.has(game.area) && game.player.hp < game.player.hpmax) { yield* healInTown(game) } diff --git a/scripts/sequences/act3-leveling.ts b/scripts/sequences/act3-leveling.ts index 34469f1..93abff9 100644 --- a/scripts/sequences/act3-leveling.ts +++ b/scripts/sequences/act3-leveling.ts @@ -18,7 +18,7 @@ export function* act3Leveling(game: Game, svc: any) { const atk = svc.get(Attack) const pickit = svc.get(Pickit) - if (game.area === Area.KurastDocks && game.player.hp < game.player.maxHp) { + if (game.area === Area.KurastDocks && game.player.hp < game.player.hpmax) { yield* healInTown(game) } diff --git a/scripts/sequences/act4-leveling.ts b/scripts/sequences/act4-leveling.ts index 9a8d8f1..b1cdd6f 100644 --- a/scripts/sequences/act4-leveling.ts +++ b/scripts/sequences/act4-leveling.ts @@ -19,7 +19,7 @@ export function* act4Leveling(game: Game, svc: any) { const atk = svc.get(Attack) const pickit = svc.get(Pickit) - if (game.area === Area.PandemoniumFortress && game.player.hp < game.player.maxHp) { + if (game.area === Area.PandemoniumFortress && game.player.hp < game.player.hpmax) { yield* healInTown(game) } diff --git a/scripts/sequences/act5-leveling.ts b/scripts/sequences/act5-leveling.ts index 465a74d..92f9704 100644 --- a/scripts/sequences/act5-leveling.ts +++ b/scripts/sequences/act5-leveling.ts @@ -19,7 +19,7 @@ export function* act5Leveling(game: Game, svc: any) { const atk = svc.get(Attack) const pickit = svc.get(Pickit) - if (game.area === Area.Harrogath && game.player.hp < game.player.maxHp) { + if (game.area === Area.Harrogath && game.player.hp < game.player.hpmax) { yield* healInTown(game) } diff --git a/scripts/services/attack.ts b/scripts/services/attack.ts index 5a8fa37..3bee63f 100644 --- a/scripts/services/attack.ts +++ b/scripts/services/attack.ts @@ -524,7 +524,7 @@ export const Attack = createService((game: Game, services) => { if (!report) return false if (report.action === 'retreat' || report.action === 'chicken') return true - const hpPct = game.player.hp / game.player.maxHp + const hpPct = game.player.hp / game.player.hpmax const maxPressure = Math.floor(4 * hpPct) + 1 // Count nearby threats diff --git a/scripts/services/pickit.ts b/scripts/services/pickit.ts index e3eb6ba..7560ed2 100644 --- a/scripts/services/pickit.ts +++ b/scripts/services/pickit.ts @@ -1,9 +1,7 @@ -import { createService, type Game } from "diablo:game" +import { createService, type Game, ItemContainer } from "diablo:game" import { Config } from "../config.js" import { matchItemNip, type PickitMatch } from "../lib/pickit-checker.js" -const GROUND = 5 // ItemContainer.Ground - export const Pickit = createService((game: Game, services) => { const cfg = services.get(Config) @@ -19,7 +17,7 @@ export const Pickit = createService((game: Game, services) => { const candidates: Array<{ item: ReturnType[number]; match: PickitMatch }> = [] for (const i of game.items) { - if (i.location !== GROUND) continue + if (i.location !== ItemContainer.Ground) continue if (i.distance >= cfg.pickRange) continue if (failedItems.has(i.unitId)) continue const m = matchItemNip(i, game) @@ -56,7 +54,7 @@ export const Pickit = createService((game: Game, services) => { game.log('[pick] GOT GOLD! ' + goldBefore + ' → ' + goldAfter) } - const still = game.items.find(i => i.unitId === item.unitId && i.location === GROUND) + const still = game.items.find(i => i.unitId === item.unitId && i.location === ItemContainer.Ground) if (still) { failedItems.add(item.unitId) } diff --git a/scripts/tests/shopping.test.ts b/scripts/tests/shopping.test.ts new file mode 100644 index 0000000..d38a917 --- /dev/null +++ b/scripts/tests/shopping.test.ts @@ -0,0 +1,73 @@ +import { test, assert } from "diablo:test" +import { UiFlags } from "diablo:constants" +import { shouldKeep, openTradeUI } from "../lib/shopping.js" + +// ── Pure functions ───────────────────────────────────────────── + +test("shouldKeep: scrolls/tomes/keys", function*(_game) { + assert(shouldKeep("tsc"), "TP scroll should be kept") + assert(shouldKeep("isc"), "ID scroll should be kept") + assert(shouldKeep("tbk"), "TP tome should be kept") + assert(shouldKeep("ibk"), "ID tome should be kept") + assert(shouldKeep("key"), "key should be kept") +}) + +test("shouldKeep: utility pots + rejuvs", function*(_game) { + assert(shouldKeep("vps"), "stamina pot should be kept") + assert(shouldKeep("yps"), "antidote should be kept") + assert(shouldKeep("wms"), "thawing pot should be kept") + assert(shouldKeep("rvs"), "rejuv should be kept") + assert(shouldKeep("rvl"), "full rejuv should be kept") +}) + +test("shouldKeep: runes + gems", function*(_game) { + assert(shouldKeep("r01"), "El rune should be kept") + assert(shouldKeep("r33"), "Zod rune should be kept") + assert(shouldKeep("gld"), "diamond should be kept") + assert(shouldKeep("gpv"), "perfect ruby should be kept") +}) + +test("shouldKeep: rejects junk weapons/pots/armor", function*(_game) { + assert(!shouldKeep("hax"), "hand axe should be sold") + assert(!shouldKeep("hp1"), "minor healing pot should be sold") + assert(!shouldKeep("mp1"), "minor mana pot should be sold") + assert(!shouldKeep("aqv"), "arrows should be sold") +}) + +// ── Live game tests ──────────────────────────────────────────── +// Require character in town (any town with a trade-capable NPC visible). + +test("game.npcs has a trade-capable NPC nearby", function*(game) { + const trader = game.npcs.find(n => n.canTrade) + assert(trader, "Expected a trade-capable NPC visible in town") + game.log(" trader classid=" + trader.classid + " at (" + trader.x + "," + trader.y + ")") +}) + +test("openTrade opens Shop UI at nearest trader", function*(game) { + const trader = game.npcs.closest(n => n.canTrade) + assert(trader, "No trade NPC found in town") + game.log(" trader classid=" + trader.classid + " dist=" + Math.round(trader.distance)) + + const opened = yield* trader.openTrade() + assert(opened, "openTrade returned false (dist=" + Math.round(trader.distance) + ")") + assert(game.getUIFlag(UiFlags.Shop), "Shop UI should be set after openTrade") + + yield* trader.close() + assert(!game.getUIFlag(UiFlags.Shop), "Shop UI should be closed after close()") + assert(!game.getUIFlag(UiFlags.NPCMenu), "NPCMenu should be closed after close()") +}) + +test("openTradeUI helper: returns true immediately if Shop already open", function*(game) { + const trader = game.npcs.closest(n => n.canTrade) + assert(trader, "No trade NPC found") + + // Open trade first (real interaction) + const opened = yield* trader.openTrade() + assert(opened, "Pre-condition: openTrade should succeed") + + // Now openTradeUI should short-circuit + const result = yield* openTradeUI(game, trader.unitId) + assert(result, "openTradeUI should return true when Shop already open") + + yield* trader.close() +}) diff --git a/scripts/threads/overlay.ts b/scripts/threads/overlay.ts index 32745ec..0a00687 100644 --- a/scripts/threads/overlay.ts +++ b/scripts/threads/overlay.ts @@ -37,7 +37,7 @@ export const Overlay = createScript(function*(game, _svc) { statusText.text = 'Aether | Level ' + level + ' | Area ' + game.area + ' | Gold ' + game.gold // HP/MP bars as text - const hpPct = Math.round(game.player.hp / game.player.maxHp * 100) + const hpPct = Math.round(game.player.hp / game.player.hpmax * 100) const mpPct = game.player.mpmax > 0 ? Math.round(game.player.mp / game.player.mpmax * 100) : 0 // Read run count from persisted state const state = game.readState<{ runsCompleted?: number }>()