玄学放置 · 市场交易弹窗与部分成交
起因 / 背景
上一篇把市场改成了高密度盘口式界面,信息密度和操作节奏都上了一个台阶。但有一个明显的交互缺陷:出售挂牌的"购买"按钮和求购委托的"出售"按钮,点击后直接发起整单请求。玩家无法选择数量,只能买光卖家的全部挂牌,或者按求购方要的数量全卖出去。
这种整单交易在现实交易中很不自然。玩家可能只想买 3 件,但卖家挂了 10 件;或者背包有 20 件材料,但求购只要 5 件。整单成交要么让玩家多花钱买不需要的数量,要么让玩家多卖掉不愿意出手的库存。交易确认弹窗和部分成交是这次要解决的两个核心问题。
还有一个连带问题:待收取的"全部收取"按钮之前没有正确刷新界面状态,金币和物品到了数据库但前端没反映出来,也需要一并修复。
关键决策
弹窗直接输入目标总量,超量自动转化。 玩家在弹窗里输入"我想买多少件"或"我想卖多少件",如果数量超过当前订单,多余的部分自动转化为反向挂单:买超了自动发求购,卖超了自动挂牌出售。这比限制在当前订单数量内、然后让玩家手动再操作一次要顺畅得多。
部分成交在服务端实现,自动转化在前端组合调用。 当前的 buyFromMarket 和 sellToOrder 只支持整单操作,要支持部分成交需要新增两个服务端接口。但自动转化本质上是"部分成交 + 发布反向挂单"两个独立操作的组合,不需要专门的服务端接口,前端顺序调用两个已有 API 即可。
待收取的 collectAll 需要刷新行情和重新拉取待收取列表。 之前的实现只调了 loadPlayerState 重新加载背包和金币,然后硬清 pending = [],但没有从服务端重新拉取待收取列表和行情摘要。如果后续其他操作触发了 refreshVisibleData,待收取列表可能与服务端状态不同步。
实现要点
服务端部分成交接口
新增 POST /api/market/buy-partial 和 POST /api/market/sell-to-order-partial。核心逻辑是:如果购买数量等于挂牌数量,走整单成交(标记 status='sold');如果小于挂牌数量,只更新挂牌的 quantity 字段,保留剩余部分继续出售。
if (quantity === listing.quantity) {
await db.update(marketListings).set({ status: 'sold' })
.where(eq(marketListings.id, listingId))
} else {
await db.update(marketListings).set({ quantity: listing.quantity - quantity })
.where(eq(marketListings.id, listingId))
}部分出售给求购委托时,还有一个额外步骤:买家未成交部分的托管金币要退回到待收取。因为求购委托创建时金币是全额托管的,部分成交后剩余的托管金额应该还给买家,而不是继续锁在委托里。
// 部分成交:减少求购数量,退回买家未成交部分的托管金币到待收取
await db.update(marketBuyOrders).set({ quantity: order.quantity - quantity })
.where(eq(marketBuyOrders.id, orderId))
const refundGold = order.price * (order.quantity - quantity)
await db.insert(marketPending).values({
playerId: order.buyerId, type: 'gold', itemId: null,
quantity: refundGold, collected: false
})这里没有直接加回买家金币,而是走待收取队列,是为了和现有交易流程保持一致——所有资金变动都经过待收取,让玩家有机会看到"退回了多少"。
前端自动转化组合逻辑
自动转化是两个 API 的顺序调用,不是服务端事务。以"购买超量自动转求购"为例:
const buyFromListing = Math.min(wantQty, listing.quantity)
const overflow = wantQty - listing.quantity
// 第一步:部分购买
const result = await buyFromMarketPartial(listingId, buyFromListing)
// 第二步:超量部分自动发布求购委托
if (overflow > 0) {
await createBuyOrder({ itemId: listing.itemId, price: listing.price, quantity: overflow })
}风险是第一步成功但第二步失败。这种情况下购买部分已完成,但求购没发布——玩家可以手动再发一次。比整单限制的体验好,因为至少买到了部分物品。后续如果要保证原子性,可以把这个组合操作移到服务端做成事务。
交易确认弹窗
MarketPanel 新增第二个 el-dialog,和已有的"新出售挂牌 / 新购买挂牌"弹窗共存。交易确认弹窗的模式用 tradeMode 区分:'buyListing'(购买出售挂牌)和 'sellToOrder'(出售给求购委托)。
弹窗里展示卖家/买家名称、单价、可用数量和持有/余额信息,用户输入目标总量。计算属性 tradeOverflow 自动判断是否有超量部分,并在弹窗里用提示文字说明转化计划:
其中 5 件从此挂牌购买,剩余 3 件将自动发布求购委托
数量上限 tradeQtyMax 根据模式不同:购买受金币余额限制(gold / price),出售受背包持有量限制。用户可以输入任意不超过上限的数量,不限制在当前订单的可用量内。
待收取刷新修复
collectAll 的核心改动是:调完 collectAllPending 和 loadPlayerState 后,不再硬清 pending = [],而是调 fetchPending() 从服务端重新拉取。同时补充 fetchSummary() 刷新行情。
async collectAll(): Promise<number> {
try {
const result = await collectAllPending()
const authStore = useAuthStore()
await authStore.loadPlayerState()
await this.fetchPending()
await this.fetchSummary()
return result.collected ?? 0
} catch {
return 0
}
}UI 层也做了对应修改:onCollectAll 检查返回值,0 项时显示 warning 而不是 success。
注意事项 / 踩到的坑
部分成交的竞态问题依然存在。 和之前整单成交一样,两个玩家同时部分购买同一个挂牌,可能导致 quantity 被减到负数。当前没有数据库行锁,靠的是放置游戏的低并发。如果后续要做正式上线,需要在更新挂牌数量时加条件判断(WHERE quantity >= 买方数量),或者使用 CAS 模式。
托管金币退回走待收取而非直接加金币。 部分出售给求购时,买家剩余的托管金币退回到待收取而不是直接加到 players.gold。这和整单取消求购的退回逻辑不同(取消时直接退金币),但和成交时的流程一致。虽然多了一步"收取",但保持了"所有资金变动都可见"的一致性。
自动转化的价格用的是当前订单价,不是市场均价。 购买超量自动转求购时,求购价等于当前挂牌的单价;出售超量自动挂牌时,挂牌价等于当前求购的收购价。玩家可能希望用不同的价格发反向单,但弹窗里没有提供单独设置反向单价格的能力——简化优先,后续可以加一个"自定义反向单价格"的输入框。
收获 / 总结
这次改动让市场的交易操作从"全有或全无"变成了"要多少给多少"。部分成交接口是基础设施,交易确认弹窗是交互载体,自动转化是让"想要多少就买/卖多少"这个意图自然落地的粘合层。
最值得记的设计决策是把自动转化做成前端组合调用而非服务端事务。这牺牲了原子性,但换来了零新接口(除了部分成交本身)和极简的服务端改动。在放置游戏的低并发场景下,这个取舍是合理的。如果将来需要严格的事务保证,可以把组合操作搬到服务端用数据库事务包装,前端调用一个接口即可。
待收取的刷新修复是一个典型的"数据到达数据库但 UI 不更新"问题。原因不是 loadPlayerState 失败,而是 pending = [] 硬清跳过了服务端验证。改成 fetchPending() 后,列表始终和服务端同步,也不会出现"收取了但列表里还挂着"的幻觉。
下一步是物品设计面板的图标展示——目前有 9 个主题共 72 个新图标和 23 个学科任务图标已经设计好,需要一个展示和测试的入口。