diff --git a/.gitignore b/.gitignore index 869575d..c07f189 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist/ .DS_Store npm-debug.log* .env*.local +playwright-report/ +test-results/ +test-output/ +.pnpm-store/ diff --git a/README.md b/README.md index 5315f62..de17e28 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ Кроссплатформенный локальный прототип для захвата нескольких окон Lineage II и сбора предметов из tooltip торгового окна. +## Прогресс иконок магазина + +Для жёлто-зелёного прогресса дополнительно выдели области `Сетка предметов Sell` и `Сетка предметов Buy`. Нужен только прямоугольник 6 × 3 с ячейками предметов, без заголовка и кнопок прокрутки. Поля необязательны, поэтому старые калибровки продолжают распознавать текст без оверлея иконок. + +На первом чистом кадре магазина занятые ячейки становятся жёлтыми. После успешного OCR и однозначного сравнения с иконкой из каталога соответствующая ячейка становится зелёной. При одинаково подходящих иконках ячейка остаётся жёлтой; приложение не угадывает слот по положению tooltip или порядку обхода. Выбранный источник разворачивается минимум до 640 CSS px, а экран калибровки показывает исходный скриншот в масштабе 2×, сохраняя координаты в исходных пикселях. + +Проверка включает обычные unit-тесты, 17 подписанных игровых скриншотов в Chromium, production build и проверку diff: + +```bash +npm run check +``` + ## Запуск Нужен Node.js 20 или новее и Chrome либо Edge. @@ -35,7 +47,7 @@ npm run dev └─ да → OCR точной строки названия и точной строки цены ``` -Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Внутри зоны tooltip отдельно ищется `Price :`, поэтому сам tooltip тоже может двигаться при наведении на разные предметы. +Заголовок и зона tooltip хранятся относительно маркера магазина. Поэтому всё торговое окно можно перемещать. Широкая зона tooltip используется только для поиска `Price :` и не передаётся целиком в OCR. После нахождения якоря приложение отдельно определяет фактическую ширину строк названия и цены по их светлым пикселям. Из заголовка `Private Store(Buy) - Hikvision` получится `side: "buy"`, `merchant: "Hikvision"`; из `Private Store(Sell) - Dwa` — `side: "sell"`, `merchant: "Dwa"`. @@ -47,9 +59,9 @@ npm run dev 2. В разделе «Калибровка» сделай снимок или загрузи готовый скриншот. 3. Выдели постоянный нижний элемент торгового окна, например `Adena + Confirm`, как маркер магазина. По возможности не захватывай изменяемые числа. 4. Выдели заголовок магазина одной строкой: от `Private Store(` до конца имени торговца. Рамку и кнопку закрытия не включай. -5. Выдели широкую область всех возможных положений tooltip, не захватывая нижний `Price` основного окна. +5. Выдели широкую горизонтальную полосу всех возможных положений tooltip. Она может выходить далеко за пределы торгового окна, но не должна захватывать нижний `Price` основного окна. 6. Внутри tooltip обведи только постоянную надпись `Price :`. -7. Отдельно выдели точную строку названия и точную строку цены. Для названия лучше использовать длинный предмет и оставить запас справа. +7. Отдельно выдели левый край, высоту и начальную ширину строки названия и строки цены. Runtime сам продлит каждую строку вправо до фактического конца текста. 8. Сохрани калибровку, нажми «Начать сбор» и вернись в игру. 9. Сначала оставь курсор вне предметов, пока в результате не появятся тип сделки и имя торговца. 10. Наводи курсор на каждый предмет. Даже если tooltip закроет заголовок, предмет будет записан в последний найденный магазин. @@ -62,7 +74,7 @@ npm run dev 2. Добавь второй снимок с tooltip. Калибратор сам найдёт нижний маркер по всему новому кадру и перенесёт относительно него остальные области. 3. На втором снимке выдели зону tooltip, `Price :`, название и цену. Уже выбранные области сохраняются. -Размер игрового окна и положение торгового окна могут отличаться. Масштаб интерфейса Lineage II должен оставаться тем же: шаблоны ищутся в исходном пиксельном размере. +Размер игрового окна и положение торгового окна могут отличаться. В калибраторе снимок показывается в масштабе 2× для точной ручной разметки, но шаблоны и сохранённые координаты остаются в исходном пиксельном размере Lineage II. После выбора всех шести областей нажми «Проверить снимки». Они обрабатываются по порядку: сначала кадр с видимым заголовком запоминает магазин, затем кадр с tooltip добавляет предмет. Подключать игровое окно для этого не нужно. @@ -80,12 +92,12 @@ npm run dev | --- | --- | --- | | Маркер магазина | Небольшой постоянный фрагмент внизу, например `Adena` и `Confirm` | Захватить баланс Adena или другое изменяемое число | | Заголовок магазина | Вся строка `Private Store(Sell) - Dwa` или `Private Store(Buy) - Hikvision` | Обрезать `Buy/Sell` или имя; захватить рамку и кнопку закрытия | -| Зона tooltip | Вся полоса возможных положений всплывающего окна над сеткой предметов | Включить нижнюю строку `Price` основного окна | +| Зона tooltip | Широкая полоса возможных положений всплывающего окна, в том числе за пределами окна трейда | Включить нижнюю строку `Price` основного окна | | Якорь Price | Только постоянные символы `Price :` внутри tooltip | Добавить цену, которая меняется у каждого предмета | -| Название предмета | Одна верхняя строка tooltip с запасом справа | Захватить иконки, фон игры или строку цены | -| Строка цены | `Price : 3,000,000 Adena`, `For Each 400 Adena` или только число | Захватить дополнительную строку `(3 Million Adena)` | +| Название предмета | Левый край и точная высота верхней строки tooltip; ширина подгоняется автоматически | Захватить иконки, фон игры или строку цены | +| Строка цены | Левый край и точная высота `Price : 3,000,000 Adena`, `For Each 400 Adena` или числа | Захватить дополнительную строку `(3 Million Adena)` | -Лучше калиброваться на предмете с длинным названием и большой ценой. Тогда прямоугольники не обрежут более короткие варианты. +Калиброваться на самом длинном предмете больше не требуется: после поиска `Price :` границы названия и цены подгоняются отдельно и останавливаются до удалённого текста интерфейса. ## Диагностика diff --git a/data/Discord_2KrJ9GGjCP.jpg b/data/Discord_2KrJ9GGjCP.jpg new file mode 100644 index 0000000..617a526 Binary files /dev/null and b/data/Discord_2KrJ9GGjCP.jpg differ diff --git a/data/Discord_7xmx8OSuCH.jpg b/data/Discord_7xmx8OSuCH.jpg new file mode 100644 index 0000000..8e06efb Binary files /dev/null and b/data/Discord_7xmx8OSuCH.jpg differ diff --git a/data/Discord_ARWzEIaayl.jpg b/data/Discord_ARWzEIaayl.jpg new file mode 100644 index 0000000..df10553 Binary files /dev/null and b/data/Discord_ARWzEIaayl.jpg differ diff --git a/data/Discord_K9RkTrU6B2.jpg b/data/Discord_K9RkTrU6B2.jpg new file mode 100644 index 0000000..a2fca81 Binary files /dev/null and b/data/Discord_K9RkTrU6B2.jpg differ diff --git a/data/Discord_KL0fnDcvr7.jpg b/data/Discord_KL0fnDcvr7.jpg new file mode 100644 index 0000000..f1498f3 Binary files /dev/null and b/data/Discord_KL0fnDcvr7.jpg differ diff --git a/data/Discord_TxbA20ZOQB.jpg b/data/Discord_TxbA20ZOQB.jpg new file mode 100644 index 0000000..bb138a5 Binary files /dev/null and b/data/Discord_TxbA20ZOQB.jpg differ diff --git a/data/Discord_dxpCzaJEWD.jpg b/data/Discord_dxpCzaJEWD.jpg new file mode 100644 index 0000000..76a7365 Binary files /dev/null and b/data/Discord_dxpCzaJEWD.jpg differ diff --git a/data/Discord_ezvlzpNHRg.jpg b/data/Discord_ezvlzpNHRg.jpg new file mode 100644 index 0000000..4c21491 Binary files /dev/null and b/data/Discord_ezvlzpNHRg.jpg differ diff --git a/data/Discord_fFbzX9vDhW.jpg b/data/Discord_fFbzX9vDhW.jpg new file mode 100644 index 0000000..82e2413 Binary files /dev/null and b/data/Discord_fFbzX9vDhW.jpg differ diff --git a/data/Discord_fb9f61z8Ek.jpg b/data/Discord_fb9f61z8Ek.jpg new file mode 100644 index 0000000..71a034b Binary files /dev/null and b/data/Discord_fb9f61z8Ek.jpg differ diff --git a/data/Discord_ikzA9VhROZ.jpg b/data/Discord_ikzA9VhROZ.jpg new file mode 100644 index 0000000..a2a6376 Binary files /dev/null and b/data/Discord_ikzA9VhROZ.jpg differ diff --git a/data/Discord_qy3EYIEcMV.jpg b/data/Discord_qy3EYIEcMV.jpg new file mode 100644 index 0000000..b17da74 Binary files /dev/null and b/data/Discord_qy3EYIEcMV.jpg differ diff --git a/data/Discord_sSJAGZLHvm.jpg b/data/Discord_sSJAGZLHvm.jpg new file mode 100644 index 0000000..b239191 Binary files /dev/null and b/data/Discord_sSJAGZLHvm.jpg differ diff --git a/data/Discord_tJFxvFi7DM.jpg b/data/Discord_tJFxvFi7DM.jpg new file mode 100644 index 0000000..0dd208d Binary files /dev/null and b/data/Discord_tJFxvFi7DM.jpg differ diff --git a/data/Discord_uqGcQhxIWc.jpg b/data/Discord_uqGcQhxIWc.jpg new file mode 100644 index 0000000..c09d836 Binary files /dev/null and b/data/Discord_uqGcQhxIWc.jpg differ diff --git a/data/Discord_w3Smu4bOQK.jpg b/data/Discord_w3Smu4bOQK.jpg new file mode 100644 index 0000000..e97b6b3 Binary files /dev/null and b/data/Discord_w3Smu4bOQK.jpg differ diff --git a/data/Discord_wQc5FDayAK.jpg b/data/Discord_wQc5FDayAK.jpg new file mode 100644 index 0000000..0e9308e Binary files /dev/null and b/data/Discord_wQc5FDayAK.jpg differ diff --git a/data/fixture-assets/catalog.json b/data/fixture-assets/catalog.json new file mode 100644 index 0000000..2d4e713 --- /dev/null +++ b/data/fixture-assets/catalog.json @@ -0,0 +1,14 @@ +{ + "source": "reviewed screenshot crops; replace with real /api/l2/items icon bytes before accepting live catalog-match evidence", + "items": { + "soulshot-c-grade": { "id": "fixture-soulshot-c-grade", "name": "Soulshot C grade", "iconUrl": "/data/fixture-assets/catalog/soulshot-c-grade.png", "matchScore": 1 }, + "blessed-spiritshot-d-grade": { "id": "fixture-blessed-spiritshot-d-grade", "name": "Blessed Spiritshot D Grade", "iconUrl": "/data/fixture-assets/catalog/blessed-spiritshot-d-grade.png", "matchScore": 1 }, + "spellbook-prominence": { "id": "fixture-spellbook-prominence", "name": "Spellbook Prominence", "iconUrl": "/data/fixture-assets/catalog/spellbook-prominence.png", "matchScore": 1 }, + "brigandine-helmet": { "id": "fixture-brigandine-helmet", "name": "Brigandine Helmet", "iconUrl": "/data/fixture-assets/catalog/brigandine-helmet.png", "matchScore": 1 }, + "tutorial-guide": { "id": "fixture-tutorial-guide", "name": "Tutorial Guide", "iconUrl": "/data/fixture-assets/catalog/tutorial-guide.png", "matchScore": 1 }, + "dimensional-fragment": { "id": "fixture-dimensional-fragment", "name": "Dimensional Fragment", "iconUrl": "/data/fixture-assets/catalog/dimensional-fragment.png", "matchScore": 1 }, + "ancient-adena": { "id": "fixture-ancient-adena", "name": "Ancient Adena", "iconUrl": "/data/fixture-assets/catalog/ancient-adena.png", "matchScore": 1 }, + "scroll-enchant-weapon-d": { "id": "fixture-scroll-enchant-weapon-d", "name": "Scroll Enchant Weapon D", "iconUrl": "/data/fixture-assets/catalog/scroll-enchant-weapon-d.png", "matchScore": 1 }, + "coarse-bone-powder": { "id": "fixture-coarse-bone-powder", "name": "Coarse Bone Powder", "iconUrl": "/data/fixture-assets/catalog/coarse-bone-powder.png", "matchScore": 1 } + } +} diff --git a/data/fixture-assets/catalog/ancient-adena.png b/data/fixture-assets/catalog/ancient-adena.png new file mode 100644 index 0000000..83e7e91 Binary files /dev/null and b/data/fixture-assets/catalog/ancient-adena.png differ diff --git a/data/fixture-assets/catalog/blessed-spiritshot-d-grade.png b/data/fixture-assets/catalog/blessed-spiritshot-d-grade.png new file mode 100644 index 0000000..fd09108 Binary files /dev/null and b/data/fixture-assets/catalog/blessed-spiritshot-d-grade.png differ diff --git a/data/fixture-assets/catalog/brigandine-helmet.png b/data/fixture-assets/catalog/brigandine-helmet.png new file mode 100644 index 0000000..47147cd Binary files /dev/null and b/data/fixture-assets/catalog/brigandine-helmet.png differ diff --git a/data/fixture-assets/catalog/coarse-bone-powder.png b/data/fixture-assets/catalog/coarse-bone-powder.png new file mode 100644 index 0000000..a38ff65 Binary files /dev/null and b/data/fixture-assets/catalog/coarse-bone-powder.png differ diff --git a/data/fixture-assets/catalog/dimensional-fragment.png b/data/fixture-assets/catalog/dimensional-fragment.png new file mode 100644 index 0000000..faa26b3 Binary files /dev/null and b/data/fixture-assets/catalog/dimensional-fragment.png differ diff --git a/data/fixture-assets/catalog/scroll-enchant-weapon-d.png b/data/fixture-assets/catalog/scroll-enchant-weapon-d.png new file mode 100644 index 0000000..14d4eac Binary files /dev/null and b/data/fixture-assets/catalog/scroll-enchant-weapon-d.png differ diff --git a/data/fixture-assets/catalog/soulshot-c-grade.png b/data/fixture-assets/catalog/soulshot-c-grade.png new file mode 100644 index 0000000..0970f60 Binary files /dev/null and b/data/fixture-assets/catalog/soulshot-c-grade.png differ diff --git a/data/fixture-assets/catalog/spellbook-prominence.png b/data/fixture-assets/catalog/spellbook-prominence.png new file mode 100644 index 0000000..eec7c5c Binary files /dev/null and b/data/fixture-assets/catalog/spellbook-prominence.png differ diff --git a/data/fixture-assets/catalog/tutorial-guide.png b/data/fixture-assets/catalog/tutorial-guide.png new file mode 100644 index 0000000..698c733 Binary files /dev/null and b/data/fixture-assets/catalog/tutorial-guide.png differ diff --git a/data/fixture-assets/templates/sale-anchor.png b/data/fixture-assets/templates/sale-anchor.png new file mode 100644 index 0000000..4784918 Binary files /dev/null and b/data/fixture-assets/templates/sale-anchor.png differ diff --git a/data/fixture-assets/templates/tooltip-anchor.png b/data/fixture-assets/templates/tooltip-anchor.png new file mode 100644 index 0000000..4d336d6 Binary files /dev/null and b/data/fixture-assets/templates/tooltip-anchor.png differ diff --git a/data/fixture-calibration.json b/data/fixture-calibration.json new file mode 100644 index 0000000..be80658 --- /dev/null +++ b/data/fixture-calibration.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "frame": { "width": 1560, "height": 1360 }, + "saleAnchor": { + "image": "fixture-assets/templates/sale-anchor.png", + "width": 79, + "height": 29 + }, + "storeHeaderRegion": { + "offsetX": -162, + "offsetY": -356, + "width": 222, + "height": 18 + }, + "tooltipSearchRegion": { + "offsetX": -300, + "offsetY": -430, + "width": 650, + "height": 220 + }, + "tooltipAnchor": { + "image": "fixture-assets/templates/tooltip-anchor.png", + "width": 42, + "height": 17 + }, + "itemNameRegion": { + "offsetX": -3, + "offsetY": -28, + "width": 260, + "height": 17 + }, + "itemPriceRegion": { + "offsetX": 0, + "offsetY": 0, + "width": 260, + "height": 17 + }, + "sellItemGridRegion": { + "offsetX": -164, + "offsetY": -305, + "width": 222, + "height": 111 + }, + "buyItemGridRegion": { + "offsetX": -164, + "offsetY": -305, + "width": 222, + "height": 111 + }, + "threshold": 0.76 +} diff --git a/data/fixtures.json b/data/fixtures.json new file mode 100644 index 0000000..dcb2184 --- /dev/null +++ b/data/fixtures.json @@ -0,0 +1,360 @@ +{ + "version": 1, + "viewerCharacter": "Deela", + "frame": { "width": 1560, "height": 1360 }, + "calibration": "fixture-calibration.json", + "cases": [ + { + "id": "sell-domestos-clean", + "file": "Discord_7xmx8OSuCH.jpg", + "groupId": "sell-domestos", + "order": 0, + "scene": "clean", + "mode": "standalone", + "expected": { + "side": "sell", + "merchant": "Domestos", + "occupiedSlots": [0], + "pendingSlots": [0], + "foundSlots": [] + } + }, + { + "id": "sell-crom-clean", + "file": "Discord_K9RkTrU6B2.jpg", + "groupId": "sell-crom", + "order": 0, + "scene": "clean", + "mode": "standalone", + "expected": { + "side": "sell", + "merchant": "Crom", + "occupiedSlots": [0, 1], + "pendingSlots": [0, 1], + "foundSlots": [] + } + }, + { + "id": "sell-gnumli-clean", + "file": "Discord_w3Smu4bOQK.jpg", + "groupId": "sell-gnumli", + "order": 0, + "scene": "clean", + "mode": "sequence", + "expected": { + "side": "sell", + "merchant": "Gnumli", + "occupiedSlots": [0, 1], + "pendingSlots": [0, 1], + "foundSlots": [] + } + }, + { + "id": "sell-gnumli-soulshot", + "file": "Discord_ARWzEIaayl.jpg", + "groupId": "sell-gnumli", + "order": 1, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "soulshot-c-grade", + "expected": { + "side": "sell", + "merchant": "Gnumli", + "occupiedSlots": [0, 1], + "hoveredSlot": 0, + "pendingSlots": [1], + "foundSlots": [0], + "item": { + "displayName": "Soulshot: C-grade", + "name": "Soulshot C grade", + "quantity": 36370, + "priceAdena": 20, + "priceMode": "price" + } + } + }, + { + "id": "sell-gnumli-blessed-spiritshot", + "file": "Discord_qy3EYIEcMV.jpg", + "groupId": "sell-gnumli", + "order": 2, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "blessed-spiritshot-d-grade", + "expected": { + "side": "sell", + "merchant": "Gnumli", + "occupiedSlots": [0, 1], + "hoveredSlot": 1, + "pendingSlots": [], + "foundSlots": [0, 1], + "item": { + "displayName": "Blessed Spiritshot: D-Grade", + "name": "Blessed Spiritshot D Grade", + "quantity": 7245, + "priceAdena": 58, + "priceMode": "price" + } + } + }, + { + "id": "sell-boroda4-clean", + "file": "Discord_sSJAGZLHvm.jpg", + "groupId": "sell-boroda4", + "order": 0, + "scene": "clean", + "mode": "sequence", + "expected": { + "side": "sell", + "merchant": "Boroda4", + "occupiedSlots": [0, 1], + "pendingSlots": [0, 1], + "foundSlots": [] + } + }, + { + "id": "sell-boroda4-spellbook", + "file": "Discord_KL0fnDcvr7.jpg", + "groupId": "sell-boroda4", + "order": 1, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "spellbook-prominence", + "expected": { + "side": "sell", + "merchant": "Boroda4", + "occupiedSlots": [0, 1], + "hoveredSlot": 0, + "pendingSlots": [1], + "foundSlots": [0], + "item": { + "displayName": "Spellbook: Prominence", + "name": "Spellbook Prominence", + "quantity": 2, + "priceAdena": 50000, + "priceMode": "price" + } + } + }, + { + "id": "sell-boroda4-helmet", + "file": "Discord_dxpCzaJEWD.jpg", + "groupId": "sell-boroda4", + "order": 2, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "brigandine-helmet", + "expected": { + "side": "sell", + "merchant": "Boroda4", + "occupiedSlots": [0, 1], + "hoveredSlot": 1, + "pendingSlots": [], + "foundSlots": [0, 1], + "item": { + "displayName": "Brigandine Helmet", + "name": "Brigandine Helmet", + "quantity": 1, + "priceAdena": 800000, + "priceMode": "price" + } + } + }, + { + "id": "sell-shotd-clean", + "file": "Discord_uqGcQhxIWc.jpg", + "groupId": "sell-shotd", + "order": 0, + "scene": "clean", + "mode": "sequence", + "expected": { + "side": "sell", + "merchant": "shotD", + "occupiedSlots": [0, 1], + "pendingSlots": [0, 1], + "foundSlots": [] + } + }, + { + "id": "sell-shotd-tutorial-guide", + "file": "Discord_fb9f61z8Ek.jpg", + "groupId": "sell-shotd", + "order": 1, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "tutorial-guide", + "expected": { + "side": "sell", + "merchant": "shotD", + "occupiedSlots": [0, 1], + "hoveredSlot": 0, + "pendingSlots": [1], + "foundSlots": [0], + "item": { + "displayName": "Tutorial Guide", + "name": "Tutorial Guide", + "quantity": 1, + "priceAdena": 5000000, + "priceMode": "price" + } + } + }, + { + "id": "sell-shotd-dimensional-fragment", + "file": "Discord_ezvlzpNHRg.jpg", + "groupId": "sell-shotd", + "order": 2, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "dimensional-fragment", + "expected": { + "side": "sell", + "merchant": "shotD", + "occupiedSlots": [0, 1], + "hoveredSlot": 1, + "pendingSlots": [], + "foundSlots": [0, 1], + "item": { + "displayName": "Dimensional Fragment", + "name": "Dimensional Fragment", + "quantity": 687, + "priceAdena": 3500, + "priceMode": "price" + } + } + }, + { + "id": "sell-1shop-clean", + "file": "Discord_tJFxvFi7DM.jpg", + "groupId": "sell-1shop", + "order": 0, + "scene": "clean", + "mode": "sequence", + "expected": { + "side": "sell", + "merchant": "1SHOP", + "occupiedSlots": [0], + "pendingSlots": [0], + "foundSlots": [] + } + }, + { + "id": "sell-1shop-ancient-adena", + "file": "Discord_fFbzX9vDhW.jpg", + "groupId": "sell-1shop", + "order": 1, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "ancient-adena", + "expected": { + "side": "sell", + "merchant": "1SHOP", + "occupiedSlots": [0], + "hoveredSlot": 0, + "pendingSlots": [], + "foundSlots": [0], + "item": { + "displayName": "Ancient Adena", + "name": "Ancient Adena", + "quantity": 1793000, + "priceAdena": 3, + "priceMode": "price" + } + } + }, + { + "id": "buy-rakot-clean", + "file": "Discord_wQc5FDayAK.jpg", + "groupId": "buy-rakot", + "order": 0, + "scene": "clean", + "mode": "sequence", + "expected": { + "side": "buy", + "merchant": "RAKOT", + "occupiedSlots": [0, 1, 2], + "pendingSlots": [0, 1, 2], + "foundSlots": [] + } + }, + { + "id": "buy-rakot-scroll", + "file": "Discord_TxbA20ZOQB.jpg", + "groupId": "buy-rakot", + "order": 1, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "scroll-enchant-weapon-d", + "expected": { + "side": "buy", + "merchant": "RAKOT", + "occupiedSlots": [0, 1, 2], + "hoveredSlot": 0, + "pendingSlots": [1, 2], + "foundSlots": [0], + "item": { + "displayName": "Scroll: Enchant Weapon (D)", + "name": "Scroll Enchant Weapon D", + "quantity": 0, + "priceAdena": 200000, + "priceMode": "each" + } + } + }, + { + "id": "buy-rakot-coarse-bone-powder", + "file": "Discord_ikzA9VhROZ.jpg", + "groupId": "buy-rakot", + "order": 2, + "scene": "tooltip", + "mode": "sequence", + "catalogAssetId": "coarse-bone-powder", + "expected": { + "side": "buy", + "merchant": "RAKOT", + "occupiedSlots": [0, 1, 2], + "hoveredSlot": 2, + "pendingSlots": [1], + "foundSlots": [0, 2], + "item": { + "displayName": "Coarse Bone Powder", + "name": "Coarse Bone Powder", + "quantity": 0, + "priceAdena": 1, + "priceMode": "each" + } + } + }, + { + "id": "buy-kapayji-ancient-adena", + "file": "Discord_2KrJ9GGjCP.jpg", + "groupId": "buy-kapayji", + "order": 0, + "scene": "tooltip", + "mode": "contextual", + "catalogAssetId": "ancient-adena", + "initialState": { + "side": "buy", + "merchant": "KapayJI", + "activeShopKey": "buy:kapayji", + "pendingSlots": [0], + "foundSlots": [] + }, + "expected": { + "side": "buy", + "merchant": "KapayJI", + "occupiedSlots": [0], + "hoveredSlot": 0, + "pendingSlots": [], + "foundSlots": [0], + "item": { + "displayName": "Ancient Adena", + "name": "Ancient Adena", + "quantity": 0, + "priceAdena": 2, + "priceMode": "each" + } + } + } + ] +} diff --git a/docs/goals/dynamic-tooltip-crop/EVIDENCE.md b/docs/goals/dynamic-tooltip-crop/EVIDENCE.md new file mode 100644 index 0000000..e370acf --- /dev/null +++ b/docs/goals/dynamic-tooltip-crop/EVIDENCE.md @@ -0,0 +1,39 @@ +# Dynamic Tooltip Crop Evidence + +## Acceptance Evidence + +- Browser geometry: a 1920 x 1080 reference keeps an intrinsic canvas of + 1920 x 1080 and renders at 3840 x 2160. The stage scroll width grows to + 3888 px, so the image is not squeezed back into the panel. See + [calibration-2x.png](./calibration-2x.png). +- Browser pointer smoke: drawing on the 2x canvas creates calibration regions + and advances through all six calibration steps. The marker drag from display + `(326.6, 604)` to `(426.6, 642)` mapped to source `(288, 259)` through + `(338, 278)` and was subsequently found by anchor matching at 99.8%. +- Real tooltip fixture: the complete calibration check finished 6/6 stages and + parsed `Sword of Revolution`, quantity `1`, and `1,700,000 Adena` at 70% OCR + confidence. The third descriptive tooltip row was not passed to item parsing. + See [tooltip-diagnostics.png](./tooltip-diagnostics.png). +- The wide tooltip region is used only for the `Price :` anchor search. The + item OCR preview contains the fitted name and price rows, not the full search + band. + +## Verification + +- `node.exe --test`: 18 tests passed, including white/yellow pixel masks, + stopping a text run before distant UI noise, and fitting a short row whose + legacy saved width crosses the frame edge. +- `node.exe node_modules/vite/bin/vite.js build`: production build passed. + Vite reports the existing large OpenCV chunk warning. +- `git diff --check`: passed; Git only reported LF-to-CRLF conversion warnings. + +## Review Notes + +- PRE plan review: aligned after clarifying the single owner of the dynamic + right edge, the no-wide-OCR fallback, and legacy calibration behavior. +- POST plan review: aligned; saved screenshots and the unbounded-run regression + close the evidence and no-wide-fallback gates. +- Correctness review: no remaining findings after isolating fitted rows in a + masked composite and applying yellow pixels only to the price row. +- Maintainability review: no remaining findings after reading pixels directly + from the source canvas and simplifying the text-run control flow. diff --git a/docs/goals/dynamic-tooltip-crop/GOAL.md b/docs/goals/dynamic-tooltip-crop/GOAL.md new file mode 100644 index 0000000..33b19bb --- /dev/null +++ b/docs/goals/dynamic-tooltip-crop/GOAL.md @@ -0,0 +1,10 @@ +# Goal: Dynamic Tooltip Crop + +Use Krypton Execution to execute `docs/goals/dynamic-tooltip-crop/PLAN.md`. + +Core rules: +- Treat PLAN.md as the source plan. +- Preserve intent, ownership, contract, cutover, evidence, and kill criteria. +- Do not add a second wide-area OCR path. +- Keep existing calibration data readable; fixed widths provide seed geometry only and must not be used as a wide OCR fallback. Empty masks produce diagnostics `not-found`. +- Capture acceptance evidence in `EVIDENCE.md`. diff --git a/docs/goals/dynamic-tooltip-crop/PLAN.md b/docs/goals/dynamic-tooltip-crop/PLAN.md new file mode 100644 index 0000000..c14d509 --- /dev/null +++ b/docs/goals/dynamic-tooltip-crop/PLAN.md @@ -0,0 +1,69 @@ +# Dynamic Tooltip Crop Implementation Plan + +**Intent:** Сделать ручную калибровку пиксель-точной при масштабе 2× и читать строки tooltip переменной ширины без OCR всего кадра. +**Current Behavior:** Калибратор показывает кадр 1:1, а runtime после поиска `Price :` использует фиксированную ширину калиброванных строк названия и цены. +**Expected Outcome:** Кадр размечается в масштабе 2×; найденные строки названия и итоговой цены tooltip локально подгоняются вправо по фактическим белым и жёлтым пикселям до первого устойчивого пустого промежутка, включая текст длиннее калиброванного образца. +**Target-Perspective Output:** Пользователь выделяет мелкие области на увеличенном кадре и получает название и цену как для короткого, так и для длинного tooltip без постороннего текста за его правой границей. +**Truth Owner:** `src/vision.js` владеет определением фактической правой границы OCR-строки; калибровка сохраняет стабильный левый край, положение и высоту строки относительно `Price :`. +**Contract Boundary:** Чистый `findTextRunEnd(columns, gap)` находит конец первого текстового пробега. Canvas-wrapper `fitOcrTextRect(canvas, rect, options)` сканирует от калиброванного левого края до правой границы кадра, использует gap 12 px и padding 6 px, применяет white mask для названия и white+yellow для цены и возвращает frame-clamped rect либо `null`, если пробег не найден. +**Cutover:** `analyzeFrame` подгоняет `nameRect` и `priceRect` перед `combineRects`; прямое использование фиксированной ширины прекращается. +**Displaced Path:** Фиксированные ширины `itemNameRegion` и `itemPriceRegion` больше не задают окончательную границу и не используются как широкий OCR fallback; при пустой маске item OCR пропускается с диагностикой. +**Value Density:** Один локальный пиксельный проход по двум тонким строкам вместо OCR широкой области или нескольких OCR-попыток. +**Evidence Gate:** На реальном тестовом кадре 1920×1080 canvas отображается 3840×2160; выделение в 2× визуально совпадает с целевыми source-пикселями; реальный tooltip читается при искусственно короткой калиброванной ширине, а удалённый соседний текст не попадает в diagnostics crop/OCR. +**Acceptance Evidence:** Browser geometry и screenshot выделения; границы двух реальных строк разной длины из предоставленного tooltip; diagnostics crop + parsed name/price этого tooltip при намеренно узкой калиброванной ширине; runnable-тест чистого определения конца текстового пробега; production build. +**Evidence Lane:** Локальный браузер и `node --test`. +**Kill Criteria:** В runtime нет второго OCR-пути по широкой области или широкого фиксированного fallback; `combineRects` не вызывается до успешной подгонки обеих строк. +**Architecture Slice:** `src/main.js`, `src/vision.js`, `src/style.css`, `test/parser.test.js`, `README.md`; документация калибровки уточняет различие зоны поиска и OCR-строк. +**Plan Review Gate:** Requires PRE review before execution. + +## Architecture Slice + +- Files to create: только этот goal-пакет. +- Files to modify: `src/main.js`, `src/vision.js`, `src/style.css`, `test/parser.test.js`, `README.md`. +- Files to avoid: сохранённый формат калибровки, парсер текста tooltip, market outbox и backend. +- Source of truth: найденный `Price :` задаёт позицию; пиксели текущего кадра задают фактическую ширину. +- Read path: кадр → поиск магазина → поиск `Price :` → калиброванные строки → подгонка по пикселям → OCR → parser. +- Write path: без новых сохраняемых полей; существующая калибровка остаётся совместимой. +- Contract boundary: чистый `findTextRunEnd` и canvas-wrapper `fitOcrTextRect` в vision-слое. +- Integration points: два прямоугольника в `analyzeFrame`, CSS-размер canvas. +- Migration/cutover: немедленный, без миграции данных. +- Displaced path: фиксированная ширина перестаёт быть OCR fallback; пустая маска становится наблюдаемым `not-found` в diagnostics. +- Acceptance evidence gate: 2× browser geometry + screenshot точного выделения + diagnostics реального tooltip + unit test длинной строки + full build. + +## Runtime Invariants + +- Левый край строк названия и цены стабилен относительно найденного `Price :`; переменной является правая граница. +- Продукт сохраняет только название, количество и итоговую цену; остальные описательные строки tooltip не являются данными продукта. +- `tooltipSearchRegion` может быть широкой горизонтальной полосой за пределами окна трейда, но исключает нижний `Price` основного окна; эта полоса используется только для template matching. +- Сканирование OCR-строки начинается с её калиброванного левого края, идёт не дальше правой границы кадра, допускает внутренние промежутки меньше 12 px и добавляет не более 6 px после последнего текстового столбца. +- Название использует белую маску; цена использует белую и жёлтую маски. +- Если текстовый пробег не найден, OCR предмета не запускается, а diagnostics явно сообщает причину. + +## Tasks + +1. Последовательно изменить `src/main.js` и `src/style.css`: отображать calibration canvas ровно в 2× intrinsic size; координаты остаются корректными через существующий `canvasPoint`. + - Allowed scope: только display size и crisp integer scaling. + - Verification: браузер показывает intrinsic 1920×1080 и rendered 3840×2160; screenshot известного выделения совпадает с source-пикселями. +2. Затем изменить `src/vision.js` и `test/parser.test.js`: добавить чистый `findTextRunEnd` и локальную подгонку правого края с раздельными масками, gap 12, padding 6 и `null` для пустой маски. + - Allowed scope: preprocessing границы; без нового OCR-прохода и без зависимости canvas в Node. + - Verification: `node --test` покрывает длинный текст, внутренние пробелы, удалённый шум и пустую маску. +3. После задачи 2 интегрировать подгонку в `src/main.js` до `combineRects`; при `null` остановить item OCR и записать причину в diagnostics. + - Allowed scope: существующий item OCR branch. + - Verification: diagnostics показывают фактический локальный crop, а широкий search region и удалённый соседний текст не попадают в OCR. +4. Обязательно обновить `README.md`: широкая полоса ищет только `Price :`; ширины строк являются начальными левыми/вертикальными ориентирами и подгоняются runtime. + - Allowed scope: только инструкции выбора `tooltipSearch`, `itemName` и `itemPrice`. + - Verification: README больше не требует калибровать ширину по самому длинному предмету. +5. Прогнать прямые эквиваленты `npm test`, `npm run build`, `git diff --check` и визуальную проверку большого кадра; записать реальные результаты в `EVIDENCE.md`. + - Acceptance evidence: geometry 2×, screenshot точного выделения, границы двух реальных строк, diagnostics OCR/parse и вывод проверочных команд. + +## Non-goals + +- Не определять всю рамку tooltip и не сохранять его описательные строки, потому что продукту нужны название, количество и итоговая цена. +- Не искать `Price :` по всему кадру без ограничивающей полосы. +- Не добавлять настройку масштаба или новый UI-контрол в этом срезе. +- Не менять формат сохранённой калибровки. + +## Risks + +- Слишком короткий допустимый пробел обрежет текст; слишком длинный захватит соседний UI. +- Горизонтальная рамка внутри строки может выглядеть как непрерывный текст; точные вертикальные калиброванные строки остаются обязательными, а пустой/неограниченный пробег должен завершаться `not-found`, не широким OCR. diff --git a/docs/goals/dynamic-tooltip-crop/calibration-2x.png b/docs/goals/dynamic-tooltip-crop/calibration-2x.png new file mode 100644 index 0000000..003d79b Binary files /dev/null and b/docs/goals/dynamic-tooltip-crop/calibration-2x.png differ diff --git a/docs/goals/dynamic-tooltip-crop/tooltip-diagnostics.png b/docs/goals/dynamic-tooltip-crop/tooltip-diagnostics.png new file mode 100644 index 0000000..c0236e2 Binary files /dev/null and b/docs/goals/dynamic-tooltip-crop/tooltip-diagnostics.png differ diff --git a/docs/goals/store-icon-progress/EVIDENCE.md b/docs/goals/store-icon-progress/EVIDENCE.md new file mode 100644 index 0000000..359326d --- /dev/null +++ b/docs/goals/store-icon-progress/EVIDENCE.md @@ -0,0 +1,37 @@ +# Store Icon Progress Overlay Evidence + +## Implemented + +- `data/fixtures.json` describes every one of the 17 committed JPG files exactly once, including clean/tooltip sequence state and reviewed merchant/item/slot expectations. +- Live capture and the browser harness call the same `src/frame-analyzer.js#analyzeFrame`; no second OCR pipeline was added. +- Calibration supports optional independent Sell and Buy 6 × 3 grid regions. The calibration canvas is displayed at 2× while stored coordinates remain source pixels. +- A clean recognized shop frame records occupied cells as `pending`; a unique icon match above the absolute threshold and second-best margin changes only that cell to `found`. +- Active-shop changes and three consecutive missing sale anchors clear grid/slot progress without deleting parsed historical items. +- The selected source preview expands to at least 640 CSS px and draws pending slots yellow, found slots green, followed by transient diagnostics and a parsed/total counter. +- Slot thumbnails and the grid-captured marker are runtime-only and are excluded from clipboard JSON and the market outbox payload. + +## Verification run (2026-08-11) + +- `node --test`: **30 passed, 0 failed**. +- `vite build`: **passed**. Existing OpenCV browser-externalization and large-chunk warnings remain. +- `git diff --check`: **passed**; only the repository's expected LF-to-CRLF conversion warnings were printed. +- Playwright screenshot lane: **17/17 cases executed** through production OpenCV/Tesseract/parser, but the semantic assertion currently reports **42 mismatches**. The lane is intentionally red and must not be represented as acceptance-complete. + +Useful browser evidence despite the red aggregate: + +- Clean `sell-gnumli` detected exactly pending slots `[0,1]`. +- Clean `buy-rakot` detected exactly pending slots `[0,1,2]`. +- In the Gnumli sequence, the Blessed Spiritshot catalog crop uniquely matched slot `1`, producing `pending=[0]`, `found=[1]`. +- A targeted rerun wrote `sell-gnumli-clean-overlay.jpg` with both occupied cells yellow and `sell-gnumli-blessed-spiritshot-overlay.jpg` with slot 0 yellow / slot 1 green under the ignored Playwright result directory. +- The contextual `buy-kapayji` tooltip-only case produced no reported mismatch, including its pending-to-found slot transition. +- The remaining failures are dominated by low-resolution Lineage font OCR (for example `Domestos` → `Domastos`, `Gnumli` → `Enumli`) and lost tooltip quantities/names. Raising the occupancy threshold from `0.15` to `0.18` removed the observed false-positive `shotD` slot 16 on the final full-corpus rerun. +- The fixture failure output now contains `fixture-report.json`, one annotated overlay and per-stage crops for every failing case under ignored `test-results/`. +- Desktop and 600 px narrow source-card states were rendered from the production CSS contract and visually inspected during implementation. The preview measured at least 640 px on desktop and collapsed to one column without horizontal overflow at 600 px, but those synthetic-layout screenshots were not retained as durable goal artifacts and do not prove the live renderer/canvas call path. + +## Unproven / blocked evidence + +- Fixture catalog PNGs are reviewed screenshot crops, not bytes returned by the real `/api/l2/items` `iconUrl`. They prove the matching path and ambiguity guard, but not live catalog artwork compatibility. +- The local host returns no usable real catalog/calibration endpoints, so hosted calibration PUT→GET preservation and a real catalog icon score remain unverified. +- Manual OS display capture was not exercised; the CSS layout screenshots do not prove the real `getDisplayMedia` source or the actual `drawSourceOverlay` canvas call path. + +Acceptance remains partial until the 17-case semantic lane is green and one real API icon is paired with an occupied live slot. diff --git a/docs/goals/store-icon-progress/GOAL.md b/docs/goals/store-icon-progress/GOAL.md new file mode 100644 index 0000000..e976ef3 --- /dev/null +++ b/docs/goals/store-icon-progress/GOAL.md @@ -0,0 +1,21 @@ +# Goal: Store Icon Progress Overlay + +Use Krypton Execution to execute `docs/goals/store-icon-progress/PLAN.md` after the required Buy-store fixture and catalog icon URL evidence are available. + +Core rules: +- Treat PLAN.md as the source plan. +- Create `data/fixtures.json` from the reviewed 17-file corpus before changing recognition behavior. +- Keep fixture expected data human-owned; tests may report actual values but must never bless or rewrite expectations automatically. +- Keep fixture calibration, templates, catalog JSON/icon bytes and sequence pending/found states human-owned and versioned. +- Run fixtures through the single `src/frame-analyzer.js#analyzeFrame` path used by live capture; remove the inline implementation from `main.js` and do not create a test-only parser. +- Use real production OpenCV/Tesseract/parser in the Playwright lane; substitute only catalog transport with committed fixture assets. +- Preserve the active result as slot-state truth owner. +- Keep old six-field calibration readable and operational. +- Do not infer the parsed slot from tooltip position or hover order. +- Mark a slot green only for a unique best visual match above both the absolute threshold and second-best margin; ties remain yellow. +- Do not send runtime images or slot state to the backend. +- Make the active source preview readable at the real source-card scale before accepting overlay evidence. +- Verify optional grid calibration survives the hosted PUT-to-GET round trip. +- Capture yellow-to-green browser evidence and record it in EVIDENCE.md. +- Finish with one `npm run check` gate covering unit tests, all fixture screenshots, the production build and `git diff --check`. +- Say `implemented but unproven` if Buy/Sell or catalog-icon matching cannot be demonstrated. diff --git a/docs/goals/store-icon-progress/PLAN.md b/docs/goals/store-icon-progress/PLAN.md new file mode 100644 index 0000000..bf2cfd7 --- /dev/null +++ b/docs/goals/store-icon-progress/PLAN.md @@ -0,0 +1,167 @@ +# Store Icon Progress Overlay Implementation Plan + +**Intent:** Показывать прогресс обхода открытого магазина прямо поверх захваченного игрового кадра: занятые иконки ожидают tooltip в жёлтой рамке и становятся зелёными после успешного распознавания и привязки к слоту. +**Current Behavior:** Overlay показывает только служебные области текущего кадра и общий статус одного tooltip; приложение не знает список занятых слотов и не хранит прогресс по каждой иконке. +**Expected Outcome:** После распознавания магазина приложение находит занятые слоты его Buy/Sell-сетки, показывает их жёлтыми, сохраняет чистый снимок сетки без tooltip и переводит точный слот в зелёный после OCR предмета и совпадения его каталоговой иконки. +**Target-Perspective Output:** В развёрнутом превью выбранного окна (не уже 640 CSS px на desktop, когда позволяет viewport) виден реальный магазин с компактным чек-листом поверх его иконок: жёлтый означает «наведи курсор», зелёный — «этот слот уже прочитан»; рядом показан счётчик `2/5 иконок`. +**Truth Owner:** Активный `result` источника владеет текущим магазином и его `iconSlots`; `src/vision.js` владеет определением занятых ячеек и сравнением визуальной иконки, а `drawSourceOverlay` только рисует состояние. +**Contract Boundary:** Калибровка хранит необязательные `sellItemGridRegion` и `buyItemGridRegion` относительно найденного маркера магазина. `detectOccupiedSlots(frame, absoluteGridRect)` возвращает `{slotId,row,column,frameRect,score}[]`. После `resolveCatalogItem` `matchCatalogIconToSlot(slots, icon)` возвращает ровно один `slotId` только при прохождении абсолютного порога и отрыва от второго результата; иначе возвращает `null`. +**Cutover:** Существующие diagnostics boxes продолжают показывать pipeline, но прогресс иконок рисуется из отдельного устойчивого `result.iconSlots`; общий статус «Предмет ожидает» заменяется счётчиком слотов, когда сетка доступна. +**Displaced Path:** Не использовать ближайший к tooltip слот, порядок наведения или «первый жёлтый» как источник истины — эти эвристики могут покрасить не ту иконку. +**Value Density:** Две необязательные области калибровки, один лёгкий пиксельный проход по 18 ячейкам при открытии магазина и одно сравнение с pending slots после нового предмета; без OCR всех иконок и без нового backend-контракта. +**Evidence Gate:** Все 17 кадров из `data/` описаны в human-reviewed manifest и прогоняются тем же analyzer path, что live capture; на полном Sell-кадре занятые слоты выделены, пустые не выделены; на полном Buy-кадре работает отдельная область; после связанного tooltip ровно соответствующий слот меняет жёлтый на зелёный, а остальные остаются жёлтыми. +**Acceptance Evidence:** `npm run test:fixtures` сравнивает merchant/side/occupied slots/tooltip item с manifest для каждого файла и проверяет связанные store sequences; browser screenshots до/после tooltip для Sell и Buy при фактическом размере source card; unit tests occupied-slot classifier, unique-best/tie и active-shop reset; production build; calibration PUT→GET round-trip; отсутствие slot-данных в clipboard export/outbox. +**Evidence Lane:** Human-reviewed `data/fixtures.json`, локальный browser fixture runner, `node --test`, production build. +**Kill Criteria:** Нет второго overlay canvas, нет позиционного угадывания hovered slot, нет обязательной миграции старой калибровки, нет отправки thumbnail/data URL в backend; inline `analyzeFrame` удалён из `main.js`, и live capture/fixture runner импортируют один `analyzeFrame` из `src/frame-analyzer.js`. +**Architecture Slice:** `data/*.jpg`, `data/fixtures.json`, `index.html`, `src/main.js`, выделяемый production analyzer seam, `src/vision.js`, `src/parser.js`, fixture tests, `package.json`, `README.md`, `src/style.css`. +**Plan Review Gate:** Requires PRE review before execution. + +## Product Direction + +- Domain: private store, Buy/Sell grid, occupied slot, tooltip, catalog icon, captured frame, scan progress. +- Color world: тёмный игровой кадр, приглушённый amber ожидания, зелёный подтверждения, красный ошибки, нейтральный синий diagnostics. +- Signature: живой чек-лист непосредственно на слотах Lineage II, а не отдельный dashboard со списком. +- Rejected defaults: отдельная карточка каждого pending slot; анимированные пульсирующие рамки; окрашивание всех пустых клеток сетки. +- Direction: сохранить реальный кадр главным слоем и добавить только семантические рамки и компактный счётчик. + +## Component Checkpoint + +- Intent: владелец инструмента быстро видит, какие предметы в конкретном магазине ещё нужно обойти; интерфейс остаётся спокойным и утилитарным. +- Hierarchy: сам кадр и рамки слотов — focal point; textual status вторичен. +- Palette: существующие `pending` amber и `found` green; новые декоративные цвета не добавляются. +- Depth: borders-only поверх видео, без теней и glow. +- Surfaces: существующее preview surface; новый контейнер не создаётся. +- Typography: существующий маленький overlay status с tabular counter. +- Spacing: существующая плотность 4/8 px; рамка не перекрывает содержимое иконки. +- Readability: активное source preview разворачивается минимум до 640 CSS px на desktop; на узком viewport занимает доступную ширину без отдельного уменьшенного дубликата. + +## Architecture Slice + +- Files to create: `data/fixtures.json`, `data/fixture-calibration.json`, reviewed template/catalog assets under `data/fixture-assets/`, `src/frame-analyzer.js`, Playwright fixture regression spec and browser evidence images. +- Files to modify: `index.html`, `src/main.js`, `src/vision.js`, `src/parser.js`, fixture/unit tests, `package.json`, `README.md`, `src/style.css`; hosted calibration endpoint/schema добавляется в scope только если round-trip отбрасывает optional regions. +- Files to avoid: `src/market-outbox.js`, import proxy, backend payload, catalog API contract. +- Source of truth: `data/fixtures.json` владеет reviewed expected result/initial state/slot transitions каждого screenshot sequence; `data/fixture-calibration.json` и `data/fixture-assets/` владеют reviewed test geometry/templates/catalog bytes; production analyzer владеет фактическим результатом кадра; `result.activeShopKey` + `result.iconSlots` владеют live progress. +- Read path: fixture JPG + fixture calibration → тот же production analyzer, что live frame → actual result → semantic comparison with manifest; live frame → sale anchor → header side/merchant → side-specific grid → occupied slots → yellow overlay; tooltip → parsed item → catalog icon → saved grid snapshot → matched slot → green overlay. +- Write path: fixture manifest/calibration/assets редактируются только при добавлении или ручной перепроверке screenshots; тесты не переписывают expected values автоматически; calibration дополняется двумя optional regions; runtime slot state остаётся только в памяти источника. +- Contract boundary: `src/frame-analyzer.js` экспортирует единственный `analyzeFrame({frame,result,calibration,templates,source,services}) -> Promise`; production OCR/OpenCV/parser импортируются внутри этого модуля или передаются только через production adapters, а `services.catalog` является единственной fixture substitution; `detectOccupiedSlots(frame, absoluteGridRect) -> [{slotId,row,column,frameRect,score}]`; `matchCatalogIconToSlot(slots, icon) -> slotId|null`; canvas wrappers live in `vision.js`. +- Integration points: fixture manifest loader/schema, production `analyzeFrame`, `emptyCalibration`, calibration UI/addRegion/rendering, `resolveCatalogItem` result, `drawSourceOverlay`, package verification scripts. +- Migration/cutover: existing v4 calibration is read unchanged; missing grid region disables only icon progress for that side. +- Displaced path: общий item status остаётся fallback только при отсутствии grid calibration. +- Acceptance evidence gate: full-window Buy and Sell fixtures plus tooltip/item catalog icon. + +## Fixture Corpus and Ground Truth + +Все кадры имеют размер 1560 x 1360 и сняты персонажем `Deela`. `merchant` ниже — персонаж открытого магазина. Номер slot считается с нуля слева направо по верхней строке. Значения `unknown` не угадываются по иконке: такой fixture проверяет заголовок и занятость слота, но не название предмета. + +| File | Scene | Expected store | Visible/hovered contents | +| --- | --- | --- | --- | +| `Discord_2KrJ9GGjCP.jpg` | Buy + tooltip | `buy:KapayJI`, occupied `[0]` | slot 0: `Ancient Adena`, quantity `0`, each `2` Adena | +| `Discord_7xmx8OSuCH.jpg` | Sell, clean | `sell:Domestos`, occupied `[0]` | slot 0: `unknown` | +| `Discord_ARWzEIaayl.jpg` | Sell + tooltip | `sell:Gnumli`, occupied `[0,1]` | slot 0: `Soulshot: C-grade`, quantity `36,370`, price `20` Adena | +| `Discord_dxpCzaJEWD.jpg` | Sell + tooltip | `sell:Boroda4`, occupied `[0,1]` | slot 1: `Brigandine Helmet`, quantity `1`, price `800,000` Adena | +| `Discord_ezvlzpNHRg.jpg` | Sell + tooltip | `sell:shotD`, occupied `[0,1]` | slot 1: `Dimensional Fragment`, quantity `687`, price `3,500` Adena | +| `Discord_fb9f61z8Ek.jpg` | Sell + tooltip | `sell:shotD`, occupied `[0,1]` | slot 0: `Tutorial Guide`, quantity `1`, price `5,000,000` Adena | +| `Discord_fFbzX9vDhW.jpg` | Sell + tooltip | `sell:1SHOP`, occupied `[0]` | slot 0: `Ancient Adena`, quantity `1,793,000`, price `3` Adena | +| `Discord_ikzA9VhROZ.jpg` | Buy + tooltip | `buy:RAKOT`, occupied `[0,1,2]` | slot 2: `Coarse Bone Powder`, quantity `0`, each `1` Adena | +| `Discord_K9RkTrU6B2.jpg` | Sell, clean | `sell:Crom`, occupied `[0,1]` | slots 0–1: `unknown` | +| `Discord_KL0fnDcvr7.jpg` | Sell + tooltip | `sell:Boroda4`, occupied `[0,1]` | slot 0: `Spellbook: Prominence`, quantity `2`, price `50,000` Adena | +| `Discord_qy3EYIEcMV.jpg` | Sell + tooltip | `sell:Gnumli`, occupied `[0,1]` | slot 1: `Blessed Spiritshot: D-Grade`, quantity `7,245`, price `58` Adena | +| `Discord_sSJAGZLHvm.jpg` | Sell, clean | `sell:Boroda4`, occupied `[0,1]` | paired contents: `Spellbook: Prominence`, `Brigandine Helmet` | +| `Discord_tJFxvFi7DM.jpg` | Sell, clean | `sell:1SHOP`, occupied `[0]` | paired content: `Ancient Adena` | +| `Discord_TxbA20ZOQB.jpg` | Buy + tooltip | `buy:RAKOT`, occupied `[0,1,2]` | slot 0: `Scroll: Enchant Weapon (D)`, quantity `0`, each `200,000` Adena | +| `Discord_uqGcQhxIWc.jpg` | Sell, clean | `sell:shotD`, occupied `[0,1]` | paired contents: `Tutorial Guide`, `Dimensional Fragment` | +| `Discord_w3Smu4bOQK.jpg` | Sell, clean | `sell:Gnumli`, occupied `[0,1]` | paired contents: `Soulshot: C-grade`, `Blessed Spiritshot: D-Grade` | +| `Discord_wQc5FDayAK.jpg` | Buy, clean | `buy:RAKOT`, occupied `[0,1,2]` | slot 0: `Scroll: Enchant Weapon (D)`; slot 1: `unknown`; slot 2: `Coarse Bone Powder` | + +Manifest records both human-facing `displayName` and parser-facing normalized `name`, for example `Spellbook: Prominence` → `Spellbook Prominence`. It also records `viewerCharacter`, `groupId`, `scene`, standalone vs contextual assertion mode, `side`, `merchant`, `occupiedSlots`, optional `hoveredSlot`, optional reviewed `initialState`, `expectedPendingSlots`, `expectedFoundSlots`, `catalogAssetId`, and optional `{displayName,name,quantity,priceAdena,priceMode}`. Every `data/*.jpg` must appear exactly once; missing files and stale manifest entries fail before OCR starts. + +`data/fixture-calibration.json` is human-reviewed versioned truth for this 1560 x 1360 corpus: sale anchor, store header, tooltip search/anchor, name/price regions and Buy/Sell grid regions. Template images are committed as explicit files under `data/fixture-assets/`; runner never derives calibration from actual results. Catalog mappings point to committed API JSON plus original icon bytes by `catalogAssetId/path`, not to manifest-derived generated icons. + +Sequence groups: + +- `sell-gnumli`: clean → Soulshot tooltip → Blessed Spiritshot tooltip. +- `sell-boroda4`: clean → Spellbook tooltip → Brigandine Helmet tooltip. +- `sell-shotd`: clean → Tutorial Guide tooltip → Dimensional Fragment tooltip. +- `sell-1shop`: clean → Ancient Adena tooltip. +- `buy-rakot`: clean → Scroll tooltip → Coarse Bone Powder tooltip; middle slot intentionally remains unknown/pending. +- `sell-domestos`, `sell-crom`: standalone clean frames guarding the confirmed header and occupancy fields. +- `buy-kapayji`: tooltip-only partial group with human-reviewed `initialState` for `buy:KapayJI` and slot 0 pending; it validates tooltip parsing/transition only and does not claim standalone header/grid recognition. + +## Fixture Test Contract + +- Unit/schema lane (`node --test`): manifest validity, exact JPG coverage, unique ids, slot ranges, paired group consistency, normalization and pure state transitions. +- Screenshot lane (`npm run test:fixtures`): `@playwright/test` starts the Vite fixture page in bundled Chromium, loads real JPGs, applies committed fixture calibration, calls `src/frame-analyzer.js#analyzeFrame`, and compares only declared expected fields. It uses real production OpenCV/Tesseract/parser; only `/api/l2/items` transport is fulfilled from committed catalog JSON/icon bytes. +- Sequence lane: feed group frames in manifest order and assert remembered `side:merchant`, item aggregation and explicit `expectedPendingSlots`/`expectedFoundSlots` after every frame. Contextual-only fixtures start from their reviewed `initialState`; they are never reported as standalone full-frame passes. +- Diagnostics on failure: write an actual-vs-expected JSON report plus crop/overlay artifacts under an ignored test-output directory; never overwrite the manifest or source JPGs. +- Stable assertions: exact semantic merchant/side/name/quantity/price and slot ids; no pixel-perfect full-screen snapshots and no assertion on OCR confidence unless a threshold regression is specifically under test. +- Three-miss reset stays in the pure state-transition unit lane because the current corpus has no three-frame no-store sequence. +- Verification command: add `npm run check` for unit tests, all 17 fixture cases, production build and `git diff --check`; hosted calibration PUT→GET remains a separately recorded environment evidence gate because it requires the deployed backend. + +## Runtime Invariants + +- Buy и Sell используют разные calibrated grid regions; side выбирается только из распознанного заголовка. +- Grid region делится на фиксированную Interlude-сетку 6 x 3; пользователь выделяет внешний прямоугольник клеток без заголовка и scroll buttons. +- Пустые ячейки не получают рамку; занятые начинаются как `pending`. +- Occupancy считается только внутри фиксированного inset каждой ячейки, исключающего border; метрика и порог выбираются по измеренному разрыву между occupied/empty примерами обоих типов магазина и фиксируются константой с fixture-тестом. +- Grid snapshot обновляется только на кадре с распознанным заголовком и без найденного tooltip, чтобы tooltip не закрыл иконки. +- Смена `side:merchant` полностью сбрасывает runtime slots, но не исторические parsed items. +- Зелёный статус ставится только если лучший visual match превышает измеренный threshold и опережает второй результат не меньше чем на измеренный margin; tie/identical icons возвращают `null`, остаются жёлтыми и получают статус «неоднозначно». +- Повторный OCR того же предмета не перекрашивает другой одинаковый slot без нового однозначного совпадения. +- Slot thumbnails, grid snapshots и match scores не попадают в outbox или exported JSON. +- После трёх последовательных кадров без `saleAnchor`/нижнего маркера магазина (существующий `result.misses`) очищаются `activeShopKey`, grid snapshot и slots; отсутствие header OCR при видимом маркере, в том числе из-за tooltip, прогресс не сбрасывает; повторное открытие того же merchant строит состояние заново. + +## Tasks + +1. Зафиксировать corpus в `data/fixtures.json` и добавить schema/coverage tests. + - Allowed scope: существующие 17 JPG остаются неизменными; expected values вводятся вручную по таблице выше; unknown fields остаются явно unknown. + - Expected output: каждый JPG описан ровно один раз, связанные кадры объединены `groupId`, human display и parser-normalized item names разделены. + - Verification: `node --test` validates schema, coverage and group consistency. + - Parallel: no; establishes regression truth. +2. Выделить `src/frame-analyzer.js#analyzeFrame` и добавить Playwright browser fixture runner без дублирования pipeline. + - Allowed scope: перенести только ownership анализа кадра из DOM-heavy `main.js`; удалить inline implementation; live capture и fixtures импортируют один symbol; runner использует production OCR/OpenCV/parser, подменяя только внешний catalog transport committed fixture-ответом. + - Expected output: `npm run test:fixtures` обрабатывает JPGs, сравнивает только declared fields и сохраняет понятные failure artifacts. + - Verification: source search находит одно определение `analyzeFrame`; намеренно испорченный expected field даёт targeted diff; после возврата manifest corpus проходит. + - Parallel: no; establishes executable evidence lane. +3. Добавить optional Sell/Buy grid calibration в `index.html`, `src/main.js`, `src/parser.js`. + - Allowed scope: два поля относительно sale anchor; существующие шесть остаются обязательным минимумом. + - Expected output: старые сохранения работают; grid fields можно выбрать, удалить, сохранить и увидеть на calibration canvas. + - Verification: parser/calibration tests, browser selection smoke и hosted PUT→GET round-trip; если поля теряются, минимально расширить серверную схему. + - Parallel: no; extends the contract. +4. Добавить в `src/vision.js` минимальные slot helpers: 6 x 3 geometry, occupied-cell classifier и unique-best matching catalog icon к pending slots. + - Allowed scope: локальная grid image analysis; использовать существующий OpenCV loader/matcher. + - Expected output: provided Sell fixture returns exactly its occupied cells; empty grid returns none. + - Verification: Node tests для geometry/classifier seam, absolute threshold, second-best margin, identical-icon tie и browser fixture for canvas wrapper. + - Parallel: no; consumes tasks 1-3 contracts and uses both Sell/Buy fixture corpus. +5. Интегрировать runtime state в `src/main.js`. + - Allowed scope: active-shop reset, clean grid snapshot, slot match after `resolveCatalogItem`, no backend writes. + - Expected output: stable pending/found slots persist across frames and reset on shop change/close. + - Verification: focused state-transition tests, three-miss close/reset, reopening same merchant and two-frame browser flow; assert no slot/snapshot fields in clipboard export and outbox. + - Parallel: no; consumes tasks 3-4. +6. Развернуть активное live-превью и расширить существующий `drawSourceOverlay`/status copy. + - Allowed scope: selected source preview минимум 640 CSS px на desktop; draw slot rects before transient diagnostics; reuse `boxColor(pending/found)`. + - Expected output: игровые слоты читаемы в карточке источника, yellow/green outlines и `parsed/total` counter; no new canvas or motion. + - Verification: desktop screenshot именно при фактическом размере source card, narrow viewport screenshot, squint/token/state checks. + - Parallel: can start after runtime contract is fixed. +7. Обновить `README.md`, добавить единый `npm run check`, прогнать tests/build/diff-check и записать browser evidence в `EVIDENCE.md`. + - Acceptance evidence: manifest coverage report `17/17`, Sell and Buy yellow states, exact green transition, shop reset. + - Parallel: no. + +## Non-goals + +- Автоматически кликать или наводить курсор в игровом окне. +- OCR названий прямо с 32 px icon. +- Сохранять slot progress между перезапусками приложения. +- Менять каталог, import payload или исторический список результатов. +- Угадывать слот по положению tooltip или порядку обхода. + +## Required Input Before Execution + +- Реальный ответ `/api/l2/items` для одного известного предмета из Sell fixture, включая `iconUrl`, и доступные по этому URL bytes (либо контролируемый локальный/hosted fixture). Same-origin сам по себе не доказывает, что catalog artwork, alpha и размер совпадают с игровой иконкой. +- Buy fixture теперь есть: `Discord_wQc5FDayAK.jpg`; по нему и clean Sell fixtures во время исполнения измерить occupied/empty metric gap и только затем зафиксировать cell inset/threshold. +- По реальной паре slot crop ↔ catalog icon измерить absolute match threshold и best-vs-second margin; до этого зелёная привязка считается непроверенной. + +## Risks + +- Кастомный клиент может изменить размер/число slot cells; фиксированная 6 x 3 геометрия тогда потребует отдельной настройки. +- Catalog icon может отличаться рамкой, альфой или масштабом от клиентского asset; evidence должен зафиксировать реальный match score до выбора порога. +- Одинаковые иконки в нескольких слотах требуют дополнительного различителя; до него неоднозначные совпадения должны оставаться жёлтыми. diff --git a/e2e/fixtures.spec.js b/e2e/fixtures.spec.js new file mode 100644 index 0000000..356cca7 --- /dev/null +++ b/e2e/fixtures.spec.js @@ -0,0 +1,29 @@ +import { test, expect } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; + +test("production analyzer matches the reviewed screenshot corpus", async ({ page }, testInfo) => { + const fixture = process.env.FIXTURE_ID; + await page.goto(fixture ? `/fixture.html?fixture=${encodeURIComponent(fixture)}` : "/fixture.html"); + const result = await page.evaluate(() => window.__fixtureRun); + await writeFile(testInfo.outputPath("fixture-report.json"), JSON.stringify(result, null, 2)); + const artifacts = await page.evaluate(() => window.__fixtureArtifacts); + for (const [fixtureId, artifact] of Object.entries(artifacts)) { + const images = [ + ["overlay", artifact.preview], + ...artifact.stages.map(({ key, image }) => [key, image]), + ]; + for (const [label, dataUrl] of images) { + if (!dataUrl) continue; + const [, encoded] = dataUrl.split(",", 2); + const extension = dataUrl.startsWith("data:image/png") ? "png" : "jpg"; + await writeFile( + testInfo.outputPath(`${fixtureId}-${label}.${extension}`), + Buffer.from(encoded, "base64"), + ); + } + } + expect(result.total).toBeGreaterThan(0); + expect(result.completed).toBe(result.total); + if (!fixture) expect(result.completed).toBe(17); + expect(result.failures, result.failures.join("\n")).toEqual([]); +}); diff --git a/fixture.html b/fixture.html new file mode 100644 index 0000000..6fa7ae9 --- /dev/null +++ b/fixture.html @@ -0,0 +1,19 @@ + + + + + + L2 fixture regression + + + +

Fixture regression

+

Подготовка…

+

+    
+  
+
diff --git a/index.html b/index.html
index 525ac30..2d00408 100644
--- a/index.html
+++ b/index.html
@@ -110,6 +110,8 @@