feat(client): server-driven NPC/stash interaction state in ClientWorld
- 0x9D act 0x05 with a non-cursor location (NPC sale, capture 06) removes the item instead of moving it to the cursor. - ClientInventoryManager.clearShop(): the server sends no RemoveFromShop after 0x30 (capture 05), so the store is freed locally. - ClientWorld.npcInteraction (npcId / trade|gamble window / stashOpen from S2C 0x77 0x10) and lastNpcTransaction (numbered 0x2A log, display only). - OnlineSession and the bot apply outgoing-command side effects through ClientWorld.noteOutgoingCommand. - Replay tests over captures 05-09.
This commit is contained in:
parent
d377c0308e
commit
2e9216fc7f
|
|
@ -700,6 +700,7 @@ export class OnlineSession {
|
||||||
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
|
||||||
try {
|
try {
|
||||||
this.gameAdapter.send(cmd)
|
this.gameAdapter.send(cmd)
|
||||||
|
this.world.noteOutgoingCommand(cmd)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)
|
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
ClickButtonId,
|
||||||
unitTypeFromId,
|
unitTypeFromId,
|
||||||
type CharClassId,
|
type CharClassId,
|
||||||
|
type ClientCommand,
|
||||||
type Pt,
|
type Pt,
|
||||||
type ServerEvent,
|
type ServerEvent,
|
||||||
type SkillLevel,
|
type SkillLevel,
|
||||||
|
|
@ -70,6 +72,32 @@ export interface PortalState {
|
||||||
readonly flags?: number | undefined
|
readonly flags?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local NPC/stash interaction state. D2Client tracks this itself: the server never
|
||||||
|
* reports "window closed" (no RemoveFromShop after 0x30, no reply to 0x4F 0x12;
|
||||||
|
* captures 05 and 08). Stash opening is server-driven (0x77 action 0x10, capture 08).
|
||||||
|
*/
|
||||||
|
export interface NpcInteractionState {
|
||||||
|
/** NPC unit id from the last `NpcInit` (0x2F); `null` after `NpcCancel` (0x30). */
|
||||||
|
readonly npcId: number | null
|
||||||
|
/** Window opened by 0x38 action 1 (`trade`) or 2 (`gamble`). */
|
||||||
|
readonly window: 'none' | 'trade' | 'gamble'
|
||||||
|
/** Set by S2C 0x77 action 0x10, cleared when the client sends 0x4F button 0x12. */
|
||||||
|
readonly stashOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One S2C 0x2A NPCTransaction, numbered so the HUD can react to each reply once. */
|
||||||
|
export interface NpcTransactionRecord {
|
||||||
|
readonly seq: number
|
||||||
|
readonly result: number
|
||||||
|
readonly flag: number
|
||||||
|
readonly itemId: number
|
||||||
|
readonly gold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** S2C 0x77 ButtonActions action that opens the stash panel (capture 08; D2Client jump table 0x6fb0a520[0x10]). */
|
||||||
|
const BUTTON_ACTION_OPEN_STASH = 0x10
|
||||||
|
|
||||||
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
|
||||||
const MAX_D2_STAT_ID = 0x1ff
|
const MAX_D2_STAT_ID = 0x1ff
|
||||||
|
|
||||||
|
|
@ -107,8 +135,55 @@ export class ClientWorld {
|
||||||
private readonly _chatLog: ChatLogEntry[] = []
|
private readonly _chatLog: ChatLogEntry[] = []
|
||||||
private readonly _soundQueue: SoundQueueEntry[] = []
|
private readonly _soundQueue: SoundQueueEntry[] = []
|
||||||
private readonly _portals = new Map<number, PortalState>()
|
private readonly _portals = new Map<number, PortalState>()
|
||||||
|
private _npcInteraction: NpcInteractionState = { npcId: null, window: 'none', stashOpen: false }
|
||||||
|
private _lastNpcTransaction: NpcTransactionRecord | null = null
|
||||||
|
private _npcTransactionSeq = 0
|
||||||
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
||||||
|
|
||||||
|
get npcInteraction(): NpcInteractionState {
|
||||||
|
return this._npcInteraction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last S2C 0x2A NPCTransaction; display-only (gold/items come from stat and item packets). */
|
||||||
|
get lastNpcTransaction(): NpcTransactionRecord | null {
|
||||||
|
return this._lastNpcTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the local side effects of an outgoing command, mirroring what D2Client
|
||||||
|
* does when it sends it: 0x38 opens the trade/gamble window and the store is
|
||||||
|
* refilled by 0x9C AddToShop; 0x30 frees the store units (no RemoveFromShop is
|
||||||
|
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
|
||||||
|
*/
|
||||||
|
noteOutgoingCommand(cmd: ClientCommand): void {
|
||||||
|
const cur = this._npcInteraction
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'NpcInit':
|
||||||
|
this._npcInteraction = { ...cur, npcId: cmd.target.id, window: 'none' }
|
||||||
|
break
|
||||||
|
case 'NpcOpenTrade':
|
||||||
|
case 'NpcOpenGamble':
|
||||||
|
this._containers.clearShop()
|
||||||
|
this._npcInteraction = {
|
||||||
|
...cur,
|
||||||
|
npcId: cmd.npcId,
|
||||||
|
window: cmd.type === 'NpcOpenTrade' ? 'trade' : 'gamble',
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'NpcCancel':
|
||||||
|
this._containers.clearShop()
|
||||||
|
this._npcInteraction = { ...cur, npcId: null, window: 'none' }
|
||||||
|
break
|
||||||
|
case 'ClickButton':
|
||||||
|
if (cmd.buttonId === ClickButtonId.CloseStash) {
|
||||||
|
this._npcInteraction = { ...cur, stashOpen: false }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onChange(cb: (ev: ServerEvent) => void): () => void {
|
onChange(cb: (ev: ServerEvent) => void): () => void {
|
||||||
this._changeListeners.add(cb)
|
this._changeListeners.add(cb)
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -590,6 +665,13 @@ export class ClientWorld {
|
||||||
|
|
||||||
case 'NpcTransaction': {
|
case 'NpcTransaction': {
|
||||||
// Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]).
|
// Gold and items are driven by stat 14 and 0x9C/0x9D only (D2Client 0x6fafbc80 ignores [11..14]).
|
||||||
|
this._lastNpcTransaction = {
|
||||||
|
seq: ++this._npcTransactionSeq,
|
||||||
|
result: ev.result,
|
||||||
|
flag: ev.flag,
|
||||||
|
itemId: ev.itemId,
|
||||||
|
gold: ev.gold,
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -635,6 +717,9 @@ export class ClientWorld {
|
||||||
|
|
||||||
case 'Trade': {
|
case 'Trade': {
|
||||||
this._trade.lastAction = ev.action
|
this._trade.lastAction = ev.action
|
||||||
|
if (ev.action === BUTTON_ACTION_OPEN_STASH) {
|
||||||
|
this._npcInteraction = { ...this._npcInteraction, stashOpen: true }
|
||||||
|
}
|
||||||
if (ev.partnerId !== undefined) {
|
if (ev.partnerId !== undefined) {
|
||||||
this._trade.partnerId = ev.partnerId
|
this._trade.partnerId = ev.partnerId
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,15 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
* Applies an `ItemEvent` from `0x9C` (`WorldItemAction`) or `0x9D` (`OwnedItemAction`).
|
* Applies an `ItemEvent` from `0x9C` (`WorldItemAction`) or `0x9D` (`OwnedItemAction`).
|
||||||
*/
|
*/
|
||||||
applyItemEvent(action: number, item: ItemData, container: ItemContainer, selfId?: number): void {
|
applyItemEvent(action: number, item: ItemData, container: ItemContainer, selfId?: number): void {
|
||||||
// Removal-to-cursor or shop removal actions:
|
// 0x05 RemoveFromContainer: the item goes to the cursor only when the packet's
|
||||||
|
// location is the cursor (pickup). Selling to an NPC sends act 0x05 with
|
||||||
|
// location 0 (capture 06-d2gs-vendor-buy-sell: 0x22 -> 0x9D act 5 loc 0 ->
|
||||||
|
// 0x2A result 1 flag 3), and the item simply leaves the player's containers.
|
||||||
|
if (action === 0x05 && container !== 'cursor') {
|
||||||
|
this.removeItemById(item.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Removal-to-cursor actions:
|
||||||
// 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
|
// 0x05 RemoveFromContainer (to cursor), 0x08 Unequip (to cursor), 0x0F RemoveFromBelt (to cursor)
|
||||||
if (action === 0x05 || action === 0x08 || action === 0x0f) {
|
if (action === 0x05 || action === 0x08 || action === 0x0f) {
|
||||||
this.removeItemById(item.id)
|
this.removeItemById(item.id)
|
||||||
|
|
@ -176,6 +184,18 @@ export class ClientInventoryManager implements ClientItemContainers {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every store unit. The server sends no RemoveFromShop after C2S 0x30
|
||||||
|
* (capture 05-d2gs-vendor-open), so the client frees the store locally.
|
||||||
|
*/
|
||||||
|
clearShop(): void {
|
||||||
|
for (const id of this.shop.keys()) {
|
||||||
|
this.allById.delete(id)
|
||||||
|
this.sockets.delete(id)
|
||||||
|
}
|
||||||
|
this.shop.clear()
|
||||||
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.inventory.clear()
|
this.inventory.clear()
|
||||||
this.equipment.clear()
|
this.equipment.clear()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* Vendor / stash / gamble ClientWorld replay over live 1.13c captures 05-09
|
||||||
|
* (WebBotThree, clvl 1, 2 gold). Everything asserted here is what the server sent.
|
||||||
|
*/
|
||||||
|
import path from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { replayD2CapFile, type ReplayResult } from '../../tools/netproto-replay.ts'
|
||||||
|
import { ClickButtonId, type ServerEvent } from '../../src/netproto/index.ts'
|
||||||
|
|
||||||
|
const FIXTURES = path.resolve(__dirname, '../fixtures/netproto')
|
||||||
|
const replay = (name: string): ReplayResult => {
|
||||||
|
const r = replayD2CapFile(path.join(FIXTURES, name), { strict: true })
|
||||||
|
expect(r.unknownPackets).toBe(0)
|
||||||
|
expect(r.opaquePackets).toBe(0)
|
||||||
|
expect(r.protocolErrors).toBe(0)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
type Tx = Extract<ServerEvent, { type: 'NpcTransaction' }>
|
||||||
|
const txs = (r: ReplayResult): Tx[] => r.events.filter((e): e is Tx => e.type === 'NpcTransaction')
|
||||||
|
const MONSTER = 1
|
||||||
|
|
||||||
|
describe('vendor/stash captures -> ClientWorld', () => {
|
||||||
|
it('05 vendor-open: Charsi store arrives only as 0x9C AddToShop and is freed locally on 0x30', () => {
|
||||||
|
const r = replay('05-d2gs-vendor-open.d2cap')
|
||||||
|
const shop = [...r.world.containers.shop.values()]
|
||||||
|
expect(shop.length).toBe(46)
|
||||||
|
for (const it of shop) {
|
||||||
|
expect(it.action).toBe(0x0b)
|
||||||
|
expect([1, 2, 3, 4]).toContain(it.storePage)
|
||||||
|
expect(it.gridX).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(it.gridX).toBeLessThan(10)
|
||||||
|
expect(it.gridY).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(it.gridY).toBeLessThan(10)
|
||||||
|
}
|
||||||
|
expect(txs(r)).toEqual([])
|
||||||
|
// The capture ends after C2S 0x30 with no RemoveFromShop; the client frees the store itself.
|
||||||
|
const npcId = r.world.npcInteraction.npcId
|
||||||
|
r.world.noteOutgoingCommand({ type: 'NpcCancel', target: { unitType: MONSTER, id: 6 } })
|
||||||
|
expect(npcId).toBeNull() // replay feeds S2C only; NpcInit was never noted
|
||||||
|
expect(r.world.containers.shop.size).toBe(0)
|
||||||
|
expect(r.world.npcInteraction.window).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('06 vendor-buy-sell: sells remove the item (act 5 loc 0), buys without gold are rejected with result 12', () => {
|
||||||
|
const r = replay('06-d2gs-vendor-buy-sell.d2cap')
|
||||||
|
const t = txs(r)
|
||||||
|
const sells = t.filter(x => x.flag === 3)
|
||||||
|
expect(sells.length).toBe(2)
|
||||||
|
for (const s of sells) {
|
||||||
|
expect(s.result).toBe(1)
|
||||||
|
expect(s.itemId).not.toBe(0xffffffff)
|
||||||
|
}
|
||||||
|
const rejects = t.filter(x => x.result === 12)
|
||||||
|
expect(rejects.length).toBeGreaterThan(0)
|
||||||
|
for (const x of rejects) {
|
||||||
|
expect(x.flag).toBe(0)
|
||||||
|
expect(x.itemId).toBe(0xffffffff)
|
||||||
|
}
|
||||||
|
// Sold items must not reappear on the cursor (0x9D act 5 with location 0).
|
||||||
|
expect(r.world.containers.cursor).toBeNull()
|
||||||
|
expect(r.world.containers.inventory.size).toBe(0)
|
||||||
|
expect(r.world.self.gold).toBe(sells[sells.length - 1]!.gold)
|
||||||
|
expect(r.world.lastNpcTransaction?.seq).toBe(t.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('07 vendor-repair: undamaged repair -> result 9, repair-all with nothing damaged -> result 2 flag 1', () => {
|
||||||
|
const r = replay('07-d2gs-vendor-repair.d2cap')
|
||||||
|
expect(txs(r).map(x => [x.result, x.flag, x.itemId])).toEqual([
|
||||||
|
[9, 0, 0xffffffff],
|
||||||
|
[2, 1, 0xffffffff],
|
||||||
|
])
|
||||||
|
expect(r.world.self.gold).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('08 stash-gold: 0x77 0x10 opens the stash; deposit/withdraw update stats 15/14; close is local', () => {
|
||||||
|
const r = replay('08-d2gs-stash-gold.d2cap')
|
||||||
|
expect(r.events.some(e => e.type === 'Trade' && e.action === 0x10)).toBe(true)
|
||||||
|
expect(r.world.npcInteraction.stashOpen).toBe(true)
|
||||||
|
expect(r.world.self.gold).toBe(2)
|
||||||
|
expect(r.world.self.stashGold).toBe(0)
|
||||||
|
r.world.noteOutgoingCommand({ type: 'ClickButton', buttonId: ClickButtonId.CloseStash, amount: 0 })
|
||||||
|
expect(r.world.npcInteraction.stashOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('09 gamble: Gheed gamble store fills via 0x9C; gambling a ring with 2 gold is rejected (result 12)', () => {
|
||||||
|
const r = replay('09-d2gs-gamble.d2cap')
|
||||||
|
const codes = [...r.world.containers.shop.values()].map(i => i.code)
|
||||||
|
expect(codes).toContain('rin')
|
||||||
|
expect(codes).toContain('amu')
|
||||||
|
expect(txs(r).map(x => [x.result, x.flag, x.itemId])).toEqual([[12, 0, 0xffffffff]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -22,6 +22,7 @@ import type { ClientUnit } from '../../src/client/world/client-unit.ts'
|
||||||
import {
|
import {
|
||||||
ClickButtonId,
|
ClickButtonId,
|
||||||
unitTypeToId,
|
unitTypeToId,
|
||||||
|
type ClientCommand,
|
||||||
type GameServerAdapter,
|
type GameServerAdapter,
|
||||||
type ItemData,
|
type ItemData,
|
||||||
type ServerEvent,
|
type ServerEvent,
|
||||||
|
|
@ -64,6 +65,12 @@ interface Ctx {
|
||||||
readonly report: Record<string, unknown>
|
readonly report: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends a command and applies its local side effects to ClientWorld, like OnlineSession does. */
|
||||||
|
function send(ctx: Ctx, cmd: ClientCommand): void {
|
||||||
|
ctx.adapter.send(cmd)
|
||||||
|
ctx.world.noteOutgoingCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
function fmtItem(it: ItemData): string {
|
function fmtItem(it: ItemData): string {
|
||||||
return `#${it.id} ${it.code} q=${it.quality ?? '-'} loc=${it.location} page=${it.storePage} grid=(${it.gridX},${it.gridY}) act=0x${it.action.toString(16)}`
|
return `#${it.id} ${it.code} q=${it.quality ?? '-'} loc=${it.location} page=${it.storePage} grid=(${it.gridX},${it.gridY}) act=0x${it.action.toString(16)}`
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +111,7 @@ async function walkTo(ctx: Ctx, kind: 'monster' | 'object', u: ClientUnit, withi
|
||||||
const hy = Math.round(sy + ((wp.y - sy) * i) / n)
|
const hy = Math.round(sy + ((wp.y - sy) * i) / n)
|
||||||
const hd = Math.hypot(hx - ctx.pos.x, hy - ctx.pos.y)
|
const hd = Math.hypot(hx - ctx.pos.x, hy - ctx.pos.y)
|
||||||
if (hd < 0.5) continue
|
if (hd < 0.5) continue
|
||||||
ctx.adapter.send({ type: 'Move', run: true, to: { x: hx, y: hy } })
|
send(ctx, { type: 'Move', run: true, to: { x: hx, y: hy } })
|
||||||
await sleep(Math.max(150, Math.ceil(hd * 140)))
|
await sleep(Math.max(150, Math.ceil(hd * 140)))
|
||||||
ctx.world.tick()
|
ctx.world.tick()
|
||||||
ctx.pos = { x: hx, y: hy }
|
ctx.pos = { x: hx, y: hy }
|
||||||
|
|
@ -148,22 +155,22 @@ async function openNpc(ctx: Ctx, npcClass: number): Promise<ClientUnit> {
|
||||||
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 }
|
||||||
ctx.adapter.send({ type: 'Interact', target })
|
send(ctx, { type: 'Interact', target })
|
||||||
await sleep(700)
|
await sleep(700)
|
||||||
ctx.adapter.send({ type: 'NpcInit', target })
|
send(ctx, { type: 'NpcInit', target })
|
||||||
await sleep(700)
|
await sleep(700)
|
||||||
return npc
|
return npc
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise<void> {
|
async function closeNpc(ctx: Ctx, npc: ClientUnit): Promise<void> {
|
||||||
ctx.adapter.send({ type: 'NpcCancel', target: { unitType: unitTypeToId('monster'), id: npc.id } })
|
send(ctx, { type: 'NpcCancel', target: { unitType: unitTypeToId('monster'), id: npc.id } })
|
||||||
await sleep(800)
|
await sleep(800)
|
||||||
ctx.log(`closed npc #${npc.id}; shop items left in ClientWorld=${ctx.world.containers.shop.size}`)
|
ctx.log(`closed npc #${npc.id}; shop items left in ClientWorld=${ctx.world.containers.shop.size}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openShop(ctx: Ctx, npc: ClientUnit, gamble: boolean): Promise<readonly ItemData[]> {
|
async function openShop(ctx: Ctx, npc: ClientUnit, gamble: boolean): Promise<readonly ItemData[]> {
|
||||||
const before = ctx.world.containers.shop.size
|
const before = ctx.world.containers.shop.size
|
||||||
ctx.adapter.send(gamble ? { type: 'NpcOpenGamble', npcId: npc.id } : { type: 'NpcOpenTrade', npcId: npc.id })
|
send(ctx, gamble ? { type: 'NpcOpenGamble', npcId: npc.id } : { type: 'NpcOpenTrade', npcId: npc.id })
|
||||||
await waitFor(ctx, () => ctx.world.containers.shop.size > before, 4000, 'shop items')
|
await waitFor(ctx, () => ctx.world.containers.shop.size > before, 4000, 'shop items')
|
||||||
await sleep(1200)
|
await sleep(1200)
|
||||||
const items = [...ctx.world.containers.shop.values()]
|
const items = [...ctx.world.containers.shop.values()]
|
||||||
|
|
@ -184,7 +191,7 @@ async function sell(ctx: Ctx, npc: ClientUnit, item: ItemData, label: string): P
|
||||||
const g0 = ctx.world.self.gold
|
const g0 = ctx.world.self.gold
|
||||||
const i = ctx.tx.length
|
const i = ctx.tx.length
|
||||||
ctx.log(`${label}: sell ${fmtItem(item)}`)
|
ctx.log(`${label}: sell ${fmtItem(item)}`)
|
||||||
ctx.adapter.send({ type: 'NpcSell', npcId: npc.id, itemId: item.id, itemMode: 0, cost: 1 })
|
send(ctx, { type: 'NpcSell', npcId: npc.id, itemId: item.id, itemMode: 0, cost: 1 })
|
||||||
const t = await waitTx(ctx, i)
|
const t = await waitTx(ctx, i)
|
||||||
ctx.log(`${label}: result=${t?.result} flag=${t?.flag} itemId=0x${(t?.itemId ?? 0).toString(16)} gold@11=${t?.gold} | stat14 ${g0} -> ${ctx.world.self.gold} (Δ${ctx.world.self.gold - g0}) stillInInv=${ctx.world.containers.inventory.has(item.id)}`)
|
ctx.log(`${label}: result=${t?.result} flag=${t?.flag} itemId=0x${(t?.itemId ?? 0).toString(16)} gold@11=${t?.gold} | stat14 ${g0} -> ${ctx.world.self.gold} (Δ${ctx.world.self.gold - g0}) stillInInv=${ctx.world.containers.inventory.has(item.id)}`)
|
||||||
ctx.report[label] = { item: item.code, result: t?.result, flag: t?.flag, goldBefore: g0, goldAfter: ctx.world.self.gold }
|
ctx.report[label] = { item: item.code, result: t?.result, flag: t?.flag, goldBefore: g0, goldAfter: ctx.world.self.gold }
|
||||||
|
|
@ -202,7 +209,7 @@ async function buy(
|
||||||
const belt0 = new Set(ctx.world.containers.belt.keys())
|
const belt0 = new Set(ctx.world.containers.belt.keys())
|
||||||
const i = ctx.tx.length
|
const i = ctx.tx.length
|
||||||
ctx.log(`${label}: buy ${fmtItem(item)} gamble=${opts.gamble} shift=${opts.shiftFill} cost=${opts.cost}`)
|
ctx.log(`${label}: buy ${fmtItem(item)} gamble=${opts.gamble} shift=${opts.shiftFill} cost=${opts.cost}`)
|
||||||
ctx.adapter.send({ type: 'NpcBuy', npcId: npc.id, itemId: item.id, itemMode: 0, ...opts })
|
send(ctx, { type: 'NpcBuy', npcId: npc.id, itemId: item.id, itemMode: 0, ...opts })
|
||||||
const t = await waitTx(ctx, i)
|
const t = await waitTx(ctx, i)
|
||||||
const newInv = [...ctx.world.containers.inventory.values()].filter((x) => !inv0.has(x.id))
|
const newInv = [...ctx.world.containers.inventory.values()].filter((x) => !inv0.has(x.id))
|
||||||
const newBelt = [...ctx.world.containers.belt.values()].filter((x) => !belt0.has(x.id))
|
const newBelt = [...ctx.world.containers.belt.values()].filter((x) => !belt0.has(x.id))
|
||||||
|
|
@ -214,7 +221,7 @@ async function buy(
|
||||||
async function stashGold(ctx: Ctx, button: number, amount: number, label: string): Promise<void> {
|
async function stashGold(ctx: Ctx, button: number, amount: number, label: string): Promise<void> {
|
||||||
const g0 = ctx.world.self.gold
|
const g0 = ctx.world.self.gold
|
||||||
const s0 = ctx.world.self.stashGold
|
const s0 = ctx.world.self.stashGold
|
||||||
ctx.adapter.send({ type: 'ClickButton', buttonId: button, amount })
|
send(ctx, { type: 'ClickButton', buttonId: button, amount })
|
||||||
await sleep(1500)
|
await sleep(1500)
|
||||||
ctx.log(`${label}: 0x4F 0x${button.toString(16)} amount=${amount} | gold ${g0} -> ${ctx.world.self.gold}, stash ${s0} -> ${ctx.world.self.stashGold}`)
|
ctx.log(`${label}: 0x4F 0x${button.toString(16)} amount=${amount} | gold ${g0} -> ${ctx.world.self.gold}, stash ${s0} -> ${ctx.world.self.stashGold}`)
|
||||||
ctx.report[label] = { amount, goldBefore: g0, goldAfter: ctx.world.self.gold, stashBefore: s0, stashAfter: ctx.world.self.stashGold }
|
ctx.report[label] = { amount, goldBefore: g0, goldAfter: ctx.world.self.gold, stashBefore: s0, stashAfter: ctx.world.self.stashGold }
|
||||||
|
|
@ -224,7 +231,7 @@ async function openStash(ctx: Ctx): Promise<void> {
|
||||||
const stash = findUnit(ctx.world, 'object', OBJECT_CLASS_STASH)
|
const stash = findUnit(ctx.world, 'object', OBJECT_CLASS_STASH)
|
||||||
ctx.log(`stash object id=${stash.id} at (${stash.x},${stash.y}) dist=${dist(ctx.world, stash).toFixed(1)}`)
|
ctx.log(`stash object id=${stash.id} at (${stash.x},${stash.y}) dist=${dist(ctx.world, stash).toFixed(1)}`)
|
||||||
await walkTo(ctx, 'object', stash, 3)
|
await walkTo(ctx, 'object', stash, 3)
|
||||||
ctx.adapter.send({ type: 'Interact', target: { unitType: unitTypeToId('object'), id: stash.id } })
|
send(ctx, { type: 'Interact', target: { unitType: unitTypeToId('object'), id: stash.id } })
|
||||||
await sleep(1500)
|
await sleep(1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue