feat: NPC shop window (opcodes 0x7A-0x7C) - #296
Conversation
Implements the client side of the jamera server's NPC shop protocol (server PR santi1584/jameraServer76#8, contract in its docs/protocol/npc-shop.md). Saying 'trade' to a focused shop npc opens a Buy/Sell window; taps send buy/sell requests that echo the catalog's server item id + subtype with an amount (1..100) — never a price. - net/7.6: ShopOpen/ShopGoods/ShopClose server opcodes, ShopBuy/ ShopSell/ShopClose client opcodes, shopProtocol parsers/builders, payload-exact wireSkips so unbound shop packets can't truncate frames - net/common: ShopItem/ShopOpenEvent/ShopGoodsEvent types + ShopProtocol on the GameProtocol interface - lib: ShopManager state mirror + shopPane window (containerPane pattern: Buy/Sell tabs, money header, owned counts, amount sheet) - jamera: shopBinding registered per session like containers - tests: protocol layouts, wireCompleteness cases, binding DOM + request bytes; asserts player-trade opcodes 0x7D-0x7F are untouched Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011T5NW34zAKZaSCTAVceruw
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
otclient-web | 8c3e514 | Commit Preview URL Branch Preview URL |
Jul 14 2026, 10:41 AM |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces support for NPC shop windows (opcodes 0x7A-0x7C) in the game client, fully separate from player-to-player trade. It includes protocol parsing and packet building, a client-side ShopManager state mirror, a draggable ShopPane UI component, and comprehensive integration tests. The review feedback highlights two valuable improvements: first, optimizing the ShopPane rendering to initialize static DOM elements and drag handlers once rather than recreating them on every state update, which prevents active drag operations from glitching; second, enhancing the usability of the sell amount sheet by appending the exact owned stack size to the quick-sell options when it does not align with standard steps.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| let side: ShopSide = 'buy'; | ||
| let shop: OpenShop | null = null; | ||
| let stopDrag: (() => void) | null = null; | ||
|
|
||
| const renderRow = (item: ShopItem): HTMLElement => { | ||
| const owned = shop?.goods.get(item.serverId) ?? 0; | ||
| const row = document.createElement('button'); | ||
| row.type = 'button'; | ||
| row.className = 'row'; | ||
| row.addEventListener('click', () => opts.onItemTap?.(side, item, owned)); | ||
|
|
||
| const thumbBox = document.createElement('span'); | ||
| thumbBox.className = 'thumb'; | ||
| const thumb = opts.renderThumb?.(item.clientId) ?? null; | ||
| if (thumb) { | ||
| thumb.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;'; | ||
| thumbBox.appendChild(thumb); | ||
| } else { | ||
| thumbBox.textContent = `#${item.serverId}`; | ||
| } | ||
| row.appendChild(thumbBox); | ||
|
|
||
| const label = document.createElement('span'); | ||
| label.className = 'label'; | ||
| label.textContent = item.name; | ||
| row.appendChild(label); | ||
|
|
||
| if (side === 'sell') { | ||
| const ownedEl = document.createElement('span'); | ||
| ownedEl.className = 'owned'; | ||
| ownedEl.textContent = `×${owned}`; | ||
| row.appendChild(ownedEl); | ||
| } | ||
|
|
||
| const price = document.createElement('span'); | ||
| price.className = 'price'; | ||
| price.textContent = `${side === 'buy' ? item.buyPrice : item.sellPrice} gp`; | ||
| row.appendChild(price); | ||
|
|
||
| return row; | ||
| }; | ||
|
|
||
| const render = (): void => { | ||
| if (!shop) { | ||
| el.style.display = 'none'; | ||
| el.replaceChildren(); | ||
| stopDrag?.(); | ||
| stopDrag = null; | ||
| return; | ||
| } | ||
| el.style.display = ''; | ||
|
|
||
| const head = document.createElement('div'); | ||
| head.className = 'head'; | ||
| const name = document.createElement('span'); | ||
| name.className = 'name'; | ||
| name.textContent = shop.npcName; | ||
| const money = document.createElement('span'); | ||
| money.className = 'money'; | ||
| money.textContent = `${shop.money} gp`; | ||
| const close = document.createElement('button'); | ||
| close.type = 'button'; | ||
| close.textContent = '✕'; | ||
| close.addEventListener('click', () => opts.onClose?.()); | ||
| head.append(name, money, close); | ||
|
|
||
| const tabs = document.createElement('div'); | ||
| tabs.className = 'tabs'; | ||
| for (const tabSide of ['buy', 'sell'] as const) { | ||
| const tab = document.createElement('button'); | ||
| tab.type = 'button'; | ||
| tab.textContent = tabSide === 'buy' ? 'Buy' : 'Sell'; | ||
| if (side === tabSide) tab.className = 'active'; | ||
| tab.addEventListener('click', () => { | ||
| side = tabSide; | ||
| render(); | ||
| }); | ||
| tabs.appendChild(tab); | ||
| } | ||
|
|
||
| const rows = document.createElement('div'); | ||
| rows.className = 'rows'; | ||
| const entries = shop.items.filter((i) => (side === 'buy' ? i.buyPrice > 0 : i.sellPrice > 0)); | ||
| if (entries.length === 0) { | ||
| const empty = document.createElement('div'); | ||
| empty.className = 'empty'; | ||
| empty.textContent = side === 'buy' ? 'Nothing for sale.' : 'Buys nothing.'; | ||
| rows.appendChild(empty); | ||
| } else { | ||
| for (const item of entries) rows.appendChild(renderRow(item)); | ||
| } | ||
|
|
||
| el.replaceChildren(head, tabs, rows); | ||
| stopDrag?.(); | ||
| stopDrag = makeDraggable(el, head); | ||
| }; | ||
|
|
||
| return { | ||
| el, | ||
| update(next: OpenShop | null): void { | ||
| if (next === null) side = 'buy'; // fresh window starts on Buy | ||
| shop = next; | ||
| render(); | ||
| }, | ||
| destroy(): void { | ||
| stopDrag?.(); | ||
| el.remove(); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Recreating the head element and re-binding the drag listeners via makeDraggable on every render() call (which happens whenever the shop state or player gold updates) will interrupt any active drag operation. If a player is dragging the window while a transaction completes or gold updates, the drag will glitch or stop.
By initializing the static DOM structure (head, tabs, rows) and the drag handler once on creation, and only updating their dynamic content during render(), we avoid this UX bug and improve rendering efficiency.
let side: ShopSide = 'buy';
let shop: OpenShop | null = null;
const head = document.createElement('div');
head.className = 'head';
const name = document.createElement('span');
name.className = 'name';
const money = document.createElement('span');
money.className = 'money';
const close = document.createElement('button');
close.type = 'button';
close.textContent = '✕';
close.addEventListener('click', () => opts.onClose?.());
head.append(name, money, close);
const tabs = document.createElement('div');
tabs.className = 'tabs';
const rows = document.createElement('div');
rows.className = 'rows';
el.append(head, tabs, rows);
const stopDrag = makeDraggable(el, head);
const renderRow = (item: ShopItem): HTMLElement => {
const owned = shop?.goods.get(item.serverId) ?? 0;
const row = document.createElement('button');
row.type = 'button';
row.className = 'row';
row.addEventListener('click', () => opts.onItemTap?.(side, item, owned));
const thumbBox = document.createElement('span');
thumbBox.className = 'thumb';
const thumb = opts.renderThumb?.(item.clientId) ?? null;
if (thumb) {
thumb.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;';
thumbBox.appendChild(thumb);
} else {
thumbBox.textContent = '#' + item.serverId;
}
row.appendChild(thumbBox);
const label = document.createElement('span');
label.className = 'label';
label.textContent = item.name;
row.appendChild(label);
if (side === 'sell') {
const ownedEl = document.createElement('span');
ownedEl.className = 'owned';
ownedEl.textContent = '×' + owned;
row.appendChild(ownedEl);
}
const price = document.createElement('span');
price.className = 'price';
price.textContent = (side === 'buy' ? item.buyPrice : item.sellPrice) + ' gp';
row.appendChild(price);
return row;
};
const render = (): void => {
if (!shop) {
el.style.display = 'none';
return;
}
el.style.display = '';
name.textContent = shop.npcName;
money.textContent = shop.money + ' gp';
tabs.replaceChildren();
for (const tabSide of ['buy', 'sell'] as const) {
const tab = document.createElement('button');
tab.type = 'button';
tab.textContent = tabSide === 'buy' ? 'Buy' : 'Sell';
if (side === tabSide) tab.className = 'active';
tab.addEventListener('click', () => {
side = tabSide;
render();
});
tabs.appendChild(tab);
}
rows.replaceChildren();
const entries = shop.items.filter((i) => (side === 'buy' ? i.buyPrice > 0 : i.sellPrice > 0));
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = side === 'buy' ? 'Nothing for sale.' : 'Buys nothing.';
rows.appendChild(empty);
} else {
for (const item of entries) rows.appendChild(renderRow(item));
}
};
return {
el,
update(next: OpenShop | null): void {
if (next === null) side = 'buy'; // fresh window starts on Buy
shop = next;
render();
},
destroy(): void {
stopDrag();
el.remove();
},
};| const cap = side === 'sell' ? Math.min(ownedCount, MAX_AMOUNT) : MAX_AMOUNT; | ||
| const amounts = AMOUNT_STEPS.filter((n) => n <= cap); | ||
| if (amounts.length === 0) return; // nothing to sell |
There was a problem hiding this comment.
When selling items, the amount sheet only displays the standard steps (1, 5, 10, 25, 50, 100) that are less than or equal to the owned count. If a player owns a non-standard amount (e.g., 3 or 7), they cannot sell their entire stack in a single click.
We can improve usability by appending the exact cap (the maximum sellable amount) to the amounts array if it's not already present in the steps.
const cap = side === 'sell' ? Math.min(ownedCount, MAX_AMOUNT) : MAX_AMOUNT;
const amounts = AMOUNT_STEPS.filter((n) => n <= cap);
if (cap > 0 && !amounts.includes(cap)) {
amounts.push(cap);
}
if (amounts.length === 0) return; // nothing to sell
Client side of the NPC shop window protocol. Server side: santi1584/jameraServer76#8 — the wire contract lives in that repo's
docs/protocol/npc-shop.mdand this PR implements it byte-for-byte.Saying
hithentradeto a shop NPC opens a draggable Buy/Sell window (containerPane pattern): item thumbnails, per-unit prices, the player's gold, and owned counts on the Sell tab. Tapping an entry opens an amount sheet (1/5/10/25/50/100, capped at 100 and, for sells, at the owned count); selecting sends the request. Requests echo the catalog's server item id + subtype with an amount — prices are never on the wire; the server prices every transaction from its own catalog.Wire (7.6, jamera extension)
0x7AShopOpen (npc name + catalog entries: u16 serverId, u16 clientSpriteId, u8 subType, string name, u32 buyPrice, u32 sellPrice),0x7BShopGoods (u32 money, u8 count × {u16 serverId, u16 owned}),0x7CShopClose.0x7Abuy /0x7Bsell (u16 serverId, u8 subType, u8 amount 1..100),0x7Cclose.0x7D–0x7F) is untouched — asserted by test.wireSkipsgets payload-exact consumers for all three S→C opcodes, so shop packets can't truncate a frame even before the binding registers (and the frame-integrity suite covers them).Follows the multi-version rule: opcode values/layouts in
net/7.6/(shopProtocol.ts), reached through a newShopProtocolmember on theGameProtocolinterface;ShopManager/shopPaneare version-agnostic; the per-session binding lives injamera/and is registered/torn down exactly like containers.Not included (deliberate):
proxy/mockOtServer.tshas no shop simulation — dev-testing needs the real server branch. Transaction feedback (success / "You do not have enough money.") arrives as normal NPC speech in chat, per the contract.How to test
feat/npc-shop-windowof jameraServer76 (docker compose) and log in with this client.hi, thentrade.bye(or walk away) — the window closes by itself.tradeagain reopens it.How to test (developer)
npm ci && npm test— 862 tests pass, including:[0x7a, id_lo, id_hi, subtype, amount]), sell caps by owned count, and the 0x7D–0x7F player-trade opcodes staying unchanged.npm run lint && npm run build— both clean.🤖 Generated with Claude Code
https://claude.ai/code/session_011T5NW34zAKZaSCTAVceruw