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:
parent
e0210d8702
commit
4b509c0ebd
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
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<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> {
|
||||
|
|
@ -151,7 +194,7 @@ function snapshot(ctx: Ctx, label: string): Record<string, unknown> {
|
|||
}
|
||||
|
||||
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)}`)
|
||||
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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue