diff --git a/tools/lib/vendor-scenarios.ts b/tools/lib/vendor-scenarios.ts index 57b112b..51733d3 100644 --- a/tools/lib/vendor-scenarios.ts +++ b/tools/lib/vendor-scenarios.ts @@ -17,6 +17,7 @@ import type { MapService } from '../../src/client/map/map-service.ts' import { findPathWorld } from '../../src/client/motion/pathfind.ts' +import { priceItem113c } from '../../src/client/ui-model/transaction-cost-input.ts' import type { ClientWorld } from '../../src/client/world/client-world.ts' import type { ClientUnit } from '../../src/client/world/client-unit.ts' import { @@ -91,15 +92,25 @@ function findUnit(world: ClientWorld, kind: 'monster' | 'object', classId: numbe async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, within = 4): Promise { if (dist(ctx.world, u, ctx.pos) <= within) return - const view = ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) - const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, u.x, u.y, { + if (!(await walkToPoint(ctx, u.x, u.y, within))) { + throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`) + } + ctx.log(`walked to (${ctx.pos.x},${ctx.pos.y}); ${kind} #${u.id} dist=${dist(ctx.world, u, ctx.pos).toFixed(1)}`) +} + +function activeView(ctx: Ctx) { + return ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) +} + +/** Walks along a MapService path in <= 3 sub-tile hops; returns false when no path exists. */ +async function walkToPoint(ctx: Ctx, tx: number, ty: number, within: number): Promise { + const view = activeView(ctx) + const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, tx, ty, { smooth: true, nearestTargetRadius: within, maxExpandedNodes: 32768, }) - if (res.path.length < 2) { - throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`) - } + if (res.path.length < 2) return false for (const wp of res.path.slice(1)) { // Subdivide into <= 3 sub-tile hops like the real client's short click-moves. const d = Math.hypot(wp.x - ctx.pos.x, wp.y - ctx.pos.y) @@ -117,7 +128,39 @@ async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, withi ctx.pos = { x: hx, y: hy } } } - ctx.log(`walked to (${ctx.pos.x},${ctx.pos.y}); ${kind} #${u.id} dist=${dist(ctx.world, u, ctx.pos).toFixed(1)}`) + return true +} + +/** + * Units are only visible near the player. When `classId` is not in view, walk to + * walkable town cells (coarse grid, farthest from everything visited first) until it shows up. + */ +async function seekUnit(ctx: Ctx, kind: 'monster' | 'object', classId: number, maxLegs = 10): Promise { + const visible = () => ctx.world.getUnitsByType(kind).some(u => u.classId === classId && u.x > 0 && u.y > 0) + const view = activeView(ctx) + const visited: Array<{ x: number; y: number }> = [{ ...ctx.pos }] + const STEP = 10 + const SPREAD = 25 + for (let leg = 0; leg < maxLegs && !visible(); leg++) { + const cands: Array<{ x: number; y: number; d: number }> = [] + for (let ly = 2; ly < view.subHeight - 2; ly += STEP) { + for (let lx = 2; lx < view.subWidth - 2; lx += STEP) { + if (view.walkable[ly * view.subWidth + lx] !== 1) continue + const x = view.subOriginX + lx + const y = view.subOriginY + ly + if (visited.some(v => Math.hypot(v.x - x, v.y - y) < SPREAD)) continue + cands.push({ x, y, d: Math.min(...visited.map(v => Math.hypot(v.x - x, v.y - y))) }) + } + } + cands.sort((a, b) => b.d - a.d) + const next = cands[0] + if (!next) break + visited.push(next) + ctx.log(`seek ${kind} class ${classId}: leg ${leg} -> (${next.x},${next.y}) view=${view.subWidth}x${view.subHeight}`) + if (!(await walkToPoint(ctx, next.x, next.y, 2))) leg-- + await sleep(400) + } + return findUnit(ctx.world, kind, classId) } async function waitFor(ctx: Ctx, pred: () => boolean, timeoutMs: number, what: string): Promise { @@ -151,7 +194,7 @@ function snapshot(ctx: Ctx, label: string): Record { } async function openNpc(ctx: Ctx, npcClass: number): Promise { - const npc = findUnit(ctx.world, 'monster', npcClass) + const npc = await seekUnit(ctx, 'monster', npcClass) ctx.log(`npc class=${npcClass} id=${npc.id} at (${npc.x},${npc.y}) dist=${dist(ctx.world, npc).toFixed(1)}`) await walkTo(ctx, 'monster', npc) const target = { unitType: unitTypeToId('monster'), id: npc.id } @@ -297,7 +340,11 @@ export async function runVendorScenario( const prefer = ['hp1', 'mp1', 'isc', 'tsc', 'hp2', 'mp2'] const target = prefer.map((c) => items.find((it) => it.code === c)).find((x) => x !== undefined) if (target) { - const boughtId = await buy(ctx, npc, target, { gamble: false, shiftFill: false, cost: 1 }, 'buy') + // Display price from the 1.13c #10107 port; D2Game ignores the cost field (0x6fcdf200). + const price = priceItem113c(world, target, 0, NPC_CLASS.Akara) + ctx.report.buyPrice = { item: target.code, price, goldBefore: world.self.gold } + ctx.log(`buy price of ${target.code} at Akara (1.13c port) = ${price}, gold=${world.self.gold}`) + const boughtId = await buy(ctx, npc, target, { gamble: false, shiftFill: false, cost: price }, 'buy') if (boughtId !== undefined) { const bought = world.containers.inventory.get(boughtId) if (bought) await sell(ctx, npc, bought, 'sell-back')