diff --git a/PRODUCT.md b/PRODUCT.md index 8c3ae02..4e8a67c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -10,7 +10,7 @@ product ## Product Purpose -Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows. +Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Основной экран показывает компактные блоки торговцев, а каждое новое распознавание сохраняется с датой и сразу отправляется в черновик рынка home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows. ## Brand Personality @@ -23,7 +23,7 @@ product ## Design Principles - Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса. -- Вести пользователя по прямому пути: источник, калибровка, результат. +- Вести пользователя по прямому пути: экран, парсинг, сохранённый черновик. Калибровка остаётся редкой настройкой. - Сохранять интерфейс компактным и предсказуемым при нескольких окнах. - Оставлять настраиваемыми только параметры, которые зависят от реального изображения. diff --git a/README.md b/README.md index de17e28..171b566 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run dev └─ да → OCR точной строки названия и точной строки цены ``` -Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Широкая зона tooltip используется только для поиска `Price :` и не передаётся целиком в OCR. После нахождения якоря приложение отдельно определяет фактическую ширину строк названия и цены по их светлым пикселям. +Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Широкая зона tooltip используется только для поиска `Price :` и не передаётся целиком в OCR. После нахождения якоря приложение отдельно определяет фактическую ширину строк названия и цены по пикселям текста; для названия учитываются белый, синий и фиолетовый цвета. Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`. @@ -55,18 +55,13 @@ npm run dev ## Сценарий -1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры. -2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. -3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа. -4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай. -5. Выдели широкую горизонтальную полосу всех возможных положений tooltip. Она может выходить далеко за пределы торгового окна, но не должна захватывать нижний `Price` основного окна. -6. Внутри tooltip обведи только постоянную надпись `Price :`. -7. Отдельно выдели левый край, высоту и начальную ширину строки названия и строки цены. Runtime сам продлит каждую строку вправо до фактического конца текста. -8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. -9. Сначала оставь курсор вне предметов, пока в результате не появятся тип сделки и имя торговца. -10. Наводи курсор на каждый предмет. Даже если tooltip закроет заголовок, предмет будет записан в последний найденный магазин. +1. В разделе «Экран» нажми «Добавить окно» и выбери окно игры. Приложение сразу откроет «Парсинг». +2. Нажми «Начать парсинг» и вернись в игру. +3. Сначала оставь курсор вне предметов, пока не появится блок торговца с типом сделки и именем. +4. Наводи курсор на каждый предмет. Товары заполняют блок торговца, следующий торговец появляется справа или на новой строке. +5. Калибровку открывай как дополнительную настройку, если текущая общая конфигурация не распознаёт окно или tooltip. -Наблюдения сначала попадают в IndexedDB браузера, затем отправляются пакетами раз в 30 секунд. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы; сервер не создаёт дубликат. +Новое наблюдение сначала попадает в IndexedDB браузера и сразу отправляется на сервер; 30-секундный повтор остаётся страховкой. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы. Сервер хранит исходный пакет без дублей и для распознанных каталожных предметов обновляет отдельный черновик рынка: `Sell` становится `sell_offer`, `Buy` становится `buy_order`. Неопознанные товары остаются только в исходном пакете. Калибровку можно сделать по снимкам разного размера и с торговым окном в разных местах: @@ -78,7 +73,7 @@ npm run dev После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно. -Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку. +Результат состоит только из блоков торговцев. В каждом блоке показаны тип сделки и последние распознанные цена/количество каждого предмета. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации не создаёт новую запись. Во вкладке приложения OCR-название проходит неточный поиск по активному каталогу. Уверенное совпадение заменяется каноническим названием и показывается с иконкой предмета; слабое совпадение остаётся с вопросительным знаком и пометкой «Не найден в каталоге». Предметы группируются по имени торговца и типу сделки. @@ -120,7 +115,7 @@ npm run dev - Источники выбираются вручную после каждого обновления страницы. - Одна калибровка рассчитана на один масштаб интерфейса Lineage II. -- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор». +- Парсинг работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить парсинг». - Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод. - Токен импорта хранится только в локальном Vite-процессе; в браузерный bundle он не попадает. diff --git a/docs/goals/merchant-hud-and-location-capture/GOAL.md b/docs/goals/merchant-hud-and-location-capture/GOAL.md new file mode 100644 index 0000000..d4a4bc7 --- /dev/null +++ b/docs/goals/merchant-hud-and-location-capture/GOAL.md @@ -0,0 +1,12 @@ +# Goal: Merchant HUD And Location Capture + +Use Krypton Execution to execute `docs/goals/merchant-hud-and-location-capture/PLAN.md`. + +Core rules: +- Treat `PLAN.md` as the source plan. +- Preserve the existing recognition loop and use its `result` as the only live HUD truth. +- Start with one Document Picture-in-Picture HUD; do not add a native wrapper unless its documented limitations block the real workflow. +- Capture a merchant location only after an explicit user action and inline preview. +- Store one bounded JPEG per shop visit and link item observations with the same `shopVisitId`. +- Preserve auth, same-origin writes, catalog ownership and append-only observation batches. +- Capture target-perspective evidence only with fresh manual-testing permission; otherwise report `implemented but visually unproven`. diff --git a/docs/goals/merchant-hud-and-location-capture/PLAN.md b/docs/goals/merchant-hud-and-location-capture/PLAN.md new file mode 100644 index 0000000..64eb6d8 --- /dev/null +++ b/docs/goals/merchant-hud-and-location-capture/PLAN.md @@ -0,0 +1,159 @@ +# Merchant HUD And Location Capture Implementation Plan + +**Intent:** Дать игроку спокойную оперативную обратную связь поверх окна Lineage II и позволить вручную сохранить примерное место торговца вместе с посещением магазина. +**Current Behavior:** Состояние распознавания видно только в большом окне аудита. Превью уже умеет рисовать найденные области и прогресс иконок, но при возврате в игру оно скрыто. Сервер сохраняет OCR-наблюдения и общую калибровку, но отдельного посещения магазина и изображения места нет. +**Expected Outcome:** Из аудита открывается один компактный always-on-top HUD. Он использует уже существующее состояние выбранного источника и показывает торговца, сторону сделки, прогресс предметов и последнюю распознанную позицию. Пользователь может снять текущий кадр, увидеть сжатую миниатюру и явно сохранить её как место текущего посещения магазина. +**Target-Perspective Output:** Игрок возвращается в Lineage II и поверх окна видит `Покупка · Dwa`, сетку с найденными и ожидающими иконками и последнюю цену. После `Снять место` он видит миниатюру, подтверждает сохранение и получает устойчивый статус `Место сохранено`. +**Truth Owner:** Браузерный `result` владеет только живым состоянием HUD и текущим `shopVisitId`. PostgreSQL в `home-service` владеет сохранённым посещением и его JPEG. Каталог `home-service` остаётся владельцем канонических имён и иконок предметов. +**Contract Boundary:** Parser отправляет authenticated same-origin JSON с UUID посещения, торговцем, стороной, временем, исходными размерами и JPEG data URL. Каждое OCR-наблюдение получает необязательный `shopVisitId`, чтобы связать товары с тем же посещением. +**Cutover:** HUD читает тот же `result`, что текущие source preview и results; отдельного второго состояния распознавания не создаётся. После появления HUD существующие маленькие status chips в source preview остаются fallback внутри страницы, но перестают быть основным игровым feedback path. +**Displaced Path:** Постоянное переключение из игры в большую вкладку `Результат` для контроля процесса демотируется до подробной диагностики. Автоматическая или покадровая запись мест не добавляется. +**Value Density:** Первый срез использует нативный Document Picture-in-Picture вместо Electron/Tauri и сохраняет только один подтверждённый сжатый кадр на посещение. +**Acceptance Evidence:** В поддерживаемом Chrome/Edge игрок открывает HUD, возвращается в игру, видит неизменного торговца при tooltip, наблюдает переход одного слота из ожидания в найденный, снимает место и после server acknowledgement видит сохранённый статус. В PostgreSQL находится одна строка посещения с тем же `shopVisitId`; связанное наблюдение содержит тот же ID. +**Evidence Lane:** Автоматически: parser unit tests, HUD model tests, contracts/backend route tests, migration smoke, parser/frontend/backend builds и `git diff --check`. Вручную только после отдельного разрешения: реальное always-on-top поведение над Lineage II, читаемость и полезность сжатого снимка. +**Kill Criteria:** Не создавать второй recognition loop, не копировать OCR-состояние в независимый store, не сохранять full-resolution кадры, не снимать экран автоматически, не добавлять native wrapper до доказанного ограничения PiP. Если требуется несколько независимых overlay одновременно или click-through поверх каждого окна игры, остановить web-срез и отдельно спланировать Windows companion. +**Architecture Slice:** `l2-market-parser` владеет HUD, visit lifecycle и компрессией кадра; shared contract, BFF, Nest service и PostgreSQL table живут в `home-service`; ingress меняется только для bounded screenshot body. +**Plan Review Gate:** Requires PRE review before execution. + +## Physical Scene And Interface Contract + +Игрок работает вечером на большом мониторе с несколькими оконными клиентами Lineage II. HUD лежит поверх текущего клиента и должен читаться на любом игровом фоне, не требуя переключения внимания. + +- Один solid dark utility surface примерно `320 × 420`, без прозрачного стекла, игровых рамок и декоративной анимации. +- Верхняя строка: выбранный источник и состояние сбора. +- Главная строка: `Покупает/Продаёт · торговец`. Она занимает фиксированное место и не прыгает при OCR. +- Сетка `6 × 3`: каноническая иконка для найденного предмета, нейтральная ячейка для ожидающего, понятный `?` для неоднозначного. +- Последний результат: название, количество, цена и короткий статус совпадения с каталогом. +- Действие `Снять место` создаёт inline-миниатюру. Рядом появляются `Сохранить` и `Переснять`; modal не используется. +- Успех, ошибка и ожидание меняют текст/цвет в фиксированном status slot. Движение только для смены состояния, 150–200 ms, без layout animation. +- Document PiP открывается только по нажатию пользователя и только в secure context. Если API недоступен, большая страница продолжает работать и объясняет, что нужен актуальный Chrome/Edge. +- Web-страница не может задавать позицию PiP и поддерживает только одно такое окно. Пользователь размещает его сам; HUD показывает выбранный источник. + +## Architecture Map + +### Files to create + +`l2-market-parser`: + +- `src/market-hud.js`: одно представление HUD из существующего `result`, открытие/закрытие Document PiP и fallback-status. +- `test/market-hud.test.js`: модель торговца, прогресс слотов, последний предмет и unsupported fallback. + +`home-service`: + +- Следующая свободная `backend/migrations/*.sql`: таблица `l2_market_shop_visits`. +- Соответствующий `backend/migrations/meta/*_snapshot.json` и `_journal.json` только через текущий Drizzle workflow. +- `frontend/src/app/api/l2/market/visits/route.ts`: authenticated same-origin BFF `PUT`. + +### Files to modify + +`l2-market-parser`: + +- `index.html`: кнопка `Поверх игры` и минимальные fallback/status hooks. +- `src/main.js`: передача выбранного `result` в HUD, lifecycle одного `shopVisitId`, захват текущего источника и явное сохранение миниатюры. +- `src/frame-analyzer.js`: новый visit ID при подтверждённой смене/повторном открытии магазина; очистка ID вместе с transient shop progress; добавление ID в observation. +- `src/style.css`: общие HUD tokens и компактная layout-сетка без отдельной визуальной системы. +- `src/parser.js`: чистый helper экспорта HUD/visit данных, только если он уменьшает дублирование. + +`home-service`: + +- `contracts/src/l2.ts`: strict `L2MarketShopVisitSchema`, bounded JPEG data URL, размеры и optional `shopVisitId` у observation. +- `backend/src/database/schema.ts`: `l2MarketShopVisits` с UUID, временем, source, side, merchant, dimensions, bounded image text и indexes. +- `backend/src/l2/market/l2-market.controller.ts`: `PUT /l2/market/visits/:id`. +- `backend/src/l2/market/l2-market.service.ts`: idempotent insert/update одного пользовательски подтверждённого кадра посещения. +- `backend/src/l2/market/l2-market.service.spec.ts` и controller spec: strict validation, idempotency и linkage ID. +- `frontend/src/lib/l2-session.spec.ts`: unauthenticated/cross-origin rejection и authenticated forwarding. +- `docs/goals/l2-craft-farm-planner/l2-vault.nginx`: exact visits route с bounded body limit только если итоговый JSON превышает текущие 16 KiB. +- `frontend/public/l2/market-audit/**`: только сгенерированный production bundle после parser build. + +### Files to avoid + +- Не менять catalog matching, OCR, calibration geometry и quote/snapshot semantics. +- Не хранить изображение внутри каждого item observation или каждого retry batch. +- Не трогать другие home-service сайты, admin access и backend public port. +- Не добавлять Electron, Tauri, React/state library, object storage или image processing dependency. + +### Source of truth and paths + +- **Read path:** `state.results` → `buildHudModel(result)` → один PiP document. Каталожные имена/иконки уже приходят через существующий fuzzy resolver. +- **Screenshot path:** выбранный source video → canvas downscale → JPEG quality reduction → inline preview → explicit confirm → BFF → Nest → PostgreSQL. +- **Observation link:** `activateShop` создаёт `shopVisitId`; каждое последующее observation этого магазина несёт ID; закрытие магазина после существующего miss policy очищает ID. +- **Compression ceiling:** начать с max width `640`, JPEG quality `0.35`; при превышении contract size уменьшать quality/width до bounded payload. Сохранять исходные и итоговые размеры. +- **Write semantics:** повторный `PUT` с тем же visit ID заменяет только подтверждённую миниатюру и capture metadata; OCR observations остаются append-only batch payloads. + +## Tasks + +### 1. Зафиксировать visit lifecycle и shared contracts + +**Allowed scope:** `src/frame-analyzer.js`, parser tests, `contracts/src/l2.ts`, market service tests. + +- Добавить `shopVisitId` и `shopVisitStartedAt` в transient result. +- Создавать ID только при первом подтверждённом заголовке после reset или реальной смене shop key. +- Не менять ID, пока tooltip закрывает заголовок. +- Добавить optional ID в observation и strict visit screenshot contract. + +**Verification:** `npm test`; focused contracts build and market specs. + +**Acceptance:** тест воспроизводит header → tooltip → несколько items и доказывает один visit ID и неизменного торговца. + +### 2. Построить один PiP HUD из существующего result + +**Allowed scope:** `market-hud.js`, `main.js`, `index.html`, `style.css`, HUD test. + +- Открывать Document PiP только по явному нажатию. +- Подключить модель выбранного source result без второго timer/recognition loop. +- Рендерить merchant, side, slot progress, icons, unresolved state и last item. +- Перерисовывать HUD после текущего `drawSourceOverlay`/result update. +- Закрытие PiP не останавливает сбор; закрытие основной страницы закрывает PiP нативно. + +**Verification:** parser unit tests and production build. + +**Acceptance:** mocked supported/unsupported window tests; модель сохраняет фиксированную геометрию при idle/found/error. + +### 3. Добавить ручной capture с inline preview + +**Allowed scope:** parser HUD/main code and tests. + +- `Снять место` доступно только при active source и подтверждённом merchant/visit ID. +- Захватить один текущий frame, downscale/compress без новой зависимости. +- До отправки показать миниатюру, размер и `Сохранить/Переснять`. +- После ошибки оставить миниатюру для повторного сохранения; не делать background screenshots. + +**Verification:** deterministic canvas compression test with a generated fixture; payload always satisfies contract limit. + +**Acceptance:** одна кнопка создаёт один preview; cancel/reshoot не пишет сервер. + +### 4. Сохранить место торговца в home-service + +**Allowed scope:** migration/schema, shared contract, market controller/service, exact BFF route and tests. + +- Создать bounded visit table and index by merchant/seenAt. +- Реализовать authenticated same-origin `PUT` and idempotent backend write. +- Не принимать PNG, произвольный MIME, oversized base64 или mismatched path/body ID. +- Не возвращать изображение из unrelated snapshot/catalog endpoints. + +**Verification:** Drizzle migration generation/check, market service/controller specs, frontend BFF test, contracts/backend/frontend builds. + +**Acceptance:** database smoke verifies one replaceable visit row and observation payload with the same `shopVisitId`. + +### 5. Интегрировать и доказать cutover + +**Allowed scope:** generated audit bundle, relevant README/evidence, exact ingress body rule if required. + +- Пересобрать parser directly into `frontend/public/l2/market-audit`. +- Проверить bundle на отсутствие embedded tokens/full-resolution fixtures. +- Programmatic public route check remains password-gated. +- Не объявлять игровой overlay доказанным без отдельного manual permission. + +**Verification:** both repository builds, focused tests, `git diff --check`, migration smoke. + +**Acceptance:** automated lane green; target lane recorded as either proven over a real game window or `implemented but visually unproven`. + +## PRE Self-Review + +- **Mode:** PRE +- **Verdict:** aligned +- **Blockers:** none for the web prototype. +- **Major constraint:** Document PiP gives one user-positioned always-on-top window, not multiple click-through overlays attached to game windows. This is explicit in UX and kill criteria. +- **Major security constraint:** screenshots may contain chat/account information; capture stays manual with inline preview, strict JPEG/size validation and authenticated storage. +- **Smallest next gate:** owner accepts the one-PiP limitation and manual screenshot confirmation, then execution starts with Task 1. + diff --git a/index.html b/index.html index 2d00408..28083ed 100644 --- a/index.html +++ b/index.html @@ -28,13 +28,13 @@ @@ -43,8 +43,8 @@

