Document merchant HUD and location capture plan
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса.
|
||||
- Вести пользователя по прямому пути: источник, калибровка, результат.
|
||||
- Вести пользователя по прямому пути: экран, парсинг, сохранённый черновик. Калибровка остаётся редкой настройкой.
|
||||
- Сохранять интерфейс компактным и предсказуемым при нескольких окнах.
|
||||
- Оставлять настраиваемыми только параметры, которые зависят от реального изображения.
|
||||
|
||||
|
||||
@@ -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 он не попадает.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
+10
-11
@@ -28,13 +28,13 @@
|
||||
|
||||
<nav class="step-nav" aria-label="Разделы">
|
||||
<button class="step-nav__item is-active" type="button" data-view="sources" aria-current="page">
|
||||
<span>1</span> Источники
|
||||
</button>
|
||||
<button class="step-nav__item" type="button" data-view="calibration">
|
||||
<span>2</span> Калибровка
|
||||
<span>1</span> Экран
|
||||
</button>
|
||||
<button class="step-nav__item" type="button" data-view="results">
|
||||
<span>3</span> Результат
|
||||
<span>2</span> Парсинг
|
||||
</button>
|
||||
<button class="step-nav__item step-nav__item--settings" type="button" data-view="calibration">
|
||||
Калибровка
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Захват экрана</p>
|
||||
<h2 id="sources-title">Окна игры</h2>
|
||||
<p>Добавь каждое окно отдельно. Браузер спросит, какое окно разрешено захватывать.</p>
|
||||
<h2 id="sources-title">Выбери экран игры</h2>
|
||||
<p>Браузер спросит, какое окно разрешено захватывать. После выбора откроется экран парсинга.</p>
|
||||
</div>
|
||||
<button id="add-source" class="button button--primary" type="button">Добавить окно</button>
|
||||
</div>
|
||||
@@ -155,13 +155,12 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Проверка пайплайна</p>
|
||||
<h2 id="results-title">Собранные предметы</h2>
|
||||
<p>Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.</p>
|
||||
<h2 id="results-title">Торговцы</h2>
|
||||
<p>Запусти парсинг и наведи мышь на товары. Каждый торговец появится отдельным блоком, данные сохранятся автоматически.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="copy-results" class="button" type="button" disabled>Копировать JSON</button>
|
||||
<button id="clear-results" class="button button--quiet" type="button">Очистить</button>
|
||||
<button id="recognize-all" class="button button--primary" type="button">Начать сбор</button>
|
||||
<button id="recognize-all" class="button button--primary" type="button">Начать парсинг</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="results-list" class="results-list"></div>
|
||||
|
||||
+40
-14
@@ -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),
|
||||
|
||||
+83
-124
@@ -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,101 +1007,79 @@ 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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
output.append(header, list);
|
||||
elements.resultsList.append(output);
|
||||
} else {
|
||||
const board = document.createElement("div");
|
||||
board.className = "merchant-board";
|
||||
groups.forEach((group) => board.append(renderMerchantCard(group)));
|
||||
elements.resultsList.append(board);
|
||||
}
|
||||
|
||||
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 section = document.createElement("section");
|
||||
section.className = "result-group";
|
||||
const header = document.createElement("header");
|
||||
const heading = document.createElement("div");
|
||||
const source = document.createElement("section");
|
||||
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);
|
||||
|
||||
source.append(title);
|
||||
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);
|
||||
source.append(error);
|
||||
}
|
||||
appendDiagnostics(source, result);
|
||||
content.append(source);
|
||||
});
|
||||
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) => {
|
||||
diagnostics.append(summary, content);
|
||||
elements.resultsList.append(diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -1113,29 +1094,12 @@ function renderResults() {
|
||||
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")} шт.`}`;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
+70
-108
@@ -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;
|
||||
}
|
||||
|
||||
+30
-14
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+73
-1
@@ -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)"),
|
||||
|
||||
Reference in New Issue
Block a user