Document merchant HUD and location capture plan

This commit is contained in:
2026-08-12 14:04:28 +03:00
parent af9cd1e92d
commit 7c696b710c
11 changed files with 545 additions and 310 deletions
+2 -2
View File
@@ -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
- Показывать реальный захваченный кадр и реальный результат, а не скрывать состояние процесса.
- Вести пользователя по прямому пути: источник, калибровка, результат.
- Вести пользователя по прямому пути: экран, парсинг, сохранённый черновик. Калибровка остаётся редкой настройкой.
- Сохранять интерфейс компактным и предсказуемым при нескольких окнах.
- Оставлять настраиваемыми только параметры, которые зависят от реального изображения.
+9 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)"),