Захват экрана

-

Окна игры

-

Добавь каждое окно отдельно. Браузер спросит, какое окно разрешено захватывать.

+

Выбери экран игры

+

Браузер спросит, какое окно разрешено захватывать. После выбора откроется экран парсинга.

@@ -155,13 +155,12 @@

Проверка пайплайна

-

Собранные предметы

-

Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.

+

Торговцы

+

Запусти парсинг и наведи мышь на товары. Каждый торговец появится отдельным блоком, данные сохранятся автоматически.

- - +
diff --git a/src/frame-analyzer.js b/src/frame-analyzer.js index bb624bd..68f4f03 100644 --- a/src/frame-analyzer.js +++ b/src/frame-analyzer.js @@ -196,6 +196,10 @@ export function recordSaleMiss(result) { if (result.misses >= 3) clearRememberedShop(result); } +export function shouldReadStoreHeader(tooltipFound) { + return !tooltipFound; +} + async function markMatchedSlot(result, item, key) { if (!item.iconUrl || !result.iconSlots?.some((slot) => slot.status === "pending")) return null; try { @@ -227,6 +231,7 @@ export async function analyzeFrame( resolveCatalogItem = async (item) => item, catalogAlternatives = false, enqueueObservation = null, + marketSnapshotId = null, }, ) { const diagnostics = { boxes: [], stages: [], preview: null }; @@ -288,7 +293,36 @@ export async function analyzeFrame( diagnostics.boxes.push(storeHeaderBox); diagnostics.boxes.push({ rect: tooltipSearchRect, label: "Зона tooltip", status: "info" }); - if (isRectInside(storeHeaderRect, frame.width, frame.height)) { + const tooltipSearchInside = isRectInside(tooltipSearchRect, frame.width, frame.height); + const tooltipSearch = tooltipSearchInside ? cropCanvas(frame, tooltipSearchRect) : null; + const tooltipMatch = tooltipSearch ? await findAnchor(tooltipSearch, tooltipTemplate) : null; + const tooltipRect = tooltipMatch + ? { + x: tooltipSearchRect.x + tooltipMatch.x, + y: tooltipSearchRect.y + tooltipMatch.y, + width: tooltipTemplate.width, + height: tooltipTemplate.height, + } + : null; + const tooltipFound = Boolean(tooltipMatch && tooltipMatch.score >= calibration.threshold); + + if (!shouldReadStoreHeader(tooltipFound)) { + const rememberedStore = result.side && result.merchant; + storeHeaderBox.status = rememberedStore ? "info" : "not-found"; + if (rememberedStore) { + storeHeaderBox.label = `Последний: ${result.side === "buy" ? "Buy" : "Sell"} · ${result.merchant}`; + } + addDiagnosticStage( + diagnostics, + "storeHeader", + "Заголовок магазина", + rememberedStore ? "skipped" : "not-found", + rememberedStore + ? `Price : уже найден. Заголовок не считывается; используется последний магазин: ${result.side === "buy" ? "Покупка" : "Продажа"}, ${result.merchant}` + : "Price : уже найден. Заголовок не считывается, а ранее магазин не был распознан", + cropPreview(frame, storeHeaderRect), + ); + } else if (isRectInside(storeHeaderRect, frame.width, frame.height)) { const headerOcr = await recognizeText(frame, storeHeaderRect, { singleLine: true, preprocess: "luminance", @@ -335,7 +369,7 @@ export async function analyzeFrame( ); } - if (!isRectInside(tooltipSearchRect, frame.width, frame.height)) { + if (!tooltipSearchInside) { addDiagnosticStage( diagnostics, "tooltip", @@ -355,15 +389,6 @@ export async function analyzeFrame( return null; } - const tooltipSearch = cropCanvas(frame, tooltipSearchRect); - const tooltipMatch = await findAnchor(tooltipSearch, tooltipTemplate); - const tooltipRect = { - x: tooltipSearchRect.x + tooltipMatch.x, - y: tooltipSearchRect.y + tooltipMatch.y, - width: tooltipTemplate.width, - height: tooltipTemplate.height, - }; - const tooltipFound = tooltipMatch.score >= calibration.threshold; diagnostics.boxes.push({ rect: tooltipRect, label: "Price :", @@ -416,7 +441,7 @@ export async function analyzeFrame( }; diagnostics.boxes.push(itemBox); - const nameRect = fitOcrTextRect(frame, calibratedNameRect); + const nameRect = fitOcrTextRect(frame, calibratedNameRect, { includeItemColors: true }); const priceRect = fitOcrTextRect(frame, calibratedPriceRect, { includeYellow: true }); if (!nameRect || !priceRect) { itemBox.status = "not-found"; @@ -443,7 +468,7 @@ export async function analyzeFrame( const itemRect = combineRects(nameRect, priceRect); itemBox.rect = itemRect; const itemOcr = await recognizeTextRows(frame, [ - { rect: nameRect }, + { rect: nameRect, includeItemColors: true }, { rect: priceRect, includeYellow: true }, ]); let item = parseTooltipText(itemOcr.text); @@ -497,9 +522,10 @@ export async function analyzeFrame( const seenAt = new Date().toISOString(); let matchedSlotId = null; - if (source && enqueueObservation) { + if (!existing && source && enqueueObservation) { await enqueueObservation({ observationId: crypto.randomUUID(), + ...(marketSnapshotId ? { marketSnapshotId } : {}), seenAt, sourceId: source.id, source: source.name.slice(0, 256), diff --git a/src/main.js b/src/main.js index a1c3e1b..21a5a62 100644 --- a/src/main.js +++ b/src/main.js @@ -3,9 +3,9 @@ import { displayCaptureIssue, isCalibrationReady, isRectInside, + merchantGroups, rectFromPoints, resolveFieldRect, - toExportableResults, } from "./parser.js"; import { findAnchor, onOcrProgress, terminateVision } from "./vision.js"; import { @@ -73,6 +73,7 @@ const state = { drawing: null, results: [], collecting: false, + marketSnapshotId: null, }; const marketOutbox = createMarketOutbox(appHosted ? "/api/l2/market/import" : undefined); @@ -105,7 +106,6 @@ const elements = { clearCalibration: document.querySelector("#clear-calibration"), recognizeAll: document.querySelector("#recognize-all"), clearResults: document.querySelector("#clear-results"), - copyResults: document.querySelector("#copy-results"), resultsList: document.querySelector("#results-list"), }; @@ -213,7 +213,8 @@ async function addSource() { state.sources.push(source); state.selectedSourceId = source.id; renderSources(); - setStatus(`Добавлено: ${source.name}`, "success"); + showView("results"); + setStatus(`Экран выбран: ${source.name}`, "success"); } catch (error) { if (error.name === "NotAllowedError") { setStatus("Захват отменён или запрещён. Проверь разрешение браузера на запись экрана", "error"); @@ -332,7 +333,7 @@ function renderSources() { const calibrate = document.createElement("button"); calibrate.className = "button"; calibrate.type = "button"; - calibrate.textContent = "Калибровать"; + calibrate.textContent = "Настроить калибровку"; calibrate.disabled = !source.active; calibrate.addEventListener("click", () => { chooseSource(source.id); @@ -341,9 +342,12 @@ function renderSources() { const select = document.createElement("button"); select.className = "button button--quiet"; select.type = "button"; - select.textContent = source.id === state.selectedSourceId ? "Выбрано" : "Выбрать"; - select.disabled = !source.active || source.id === state.selectedSourceId; - select.addEventListener("click", () => chooseSource(source.id)); + select.textContent = source.id === state.selectedSourceId ? "Открыть парсинг" : "Парсить этот экран"; + select.disabled = !source.active; + select.addEventListener("click", () => { + chooseSource(source.id); + showView("results"); + }); const remove = document.createElement("button"); remove.className = "button button--quiet"; remove.type = "button"; @@ -926,7 +930,7 @@ async function loadSharedCalibration() { function clearCalibration() { state.collecting = false; - elements.recognizeAll.textContent = "Начать сбор"; + elements.recognizeAll.textContent = "Начать парсинг"; state.calibration = emptyCalibration(); state.references.forEach((reference) => { reference.saleAnchor = null; @@ -945,7 +949,6 @@ function appendDiagnostics(section, result) { const details = document.createElement("details"); details.className = "diagnostics"; - details.open = true; const successful = result.diagnostics.stages.filter((stage) => stage.status === "found").length; const summary = document.createElement("summary"); summary.textContent = `Диагностика последнего кадра: ${successful}/${result.diagnostics.stages.length} этапов выполнено`; @@ -1004,138 +1007,99 @@ function appendDiagnostics(section, result) { function renderResults() { elements.resultsList.replaceChildren(); - elements.copyResults.disabled = !state.results.some((result) => result.items.length); + const groups = merchantGroups(state.results); - if (!state.results.length) { + if (!groups.length) { const empty = document.createElement("div"); empty.className = "empty-state empty-state--wide"; const title = document.createElement("strong"); - title.textContent = "Результатов пока нет"; + title.textContent = state.collecting ? "Ищу первого торговца" : "Торговцев пока нет"; const text = document.createElement("span"); - text.textContent = "Запусти сбор, вернись в игру и наведи курсор на предмет."; + text.textContent = state.collecting + ? "Убери курсор с товара, чтобы сначала прочитать имя и тип магазина." + : "Начни парсинг, вернись в игру и открой магазин торговца."; empty.append(title, text); elements.resultsList.append(empty); - return; + } else { + const board = document.createElement("div"); + board.className = "merchant-board"; + groups.forEach((group) => board.append(renderMerchantCard(group))); + elements.resultsList.append(board); } - const uniqueLines = [ - ...new Set( - state.results.flatMap((result) => - result.items.map((item) => { - const side = (item.side || result.side) === "buy" ? "Покупка" : "Продажа"; - const merchant = item.merchant || result.merchant; - const quantity = item.quantity == null ? "количество не указано" : `${item.quantity.toLocaleString("ru-RU")} шт.`; - return `${side} · ${merchant} · ${item.name} · ${quantity} · ${item.priceAdena.toLocaleString("ru-RU")} Adena`; - }), - ), - ), - ]; - - if (uniqueLines.length) { - const output = document.createElement("section"); - output.className = "unique-lines"; - const header = document.createElement("header"); - const title = document.createElement("h3"); - title.textContent = "Уникальные строки"; - const count = document.createElement("span"); - count.textContent = String(uniqueLines.length); - header.append(title, count); - const list = document.createElement("ol"); - uniqueLines.forEach((line) => { - const item = document.createElement("li"); - item.textContent = line; - list.append(item); + if (state.results.some((result) => result.diagnostics || result.error)) { + const diagnostics = document.createElement("details"); + diagnostics.className = "source-diagnostics"; + const summary = document.createElement("summary"); + summary.textContent = "Диагностика распознавания"; + const content = document.createElement("div"); + state.results.forEach((result) => { + const source = document.createElement("section"); + const title = document.createElement("h3"); + title.textContent = result.source; + source.append(title); + if (result.error) { + const error = document.createElement("p"); + error.className = "result-error"; + error.textContent = result.error; + source.append(error); + } + appendDiagnostics(source, result); + content.append(source); }); - output.append(header, list); - elements.resultsList.append(output); + diagnostics.append(summary, content); + elements.resultsList.append(diagnostics); } +} - state.results.forEach((result) => { - const section = document.createElement("section"); - section.className = "result-group"; - const header = document.createElement("header"); - const heading = document.createElement("div"); - const title = document.createElement("h3"); - title.textContent = result.source; - const meta = document.createElement("p"); - meta.textContent = result.saleFound - ? `${result.side === "buy" ? "Покупка" : result.side === "sell" ? "Продажа" : "Тип распознаётся"} · ${result.merchant || "торговец распознаётся"}, маркер ${(result.score * 100).toFixed(1)}%` - : `Торговое окно не найдено, лучшее совпадение ${(result.score * 100).toFixed(1)}%`; - heading.append(title, meta); - const badge = document.createElement("span"); - badge.className = "result-badge"; - badge.dataset.tone = result.saleFound ? "success" : result.items.length ? "neutral" : "error"; - badge.textContent = result.saleFound - ? `${result.items.length} предметов` - : result.items.length - ? `${result.items.length} сохранено` - : "Нет магазина"; - header.append(heading, badge); - section.append(header); +function renderMerchantCard(group) { + const card = document.createElement("section"); + card.className = "merchant-card"; + if (group.active) card.classList.add("is-active"); + const header = document.createElement("header"); + const heading = document.createElement("div"); + const title = document.createElement("h3"); + title.textContent = group.merchant; + const source = document.createElement("small"); + source.textContent = group.sources.join(", "); + heading.append(title, source); + const meta = document.createElement("span"); + meta.textContent = `${group.side === "buy" ? "Покупает" : "Продаёт"} · ${group.items.length}`; + header.append(heading, meta); + const items = document.createElement("div"); + items.className = "merchant-items"; + if (!group.items.length) { + const waiting = document.createElement("p"); + waiting.className = "merchant-waiting"; + waiting.textContent = "Торговец найден. Наводи курсор на товары."; + items.append(waiting); + } + group.items.forEach((item) => items.append(renderMarketItem(item))); + card.append(header, items); + return card; +} - if (result.error) { - const error = document.createElement("p"); - error.className = "result-error"; - error.textContent = result.error; - section.append(error); - } else if (result.items.length) { - const shops = new Map(); - result.items.forEach((item) => { - const shopKey = `${item.side}:${item.merchant}`; - if (!shops.has(shopKey)) shops.set(shopKey, []); - shops.get(shopKey).push(item); - }); - const shopList = document.createElement("div"); - shopList.className = "merchant-list"; - shops.forEach((items) => { - const shop = document.createElement("section"); - shop.className = "merchant-card"; - const shopHeader = document.createElement("header"); - const shopTitle = document.createElement("h4"); - shopTitle.textContent = items[0].merchant; - const shopMeta = document.createElement("span"); - shopMeta.textContent = `${items[0].side === "buy" ? "Покупает" : "Продаёт"} · ${items.length} поз.`; - shopHeader.append(shopTitle, shopMeta); - const itemGrid = document.createElement("div"); - itemGrid.className = "market-item-grid"; - items.forEach((item) => { - const tile = document.createElement("article"); - tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`; - const icon = item.iconUrl ? document.createElement("img") : document.createElement("span"); - if (item.iconUrl) { - icon.src = item.iconUrl; - icon.alt = ""; - } else { - icon.className = "market-item__missing-icon"; - icon.textContent = "?"; - } - const copy = document.createElement("div"); - const name = document.createElement("strong"); - name.textContent = item.name; - const price = document.createElement("span"); - price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena${item.quantity == null ? "" : ` · ${item.quantity.toLocaleString("ru-RU")} шт.`}`; - const status = document.createElement("small"); - status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`; - copy.append(name, price, status); - tile.append(icon, copy); - itemGrid.append(tile); - }); - shop.append(shopHeader, itemGrid); - shopList.append(shop); - }); - section.append(shopList); - } else if (result.saleFound) { - const hint = document.createElement("p"); - hint.className = "result-hint"; - hint.textContent = result.side && result.merchant - ? "Магазин запомнен. Наводи курсор на иконки предметов и задерживай его до появления tooltip." - : "Сначала убери курсор с предмета, чтобы приложение увидело заголовок магазина. Затем наводи на предметы."; - section.append(hint); - } - - appendDiagnostics(section, result); - elements.resultsList.append(section); - }); +function renderMarketItem(item) { + const tile = document.createElement("article"); + tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`; + const icon = item.iconUrl ? document.createElement("img") : document.createElement("span"); + if (item.iconUrl) { + icon.src = item.iconUrl; + icon.alt = ""; + } else { + icon.className = "market-item__missing-icon"; + icon.textContent = "?"; + } + const copy = document.createElement("div"); + const name = document.createElement("strong"); + name.textContent = item.name; + const price = document.createElement("span"); + price.textContent = `${item.priceAdena.toLocaleString("ru-RU")} Adena${item.quantity ? ` · ${item.quantity.toLocaleString("ru-RU")} шт.` : ""}`; + const status = document.createElement("small"); + status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`; + copy.append(name, price, status); + tile.append(icon, copy); + return tile; } function resultFor(source) { @@ -1172,6 +1136,7 @@ async function analyzeCurrentFrame(frame, result, saleTemplate, tooltipTemplate, source, resolveCatalogItem, catalogAlternatives: appHosted, + marketSnapshotId: state.marketSnapshotId, enqueueObservation: source ? async (observation) => { try { @@ -1183,7 +1148,10 @@ async function analyzeCurrentFrame(frame, result, saleTemplate, tooltipTemplate, } : null, }); - if (outcome?.added) setStatus(`Добавлен предмет: ${outcome.item.name}`, "success"); + if (outcome?.added) { + setStatus(`Добавлен предмет: ${outcome.item.name}`, "success"); + void syncMarketOutbox(); + } return outcome; } @@ -1231,7 +1199,7 @@ async function testCalibration() { } state.collecting = false; - elements.recognizeAll.textContent = "Начать сбор"; + elements.recognizeAll.textContent = "Начать парсинг"; elements.testCalibration.disabled = true; setStatus(`Проверяю снимки: 0/${state.references.length}`, "working"); let result; @@ -1278,8 +1246,8 @@ async function testCalibration() { async function toggleCollection() { if (state.collecting) { state.collecting = false; - elements.recognizeAll.textContent = "Начать сбор"; - setStatus("Сбор остановлен", "neutral"); + elements.recognizeAll.textContent = "Начать парсинг"; + setStatus("Парсинг остановлен", "neutral"); void syncMarketOutbox(); return; } @@ -1295,8 +1263,10 @@ async function toggleCollection() { } state.collecting = true; - elements.recognizeAll.textContent = "Остановить сбор"; - setStatus("Через 2 секунды начнётся сбор. Вернись в игру", "working"); + state.marketSnapshotId = crypto.randomUUID(); + elements.recognizeAll.textContent = "Остановить парсинг"; + setStatus("Через 2 секунды начнётся парсинг. Вернись в игру", "working"); + renderResults(); onOcrProgress((progress) => { if (progress.status === "recognizing text") { @@ -1322,7 +1292,7 @@ async function toggleCollection() { setStatus(error.message || "Ошибка распознавания", "error"); } finally { state.collecting = false; - elements.recognizeAll.textContent = "Начать сбор"; + elements.recognizeAll.textContent = "Начать парсинг"; } } @@ -1343,16 +1313,6 @@ function clearResults() { setStatus("Результаты очищены", "neutral"); } -async function copyResults() { - try { - const exportable = toExportableResults(state.results); - await navigator.clipboard.writeText(JSON.stringify(exportable, null, 2)); - setStatus("JSON скопирован", "success"); - } catch { - setStatus("Не удалось скопировать JSON", "error"); - } -} - elements.nav.forEach((button) => button.addEventListener("click", () => showView(button.dataset.view))); elements.addSource.addEventListener("click", addSource); elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value)); @@ -1378,7 +1338,6 @@ elements.testCalibration.addEventListener("click", testCalibration); elements.clearCalibration.addEventListener("click", clearCalibration); elements.recognizeAll.addEventListener("click", toggleCollection); elements.clearResults.addEventListener("click", clearResults); -elements.copyResults.addEventListener("click", copyResults); elements.canvas.addEventListener("pointerdown", (event) => { if (!state.referenceCanvas) return; diff --git a/src/parser.js b/src/parser.js index 1f51a53..533f3c2 100644 --- a/src/parser.js +++ b/src/parser.js @@ -196,3 +196,38 @@ export function toExportableResults(results) { items: result.items.map(({ key, ...item }) => item), })); } + +export function merchantGroups(results) { + const groups = new Map(); + const ensureGroup = (side, merchant, source) => { + if (!side || !merchant) return null; + const key = `${side}:${merchant.toLocaleLowerCase("en-US")}`; + const group = groups.get(key) ?? { key, side, merchant, sources: new Set(), items: new Map(), active: false }; + group.sources.add(source); + groups.set(key, group); + return group; + }; + + for (const result of results) { + if (result.saleFound) { + const active = ensureGroup(result.side, result.merchant, result.source); + if (active) active.active = true; + } + for (const item of result.items) { + const group = ensureGroup(item.side || result.side, item.merchant || result.merchant, result.source); + if (!group) continue; + const itemKey = item.itemId ? `id:${item.itemId}` : `name:${item.name.toLocaleLowerCase("en-US")}`; + const previous = group.items.get(itemKey); + if (!previous || (item.seenAt || "") >= (previous.seenAt || "")) group.items.set(itemKey, item); + } + } + + return [...groups.values()].map((group) => ({ + key: group.key, + side: group.side, + merchant: group.merchant, + sources: [...group.sources], + items: [...group.items.values()], + active: group.active, + })); +} diff --git a/src/style.css b/src/style.css index efb7e52..f5c4b0a 100644 --- a/src/style.css +++ b/src/style.css @@ -193,11 +193,16 @@ h3 { .step-nav { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; margin-block: 18px 34px; border-block-end: 1px solid var(--border); } +.step-nav__item--settings { + padding-inline: 22px; + font-size: 0.82rem; +} + .step-nav__item { display: flex; align-items: center; @@ -845,113 +850,17 @@ input[type="range"] { gap: 18px; } -.unique-lines { - overflow: hidden; - border: 1px solid var(--border-strong); - border-radius: 8px; - background: var(--surface); -} - -.unique-lines header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 20px; - border-block-end: 1px solid var(--border); -} - -.unique-lines h3 { - margin: 0; -} - -.unique-lines header span { - min-width: 28px; - padding: 4px 8px; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent); - font-size: 0.75rem; - font-weight: 750; - text-align: center; -} - -.unique-lines ol { - margin: 0; - padding: 0; - list-style-position: inside; -} - -.unique-lines li { - padding: 11px 20px; - border-block-end: 1px solid var(--border); - font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; - font-size: 0.82rem; - line-height: 1.5; -} - -.unique-lines li:last-child { - border-block-end: 0; -} - -.result-group { - overflow: hidden; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); -} - -.result-group > header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - padding: 18px 20px; - border-block-end: 1px solid var(--border); -} - -.result-group header h3 { - margin-block-end: 3px; -} - -.result-group header p { - margin: 0; - color: var(--text-muted); - font-size: 0.82rem; -} - -.result-badge { - padding: 6px 9px; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 700; -} - -.result-badge[data-tone="success"] { - background: var(--success-soft); - color: var(--success); -} - -.result-badge[data-tone="error"] { - background: var(--error-soft); - color: var(--error); -} - -.result-badge[data-tone="neutral"] { - background: var(--surface-muted); - color: var(--text-muted); -} - .result-error { margin: 0; padding: 22px 20px; color: var(--error); } -.merchant-list { +.merchant-board { display: grid; - gap: 12px; - padding: 16px; + grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); + align-items: start; + gap: 14px; } .merchant-card { @@ -961,6 +870,10 @@ input[type="range"] { background: var(--surface-raised); } +.merchant-card.is-active { + border-color: var(--success); +} + .merchant-card > header { display: flex; align-items: center; @@ -971,24 +884,77 @@ input[type="range"] { background: var(--surface-muted); } -.merchant-card h4 { +.merchant-card h3 { margin: 0; font-size: 0.92rem; } +.merchant-card header div { + min-width: 0; +} + +.merchant-card header small { + display: block; + margin-block-start: 3px; + overflow: hidden; + color: var(--text-muted); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + .merchant-card header span { color: var(--text-muted); font-size: 0.75rem; font-weight: 700; } -.market-item-grid { +.merchant-items { display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1px; background: var(--border); } +.merchant-waiting { + min-height: 68px; + margin: 0; + padding: 16px 13px; + background: var(--surface); + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.45; +} + +.source-diagnostics { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.source-diagnostics > summary { + padding: 13px 16px; + cursor: pointer; + color: var(--text-muted); + font-size: 0.82rem; + font-weight: 700; +} + +.source-diagnostics > div { + display: grid; + gap: 12px; + padding: 0 16px 16px; +} + +.source-diagnostics > div > section { + border: 1px solid var(--border); + border-radius: 7px; +} + +.source-diagnostics h3 { + margin: 0; + padding: 12px 16px; +} + .market-item { display: grid; grid-template-columns: 44px minmax(0, 1fr); @@ -1290,10 +1256,6 @@ td:nth-child(5) { padding: 12px; } - .result-group { - overflow-x: auto; - } - .stage-row { grid-template-columns: 11px 1fr; } diff --git a/src/vision.js b/src/vision.js index ef8ae7c..29cf932 100644 --- a/src/vision.js +++ b/src/vision.js @@ -32,6 +32,16 @@ export function isYellowTextPixel(red, green, blue, alpha = 255) { ); } +export function isColoredItemTextPixel(red, green, blue, alpha = 255) { + if (alpha < 128 || blue < 110 || Math.max(red, green, blue) - Math.min(red, green, blue) < 35) { + return false; + } + + const blueText = blue - red >= 25 && blue - green >= 15; + const purpleText = red >= 100 && Math.min(red, blue) - green >= 25; + return blueText || purpleText; +} + export function findTextRunEnd(columns, gap = TEXT_RUN_GAP) { let lastText = -1; let emptyColumns = 0; @@ -170,7 +180,7 @@ export function chooseUniqueBest(candidates, { threshold, margin }) { export function fitOcrTextRect( sourceCanvas, rect, - { includeYellow = false } = {}, + { includeYellow = false, includeItemColors = false } = {}, ) { const x = Math.round(rect.x); const y = Math.round(rect.y); @@ -195,7 +205,8 @@ export function fitOcrTextRect( const alpha = image.data[index + 3]; if ( isWhiteTextPixel(red, green, blue, alpha) || - (includeYellow && isYellowTextPixel(red, green, blue, alpha)) + (includeYellow && isYellowTextPixel(red, green, blue, alpha)) || + (includeItemColors && isColoredItemTextPixel(red, green, blue, alpha)) ) { columns[column] = 1; } @@ -293,7 +304,7 @@ export async function findAnchor(sourceCanvas, templateCanvas) { function prepareOcrCrop( sourceCanvas, rect, - { includeYellow = false, preprocess = true, scale = 3 } = {}, + { includeYellow = false, includeItemColors = false, preprocess = true, scale = 3 } = {}, ) { const canvas = document.createElement("canvas"); canvas.width = rect.width * scale; @@ -324,7 +335,8 @@ function prepareOcrCrop( const isLightPixel = preprocess === "luminance" && (red * 0.2126 + green * 0.7152 + blue * 0.0722) >= 105; const value = isLightPixel || isWhiteTextPixel(red, green, blue, alpha) || - (includeYellow && isYellowTextPixel(red, green, blue, alpha)) + (includeYellow && isYellowTextPixel(red, green, blue, alpha)) || + (includeItemColors && isColoredItemTextPixel(red, green, blue, alpha)) ? 0 : 255; image.data[index] = value; @@ -344,6 +356,7 @@ export async function recognizeText( singleLine = false, singleWord = false, includeYellow = false, + includeItemColors = false, preprocess = true, scale = 3, whitelist = "", @@ -357,7 +370,7 @@ export async function recognizeText( preserve_interword_spaces: "1", }); - const image = prepareOcrCrop(sourceCanvas, rect, { includeYellow, preprocess, scale }); + const image = prepareOcrCrop(sourceCanvas, rect, { includeYellow, includeItemColors, preprocess, scale }); const { data } = await worker.recognize(image); const text = singleLine ? normalizeOcrText(data.text) : data.text.trim(); @@ -378,7 +391,7 @@ export async function recognizeTextRows(sourceCanvas, rows) { canvas.height = bottom - top; const context = canvas.getContext("2d", { willReadFrequently: true }); - rows.forEach(({ rect, includeYellow = false }) => { + rows.forEach(({ rect, includeYellow = false, includeItemColors = false }) => { const targetX = rect.x - left; const targetY = rect.y - top; context.drawImage( @@ -393,16 +406,16 @@ export async function recognizeTextRows(sourceCanvas, rows) { rect.height, ); - if (!includeYellow) { + if (!includeYellow || !includeItemColors) { const image = context.getImageData(targetX, targetY, rect.width, rect.height); for (let index = 0; index < image.data.length; index += 4) { + const red = image.data[index]; + const green = image.data[index + 1]; + const blue = image.data[index + 2]; + const alpha = image.data[index + 3]; if ( - isYellowTextPixel( - image.data[index], - image.data[index + 1], - image.data[index + 2], - image.data[index + 3], - ) + (!includeYellow && isYellowTextPixel(red, green, blue, alpha)) || + (!includeItemColors && isColoredItemTextPixel(red, green, blue, alpha)) ) { image.data[index] = 0; image.data[index + 1] = 0; @@ -416,7 +429,10 @@ export async function recognizeTextRows(sourceCanvas, rows) { return recognizeText( canvas, { x: 0, y: 0, width: canvas.width, height: canvas.height }, - { includeYellow: rows.some((row) => row.includeYellow) }, + { + includeYellow: rows.some((row) => row.includeYellow), + includeItemColors: rows.some((row) => row.includeItemColors), + }, ); } diff --git a/test/parser.test.js b/test/parser.test.js index 13174b3..db09714 100644 --- a/test/parser.test.js +++ b/test/parser.test.js @@ -6,6 +6,7 @@ import { displayCaptureIssue, isCalibrationReady, isRectInside, + merchantGroups, parseFieldValue, parseStoreHeaderText, parseTooltipText, @@ -19,10 +20,16 @@ import { findTextRunEnd, fitOcrTextRect, occupiedSlotScore, + isColoredItemTextPixel, isWhiteTextPixel, isYellowTextPixel, } from "../src/vision.js"; -import { activateShop, recordSaleMiss, resetShopProgress } from "../src/frame-analyzer.js"; +import { + activateShop, + recordSaleMiss, + resetShopProgress, + shouldReadStoreHeader, +} from "../src/frame-analyzer.js"; test("keeps white text pixels and removes colored or dark pixels", () => { assert.equal(isWhiteTextPixel(220, 215, 195), true); @@ -37,6 +44,13 @@ test("keeps yellow tooltip prices without accepting brown UI pixels", () => { assert.equal(isYellowTextPixel(190, 120, 40), false); }); +test("keeps blue and purple item names without accepting brown UI pixels", () => { + assert.equal(isColoredItemTextPixel(80, 145, 230), true); + assert.equal(isColoredItemTextPixel(190, 90, 220), true); + assert.equal(isColoredItemTextPixel(220, 215, 195), false); + assert.equal(isColoredItemTextPixel(190, 120, 40), false); +}); + test("stops a tooltip text run before distant UI noise", () => { const columns = Array(48).fill(false); for (let index = 2; index <= 7; index += 1) columns[index] = true; @@ -130,6 +144,33 @@ test("fits a short row at the frame edge even when the saved width is wider", () ); }); +test("fits colored item-name rows only when item colors are enabled", () => { + const data = new Uint8ClampedArray(20 * 3 * 4); + for (let column = 0; column < 4; column += 1) { + const index = (20 + column) * 4; + data[index] = 190; + data[index + 1] = 90; + data[index + 2] = 220; + data[index + 3] = 255; + } + const canvas = { + width: 40, + height: 20, + getContext() { + return { getImageData: () => ({ data }) }; + }, + }; + const rect = { x: 20, y: 5, width: 50, height: 3 }; + + assert.equal(fitOcrTextRect(canvas, rect), null); + assert.deepEqual(fitOcrTextRect(canvas, rect, { includeItemColors: true }), { + x: 20, + y: 5, + width: 10, + height: 3, + }); +}); + test("keeps only letters, digits and spaces in OCR item names", () => { assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade"); assert.equal(cleanItemName("Кристалл № 12"), "Кристалл 12"); @@ -218,6 +259,32 @@ test("keeps transient grid images and slot state out of copied JSON", () => { assert.doesNotMatch(JSON.stringify(exported), /iconSlots|gridCaptured|thumbnail|internal/); }); +test("builds one merchant board and keeps the latest price per item", () => { + const groups = merchantGroups([ + { + source: "L2 window 1", + saleFound: true, + side: "sell", + merchant: "Dwa", + items: [ + { itemId: 1864, name: "Stem", side: "sell", merchant: "Dwa", priceAdena: 100, seenAt: "2026-08-12T10:00:00.000Z" }, + { itemId: 1864, name: "Stem", side: "sell", merchant: "Dwa", priceAdena: 120, seenAt: "2026-08-12T10:01:00.000Z" }, + ], + }, + { + source: "L2 window 2", + saleFound: false, + items: [{ itemId: 57, name: "Adena", side: "buy", merchant: "Gnumli", priceAdena: 1, seenAt: "2026-08-12T10:02:00.000Z" }], + }, + ]); + + assert.equal(groups.length, 2); + assert.equal(groups[0].merchant, "Dwa"); + assert.equal(groups[0].active, true); + assert.deepEqual(groups[0].items.map(({ priceAdena }) => priceAdena), [120]); + assert.equal(groups[1].merchant, "Gnumli"); +}); + test("resets only transient shop progress when a shop closes or changes", () => { const result = { activeShopKey: "sell:gnumli", @@ -264,6 +331,11 @@ test("keeps shop progress through two misses and clears it on the third", () => assert.equal(result.gridCaptured, false); }); +test("does not read a store header while a Price tooltip is visible", () => { + assert.equal(shouldReadStoreHeader(true), false); + assert.equal(shouldReadStoreHeader(false), true); +}); + test("parses a hovered item tooltip", () => { assert.deepEqual( parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),