Document merchant HUD and location capture plan
This commit is contained in:
+2
-2
@@ -10,7 +10,7 @@ product
|
|||||||
|
|
||||||
## Product Purpose
|
## Product Purpose
|
||||||
|
|
||||||
Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Каждое распознавание сохраняется с датой и периодически отправляется в home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows.
|
Приложение подключает одно или несколько выбранных окон, подтверждает открытое торговое окно по его нижнему элементу, читает тип сделки и имя торговца из заголовка магазина и накапливает названия и цены из tooltip при ручном наведении на предметы. Основной экран показывает компактные блоки торговцев, а каждое новое распознавание сохраняется с датой и сразу отправляется в черновик рынка home-service. Успех прототипа означает, что весь путь от выбора окна до видимого и сохранённого результата работает локально на macOS и Windows.
|
||||||
|
|
||||||
## Brand Personality
|
## Brand Personality
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ product
|
|||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса.
|
- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса.
|
||||||
- Вести пользователя по прямому пути: источник, калибровка, результат.
|
- Вести пользователя по прямому пути: экран, парсинг, сохранённый черновик. Калибровка остаётся редкой настройкой.
|
||||||
- Сохранять интерфейс компактным и предсказуемым при нескольких окнах.
|
- Сохранять интерфейс компактным и предсказуемым при нескольких окнах.
|
||||||
- Оставлять настраиваемыми только параметры, которые зависят от реального изображения.
|
- Оставлять настраиваемыми только параметры, которые зависят от реального изображения.
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ npm run dev
|
|||||||
└─ да → OCR точной строки названия и точной строки цены
|
└─ да → 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"`.
|
Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`.
|
||||||
|
|
||||||
@@ -55,18 +55,13 @@ npm run dev
|
|||||||
|
|
||||||
## Сценарий
|
## Сценарий
|
||||||
|
|
||||||
1. В разделе «Источники» нажми «Добавить окно» для каждого окна игры.
|
1. В разделе «Экран» нажми «Добавить окно» и выбери окно игры. Приложение сразу откроет «Парсинг».
|
||||||
2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот.
|
2. Нажми «Начать парсинг» и вернись в игру.
|
||||||
3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа.
|
3. Сначала оставь курсор вне предметов, пока не появится блок торговца с типом сделки и именем.
|
||||||
4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай.
|
4. Наводи курсор на каждый предмет. Товары заполняют блок торговца, следующий торговец появляется справа или на новой строке.
|
||||||
5. Выдели широкую горизонтальную полосу всех возможных положений tooltip. Она может выходить далеко за пределы торгового окна, но не должна захватывать нижний `Price` основного окна.
|
5. Калибровку открывай как дополнительную настройку, если текущая общая конфигурация не распознаёт окно или tooltip.
|
||||||
6. Внутри tooltip обведи только постоянную надпись `Price :`.
|
|
||||||
7. Отдельно выдели левый край, высоту и начальную ширину строки названия и строки цены. Runtime сам продлит каждую строку вправо до фактического конца текста.
|
|
||||||
8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру.
|
|
||||||
9. Сначала оставь курсор вне предметов, пока в результате не появятся тип сделки и имя торговца.
|
|
||||||
10. Наводи курсор на каждый предмет. Даже если tooltip закроет заголовок, предмет будет записан в последний найденный магазин.
|
|
||||||
|
|
||||||
Наблюдения сначала попадают в IndexedDB браузера, затем отправляются пакетами раз в 30 секунд. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы; сервер не создаёт дубликат.
|
Новое наблюдение сначала попадает в IndexedDB браузера и сразу отправляется на сервер; 30-секундный повтор остаётся страховкой. При недоступном сервере пакет остаётся локально и повторяется с тем же ID после восстановления связи или перезапуска страницы. Сервер хранит исходный пакет без дублей и для распознанных каталожных предметов обновляет отдельный черновик рынка: `Sell` становится `sell_offer`, `Buy` становится `buy_order`. Неопознанные товары остаются только в исходном пакете.
|
||||||
|
|
||||||
Калибровку можно сделать по снимкам разного размера и с торговым окном в разных местах:
|
Калибровку можно сделать по снимкам разного размера и с торговым окном в разных местах:
|
||||||
|
|
||||||
@@ -78,7 +73,7 @@ npm run dev
|
|||||||
|
|
||||||
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно.
|
После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно.
|
||||||
|
|
||||||
Над подробными результатами выводятся уникальные строки вида `Покупка · Lui · Animal Skin · 18 шт. · 400 Adena`. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации торговца, типа сделки, предмета, количества и цены не создаёт новую строку.
|
Результат состоит только из блоков торговцев. В каждом блоке показаны тип сделки и последние распознанные цена/количество каждого предмета. Количество читается из числовых скобок в конце названия, например `(18)` или `(5,600)`; если их нет, используется `1`. Повторное распознавание той же комбинации не создаёт новую запись.
|
||||||
|
|
||||||
Во вкладке приложения OCR-название проходит неточный поиск по активному каталогу. Уверенное совпадение заменяется каноническим названием и показывается с иконкой предмета; слабое совпадение остаётся с вопросительным знаком и пометкой «Не найден в каталоге». Предметы группируются по имени торговца и типу сделки.
|
Во вкладке приложения OCR-название проходит неточный поиск по активному каталогу. Уверенное совпадение заменяется каноническим названием и показывается с иконкой предмета; слабое совпадение остаётся с вопросительным знаком и пометкой «Не найден в каталоге». Предметы группируются по имени торговца и типу сделки.
|
||||||
|
|
||||||
@@ -120,7 +115,7 @@ npm run dev
|
|||||||
|
|
||||||
- Источники выбираются вручную после каждого обновления страницы.
|
- Источники выбираются вручную после каждого обновления страницы.
|
||||||
- Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
|
- Одна калибровка рассчитана на один масштаб интерфейса Lineage II.
|
||||||
- Сбор работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить сбор».
|
- Парсинг работает, пока открыта страница прототипа и он не остановлен кнопкой «Остановить парсинг».
|
||||||
- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
|
- Курсор перемещает пользователь, приложение не управляет игрой и не эмулирует ввод.
|
||||||
- Токен импорта хранится только в локальном Vite-процессе; в браузерный bundle он не попадает.
|
- Токен импорта хранится только в локальном 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="Разделы">
|
<nav class="step-nav" aria-label="Разделы">
|
||||||
<button class="step-nav__item is-active" type="button" data-view="sources" aria-current="page">
|
<button class="step-nav__item is-active" type="button" data-view="sources" aria-current="page">
|
||||||
<span>1</span> Источники
|
<span>1</span> Экран
|
||||||
</button>
|
|
||||||
<button class="step-nav__item" type="button" data-view="calibration">
|
|
||||||
<span>2</span> Калибровка
|
|
||||||
</button>
|
</button>
|
||||||
<button class="step-nav__item" type="button" data-view="results">
|
<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>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Захват экрана</p>
|
<p class="eyebrow">Захват экрана</p>
|
||||||
<h2 id="sources-title">Окна игры</h2>
|
<h2 id="sources-title">Выбери экран игры</h2>
|
||||||
<p>Добавь каждое окно отдельно. Браузер спросит, какое окно разрешено захватывать.</p>
|
<p>Браузер спросит, какое окно разрешено захватывать. После выбора откроется экран парсинга.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="add-source" class="button button--primary" type="button">Добавить окно</button>
|
<button id="add-source" class="button button--primary" type="button">Добавить окно</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,13 +155,12 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Проверка пайплайна</p>
|
<p class="eyebrow">Проверка пайплайна</p>
|
||||||
<h2 id="results-title">Собранные предметы</h2>
|
<h2 id="results-title">Торговцы</h2>
|
||||||
<p>Запусти сбор и наведи мышь на предметы в игре. Каждый новый tooltip будет добавлен в таблицу.</p>
|
<p>Запусти парсинг и наведи мышь на товары. Каждый торговец появится отдельным блоком, данные сохранятся автоматически.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<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="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>
|
</div>
|
||||||
<div id="results-list" class="results-list"></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);
|
if (result.misses >= 3) clearRememberedShop(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldReadStoreHeader(tooltipFound) {
|
||||||
|
return !tooltipFound;
|
||||||
|
}
|
||||||
|
|
||||||
async function markMatchedSlot(result, item, key) {
|
async function markMatchedSlot(result, item, key) {
|
||||||
if (!item.iconUrl || !result.iconSlots?.some((slot) => slot.status === "pending")) return null;
|
if (!item.iconUrl || !result.iconSlots?.some((slot) => slot.status === "pending")) return null;
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +231,7 @@ export async function analyzeFrame(
|
|||||||
resolveCatalogItem = async (item) => item,
|
resolveCatalogItem = async (item) => item,
|
||||||
catalogAlternatives = false,
|
catalogAlternatives = false,
|
||||||
enqueueObservation = null,
|
enqueueObservation = null,
|
||||||
|
marketSnapshotId = null,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const diagnostics = { boxes: [], stages: [], preview: null };
|
const diagnostics = { boxes: [], stages: [], preview: null };
|
||||||
@@ -288,7 +293,36 @@ export async function analyzeFrame(
|
|||||||
diagnostics.boxes.push(storeHeaderBox);
|
diagnostics.boxes.push(storeHeaderBox);
|
||||||
diagnostics.boxes.push({ rect: tooltipSearchRect, label: "Зона tooltip", status: "info" });
|
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, {
|
const headerOcr = await recognizeText(frame, storeHeaderRect, {
|
||||||
singleLine: true,
|
singleLine: true,
|
||||||
preprocess: "luminance",
|
preprocess: "luminance",
|
||||||
@@ -335,7 +369,7 @@ export async function analyzeFrame(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRectInside(tooltipSearchRect, frame.width, frame.height)) {
|
if (!tooltipSearchInside) {
|
||||||
addDiagnosticStage(
|
addDiagnosticStage(
|
||||||
diagnostics,
|
diagnostics,
|
||||||
"tooltip",
|
"tooltip",
|
||||||
@@ -355,15 +389,6 @@ export async function analyzeFrame(
|
|||||||
return null;
|
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({
|
diagnostics.boxes.push({
|
||||||
rect: tooltipRect,
|
rect: tooltipRect,
|
||||||
label: "Price :",
|
label: "Price :",
|
||||||
@@ -416,7 +441,7 @@ export async function analyzeFrame(
|
|||||||
};
|
};
|
||||||
diagnostics.boxes.push(itemBox);
|
diagnostics.boxes.push(itemBox);
|
||||||
|
|
||||||
const nameRect = fitOcrTextRect(frame, calibratedNameRect);
|
const nameRect = fitOcrTextRect(frame, calibratedNameRect, { includeItemColors: true });
|
||||||
const priceRect = fitOcrTextRect(frame, calibratedPriceRect, { includeYellow: true });
|
const priceRect = fitOcrTextRect(frame, calibratedPriceRect, { includeYellow: true });
|
||||||
if (!nameRect || !priceRect) {
|
if (!nameRect || !priceRect) {
|
||||||
itemBox.status = "not-found";
|
itemBox.status = "not-found";
|
||||||
@@ -443,7 +468,7 @@ export async function analyzeFrame(
|
|||||||
const itemRect = combineRects(nameRect, priceRect);
|
const itemRect = combineRects(nameRect, priceRect);
|
||||||
itemBox.rect = itemRect;
|
itemBox.rect = itemRect;
|
||||||
const itemOcr = await recognizeTextRows(frame, [
|
const itemOcr = await recognizeTextRows(frame, [
|
||||||
{ rect: nameRect },
|
{ rect: nameRect, includeItemColors: true },
|
||||||
{ rect: priceRect, includeYellow: true },
|
{ rect: priceRect, includeYellow: true },
|
||||||
]);
|
]);
|
||||||
let item = parseTooltipText(itemOcr.text);
|
let item = parseTooltipText(itemOcr.text);
|
||||||
@@ -497,9 +522,10 @@ export async function analyzeFrame(
|
|||||||
const seenAt = new Date().toISOString();
|
const seenAt = new Date().toISOString();
|
||||||
let matchedSlotId = null;
|
let matchedSlotId = null;
|
||||||
|
|
||||||
if (source && enqueueObservation) {
|
if (!existing && source && enqueueObservation) {
|
||||||
await enqueueObservation({
|
await enqueueObservation({
|
||||||
observationId: crypto.randomUUID(),
|
observationId: crypto.randomUUID(),
|
||||||
|
...(marketSnapshotId ? { marketSnapshotId } : {}),
|
||||||
seenAt,
|
seenAt,
|
||||||
sourceId: source.id,
|
sourceId: source.id,
|
||||||
source: source.name.slice(0, 256),
|
source: source.name.slice(0, 256),
|
||||||
|
|||||||
+83
-124
@@ -3,9 +3,9 @@ import {
|
|||||||
displayCaptureIssue,
|
displayCaptureIssue,
|
||||||
isCalibrationReady,
|
isCalibrationReady,
|
||||||
isRectInside,
|
isRectInside,
|
||||||
|
merchantGroups,
|
||||||
rectFromPoints,
|
rectFromPoints,
|
||||||
resolveFieldRect,
|
resolveFieldRect,
|
||||||
toExportableResults,
|
|
||||||
} from "./parser.js";
|
} from "./parser.js";
|
||||||
import { findAnchor, onOcrProgress, terminateVision } from "./vision.js";
|
import { findAnchor, onOcrProgress, terminateVision } from "./vision.js";
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +73,7 @@ const state = {
|
|||||||
drawing: null,
|
drawing: null,
|
||||||
results: [],
|
results: [],
|
||||||
collecting: false,
|
collecting: false,
|
||||||
|
marketSnapshotId: null,
|
||||||
};
|
};
|
||||||
const marketOutbox = createMarketOutbox(appHosted ? "/api/l2/market/import" : undefined);
|
const marketOutbox = createMarketOutbox(appHosted ? "/api/l2/market/import" : undefined);
|
||||||
|
|
||||||
@@ -105,7 +106,6 @@ const elements = {
|
|||||||
clearCalibration: document.querySelector("#clear-calibration"),
|
clearCalibration: document.querySelector("#clear-calibration"),
|
||||||
recognizeAll: document.querySelector("#recognize-all"),
|
recognizeAll: document.querySelector("#recognize-all"),
|
||||||
clearResults: document.querySelector("#clear-results"),
|
clearResults: document.querySelector("#clear-results"),
|
||||||
copyResults: document.querySelector("#copy-results"),
|
|
||||||
resultsList: document.querySelector("#results-list"),
|
resultsList: document.querySelector("#results-list"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,7 +213,8 @@ async function addSource() {
|
|||||||
state.sources.push(source);
|
state.sources.push(source);
|
||||||
state.selectedSourceId = source.id;
|
state.selectedSourceId = source.id;
|
||||||
renderSources();
|
renderSources();
|
||||||
setStatus(`Добавлено: ${source.name}`, "success");
|
showView("results");
|
||||||
|
setStatus(`Экран выбран: ${source.name}`, "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === "NotAllowedError") {
|
if (error.name === "NotAllowedError") {
|
||||||
setStatus("Захват отменён или запрещён. Проверь разрешение браузера на запись экрана", "error");
|
setStatus("Захват отменён или запрещён. Проверь разрешение браузера на запись экрана", "error");
|
||||||
@@ -332,7 +333,7 @@ function renderSources() {
|
|||||||
const calibrate = document.createElement("button");
|
const calibrate = document.createElement("button");
|
||||||
calibrate.className = "button";
|
calibrate.className = "button";
|
||||||
calibrate.type = "button";
|
calibrate.type = "button";
|
||||||
calibrate.textContent = "Калибровать";
|
calibrate.textContent = "Настроить калибровку";
|
||||||
calibrate.disabled = !source.active;
|
calibrate.disabled = !source.active;
|
||||||
calibrate.addEventListener("click", () => {
|
calibrate.addEventListener("click", () => {
|
||||||
chooseSource(source.id);
|
chooseSource(source.id);
|
||||||
@@ -341,9 +342,12 @@ function renderSources() {
|
|||||||
const select = document.createElement("button");
|
const select = document.createElement("button");
|
||||||
select.className = "button button--quiet";
|
select.className = "button button--quiet";
|
||||||
select.type = "button";
|
select.type = "button";
|
||||||
select.textContent = source.id === state.selectedSourceId ? "Выбрано" : "Выбрать";
|
select.textContent = source.id === state.selectedSourceId ? "Открыть парсинг" : "Парсить этот экран";
|
||||||
select.disabled = !source.active || source.id === state.selectedSourceId;
|
select.disabled = !source.active;
|
||||||
select.addEventListener("click", () => chooseSource(source.id));
|
select.addEventListener("click", () => {
|
||||||
|
chooseSource(source.id);
|
||||||
|
showView("results");
|
||||||
|
});
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.className = "button button--quiet";
|
remove.className = "button button--quiet";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
@@ -926,7 +930,7 @@ async function loadSharedCalibration() {
|
|||||||
|
|
||||||
function clearCalibration() {
|
function clearCalibration() {
|
||||||
state.collecting = false;
|
state.collecting = false;
|
||||||
elements.recognizeAll.textContent = "Начать сбор";
|
elements.recognizeAll.textContent = "Начать парсинг";
|
||||||
state.calibration = emptyCalibration();
|
state.calibration = emptyCalibration();
|
||||||
state.references.forEach((reference) => {
|
state.references.forEach((reference) => {
|
||||||
reference.saleAnchor = null;
|
reference.saleAnchor = null;
|
||||||
@@ -945,7 +949,6 @@ function appendDiagnostics(section, result) {
|
|||||||
|
|
||||||
const details = document.createElement("details");
|
const details = document.createElement("details");
|
||||||
details.className = "diagnostics";
|
details.className = "diagnostics";
|
||||||
details.open = true;
|
|
||||||
const successful = result.diagnostics.stages.filter((stage) => stage.status === "found").length;
|
const successful = result.diagnostics.stages.filter((stage) => stage.status === "found").length;
|
||||||
const summary = document.createElement("summary");
|
const summary = document.createElement("summary");
|
||||||
summary.textContent = `Диагностика последнего кадра: ${successful}/${result.diagnostics.stages.length} этапов выполнено`;
|
summary.textContent = `Диагностика последнего кадра: ${successful}/${result.diagnostics.stages.length} этапов выполнено`;
|
||||||
@@ -1004,101 +1007,79 @@ function appendDiagnostics(section, result) {
|
|||||||
|
|
||||||
function renderResults() {
|
function renderResults() {
|
||||||
elements.resultsList.replaceChildren();
|
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");
|
const empty = document.createElement("div");
|
||||||
empty.className = "empty-state empty-state--wide";
|
empty.className = "empty-state empty-state--wide";
|
||||||
const title = document.createElement("strong");
|
const title = document.createElement("strong");
|
||||||
title.textContent = "Результатов пока нет";
|
title.textContent = state.collecting ? "Ищу первого торговца" : "Торговцев пока нет";
|
||||||
const text = document.createElement("span");
|
const text = document.createElement("span");
|
||||||
text.textContent = "Запусти сбор, вернись в игру и наведи курсор на предмет.";
|
text.textContent = state.collecting
|
||||||
|
? "Убери курсор с товара, чтобы сначала прочитать имя и тип магазина."
|
||||||
|
: "Начни парсинг, вернись в игру и открой магазин торговца.";
|
||||||
empty.append(title, text);
|
empty.append(title, text);
|
||||||
elements.resultsList.append(empty);
|
elements.resultsList.append(empty);
|
||||||
return;
|
} else {
|
||||||
}
|
const board = document.createElement("div");
|
||||||
|
board.className = "merchant-board";
|
||||||
const uniqueLines = [
|
groups.forEach((group) => board.append(renderMerchantCard(group)));
|
||||||
...new Set(
|
elements.resultsList.append(board);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
state.results.forEach((result) => {
|
||||||
const section = document.createElement("section");
|
const source = document.createElement("section");
|
||||||
section.className = "result-group";
|
|
||||||
const header = document.createElement("header");
|
|
||||||
const heading = document.createElement("div");
|
|
||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.textContent = result.source;
|
title.textContent = result.source;
|
||||||
const meta = document.createElement("p");
|
source.append(title);
|
||||||
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);
|
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
error.className = "result-error";
|
error.className = "result-error";
|
||||||
error.textContent = result.error;
|
error.textContent = result.error;
|
||||||
section.append(error);
|
source.append(error);
|
||||||
} else if (result.items.length) {
|
}
|
||||||
const shops = new Map();
|
appendDiagnostics(source, result);
|
||||||
result.items.forEach((item) => {
|
content.append(source);
|
||||||
const shopKey = `${item.side}:${item.merchant}`;
|
|
||||||
if (!shops.has(shopKey)) shops.set(shopKey, []);
|
|
||||||
shops.get(shopKey).push(item);
|
|
||||||
});
|
});
|
||||||
const shopList = document.createElement("div");
|
diagnostics.append(summary, content);
|
||||||
shopList.className = "merchant-list";
|
elements.resultsList.append(diagnostics);
|
||||||
shops.forEach((items) => {
|
}
|
||||||
const shop = document.createElement("section");
|
}
|
||||||
shop.className = "merchant-card";
|
|
||||||
const shopHeader = document.createElement("header");
|
function renderMerchantCard(group) {
|
||||||
const shopTitle = document.createElement("h4");
|
const card = document.createElement("section");
|
||||||
shopTitle.textContent = items[0].merchant;
|
card.className = "merchant-card";
|
||||||
const shopMeta = document.createElement("span");
|
if (group.active) card.classList.add("is-active");
|
||||||
shopMeta.textContent = `${items[0].side === "buy" ? "Покупает" : "Продаёт"} · ${items.length} поз.`;
|
const header = document.createElement("header");
|
||||||
shopHeader.append(shopTitle, shopMeta);
|
const heading = document.createElement("div");
|
||||||
const itemGrid = document.createElement("div");
|
const title = document.createElement("h3");
|
||||||
itemGrid.className = "market-item-grid";
|
title.textContent = group.merchant;
|
||||||
items.forEach((item) => {
|
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");
|
const tile = document.createElement("article");
|
||||||
tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`;
|
tile.className = `market-item${item.itemId ? "" : " is-unmatched"}`;
|
||||||
const icon = item.iconUrl ? document.createElement("img") : document.createElement("span");
|
const icon = item.iconUrl ? document.createElement("img") : document.createElement("span");
|
||||||
@@ -1113,29 +1094,12 @@ function renderResults() {
|
|||||||
const name = document.createElement("strong");
|
const name = document.createElement("strong");
|
||||||
name.textContent = item.name;
|
name.textContent = item.name;
|
||||||
const price = document.createElement("span");
|
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");
|
const status = document.createElement("small");
|
||||||
status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`;
|
status.textContent = item.itemId ? `ID ${item.itemId} · OCR ${item.confidence}%` : `Не найден в каталоге · OCR ${item.confidence}%`;
|
||||||
copy.append(name, price, status);
|
copy.append(name, price, status);
|
||||||
tile.append(icon, copy);
|
tile.append(icon, copy);
|
||||||
itemGrid.append(tile);
|
return 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 resultFor(source) {
|
function resultFor(source) {
|
||||||
@@ -1172,6 +1136,7 @@ async function analyzeCurrentFrame(frame, result, saleTemplate, tooltipTemplate,
|
|||||||
source,
|
source,
|
||||||
resolveCatalogItem,
|
resolveCatalogItem,
|
||||||
catalogAlternatives: appHosted,
|
catalogAlternatives: appHosted,
|
||||||
|
marketSnapshotId: state.marketSnapshotId,
|
||||||
enqueueObservation: source
|
enqueueObservation: source
|
||||||
? async (observation) => {
|
? async (observation) => {
|
||||||
try {
|
try {
|
||||||
@@ -1183,7 +1148,10 @@ async function analyzeCurrentFrame(frame, result, saleTemplate, tooltipTemplate,
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
if (outcome?.added) setStatus(`Добавлен предмет: ${outcome.item.name}`, "success");
|
if (outcome?.added) {
|
||||||
|
setStatus(`Добавлен предмет: ${outcome.item.name}`, "success");
|
||||||
|
void syncMarketOutbox();
|
||||||
|
}
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,7 +1199,7 @@ async function testCalibration() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.collecting = false;
|
state.collecting = false;
|
||||||
elements.recognizeAll.textContent = "Начать сбор";
|
elements.recognizeAll.textContent = "Начать парсинг";
|
||||||
elements.testCalibration.disabled = true;
|
elements.testCalibration.disabled = true;
|
||||||
setStatus(`Проверяю снимки: 0/${state.references.length}`, "working");
|
setStatus(`Проверяю снимки: 0/${state.references.length}`, "working");
|
||||||
let result;
|
let result;
|
||||||
@@ -1278,8 +1246,8 @@ async function testCalibration() {
|
|||||||
async function toggleCollection() {
|
async function toggleCollection() {
|
||||||
if (state.collecting) {
|
if (state.collecting) {
|
||||||
state.collecting = false;
|
state.collecting = false;
|
||||||
elements.recognizeAll.textContent = "Начать сбор";
|
elements.recognizeAll.textContent = "Начать парсинг";
|
||||||
setStatus("Сбор остановлен", "neutral");
|
setStatus("Парсинг остановлен", "neutral");
|
||||||
void syncMarketOutbox();
|
void syncMarketOutbox();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1295,8 +1263,10 @@ async function toggleCollection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.collecting = true;
|
state.collecting = true;
|
||||||
elements.recognizeAll.textContent = "Остановить сбор";
|
state.marketSnapshotId = crypto.randomUUID();
|
||||||
setStatus("Через 2 секунды начнётся сбор. Вернись в игру", "working");
|
elements.recognizeAll.textContent = "Остановить парсинг";
|
||||||
|
setStatus("Через 2 секунды начнётся парсинг. Вернись в игру", "working");
|
||||||
|
renderResults();
|
||||||
|
|
||||||
onOcrProgress((progress) => {
|
onOcrProgress((progress) => {
|
||||||
if (progress.status === "recognizing text") {
|
if (progress.status === "recognizing text") {
|
||||||
@@ -1322,7 +1292,7 @@ async function toggleCollection() {
|
|||||||
setStatus(error.message || "Ошибка распознавания", "error");
|
setStatus(error.message || "Ошибка распознавания", "error");
|
||||||
} finally {
|
} finally {
|
||||||
state.collecting = false;
|
state.collecting = false;
|
||||||
elements.recognizeAll.textContent = "Начать сбор";
|
elements.recognizeAll.textContent = "Начать парсинг";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,16 +1313,6 @@ function clearResults() {
|
|||||||
setStatus("Результаты очищены", "neutral");
|
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.nav.forEach((button) => button.addEventListener("click", () => showView(button.dataset.view)));
|
||||||
elements.addSource.addEventListener("click", addSource);
|
elements.addSource.addEventListener("click", addSource);
|
||||||
elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value));
|
elements.calibrationSource.addEventListener("change", (event) => chooseSource(event.target.value));
|
||||||
@@ -1378,7 +1338,6 @@ elements.testCalibration.addEventListener("click", testCalibration);
|
|||||||
elements.clearCalibration.addEventListener("click", clearCalibration);
|
elements.clearCalibration.addEventListener("click", clearCalibration);
|
||||||
elements.recognizeAll.addEventListener("click", toggleCollection);
|
elements.recognizeAll.addEventListener("click", toggleCollection);
|
||||||
elements.clearResults.addEventListener("click", clearResults);
|
elements.clearResults.addEventListener("click", clearResults);
|
||||||
elements.copyResults.addEventListener("click", copyResults);
|
|
||||||
|
|
||||||
elements.canvas.addEventListener("pointerdown", (event) => {
|
elements.canvas.addEventListener("pointerdown", (event) => {
|
||||||
if (!state.referenceCanvas) return;
|
if (!state.referenceCanvas) return;
|
||||||
|
|||||||
@@ -196,3 +196,38 @@ export function toExportableResults(results) {
|
|||||||
items: result.items.map(({ key, ...item }) => item),
|
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 {
|
.step-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
|
||||||
margin-block: 18px 34px;
|
margin-block: 18px 34px;
|
||||||
border-block-end: 1px solid var(--border);
|
border-block-end: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.step-nav__item--settings {
|
||||||
|
padding-inline: 22px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.step-nav__item {
|
.step-nav__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -845,113 +850,17 @@ input[type="range"] {
|
|||||||
gap: 18px;
|
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 {
|
.result-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 22px 20px;
|
padding: 22px 20px;
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.merchant-list {
|
.merchant-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr));
|
||||||
padding: 16px;
|
align-items: start;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.merchant-card {
|
.merchant-card {
|
||||||
@@ -961,6 +870,10 @@ input[type="range"] {
|
|||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.merchant-card.is-active {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.merchant-card > header {
|
.merchant-card > header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -971,24 +884,77 @@ input[type="range"] {
|
|||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.merchant-card h4 {
|
.merchant-card h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.92rem;
|
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 {
|
.merchant-card header span {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-item-grid {
|
.merchant-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border);
|
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 {
|
.market-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 44px minmax(0, 1fr);
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
@@ -1290,10 +1256,6 @@ td:nth-child(5) {
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-group {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stage-row {
|
.stage-row {
|
||||||
grid-template-columns: 11px 1fr;
|
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) {
|
export function findTextRunEnd(columns, gap = TEXT_RUN_GAP) {
|
||||||
let lastText = -1;
|
let lastText = -1;
|
||||||
let emptyColumns = 0;
|
let emptyColumns = 0;
|
||||||
@@ -170,7 +180,7 @@ export function chooseUniqueBest(candidates, { threshold, margin }) {
|
|||||||
export function fitOcrTextRect(
|
export function fitOcrTextRect(
|
||||||
sourceCanvas,
|
sourceCanvas,
|
||||||
rect,
|
rect,
|
||||||
{ includeYellow = false } = {},
|
{ includeYellow = false, includeItemColors = false } = {},
|
||||||
) {
|
) {
|
||||||
const x = Math.round(rect.x);
|
const x = Math.round(rect.x);
|
||||||
const y = Math.round(rect.y);
|
const y = Math.round(rect.y);
|
||||||
@@ -195,7 +205,8 @@ export function fitOcrTextRect(
|
|||||||
const alpha = image.data[index + 3];
|
const alpha = image.data[index + 3];
|
||||||
if (
|
if (
|
||||||
isWhiteTextPixel(red, green, blue, alpha) ||
|
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;
|
columns[column] = 1;
|
||||||
}
|
}
|
||||||
@@ -293,7 +304,7 @@ export async function findAnchor(sourceCanvas, templateCanvas) {
|
|||||||
function prepareOcrCrop(
|
function prepareOcrCrop(
|
||||||
sourceCanvas,
|
sourceCanvas,
|
||||||
rect,
|
rect,
|
||||||
{ includeYellow = false, preprocess = true, scale = 3 } = {},
|
{ includeYellow = false, includeItemColors = false, preprocess = true, scale = 3 } = {},
|
||||||
) {
|
) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = rect.width * scale;
|
canvas.width = rect.width * scale;
|
||||||
@@ -324,7 +335,8 @@ function prepareOcrCrop(
|
|||||||
const isLightPixel =
|
const isLightPixel =
|
||||||
preprocess === "luminance" && (red * 0.2126 + green * 0.7152 + blue * 0.0722) >= 105;
|
preprocess === "luminance" && (red * 0.2126 + green * 0.7152 + blue * 0.0722) >= 105;
|
||||||
const value = isLightPixel || isWhiteTextPixel(red, green, blue, alpha) ||
|
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
|
? 0
|
||||||
: 255;
|
: 255;
|
||||||
image.data[index] = value;
|
image.data[index] = value;
|
||||||
@@ -344,6 +356,7 @@ export async function recognizeText(
|
|||||||
singleLine = false,
|
singleLine = false,
|
||||||
singleWord = false,
|
singleWord = false,
|
||||||
includeYellow = false,
|
includeYellow = false,
|
||||||
|
includeItemColors = false,
|
||||||
preprocess = true,
|
preprocess = true,
|
||||||
scale = 3,
|
scale = 3,
|
||||||
whitelist = "",
|
whitelist = "",
|
||||||
@@ -357,7 +370,7 @@ export async function recognizeText(
|
|||||||
preserve_interword_spaces: "1",
|
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 { data } = await worker.recognize(image);
|
||||||
const text = singleLine ? normalizeOcrText(data.text) : data.text.trim();
|
const text = singleLine ? normalizeOcrText(data.text) : data.text.trim();
|
||||||
|
|
||||||
@@ -378,7 +391,7 @@ export async function recognizeTextRows(sourceCanvas, rows) {
|
|||||||
canvas.height = bottom - top;
|
canvas.height = bottom - top;
|
||||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
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 targetX = rect.x - left;
|
||||||
const targetY = rect.y - top;
|
const targetY = rect.y - top;
|
||||||
context.drawImage(
|
context.drawImage(
|
||||||
@@ -393,16 +406,16 @@ export async function recognizeTextRows(sourceCanvas, rows) {
|
|||||||
rect.height,
|
rect.height,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!includeYellow) {
|
if (!includeYellow || !includeItemColors) {
|
||||||
const image = context.getImageData(targetX, targetY, rect.width, rect.height);
|
const image = context.getImageData(targetX, targetY, rect.width, rect.height);
|
||||||
for (let index = 0; index < image.data.length; index += 4) {
|
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 (
|
if (
|
||||||
isYellowTextPixel(
|
(!includeYellow && isYellowTextPixel(red, green, blue, alpha)) ||
|
||||||
image.data[index],
|
(!includeItemColors && isColoredItemTextPixel(red, green, blue, alpha))
|
||||||
image.data[index + 1],
|
|
||||||
image.data[index + 2],
|
|
||||||
image.data[index + 3],
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
image.data[index] = 0;
|
image.data[index] = 0;
|
||||||
image.data[index + 1] = 0;
|
image.data[index + 1] = 0;
|
||||||
@@ -416,7 +429,10 @@ export async function recognizeTextRows(sourceCanvas, rows) {
|
|||||||
return recognizeText(
|
return recognizeText(
|
||||||
canvas,
|
canvas,
|
||||||
{ x: 0, y: 0, width: canvas.width, height: canvas.height },
|
{ 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,
|
displayCaptureIssue,
|
||||||
isCalibrationReady,
|
isCalibrationReady,
|
||||||
isRectInside,
|
isRectInside,
|
||||||
|
merchantGroups,
|
||||||
parseFieldValue,
|
parseFieldValue,
|
||||||
parseStoreHeaderText,
|
parseStoreHeaderText,
|
||||||
parseTooltipText,
|
parseTooltipText,
|
||||||
@@ -19,10 +20,16 @@ import {
|
|||||||
findTextRunEnd,
|
findTextRunEnd,
|
||||||
fitOcrTextRect,
|
fitOcrTextRect,
|
||||||
occupiedSlotScore,
|
occupiedSlotScore,
|
||||||
|
isColoredItemTextPixel,
|
||||||
isWhiteTextPixel,
|
isWhiteTextPixel,
|
||||||
isYellowTextPixel,
|
isYellowTextPixel,
|
||||||
} from "../src/vision.js";
|
} 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", () => {
|
test("keeps white text pixels and removes colored or dark pixels", () => {
|
||||||
assert.equal(isWhiteTextPixel(220, 215, 195), true);
|
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);
|
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", () => {
|
test("stops a tooltip text run before distant UI noise", () => {
|
||||||
const columns = Array(48).fill(false);
|
const columns = Array(48).fill(false);
|
||||||
for (let index = 2; index <= 7; index += 1) columns[index] = true;
|
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", () => {
|
test("keeps only letters, digits and spaces in OCR item names", () => {
|
||||||
assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade");
|
assert.equal(cleanItemName(" ~~ Soulshot: S-grade ` "), "Soulshot S grade");
|
||||||
assert.equal(cleanItemName("Кристалл № 12"), "Кристалл 12");
|
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/);
|
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", () => {
|
test("resets only transient shop progress when a shop closes or changes", () => {
|
||||||
const result = {
|
const result = {
|
||||||
activeShopKey: "sell:gnumli",
|
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);
|
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", () => {
|
test("parses a hovered item tooltip", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
parseTooltipText("Tears of Eva\nPrice : 3,000,000 Adena\n(3 Million Adena)"),
|
||||||
|
|||||||
Reference in New Issue
Block a user