Skip to content

feat: NPC shop window (opcodes 0x7A-0x7C) - #296

Draft
santi1584 wants to merge 1 commit into
mainfrom
feat/npc-shop-window
Draft

feat: NPC shop window (opcodes 0x7A-0x7C)#296
santi1584 wants to merge 1 commit into
mainfrom
feat/npc-shop-window

Conversation

@santi1584

Copy link
Copy Markdown
Collaborator

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.md and this PR implements it byte-for-byte.

Saying hi then trade to 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)

  • S→C 0x7A ShopOpen (npc name + catalog entries: u16 serverId, u16 clientSpriteId, u8 subType, string name, u32 buyPrice, u32 sellPrice), 0x7B ShopGoods (u32 money, u8 count × {u16 serverId, u16 owned}), 0x7C ShopClose.
  • C→S 0x7A buy / 0x7B sell (u16 serverId, u8 subType, u8 amount 1..100), 0x7C close.
  • Player-to-player trade (0x7D–0x7F) is untouched — asserted by test.
  • wireSkips gets 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 new ShopProtocol member on the GameProtocol interface; ShopManager/shopPane are version-agnostic; the per-session binding lives in jamera/ and is registered/torn down exactly like containers.

Not included (deliberate): proxy/mockOtServer.ts has 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

  • Run the server branch feat/npc-shop-window of jameraServer76 (docker compose) and log in with this client.
  • Walk to a shop NPC (e.g. Bashira or Al Dee), say hi, then trade.
  • A shop window titled with the NPC's name opens, showing your gold in the header and the catalog with prices under the Buy tab.
  • Tap an item, pick Buy 1 — the NPC replies "It was a pleasure doing business with you." in chat, your gold in the header drops by the price, and the item appears in your inventory/backpack.
  • Open the Sell tab — items the NPC buys show a green ×N owned count; sell one and confirm gold increases.
  • Try buying with insufficient gold — the NPC answers "You do not have enough money." and nothing is charged.
  • Say bye (or walk away) — the window closes by itself.
  • Tap on the window — it closes; saying trade again reopens it.

How to test (developer)

  • npm ci && npm test — 862 tests pass, including:
    npx vitest run src/__tests__/shopProtocol.test.ts src/__tests__/shopBinding.test.ts src/__tests__/wireCompleteness.test.ts
    
    covering packet layouts, frame integrity for 0x7A–0x7C, DOM rendering, exact request bytes ([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

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
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6d1df99f-91eb-4f28-b4e3-e5018d4ad4d9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/npc-shop-window

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/shopPane.ts
Comment on lines +96 to +204
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();
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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();
    },
  };

Comment on lines +66 to +68
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant