test(tools): vendor scenarios seek out-of-view NPCs and send 1.13c display prices

NPCs outside the visible rooms are found by walking to the walkable town
cells farthest from everything visited. vendor-verify sends the #10107
buy price as the (server-ignored) cost field and reports it.
This commit is contained in:
troytt 2026-10-01 10:37:31 +00:00
parent e0210d8702
commit 4b509c0ebd
1 changed files with 55 additions and 8 deletions

View File

@ -17,6 +17,7 @@
import type { MapService } from '../../src/client/map/map-service.ts' import type { MapService } from '../../src/client/map/map-service.ts'
import { findPathWorld } from '../../src/client/motion/pathfind.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 { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ClientUnit } from '../../src/client/world/client-unit.ts' import type { ClientUnit } from '../../src/client/world/client-unit.ts'
import { 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<void> { async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, within = 4): Promise<void> {
if (dist(ctx.world, u, ctx.pos) <= within) return if (dist(ctx.world, u, ctx.pos) <= within) return
const view = ctx.map.getActiveLevelView() ?? ctx.map.getLevelView(ctx.world.areaId || 1, 'all', true) if (!(await walkToPoint(ctx, u.x, u.y, within))) {
const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, u.x, u.y, { 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<boolean> {
const view = activeView(ctx)
const res = findPathWorld(view, ctx.pos.x, ctx.pos.y, tx, ty, {
smooth: true, smooth: true,
nearestTargetRadius: within, nearestTargetRadius: within,
maxExpandedNodes: 32768, maxExpandedNodes: 32768,
}) })
if (res.path.length < 2) { if (res.path.length < 2) return false
throw new Error(`no path to ${kind} #${u.id} (class ${u.classId}) from (${ctx.pos.x},${ctx.pos.y})`)
}
for (const wp of res.path.slice(1)) { for (const wp of res.path.slice(1)) {
// Subdivide into <= 3 sub-tile hops like the real client's short click-moves. // 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) 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.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<ClientUnit> {
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<boolean> { async function waitFor(ctx: Ctx, pred: () => boolean, timeoutMs: number, what: string): Promise<boolean> {
@ -151,7 +194,7 @@ function snapshot(ctx: Ctx, label: string): Record<string, unknown> {
} }
async function openNpc(ctx: Ctx, npcClass: number): Promise<ClientUnit> { async function openNpc(ctx: Ctx, npcClass: number): Promise<ClientUnit> {
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)}`) 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) await walkTo(ctx, 'monster', npc)
const target = { unitType: unitTypeToId('monster'), id: npc.id } 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 prefer = ['hp1', 'mp1', 'isc', 'tsc', 'hp2', 'mp2']
const target = prefer.map((c) => items.find((it) => it.code === c)).find((x) => x !== undefined) const target = prefer.map((c) => items.find((it) => it.code === c)).find((x) => x !== undefined)
if (target) { 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) { if (boughtId !== undefined) {
const bought = world.containers.inventory.get(boughtId) const bought = world.containers.inventory.get(boughtId)
if (bought) await sell(ctx, npc, bought, 'sell-back') if (bought) await sell(ctx, npc, bought, 'sell-back')