diff --git a/.gitignore b/.gitignore index e84f3b8..4f919d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,25 @@ +# C# build output +bin/ +obj/ + +# Node.js node_modules/ dist/ + +# Python +__pycache__/ +.venv/ + +# Secrets / config .env config.json + +# Runtime data browser-data/ *.log debug-screenshots/ items/ -eng.traineddata + +# IDE / tools .claude/ nul - -# OcrDaemon build output -tools/OcrDaemon/bin/ -tools/OcrDaemon/obj/ diff --git a/Poe2Trade.sln b/Poe2Trade.sln new file mode 100644 index 0000000..7611b70 --- /dev/null +++ b/Poe2Trade.sln @@ -0,0 +1,83 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "csharp", "csharp", "{67A27DFE-D2C5-479D-86FE-7E156BD0CFAA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Core", "src\Poe2Trade.Core\Poe2Trade.Core.csproj", "{6432F6A5-11A0-4960-AFFC-E810D4325C35}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Game", "src\Poe2Trade.Game\Poe2Trade.Game.csproj", "{97B8362D-777C-4ED1-B964-D6598B333E4C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Screen", "src\Poe2Trade.Screen\Poe2Trade.Screen.csproj", "{F92C5EA2-8999-41BC-9B28-D52AD5F3542C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Items", "src\Poe2Trade.Items\Poe2Trade.Items.csproj", "{9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Trade", "src\Poe2Trade.Trade\Poe2Trade.Trade.csproj", "{8F73A696-EB54-4C6F-9603-5A6BAC5D334A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Log", "src\Poe2Trade.Log\Poe2Trade.Log.csproj", "{B68D787D-7A83-4D8F-9F10-0B72C2E99B49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Bot", "src\Poe2Trade.Bot\Poe2Trade.Bot.csproj", "{188C4F87-153F-4182-B816-9FB56F08CF3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Inventory", "src\Poe2Trade.Inventory\Poe2Trade.Inventory.csproj", "{F186DDC8-6843-43E9-8BD3-9F914C5E784E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Poe2Trade.Ui", "src\Poe2Trade.Ui\Poe2Trade.Ui.csproj", "{859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6432F6A5-11A0-4960-AFFC-E810D4325C35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6432F6A5-11A0-4960-AFFC-E810D4325C35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6432F6A5-11A0-4960-AFFC-E810D4325C35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6432F6A5-11A0-4960-AFFC-E810D4325C35}.Release|Any CPU.Build.0 = Release|Any CPU + {97B8362D-777C-4ED1-B964-D6598B333E4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97B8362D-777C-4ED1-B964-D6598B333E4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97B8362D-777C-4ED1-B964-D6598B333E4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97B8362D-777C-4ED1-B964-D6598B333E4C}.Release|Any CPU.Build.0 = Release|Any CPU + {F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F92C5EA2-8999-41BC-9B28-D52AD5F3542C}.Release|Any CPU.Build.0 = Release|Any CPU + {9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00}.Release|Any CPU.Build.0 = Release|Any CPU + {8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F73A696-EB54-4C6F-9603-5A6BAC5D334A}.Release|Any CPU.Build.0 = Release|Any CPU + {B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B68D787D-7A83-4D8F-9F10-0B72C2E99B49}.Release|Any CPU.Build.0 = Release|Any CPU + {188C4F87-153F-4182-B816-9FB56F08CF3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {188C4F87-153F-4182-B816-9FB56F08CF3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {188C4F87-153F-4182-B816-9FB56F08CF3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {188C4F87-153F-4182-B816-9FB56F08CF3A}.Release|Any CPU.Build.0 = Release|Any CPU + {F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F186DDC8-6843-43E9-8BD3-9F914C5E784E}.Release|Any CPU.Build.0 = Release|Any CPU + {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6432F6A5-11A0-4960-AFFC-E810D4325C35} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {97B8362D-777C-4ED1-B964-D6598B333E4C} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {F92C5EA2-8999-41BC-9B28-D52AD5F3542C} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {9CAB0D49-1E24-4F76-ABF8-9A5ED6819F00} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {8F73A696-EB54-4C6F-9603-5A6BAC5D334A} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {B68D787D-7A83-4D8F-9F10-0B72C2E99B49} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {188C4F87-153F-4182-B816-9FB56F08CF3A} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {F186DDC8-6843-43E9-8BD3-9F914C5E784E} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + {859F870E-F013-4C2B-AFEC-7A8C6A5FE3F3} = {67A27DFE-D2C5-479D-86FE-7E156BD0CFAA} + EndGlobalSection +EndGlobal diff --git a/crop-test-cmd.txt b/crop-test-cmd.txt deleted file mode 100644 index 09ac825..0000000 --- a/crop-test-cmd.txt +++ /dev/null @@ -1 +0,0 @@ -{"cmd":"crop-test","engine":"diff"} diff --git a/crop-test-stderr.txt b/crop-test-stderr.txt deleted file mode 100644 index a6f68d4..0000000 --- a/crop-test-stderr.txt +++ /dev/null @@ -1,2 +0,0 @@ -{"ok":true,"ready":true} -{"ok":true,"method":"edge","avgIoU":0.7689866918165986,"results":[{"id":"1","iou":0.9028985507246376,"expected":{"x":0,"y":84,"width":1185,"height":690},"actual":{"x":0,"y":117,"width":1185,"height":623},"deltaTop":33,"deltaLeft":0,"deltaRight":0,"deltaBottom":-34},{"id":"2","iou":0.6861386480207926,"expected":{"x":304,"y":0,"width":679,"height":470},"actual":{"x":428,"y":40,"width":564,"height":474},"deltaTop":40,"deltaLeft":124,"deltaRight":9,"deltaBottom":44},{"id":"3","iou":0.8734518726233722,"expected":{"x":473,"y":334,"width":641,"height":580},"actual":{"x":472,"y":373,"width":609,"height":548},"deltaTop":39,"deltaLeft":-1,"deltaRight":-33,"deltaBottom":7},{"id":"4","iou":0.4827177898385173,"expected":{"x":209,"y":264,"width":888,"height":651},"actual":{"x":0,"y":294,"width":767,"height":634},"deltaTop":30,"deltaLeft":-209,"deltaRight":-330,"deltaBottom":13},{"id":"5","iou":0.8933684252502293,"expected":{"x":763,"y":0,"width":1111,"height":560},"actual":{"x":758,"y":39,"width":1080,"height":523},"deltaTop":39,"deltaLeft":-5,"deltaRight":-36,"deltaBottom":2},{"id":"6","iou":0.9159954398801851,"expected":{"x":1541,"y":154,"width":807,"height":460},"actual":{"x":1486,"y":157,"width":870,"height":460},"deltaTop":3,"deltaLeft":-55,"deltaRight":8,"deltaBottom":3},{"id":"7","iou":0.6283361163784564,"expected":{"x":1921,"y":40,"width":637,"height":330},"actual":{"x":1946,"y":72,"width":447,"height":302},"deltaTop":32,"deltaLeft":25,"deltaRight":-165,"deltaBottom":4}]} diff --git a/crop-test-stdout.txt b/crop-test-stdout.txt deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 601018f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2888 +0,0 @@ -{ - "name": "poe2trade", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "poe2trade", - "version": "1.0.0", - "dependencies": { - "chokidar": "^4.0.3", - "clipboard-sys": "^1.2.0", - "commander": "^13.1.0", - "dotenv": "^16.4.7", - "express": "^5.2.1", - "koffi": "^2.9.2", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0", - "playwright": "^1.50.1", - "screenshot-desktop": "^1.15.0", - "sharp": "^0.33.5", - "tesseract.js": "^5.1.1", - "windows-media-ocr": "^0.0.10", - "ws": "^8.19.0" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^22.13.1", - "@types/ws": "^8.18.1", - "tsx": "^4.19.2", - "typescript": "^5.7.3" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/bmp-js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", - "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/clipboard-sys": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clipboard-sys/-/clipboard-sys-1.2.1.tgz", - "integrity": "sha512-Ln/sq2sspjDrrBzgWHGN1hwo/GCZNpGBU+W1Cr2x31P7FlUTNiot0tbuqS/OXANd3konhUXrk4qPPtXpc4OXNw==", - "license": "MIT", - "os": [ - "linux", - "darwin", - "win32" - ], - "dependencies": { - "execa": "5.1.1", - "fs-extra": "^10.1.0" - } - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/dateformat": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", - "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-copy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", - "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/help-me": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", - "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/idb-keyval": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", - "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/is-electron": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", - "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", - "license": "MIT" - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/koffi": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.1.tgz", - "integrity": "sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "license": "MIT", - "bin": { - "opencollective-postinstall": "index.js" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-pretty": { - "version": "13.1.3", - "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", - "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", - "license": "MIT", - "dependencies": { - "colorette": "^2.0.7", - "dateformat": "^4.6.3", - "fast-copy": "^4.0.0", - "fast-safe-stringify": "^2.1.1", - "help-me": "^5.0.0", - "joycon": "^3.1.1", - "minimist": "^1.2.6", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^3.0.0", - "pump": "^3.0.0", - "secure-json-parse": "^4.0.0", - "sonic-boom": "^4.0.1", - "strip-json-comments": "^5.0.2" - }, - "bin": { - "pino-pretty": "bin.js" - } - }, - "node_modules/pino-pretty/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/screenshot-desktop": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/screenshot-desktop/-/screenshot-desktop-1.15.3.tgz", - "integrity": "sha512-bHztitCmaa+A+ssxRa3LDNepQzCHEEAkz1FaJjoZx2yDMkHIkHLKKcc5xMgkGNas97wSGboSB2BoO0c4RnjlJw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/bencevans" - } - ], - "license": "MIT", - "dependencies": { - "temp": "^0.9.4" - } - }, - "node_modules/secure-json-parse": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", - "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", - "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "license": "MIT", - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/tesseract.js": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz", - "integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "bmp-js": "^0.1.0", - "idb-keyval": "^6.2.0", - "is-electron": "^2.2.2", - "is-url": "^1.2.4", - "node-fetch": "^2.6.9", - "opencollective-postinstall": "^2.0.3", - "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^5.1.1", - "wasm-feature-detect": "^1.2.11", - "zlibjs": "^0.3.1" - } - }, - "node_modules/tesseract.js-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz", - "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==", - "license": "Apache-2.0" - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wasm-feature-detect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", - "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/windows-media-ocr": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/windows-media-ocr/-/windows-media-ocr-0.0.10.tgz", - "integrity": "sha512-OZkYZRwv/wByIvOLfAAFvEJqZF6v4CyYYYLvMjvHqrXpGX4pFfIb1bMoO7nrm/KpSnXISWs8EhzVICcGQUEuAg==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/zlibjs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", - "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", - "license": "MIT", - "engines": { - "node": "*" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index d582e8e..0000000 --- a/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "poe2trade", - "version": "1.0.0", - "description": "POE2 trade bot - automated item purchasing via trade site monitoring", - "type": "module", - "main": "dist/index.js", - "scripts": { - "dev": "dotnet build tools/OcrDaemon -c Release && tsx src/index.ts", - "build": "tsc", - "build:daemon": "dotnet build tools/OcrDaemon -c Release", - "start": "node dist/index.js", - "stop:daemon": "taskkill /IM OcrDaemon.exe /F 2>nul || exit /b 0", - "test:ocr": "taskkill /IM OcrDaemon.exe /F 2>nul & dotnet build tools/OcrDaemon -c Release && echo {\"cmd\":\"test\"} | tools\\OcrDaemon\\bin\\Release\\net8.0-windows10.0.19041.0\\OcrDaemon.exe", - "tune:ocr": "taskkill /IM OcrDaemon.exe /F 2>nul & dotnet build tools/OcrDaemon -c Release && echo {\"cmd\":\"tune\"} | tools\\OcrDaemon\\bin\\Release\\net8.0-windows10.0.19041.0\\OcrDaemon.exe", - "generate:words": "node tools/OcrDaemon/tessdata/generate-words.mjs" - }, - "dependencies": { - "chokidar": "^4.0.3", - "clipboard-sys": "^1.2.0", - "commander": "^13.1.0", - "dotenv": "^16.4.7", - "express": "^5.2.1", - "koffi": "^2.9.2", - "pino": "^9.6.0", - "pino-pretty": "^13.0.0", - "playwright": "^1.50.1", - "ws": "^8.19.0" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^22.13.1", - "@types/ws": "^8.18.1", - "tsx": "^4.19.2", - "typescript": "^5.7.3" - } -} diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs new file mode 100644 index 0000000..f932b6f --- /dev/null +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -0,0 +1,296 @@ +using Microsoft.Playwright; +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Inventory; +using Poe2Trade.GameLog; +using Poe2Trade.Screen; +using Poe2Trade.Trade; +using Serilog; + +namespace Poe2Trade.Bot; + +public class BotStatus +{ + public bool Paused { get; set; } + public string State { get; set; } = "Idle"; + public List Links { get; set; } = []; + public int TradesCompleted { get; set; } + public int TradesFailed { get; set; } + public long Uptime { get; set; } +} + +public class BotOrchestrator : IAsyncDisposable +{ + private bool _paused; + private string _state = "Idle"; + private int _tradesCompleted; + private int _tradesFailed; + private readonly long _startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + private bool _started; + + public LinkManager Links { get; } + public ConfigStore Store { get; } + public AppConfig Config { get; } + + public GameController Game { get; private set; } = null!; + public ScreenReader Screen { get; private set; } = null!; + public ClientLogWatcher LogWatcher { get; private set; } = null!; + public TradeMonitor TradeMonitor { get; private set; } = null!; + public InventoryManager Inventory { get; private set; } = null!; + public TradeExecutor TradeExecutor { get; private set; } = null!; + public TradeQueue TradeQueue { get; private set; } = null!; + private readonly Dictionary _scrapExecutors = new(); + + // Events + public event Action? StatusUpdated; + public event Action? LogMessage; // level, message + + public BotOrchestrator(ConfigStore store, AppConfig config) + { + Store = store; + Config = config; + _paused = store.Settings.Paused; + Links = new LinkManager(store); + } + + public bool IsReady => _started; + public bool IsPaused => _paused; + + public string State + { + get => _state; + set + { + if (_state == value) return; + _state = value; + StatusUpdated?.Invoke(); + } + } + + public void Pause() + { + _paused = true; + Store.SetPaused(true); + Log.Information("Bot paused"); + StatusUpdated?.Invoke(); + } + + public void Resume() + { + _paused = false; + Store.SetPaused(false); + Log.Information("Bot resumed"); + StatusUpdated?.Invoke(); + } + + public TradeLink AddLink(string url, string? name = null, LinkMode? mode = null, PostAction? postAction = null) + { + var link = Links.AddLink(url, name ?? "", mode, postAction); + StatusUpdated?.Invoke(); + return link; + } + + public void RemoveLink(string id) + { + Links.RemoveLink(id); + StatusUpdated?.Invoke(); + } + + public void ToggleLink(string id, bool active) + { + var link = Links.ToggleLink(id, active); + if (link == null) return; + StatusUpdated?.Invoke(); + + if (active) + _ = ActivateLink(link); + else + _ = DeactivateLink(id); + } + + public BotStatus GetStatus() => new() + { + Paused = _paused, + State = _state, + Links = Links.GetLinks(), + TradesCompleted = _tradesCompleted, + TradesFailed = _tradesFailed, + Uptime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _startTime, + }; + + public void UpdateExecutorState() + { + var execState = TradeExecutor.State; + if (execState != TradeState.Idle) + { + State = execState.ToString(); + return; + } + foreach (var scrapExec in _scrapExecutors.Values) + { + if (scrapExec.State != ScrapState.Idle) + { + State = scrapExec.State.ToString(); + return; + } + } + State = "Idle"; + } + + public async Task Start(IReadOnlyList cliUrls) + { + Screen = new ScreenReader(); + Game = new GameController(Config); + LogWatcher = new ClientLogWatcher(Config.Poe2LogPath); + LogWatcher.Start(); + Emit("info", "Watching Client.txt for game events"); + + TradeMonitor = new TradeMonitor(Config); + await TradeMonitor.Start(); + Emit("info", "Browser launched"); + + Inventory = new InventoryManager(Game, Screen, LogWatcher, Config); + + // Warmup OCR daemon + var ocrWarmup = Screen.Warmup().ContinueWith(t => + { + if (t.IsFaulted) Log.Warning(t.Exception!, "OCR warmup failed"); + }); + + // Check if already in hideout + var inHideout = LogWatcher.CurrentArea.Contains("hideout", StringComparison.OrdinalIgnoreCase); + if (inHideout) + { + Log.Information("Already in hideout: {Area}", LogWatcher.CurrentArea); + Inventory.SetLocation(true); + } + else + { + Emit("info", "Sending /hideout command..."); + await Game.FocusGame(); + var arrivedHome = await Inventory.WaitForAreaTransition(Config.TravelTimeoutMs, () => Game.GoToHideout()); + Inventory.SetLocation(true); + if (!arrivedHome) + Log.Warning("Timed out waiting for hideout transition on startup"); + } + + State = "InHideout"; + Emit("info", "In hideout, ready to trade"); + + await ocrWarmup; + + Emit("info", "Checking inventory for leftover items..."); + await Inventory.ClearToStash(); + Emit("info", "Inventory cleared"); + + // Create executors + TradeExecutor = new TradeExecutor(Game, Screen, TradeMonitor, Inventory, Config); + TradeExecutor.StateChanged += _ => UpdateExecutorState(); + TradeQueue = new TradeQueue(TradeExecutor, Config); + TradeQueue.TradeCompleted += () => { _tradesCompleted++; StatusUpdated?.Invoke(); }; + TradeQueue.TradeFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); }; + + // Load links + var allUrls = new HashSet(cliUrls); + foreach (var l in Store.Settings.Links) + allUrls.Add(l.Url); + + foreach (var url in allUrls) + { + var link = Links.AddLink(url); + if (link.Active) + await ActivateLink(link); + else + Emit("info", $"Loaded (inactive): {link.Name}"); + } + + // Wire trade monitor events + TradeMonitor.NewListings += OnNewListings; + + _started = true; + Emit("info", $"Loaded {allUrls.Count} trade link(s)"); + Log.Information("Bot started"); + } + + public async ValueTask DisposeAsync() + { + Log.Information("Shutting down bot..."); + foreach (var exec in _scrapExecutors.Values) + await exec.Stop(); + Screen.Dispose(); + await TradeMonitor.DisposeAsync(); + LogWatcher.Dispose(); + } + + private void OnNewListings(string searchId, List itemIds, IPage page) + { + if (_paused) + { + Emit("warn", $"New listings ({itemIds.Count}) skipped - bot paused"); + return; + } + if (!Links.IsActive(searchId)) return; + + Log.Information("New listings: {SearchId} ({Count} items)", searchId, itemIds.Count); + Emit("info", $"New listings: {itemIds.Count} items from {searchId}"); + + TradeQueue.Enqueue(new TradeInfo( + SearchId: searchId, + ItemIds: itemIds, + WhisperText: "", + Timestamp: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + TradeUrl: "", + Page: page + )); + } + + private async Task ActivateLink(TradeLink link) + { + try + { + if (link.Mode == LinkMode.Scrap) + { + var scrapExec = new ScrapExecutor(Game, Screen, TradeMonitor, Inventory, Config); + scrapExec.StateChanged += _ => UpdateExecutorState(); + scrapExec.ItemBought += () => { _tradesCompleted++; StatusUpdated?.Invoke(); }; + scrapExec.ItemFailed += () => { _tradesFailed++; StatusUpdated?.Invoke(); }; + _scrapExecutors[link.Id] = scrapExec; + Emit("info", $"Scrap loop started: {link.Name}"); + StatusUpdated?.Invoke(); + + _ = scrapExec.RunScrapLoop(link.Url, link.PostAction).ContinueWith(t => + { + if (t.IsFaulted) + { + Log.Error(t.Exception!, "Scrap loop error: {LinkId}", link.Id); + Emit("error", $"Scrap loop failed: {link.Name}"); + _scrapExecutors.Remove(link.Id); + } + }); + } + else + { + await TradeMonitor.AddSearch(link.Url); + Emit("info", $"Monitoring: {link.Name}"); + StatusUpdated?.Invoke(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Failed to activate link: {Url}", link.Url); + Emit("error", $"Failed to activate: {link.Name}"); + } + } + + private async Task DeactivateLink(string id) + { + if (_scrapExecutors.TryGetValue(id, out var scrapExec)) + { + await scrapExec.Stop(); + _scrapExecutors.Remove(id); + } + await TradeMonitor.PauseSearch(id); + } + + private void Emit(string level, string message) => LogMessage?.Invoke(level, message); +} diff --git a/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj b/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj new file mode 100644 index 0000000..e39188c --- /dev/null +++ b/src/Poe2Trade.Bot/Poe2Trade.Bot.csproj @@ -0,0 +1,15 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + + + + + + + + + + diff --git a/src/Poe2Trade.Bot/ScrapExecutor.cs b/src/Poe2Trade.Bot/ScrapExecutor.cs new file mode 100644 index 0000000..0d0978c --- /dev/null +++ b/src/Poe2Trade.Bot/ScrapExecutor.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Microsoft.Playwright; +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Inventory; +using Poe2Trade.Screen; +using Poe2Trade.Trade; +using Serilog; + +namespace Poe2Trade.Bot; + +public class ScrapExecutor +{ + private ScrapState _state = ScrapState.Idle; + private bool _stopped; + private IPage? _activePage; + private PostAction _postAction = PostAction.Salvage; + private readonly GameController _game; + private readonly ScreenReader _screen; + private readonly TradeMonitor _tradeMonitor; + private readonly InventoryManager _inventory; + private readonly AppConfig _config; + + public event Action? StateChanged; + public event Action? ItemBought; + public event Action? ItemFailed; + + public ScrapExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor, + InventoryManager inventory, AppConfig config) + { + _game = game; + _screen = screen; + _tradeMonitor = tradeMonitor; + _inventory = inventory; + _config = config; + } + + public ScrapState State => _state; + + private void SetState(ScrapState s) + { + _state = s; + StateChanged?.Invoke(s); + } + + public async Task Stop() + { + _stopped = true; + if (_activePage != null) + { + try { await _activePage.CloseAsync(); } catch { } + _activePage = null; + } + SetState(ScrapState.Idle); + Log.Information("Scrap executor stopped"); + } + + public async Task RunScrapLoop(string tradeUrl, PostAction postAction = PostAction.Salvage) + { + _stopped = false; + _postAction = postAction; + Log.Information("Starting scrap loop: {Url} postAction={Action}", tradeUrl, postAction); + + await _inventory.ScanInventory(_postAction); + + var (page, items) = await _tradeMonitor.OpenScrapPage(tradeUrl); + _activePage = page; + Log.Information("Trade page opened: {Count} items", items.Count); + + while (!_stopped) + { + var salvageFailed = false; + foreach (var item in items) + { + if (_stopped) break; + + if (!_inventory.Tracker.CanFit(item.W, item.H)) + { + if (salvageFailed) continue; + Log.Information("No room for {W}x{H}, processing...", item.W, item.H); + await ProcessItems(); + if (_state == ScrapState.Failed) + { + salvageFailed = true; + SetState(ScrapState.Idle); + continue; + } + await _inventory.ScanInventory(_postAction); + } + + if (!_inventory.Tracker.CanFit(item.W, item.H)) + { + Log.Warning("Item {W}x{H} still cannot fit after processing, skipping", item.W, item.H); + continue; + } + + var success = await BuyItem(page, item); + if (!success) Log.Warning("Failed to buy item {Id}", item.Id); + + await Helpers.RandomDelay(500, 1000); + } + + if (_stopped) break; + + Log.Information("Page exhausted, refreshing..."); + items = await RefreshPage(page); + Log.Information("Page refreshed: {Count} items", items.Count); + + if (items.Count == 0) + { + Log.Information("No items after refresh, waiting..."); + await Helpers.Sleep(5000); + if (_stopped) break; + items = await RefreshPage(page); + } + } + + _activePage = null; + SetState(ScrapState.Idle); + Log.Information("Scrap loop ended"); + } + + private async Task BuyItem(IPage page, TradeItem item) + { + try + { + var alreadyAtSeller = !_inventory.IsAtOwnHideout + && !string.IsNullOrEmpty(item.Account) + && item.Account == _inventory.SellerAccount; + + if (alreadyAtSeller) + { + Log.Information("Already at seller hideout, skipping travel"); + } + else + { + SetState(ScrapState.Traveling); + var arrived = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, + async () => + { + if (!await _tradeMonitor.ClickTravelToHideout(page, item.Id)) + throw new Exception("Failed to click Travel to Hideout"); + }); + if (!arrived) + { + Log.Error("Timed out waiting for hideout arrival: {ItemId}", item.Id); + SetState(ScrapState.Failed); + return false; + } + _inventory.SetLocation(false, item.Account); + await _game.FocusGame(); + await Helpers.Sleep(1500); + } + + SetState(ScrapState.Buying); + var sellerLayout = GridLayouts.Seller; + var cellCenter = _screen.Grid.GetCellCenter(sellerLayout, item.StashY, item.StashX); + Log.Information("CTRL+clicking seller stash at ({X},{Y})", cellCenter.X, cellCenter.Y); + + await _game.CtrlLeftClickAt(cellCenter.X, cellCenter.Y); + await Helpers.RandomDelay(200, 400); + + _inventory.Tracker.TryPlace(item.W, item.H, _postAction); + Log.Information("Item bought: {Id} (free={Free})", item.Id, _inventory.Tracker.FreeCells); + SetState(ScrapState.Idle); + ItemBought?.Invoke(); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error buying item {Id}", item.Id); + SetState(ScrapState.Failed); + ItemFailed?.Invoke(); + return false; + } + } + + private async Task ProcessItems() + { + try + { + SetState(ScrapState.Salvaging); + await _inventory.ProcessInventory(); + SetState(ScrapState.Idle); + Log.Information("Process cycle complete"); + } + catch (Exception ex) + { + Log.Error(ex, "Process cycle failed"); + SetState(ScrapState.Failed); + } + } + + private async Task> RefreshPage(IPage page) + { + var items = new List(); + + void OnResponse(object? _, IResponse response) + { + if (!response.Url.Contains("/api/trade2/fetch/")) return; + try + { + var body = response.TextAsync().GetAwaiter().GetResult(); + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("result", out var results) && + results.ValueKind == JsonValueKind.Array) + { + foreach (var r in results.EnumerateArray()) + items.Add(TradeMonitor.ParseTradeItem(r)); + } + } + catch { } + } + + page.Response += OnResponse; + await page.ReloadAsync(new PageReloadOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await Helpers.Sleep(2000); + page.Response -= OnResponse; + return items; + } +} diff --git a/src/Poe2Trade.Bot/TradeExecutor.cs b/src/Poe2Trade.Bot/TradeExecutor.cs new file mode 100644 index 0000000..e4fa788 --- /dev/null +++ b/src/Poe2Trade.Bot/TradeExecutor.cs @@ -0,0 +1,147 @@ +using Microsoft.Playwright; +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Inventory; +using Poe2Trade.Screen; +using Poe2Trade.Trade; +using Serilog; + +namespace Poe2Trade.Bot; + +public class TradeExecutor +{ + private TradeState _state = TradeState.Idle; + private readonly GameController _game; + private readonly ScreenReader _screen; + private readonly TradeMonitor _tradeMonitor; + private readonly InventoryManager _inventory; + private readonly AppConfig _config; + + public event Action? StateChanged; + + public TradeExecutor(GameController game, ScreenReader screen, TradeMonitor tradeMonitor, + InventoryManager inventory, AppConfig config) + { + _game = game; + _screen = screen; + _tradeMonitor = tradeMonitor; + _inventory = inventory; + _config = config; + } + + public TradeState State => _state; + + private void SetState(TradeState s) + { + _state = s; + StateChanged?.Invoke(s); + } + + public async Task ExecuteTrade(TradeInfo trade) + { + var page = trade.Page as IPage; + if (page == null) + { + Log.Error("Trade has no page reference"); + return false; + } + + try + { + // Step 1: Travel to seller hideout + SetState(TradeState.Traveling); + Log.Information("Clicking Travel to Hideout for {SearchId}...", trade.SearchId); + + var arrived = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, + async () => + { + if (!await _tradeMonitor.ClickTravelToHideout(page, trade.ItemIds[0])) + throw new Exception("Failed to click Travel to Hideout"); + }); + if (!arrived) + { + Log.Error("Timed out waiting for hideout arrival"); + SetState(TradeState.Failed); + return false; + } + + SetState(TradeState.InSellersHideout); + _inventory.SetLocation(false); + Log.Information("Arrived at seller hideout"); + + // Step 2: Focus game and find stash + await _game.FocusGame(); + await Helpers.Sleep(1500); + + var angePos = await _inventory.FindAndClickNameplate("Ange"); + if (angePos == null) + Log.Warning("Could not find Ange nameplate, trying Stash directly"); + else + await Helpers.Sleep(1000); + + var stashPos = await _inventory.FindAndClickNameplate("Stash"); + if (stashPos == null) + { + Log.Error("Could not find Stash in seller hideout"); + SetState(TradeState.Failed); + return false; + } + await Helpers.Sleep(1000); + + // Step 3: Scan stash and buy + SetState(TradeState.ScanningStash); + await ScanAndBuyItems(); + + // Step 4: Wait for more + SetState(TradeState.WaitingForMore); + Log.Information("Waiting {Ms}ms for more items...", _config.WaitForMoreItemsMs); + await Helpers.Sleep(_config.WaitForMoreItemsMs); + await ScanAndBuyItems(); + + // Step 5: Go home + SetState(TradeState.GoingHome); + await _game.FocusGame(); + await Helpers.Sleep(300); + + var home = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, () => _game.GoToHideout()); + if (!home) Log.Warning("Timed out going home"); + + _inventory.SetLocation(true); + + // Step 6: Store items + SetState(TradeState.InHideout); + await Helpers.Sleep(1000); + await _inventory.ProcessInventory(); + + SetState(TradeState.Idle); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Trade execution failed"); + SetState(TradeState.Failed); + + try + { + await _game.FocusGame(); + await _game.PressEscape(); + await Helpers.Sleep(500); + await _game.GoToHideout(); + } + catch { /* best-effort recovery */ } + + SetState(TradeState.Idle); + return false; + } + } + + private async Task ScanAndBuyItems() + { + var stashRegion = new Region(20, 140, 630, 750); + var stashText = await _screen.ReadRegionText(stashRegion); + Log.Information("Stash OCR: {Text}", stashText.Length > 200 ? stashText[..200] : stashText); + SetState(TradeState.Buying); + } +} diff --git a/src/Poe2Trade.Bot/TradeQueue.cs b/src/Poe2Trade.Bot/TradeQueue.cs new file mode 100644 index 0000000..1f66d4a --- /dev/null +++ b/src/Poe2Trade.Bot/TradeQueue.cs @@ -0,0 +1,71 @@ +using Poe2Trade.Core; +using Serilog; + +namespace Poe2Trade.Bot; + +public class TradeQueue +{ + private readonly Queue _queue = new(); + private readonly TradeExecutor _executor; + private readonly AppConfig _config; + private bool _processing; + + public TradeQueue(TradeExecutor executor, AppConfig config) + { + _executor = executor; + _config = config; + } + + public int Length => _queue.Count; + public bool IsProcessing => _processing; + + public event Action? TradeCompleted; + public event Action? TradeFailed; + + public void Enqueue(TradeInfo trade) + { + var existingIds = _queue.SelectMany(t => t.ItemIds).ToHashSet(); + var newIds = trade.ItemIds.Where(id => !existingIds.Contains(id)).ToList(); + if (newIds.Count == 0) + { + Log.Information("Skipping duplicate trade: {ItemIds}", string.Join(",", trade.ItemIds)); + return; + } + + var deduped = trade with { ItemIds = newIds }; + _queue.Enqueue(deduped); + Log.Information("Trade enqueued: {Count} items, queue={QueueLen}", newIds.Count, _queue.Count); + _ = ProcessNext(); + } + + private async Task ProcessNext() + { + if (_processing || _queue.Count == 0) return; + _processing = true; + + var trade = _queue.Dequeue(); + try + { + Log.Information("Processing trade: {SearchId} ({Count} items)", trade.SearchId, trade.ItemIds.Count); + var success = await _executor.ExecuteTrade(trade); + if (success) + { + Log.Information("Trade completed"); + TradeCompleted?.Invoke(); + } + else + { + Log.Information("Trade failed"); + TradeFailed?.Invoke(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Trade execution error"); + } + + _processing = false; + await Helpers.RandomDelay(_config.BetweenTradesDelayMs, _config.BetweenTradesDelayMs + 3000); + _ = ProcessNext(); + } +} diff --git a/src/Poe2Trade.Core/AppConfig.cs b/src/Poe2Trade.Core/AppConfig.cs new file mode 100644 index 0000000..bfd881b --- /dev/null +++ b/src/Poe2Trade.Core/AppConfig.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; + +namespace Poe2Trade.Core; + +public class AppConfig +{ + public List TradeUrls { get; set; } = []; + public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt"; + public string Poe2WindowTitle { get; set; } = "Path of Exile 2"; + public string BrowserUserDataDir { get; set; } = "./browser-data"; + public int TravelTimeoutMs { get; set; } = 15000; + public int StashScanTimeoutMs { get; set; } = 10000; + public int WaitForMoreItemsMs { get; set; } = 20000; + public int BetweenTradesDelayMs { get; set; } = 5000; + + public static AppConfig Load(string? configPath = null) + { + var builder = new ConfigurationBuilder(); + var path = configPath ?? "appsettings.json"; + if (File.Exists(path)) + { + builder.AddJsonFile(path, optional: true); + } + var configuration = builder.Build(); + var config = new AppConfig(); + configuration.Bind(config); + return config; + } +} diff --git a/src/Poe2Trade.Core/ConfigStore.cs b/src/Poe2Trade.Core/ConfigStore.cs new file mode 100644 index 0000000..1e438df --- /dev/null +++ b/src/Poe2Trade.Core/ConfigStore.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Serilog; + +namespace Poe2Trade.Core; + +public class SavedLink +{ + public string Url { get; set; } = ""; + public string Name { get; set; } = ""; + public bool Active { get; set; } = true; + public LinkMode Mode { get; set; } = LinkMode.Live; + public PostAction PostAction { get; set; } = PostAction.Stash; + public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o"); +} + +public class SavedSettings +{ + public bool Paused { get; set; } + public List Links { get; set; } = []; + public string Poe2LogPath { get; set; } = @"C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt"; + public string Poe2WindowTitle { get; set; } = "Path of Exile 2"; + public string BrowserUserDataDir { get; set; } = "./browser-data"; + public int TravelTimeoutMs { get; set; } = 15000; + public int StashScanTimeoutMs { get; set; } = 10000; + public int WaitForMoreItemsMs { get; set; } = 20000; + public int BetweenTradesDelayMs { get; set; } = 5000; + public double? WindowX { get; set; } + public double? WindowY { get; set; } + public double? WindowWidth { get; set; } + public double? WindowHeight { get; set; } +} + +public class ConfigStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + + private readonly string _filePath; + private SavedSettings _data; + + public ConfigStore(string? configPath = null) + { + _filePath = configPath ?? Path.GetFullPath("config.json"); + _data = Load(); + } + + public SavedSettings Settings => _data; + public IReadOnlyList Links => _data.Links; + + public void AddLink(string url, string name = "", LinkMode mode = LinkMode.Live, PostAction? postAction = null) + { + url = StripLive(url); + if (_data.Links.Any(l => l.Url == url)) return; + _data.Links.Add(new SavedLink + { + Url = url, + Name = name, + Active = true, + Mode = mode, + PostAction = postAction ?? (mode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash), + AddedAt = DateTime.UtcNow.ToString("o") + }); + Save(); + } + + public void RemoveLink(string url) + { + _data.Links.RemoveAll(l => l.Url == url); + Save(); + } + + public void RemoveLinkById(string id) + { + _data.Links.RemoveAll(l => l.Url.Split('/').Last() == id); + Save(); + } + + public SavedLink? UpdateLinkById(string id, Action update) + { + var link = _data.Links.FirstOrDefault(l => l.Url.Split('/').Last() == id); + if (link == null) return null; + update(link); + Save(); + return link; + } + + public void SetPaused(bool paused) + { + _data.Paused = paused; + Save(); + } + + public void UpdateSettings(Action update) + { + update(_data); + Save(); + } + + public void Save() + { + try + { + var json = JsonSerializer.Serialize(_data, JsonOptions); + File.WriteAllText(_filePath, json); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to save config.json to {Path}", _filePath); + } + } + + private SavedSettings Load() + { + if (!File.Exists(_filePath)) + { + Log.Information("No config.json found at {Path}, using defaults", _filePath); + return new SavedSettings(); + } + + try + { + var raw = File.ReadAllText(_filePath); + var parsed = JsonSerializer.Deserialize(raw, JsonOptions); + if (parsed == null) return new SavedSettings(); + + // Migrate links: strip /live from URLs + foreach (var link in parsed.Links) + { + link.Url = StripLive(link.Url); + } + + Log.Information("Loaded config.json from {Path} ({LinkCount} links)", _filePath, parsed.Links.Count); + return parsed; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to read config.json at {Path}, using defaults", _filePath); + return new SavedSettings(); + } + } + + private static string StripLive(string url) => System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", ""); +} diff --git a/src/Poe2Trade.Core/Helpers.cs b/src/Poe2Trade.Core/Helpers.cs new file mode 100644 index 0000000..6442c0f --- /dev/null +++ b/src/Poe2Trade.Core/Helpers.cs @@ -0,0 +1,14 @@ +namespace Poe2Trade.Core; + +public static class Helpers +{ + private static readonly Random Rng = new(); + + public static Task Sleep(int ms) => Task.Delay(ms); + + public static Task RandomDelay(int minMs, int maxMs) + { + var delay = Rng.Next(minMs, maxMs + 1); + return Task.Delay(delay); + } +} diff --git a/src/Poe2Trade.Core/LinkManager.cs b/src/Poe2Trade.Core/LinkManager.cs new file mode 100644 index 0000000..0837609 --- /dev/null +++ b/src/Poe2Trade.Core/LinkManager.cs @@ -0,0 +1,129 @@ +using Serilog; + +namespace Poe2Trade.Core; + +public class TradeLink +{ + public string Id { get; set; } = ""; + public string Url { get; set; } = ""; + public string Name { get; set; } = ""; + public string Label { get; set; } = ""; + public bool Active { get; set; } = true; + public LinkMode Mode { get; set; } = LinkMode.Live; + public PostAction PostAction { get; set; } = PostAction.Stash; + public string AddedAt { get; set; } = DateTime.UtcNow.ToString("o"); +} + +public class LinkManager +{ + private readonly Dictionary _links = new(); + private readonly ConfigStore _store; + + public LinkManager(ConfigStore store) + { + _store = store; + } + + public TradeLink AddLink(string url, string name = "", LinkMode? mode = null, PostAction? postAction = null) + { + url = StripLive(url); + var id = ExtractId(url); + var label = ExtractLabel(url); + var savedLink = _store.Links.FirstOrDefault(l => l.Url == url); + var resolvedMode = mode ?? savedLink?.Mode ?? LinkMode.Live; + var link = new TradeLink + { + Id = id, + Url = url, + Name = name != "" ? name : savedLink?.Name ?? "", + Label = label, + Active = savedLink?.Active ?? true, + Mode = resolvedMode, + PostAction = postAction ?? savedLink?.PostAction ?? (resolvedMode == LinkMode.Scrap ? PostAction.Salvage : PostAction.Stash), + AddedAt = DateTime.UtcNow.ToString("o") + }; + _links[id] = link; + _store.AddLink(url, link.Name, link.Mode, link.PostAction); + Log.Information("Trade link added: {Id} {Url} mode={Mode}", id, url, link.Mode); + return link; + } + + public void RemoveLink(string id) + { + if (_links.TryGetValue(id, out var link)) + { + _links.Remove(id); + _store.RemoveLink(link.Url); + } + else + { + _store.RemoveLinkById(id); + } + Log.Information("Trade link removed: {Id}", id); + } + + public TradeLink? ToggleLink(string id, bool active) + { + if (!_links.TryGetValue(id, out var link)) return null; + link.Active = active; + _store.UpdateLinkById(id, l => l.Active = active); + Log.Information("Trade link {Action}: {Id}", active ? "activated" : "deactivated", id); + return link; + } + + public void UpdateName(string id, string name) + { + if (!_links.TryGetValue(id, out var link)) return; + link.Name = name; + _store.UpdateLinkById(id, l => l.Name = name); + } + + public TradeLink? UpdateMode(string id, LinkMode mode) + { + if (!_links.TryGetValue(id, out var link)) return null; + link.Mode = mode; + _store.UpdateLinkById(id, l => l.Mode = mode); + return link; + } + + public TradeLink? UpdatePostAction(string id, PostAction postAction) + { + if (!_links.TryGetValue(id, out var link)) return null; + link.PostAction = postAction; + _store.UpdateLinkById(id, l => l.PostAction = postAction); + return link; + } + + public bool IsActive(string id) => _links.TryGetValue(id, out var link) && link.Active; + + public List GetLinks() => _links.Values.ToList(); + + public TradeLink? GetLink(string id) => _links.GetValueOrDefault(id); + + private static string StripLive(string url) => + System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", ""); + + private static string ExtractId(string url) + { + var parts = url.Split('/'); + return parts.Length > 0 ? parts[^1] : url; + } + + private static string ExtractLabel(string url) + { + try + { + var uri = new Uri(url); + var parts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + var poe2Idx = Array.IndexOf(parts, "poe2"); + if (poe2Idx >= 0 && parts.Length > poe2Idx + 2) + { + var league = Uri.UnescapeDataString(parts[poe2Idx + 1]); + var searchId = parts[poe2Idx + 2]; + return $"{league} / {searchId}"; + } + } + catch { /* fallback */ } + return url.Length > 60 ? url[..60] : url; + } +} diff --git a/src/Poe2Trade.Core/Logging.cs b/src/Poe2Trade.Core/Logging.cs new file mode 100644 index 0000000..580601a --- /dev/null +++ b/src/Poe2Trade.Core/Logging.cs @@ -0,0 +1,19 @@ +using Serilog; +using Serilog.Events; + +namespace Poe2Trade.Core; + +public static class Logging +{ + public static void Setup() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File("logs/poe2trade-.log", + rollingInterval: RollingInterval.Day, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + } +} diff --git a/src/Poe2Trade.Core/Poe2Trade.Core.csproj b/src/Poe2Trade.Core/Poe2Trade.Core.csproj new file mode 100644 index 0000000..e8be0c4 --- /dev/null +++ b/src/Poe2Trade.Core/Poe2Trade.Core.csproj @@ -0,0 +1,16 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + + + + + + + + + + + diff --git a/src/Poe2Trade.Core/Types.cs b/src/Poe2Trade.Core/Types.cs new file mode 100644 index 0000000..d02c702 --- /dev/null +++ b/src/Poe2Trade.Core/Types.cs @@ -0,0 +1,70 @@ +namespace Poe2Trade.Core; + +public record Region(int X, int Y, int Width, int Height); + +public record TradeInfo( + string SearchId, + List ItemIds, + string WhisperText, + long Timestamp, + string TradeUrl, + object? Page // Playwright Page reference +); + +public record TradeItem( + string Id, + int W, + int H, + int StashX, + int StashY, + string Account +); + +public record LogEvent( + DateTime Timestamp, + LogEventType Type, + Dictionary Data +); + +public enum LogEventType +{ + AreaEntered, + WhisperReceived, + TradeAccepted, + Unknown +} + +public enum TradeState +{ + Idle, + Traveling, + InSellersHideout, + ScanningStash, + Buying, + WaitingForMore, + GoingHome, + InHideout, + Failed +} + +public enum ScrapState +{ + Idle, + Traveling, + Buying, + Salvaging, + Storing, + Failed +} + +public enum LinkMode +{ + Live, + Scrap +} + +public enum PostAction +{ + Stash, + Salvage +} diff --git a/src/Poe2Trade.Game/ClipboardHelper.cs b/src/Poe2Trade.Game/ClipboardHelper.cs new file mode 100644 index 0000000..27c367d --- /dev/null +++ b/src/Poe2Trade.Game/ClipboardHelper.cs @@ -0,0 +1,111 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Poe2Trade.Game; + +/// +/// Win32 clipboard access without WinForms dependency. +/// +public static class ClipboardHelper +{ + public static string Read() + { + if (!ClipboardNative.OpenClipboard(IntPtr.Zero)) + return ""; + + try + { + var handle = ClipboardNative.GetClipboardData(ClipboardNative.CF_UNICODETEXT); + if (handle == IntPtr.Zero) return ""; + + var ptr = ClipboardNative.GlobalLock(handle); + if (ptr == IntPtr.Zero) return ""; + + try + { + return Marshal.PtrToStringUni(ptr) ?? ""; + } + finally + { + ClipboardNative.GlobalUnlock(handle); + } + } + finally + { + ClipboardNative.CloseClipboard(); + } + } + + public static void Write(string text) + { + if (!ClipboardNative.OpenClipboard(IntPtr.Zero)) + return; + + try + { + ClipboardNative.EmptyClipboard(); + var bytes = Encoding.Unicode.GetBytes(text + "\0"); + var hGlobal = ClipboardNative.GlobalAlloc(ClipboardNative.GMEM_MOVEABLE, (UIntPtr)bytes.Length); + if (hGlobal == IntPtr.Zero) return; + + var ptr = ClipboardNative.GlobalLock(hGlobal); + if (ptr == IntPtr.Zero) + { + ClipboardNative.GlobalFree(hGlobal); + return; + } + + try + { + Marshal.Copy(bytes, 0, ptr, bytes.Length); + } + finally + { + ClipboardNative.GlobalUnlock(hGlobal); + } + + ClipboardNative.SetClipboardData(ClipboardNative.CF_UNICODETEXT, hGlobal); + } + finally + { + ClipboardNative.CloseClipboard(); + } + } +} + +internal static partial class ClipboardNative +{ + public const uint CF_UNICODETEXT = 13; + public const uint GMEM_MOVEABLE = 0x0002; + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool OpenClipboard(IntPtr hWndNewOwner); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool CloseClipboard(); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool EmptyClipboard(); + + [LibraryImport("user32.dll")] + public static partial IntPtr GetClipboardData(uint uFormat); + + [LibraryImport("user32.dll")] + public static partial IntPtr SetClipboardData(uint uFormat, IntPtr hMem); + + [LibraryImport("kernel32.dll")] + public static partial IntPtr GlobalAlloc(uint uFlags, UIntPtr dwBytes); + + [LibraryImport("kernel32.dll")] + public static partial IntPtr GlobalLock(IntPtr hMem); + + [LibraryImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GlobalUnlock(IntPtr hMem); + + [LibraryImport("kernel32.dll")] + public static partial IntPtr GlobalFree(IntPtr hMem); +} diff --git a/src/Poe2Trade.Game/GameController.cs b/src/Poe2Trade.Game/GameController.cs new file mode 100644 index 0000000..8b338cb --- /dev/null +++ b/src/Poe2Trade.Game/GameController.cs @@ -0,0 +1,77 @@ +using Poe2Trade.Core; +using Serilog; + +namespace Poe2Trade.Game; + +public class GameController +{ + private readonly WindowManager _windowManager; + private readonly InputSender _input; + + public GameController(AppConfig config) + { + _windowManager = new WindowManager(config.Poe2WindowTitle); + _input = new InputSender(); + } + + public async Task FocusGame() + { + var result = _windowManager.FocusWindow(); + if (result) + await Helpers.Sleep(300); + return result; + } + + public bool IsGameFocused() => _windowManager.IsGameFocused(); + public RECT? GetWindowRect() => _windowManager.GetWindowRect(); + + public async Task SendChat(string message) + { + Log.Information("Sending chat message: {Message}", message); + await _input.PressKey(InputSender.VK.RETURN); + await Helpers.RandomDelay(100, 200); + await _input.SelectAll(); + await Helpers.Sleep(50); + await _input.PressKey(InputSender.VK.BACK); + await Helpers.Sleep(50); + await _input.TypeText(message); + await Helpers.RandomDelay(50, 100); + await _input.PressKey(InputSender.VK.RETURN); + await Helpers.Sleep(100); + } + + public async Task SendChatViaPaste(string message) + { + Log.Information("Sending chat message via paste: {Message}", message); + ClipboardHelper.Write(message); + await Helpers.Sleep(50); + await _input.PressKey(InputSender.VK.RETURN); + await Helpers.RandomDelay(100, 200); + await _input.SelectAll(); + await Helpers.Sleep(50); + await _input.PressKey(InputSender.VK.BACK); + await Helpers.Sleep(50); + await _input.Paste(); + await Helpers.RandomDelay(100, 200); + await _input.PressKey(InputSender.VK.RETURN); + await Helpers.Sleep(100); + } + + public Task GoToHideout() + { + Log.Information("Sending /hideout command"); + return SendChatViaPaste("/hideout"); + } + + public Task CtrlRightClickAt(int x, int y) => _input.CtrlRightClick(x, y); + public Task MoveMouseTo(int x, int y) => _input.MoveMouse(x, y); + public void MoveMouseInstant(int x, int y) => _input.MoveMouseInstant(x, y); + public Task MoveMouseFast(int x, int y) => _input.MoveMouseFast(x, y); + public Task LeftClickAt(int x, int y) => _input.LeftClick(x, y); + public Task RightClickAt(int x, int y) => _input.RightClick(x, y); + public Task PressEscape() => _input.PressKey(InputSender.VK.ESCAPE); + public Task OpenInventory() => _input.PressKey(InputSender.VK.I); + public Task CtrlLeftClickAt(int x, int y) => _input.CtrlLeftClick(x, y); + public Task HoldCtrl() => _input.KeyDown(InputSender.VK.CONTROL); + public Task ReleaseCtrl() => _input.KeyUp(InputSender.VK.CONTROL); +} diff --git a/src/Poe2Trade.Game/InputSender.cs b/src/Poe2Trade.Game/InputSender.cs new file mode 100644 index 0000000..e269ee5 --- /dev/null +++ b/src/Poe2Trade.Game/InputSender.cs @@ -0,0 +1,398 @@ +using System.Runtime.InteropServices; +using Poe2Trade.Core; + +namespace Poe2Trade.Game; + +public class InputSender +{ + private readonly int _screenWidth; + private readonly int _screenHeight; + private static readonly Random Rng = new(); + + public InputSender() + { + _screenWidth = InputNative.GetSystemMetrics(InputNative.SM_CXSCREEN); + _screenHeight = InputNative.GetSystemMetrics(InputNative.SM_CYSCREEN); + } + + // Virtual key codes + public static class VK + { + public const int RETURN = 0x0D; + public const int CONTROL = 0x11; + public const int MENU = 0x12; + public const int SHIFT = 0x10; + public const int ESCAPE = 0x1B; + public const int TAB = 0x09; + public const int SPACE = 0x20; + public const int DELETE = 0x2E; + public const int BACK = 0x08; + public const int V = 0x56; + public const int A = 0x41; + public const int C = 0x43; + public const int I = 0x49; + } + + public async Task PressKey(int vkCode) + { + var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0); + SendScanKeyDown(scanCode); + await Helpers.RandomDelay(30, 50); + SendScanKeyUp(scanCode); + await Helpers.RandomDelay(20, 40); + } + + public async Task KeyDown(int vkCode) + { + var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0); + SendScanKeyDown(scanCode); + await Helpers.RandomDelay(15, 30); + } + + public async Task KeyUp(int vkCode) + { + var scanCode = InputNative.MapVirtualKeyW((uint)vkCode, 0); + SendScanKeyUp(scanCode); + await Helpers.RandomDelay(15, 30); + } + + public async Task TypeText(string text) + { + foreach (var ch in text) + { + SendUnicodeChar(ch); + await Helpers.RandomDelay(20, 50); + } + } + + public async Task Paste() + { + await KeyDown(VK.CONTROL); + await Helpers.Sleep(30); + await PressKey(VK.V); + await KeyUp(VK.CONTROL); + await Helpers.Sleep(50); + } + + public async Task SelectAll() + { + await KeyDown(VK.CONTROL); + await Helpers.Sleep(30); + await PressKey(VK.A); + await KeyUp(VK.CONTROL); + await Helpers.Sleep(50); + } + + public (int X, int Y) GetCursorPos() + { + InputNative.GetCursorPos(out var pt); + return (pt.X, pt.Y); + } + + private void MoveMouseRaw(int x, int y) + { + var normalizedX = (int)Math.Round((double)x * 65535 / _screenWidth); + var normalizedY = (int)Math.Round((double)y * 65535 / _screenHeight); + SendMouseInput(normalizedX, normalizedY, 0, + InputNative.MOUSEEVENTF_MOVE | InputNative.MOUSEEVENTF_ABSOLUTE); + } + + public async Task MoveMouse(int x, int y) + { + var (sx, sy) = GetCursorPos(); + var dx = x - sx; + var dy = y - sy; + var distance = Math.Sqrt(dx * dx + dy * dy); + + if (distance < 10) + { + MoveMouseRaw(x, y); + await Helpers.RandomDelay(10, 20); + return; + } + + var perpX = -dy / distance; + var perpY = dx / distance; + var spread = distance * 0.3; + + var cp1X = sx + dx * 0.25 + perpX * (Rng.NextDouble() - 0.5) * spread; + var cp1Y = sy + dy * 0.25 + perpY * (Rng.NextDouble() - 0.5) * spread; + var cp2X = sx + dx * 0.75 + perpX * (Rng.NextDouble() - 0.5) * spread; + var cp2Y = sy + dy * 0.75 + perpY * (Rng.NextDouble() - 0.5) * spread; + + var steps = Math.Clamp((int)Math.Round(distance / 30), 8, 20); + + for (var i = 1; i <= steps; i++) + { + var rawT = (double)i / steps; + var t = EaseInOutQuad(rawT); + var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y); + + var jitterX = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0; + var jitterY = i < steps ? (int)Math.Round((Rng.NextDouble() - 0.5) * 2) : 0; + + MoveMouseRaw((int)Math.Round(px) + jitterX, (int)Math.Round(py) + jitterY); + await Task.Delay(1 + Rng.Next(2)); + } + + MoveMouseRaw(x, y); + await Helpers.RandomDelay(5, 15); + } + + public void MoveMouseInstant(int x, int y) => MoveMouseRaw(x, y); + + public async Task MoveMouseFast(int x, int y) + { + var (sx, sy) = GetCursorPos(); + var dx = x - sx; + var dy = y - sy; + var distance = Math.Sqrt(dx * dx + dy * dy); + + if (distance < 10) + { + MoveMouseRaw(x, y); + return; + } + + var perpX = -dy / distance; + var perpY = dx / distance; + var spread = distance * 0.15; + + var cp1X = sx + dx * 0.3 + perpX * (Rng.NextDouble() - 0.5) * spread; + var cp1Y = sy + dy * 0.3 + perpY * (Rng.NextDouble() - 0.5) * spread; + var cp2X = sx + dx * 0.7 + perpX * (Rng.NextDouble() - 0.5) * spread; + var cp2Y = sy + dy * 0.7 + perpY * (Rng.NextDouble() - 0.5) * spread; + + for (var i = 1; i <= 5; i++) + { + var t = EaseInOutQuad((double)i / 5); + var (px, py) = CubicBezier(t, sx, sy, cp1X, cp1Y, cp2X, cp2Y, x, y); + MoveMouseRaw((int)Math.Round(px), (int)Math.Round(py)); + await Task.Delay(2); + } + MoveMouseRaw(x, y); + } + + public async Task LeftClick(int x, int y) + { + await MoveMouse(x, y); + await Helpers.RandomDelay(20, 50); + SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTDOWN); + await Helpers.RandomDelay(15, 40); + SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_LEFTUP); + await Helpers.RandomDelay(15, 30); + } + + public async Task RightClick(int x, int y) + { + await MoveMouse(x, y); + await Helpers.RandomDelay(20, 50); + SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTDOWN); + await Helpers.RandomDelay(15, 40); + SendMouseInput(0, 0, 0, InputNative.MOUSEEVENTF_RIGHTUP); + await Helpers.RandomDelay(15, 30); + } + + public async Task CtrlRightClick(int x, int y) + { + await KeyDown(VK.CONTROL); + await Helpers.RandomDelay(30, 60); + await RightClick(x, y); + await KeyUp(VK.CONTROL); + await Helpers.RandomDelay(30, 60); + } + + public async Task CtrlLeftClick(int x, int y) + { + await KeyDown(VK.CONTROL); + await Helpers.RandomDelay(30, 60); + await LeftClick(x, y); + await KeyUp(VK.CONTROL); + await Helpers.RandomDelay(30, 60); + } + + // -- Private helpers -- + + private void SendMouseInput(int dx, int dy, int mouseData, uint flags) + { + var input = new InputNative.INPUT + { + type = InputNative.INPUT_MOUSE, + u = new InputNative.InputUnion + { + mi = new InputNative.MOUSEINPUT + { + dx = dx, dy = dy, + mouseData = mouseData, + dwFlags = flags, + time = 0, + dwExtraInfo = UIntPtr.Zero + } + } + }; + InputNative.SendInput(1, [input], Marshal.SizeOf()); + } + + private void SendScanKeyDown(uint scanCode) + { + var input = new InputNative.INPUT + { + type = InputNative.INPUT_KEYBOARD, + u = new InputNative.InputUnion + { + ki = new InputNative.KEYBDINPUT + { + wVk = 0, + wScan = (ushort)scanCode, + dwFlags = InputNative.KEYEVENTF_SCANCODE, + time = 0, + dwExtraInfo = UIntPtr.Zero + } + } + }; + InputNative.SendInput(1, [input], Marshal.SizeOf()); + } + + private void SendScanKeyUp(uint scanCode) + { + var input = new InputNative.INPUT + { + type = InputNative.INPUT_KEYBOARD, + u = new InputNative.InputUnion + { + ki = new InputNative.KEYBDINPUT + { + wVk = 0, + wScan = (ushort)scanCode, + dwFlags = InputNative.KEYEVENTF_SCANCODE | InputNative.KEYEVENTF_KEYUP, + time = 0, + dwExtraInfo = UIntPtr.Zero + } + } + }; + InputNative.SendInput(1, [input], Marshal.SizeOf()); + } + + private void SendUnicodeChar(char ch) + { + var code = (ushort)ch; + var down = new InputNative.INPUT + { + type = InputNative.INPUT_KEYBOARD, + u = new InputNative.InputUnion + { + ki = new InputNative.KEYBDINPUT + { + wVk = 0, wScan = code, + dwFlags = InputNative.KEYEVENTF_UNICODE, + time = 0, dwExtraInfo = UIntPtr.Zero + } + } + }; + var up = new InputNative.INPUT + { + type = InputNative.INPUT_KEYBOARD, + u = new InputNative.InputUnion + { + ki = new InputNative.KEYBDINPUT + { + wVk = 0, wScan = code, + dwFlags = InputNative.KEYEVENTF_UNICODE | InputNative.KEYEVENTF_KEYUP, + time = 0, dwExtraInfo = UIntPtr.Zero + } + } + }; + InputNative.SendInput(1, [down], Marshal.SizeOf()); + InputNative.SendInput(1, [up], Marshal.SizeOf()); + } + + private static double EaseInOutQuad(double t) => + t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2; + + private static (double X, double Y) CubicBezier(double t, + double p0x, double p0y, double p1x, double p1y, + double p2x, double p2y, double p3x, double p3y) + { + var u = 1 - t; + var u2 = u * u; + var u3 = u2 * u; + var t2 = t * t; + var t3 = t2 * t; + return ( + u3 * p0x + 3 * u2 * t * p1x + 3 * u * t2 * p2x + t3 * p3x, + u3 * p0y + 3 * u2 * t * p1y + 3 * u * t2 * p2y + t3 * p3y + ); + } +} + +internal static partial class InputNative +{ + public const uint INPUT_MOUSE = 0; + public const uint INPUT_KEYBOARD = 1; + public const uint KEYEVENTF_SCANCODE = 0x0008; + public const uint KEYEVENTF_KEYUP = 0x0002; + public const uint KEYEVENTF_UNICODE = 0x0004; + public const uint MOUSEEVENTF_MOVE = 0x0001; + public const uint MOUSEEVENTF_LEFTDOWN = 0x0002; + public const uint MOUSEEVENTF_LEFTUP = 0x0004; + public const uint MOUSEEVENTF_RIGHTDOWN = 0x0008; + public const uint MOUSEEVENTF_RIGHTUP = 0x0010; + public const uint MOUSEEVENTF_ABSOLUTE = 0x8000; + public const int SM_CXSCREEN = 0; + public const int SM_CYSCREEN = 1; + + [StructLayout(LayoutKind.Sequential)] + public struct MOUSEINPUT + { + public int dx; + public int dy; + public int mouseData; + public uint dwFlags; + public uint time; + public UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + public struct KEYBDINPUT + { + public ushort wVk; + public ushort wScan; + public uint dwFlags; + public uint time; + public UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Explicit)] + public struct InputUnion + { + [FieldOffset(0)] public MOUSEINPUT mi; + [FieldOffset(0)] public KEYBDINPUT ki; + } + + [StructLayout(LayoutKind.Sequential)] + public struct INPUT + { + public uint type; + public InputUnion u; + } + + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + [LibraryImport("user32.dll")] + public static partial uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + [LibraryImport("user32.dll")] + public static partial uint MapVirtualKeyW(uint uCode, uint uMapType); + + [LibraryImport("user32.dll")] + public static partial int GetSystemMetrics(int nIndex); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetCursorPos(out POINT lpPoint); +} diff --git a/src/Poe2Trade.Game/Poe2Trade.Game.csproj b/src/Poe2Trade.Game/Poe2Trade.Game.csproj new file mode 100644 index 0000000..d7dab72 --- /dev/null +++ b/src/Poe2Trade.Game/Poe2Trade.Game.csproj @@ -0,0 +1,11 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + true + + + + + diff --git a/src/Poe2Trade.Game/WindowManager.cs b/src/Poe2Trade.Game/WindowManager.cs new file mode 100644 index 0000000..04838db --- /dev/null +++ b/src/Poe2Trade.Game/WindowManager.cs @@ -0,0 +1,113 @@ +using System.Runtime.InteropServices; +using Serilog; + +namespace Poe2Trade.Game; + +public class WindowManager +{ + private IntPtr _hwnd = IntPtr.Zero; + private readonly string _windowTitle; + + public WindowManager(string windowTitle) + { + _windowTitle = windowTitle; + } + + public IntPtr FindWindow() + { + _hwnd = NativeMethods.FindWindowW(null, _windowTitle); + if (_hwnd == IntPtr.Zero) + Log.Warning("Window not found: {Title}", _windowTitle); + else + Log.Information("Window found: {Title} hwnd={Hwnd}", _windowTitle, _hwnd); + return _hwnd; + } + + public bool FocusWindow() + { + if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd)) + FindWindow(); + if (_hwnd == IntPtr.Zero) return false; + + // Restore if minimized + NativeMethods.ShowWindow(_hwnd, NativeMethods.SW_RESTORE); + + // Alt-key trick to bypass SetForegroundWindow restriction + var altScan = NativeMethods.MapVirtualKeyW(NativeMethods.VK_MENU, 0); + NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, 0, UIntPtr.Zero); + NativeMethods.keybd_event(NativeMethods.VK_MENU, (byte)altScan, NativeMethods.KEYEVENTF_KEYUP, UIntPtr.Zero); + + NativeMethods.BringWindowToTop(_hwnd); + var result = NativeMethods.SetForegroundWindow(_hwnd); + if (!result) + Log.Warning("SetForegroundWindow failed"); + return result; + } + + public RECT? GetWindowRect() + { + if (_hwnd == IntPtr.Zero || !NativeMethods.IsWindow(_hwnd)) + FindWindow(); + if (_hwnd == IntPtr.Zero) return null; + + if (NativeMethods.GetWindowRect(_hwnd, out var rect)) + return rect; + return null; + } + + public bool IsGameFocused() + { + var fg = NativeMethods.GetForegroundWindow(); + return fg == _hwnd && _hwnd != IntPtr.Zero; + } + + public IntPtr Hwnd => _hwnd; +} + +[StructLayout(LayoutKind.Sequential)] +public struct RECT +{ + public int Left; + public int Top; + public int Right; + public int Bottom; +} + +internal static partial class NativeMethods +{ + public const int SW_RESTORE = 9; + public const byte VK_MENU = 0x12; + public const uint KEYEVENTF_KEYUP = 0x0002; + + [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)] + public static partial IntPtr FindWindowW(string? lpClassName, string lpWindowName); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool SetForegroundWindow(IntPtr hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool BringWindowToTop(IntPtr hWnd); + + [LibraryImport("user32.dll")] + public static partial IntPtr GetForegroundWindow(); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool IsWindow(IntPtr hWnd); + + [LibraryImport("user32.dll")] + public static partial void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + + [LibraryImport("user32.dll")] + public static partial uint MapVirtualKeyW(uint uCode, uint uMapType); +} diff --git a/src/Poe2Trade.Inventory/InventoryManager.cs b/src/Poe2Trade.Inventory/InventoryManager.cs new file mode 100644 index 0000000..de168ff --- /dev/null +++ b/src/Poe2Trade.Inventory/InventoryManager.cs @@ -0,0 +1,260 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.GameLog; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Inventory; + +public class InventoryManager +{ + private static readonly string SalvageTemplate = Path.Combine("assets", "salvage.png"); + + public InventoryTracker Tracker { get; } = new(); + + private bool _atOwnHideout = true; + private string _sellerAccount = ""; + private readonly GameController _game; + private readonly ScreenReader _screen; + private readonly ClientLogWatcher _logWatcher; + private readonly AppConfig _config; + + public bool IsAtOwnHideout => _atOwnHideout; + public string SellerAccount => _sellerAccount; + + public InventoryManager(GameController game, ScreenReader screen, ClientLogWatcher logWatcher, AppConfig config) + { + _game = game; + _screen = screen; + _logWatcher = logWatcher; + _config = config; + } + + public void SetLocation(bool atHome, string? seller = null) + { + _atOwnHideout = atHome; + _sellerAccount = seller ?? ""; + } + + public async Task ScanInventory(PostAction defaultAction = PostAction.Stash) + { + Log.Information("Scanning inventory..."); + await _game.FocusGame(); + await Helpers.Sleep(300); + await _game.OpenInventory(); + + var result = await _screen.Grid.Scan("inventory"); + + var cells = new bool[5, 12]; + foreach (var cell in result.Occupied) + { + if (cell.Row < 5 && cell.Col < 12) + cells[cell.Row, cell.Col] = true; + } + Tracker.InitFromScan(cells, result.Items, defaultAction); + + await _game.PressEscape(); + await Helpers.Sleep(300); + } + + public async Task ClearToStash() + { + Log.Information("Checking inventory for leftover items..."); + await ScanInventory(PostAction.Stash); + + if (Tracker.GetItems().Count == 0) + { + Log.Information("Inventory empty, nothing to clear"); + return; + } + + Log.Information("Found {Count} leftover items, depositing to stash", Tracker.GetItems().Count); + await DepositItemsToStash(Tracker.GetItems()); + Tracker.Clear(); + Log.Information("Inventory cleared to stash"); + } + + public async Task EnsureAtOwnHideout() + { + if (_atOwnHideout) + { + Log.Information("Already at own hideout"); + return true; + } + + await _game.FocusGame(); + await Helpers.Sleep(300); + + var arrived = await WaitForAreaTransition(_config.TravelTimeoutMs, () => _game.GoToHideout()); + if (!arrived) + { + Log.Error("Timed out going to own hideout"); + return false; + } + + await Helpers.Sleep(1500); + _atOwnHideout = true; + _sellerAccount = ""; + return true; + } + + public async Task DepositItemsToStash(List items) + { + if (items.Count == 0) return; + + var stashPos = await FindAndClickNameplate("Stash"); + if (stashPos == null) + { + Log.Error("Could not find Stash nameplate"); + return; + } + await Helpers.Sleep(1000); + + var inventoryLayout = GridLayouts.Inventory; + Log.Information("Depositing {Count} items to stash", items.Count); + + await _game.HoldCtrl(); + foreach (var item in items) + { + var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col); + await _game.LeftClickAt(center.X, center.Y); + await Helpers.Sleep(150); + } + await _game.ReleaseCtrl(); + await Helpers.Sleep(500); + + await _game.PressEscape(); + await Helpers.Sleep(500); + Log.Information("Items deposited to stash"); + } + + public async Task SalvageItems(List items) + { + if (items.Count == 0) return true; + + var nameplate = await FindAndClickNameplate("SALVAGE BENCH"); + if (nameplate == null) + { + Log.Error("Could not find Salvage nameplate"); + return false; + } + await Helpers.Sleep(1000); + + var salvageBtn = await _screen.TemplateMatch(SalvageTemplate); + if (salvageBtn != null) + { + await _game.LeftClickAt(salvageBtn.X, salvageBtn.Y); + await Helpers.Sleep(500); + } + else + { + Log.Warning("Could not find salvage button via template match"); + } + + var inventoryLayout = GridLayouts.Inventory; + Log.Information("Salvaging {Count} inventory items", items.Count); + + await _game.HoldCtrl(); + foreach (var item in items) + { + var center = _screen.Grid.GetCellCenter(inventoryLayout, item.Row, item.Col); + await _game.LeftClickAt(center.X, center.Y); + await Helpers.Sleep(150); + } + await _game.ReleaseCtrl(); + await Helpers.Sleep(500); + + await _game.PressEscape(); + await Helpers.Sleep(500); + return true; + } + + public async Task ProcessInventory() + { + try + { + var home = await EnsureAtOwnHideout(); + if (!home) + { + Log.Error("Cannot process inventory: failed to reach hideout"); + return; + } + + if (Tracker.HasItemsWithAction(PostAction.Salvage)) + { + var salvageItems = Tracker.GetItemsByAction(PostAction.Salvage); + if (await SalvageItems(salvageItems)) + Tracker.RemoveItemsByAction(PostAction.Salvage); + else + Log.Warning("Salvage failed, depositing all to stash"); + } + + await ScanInventory(PostAction.Stash); + + var allItems = Tracker.GetItems(); + if (allItems.Count > 0) + await DepositItemsToStash(allItems); + + Tracker.Clear(); + Log.Information("Inventory processing complete"); + } + catch (Exception ex) + { + Log.Error(ex, "Inventory processing failed"); + try { await _game.PressEscape(); await Helpers.Sleep(300); } catch { } + Tracker.Clear(); + } + } + + public async Task<(int X, int Y)?> FindAndClickNameplate(string name, int maxRetries = 3, int retryDelayMs = 1000) + { + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + Log.Information("Searching for nameplate '{Name}' (attempt {Attempt}/{Max})", name, attempt, maxRetries); + var pos = await _screen.FindTextOnScreen(name, fuzzy: true); + if (pos.HasValue) + { + Log.Information("Clicking nameplate '{Name}' at ({X},{Y})", name, pos.Value.X, pos.Value.Y); + await _game.LeftClickAt(pos.Value.X, pos.Value.Y); + return pos; + } + if (attempt < maxRetries) + await Helpers.Sleep(retryDelayMs); + } + + Log.Warning("Nameplate '{Name}' not found after {Max} retries", name, maxRetries); + return null; + } + + public async Task WaitForAreaTransition(int timeoutMs, Func? triggerAction = null) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cts = new CancellationTokenSource(timeoutMs); + cts.Token.Register(() => tcs.TrySetResult(false)); + + void Handler(string _) => tcs.TrySetResult(true); + + _logWatcher.AreaEntered += Handler; + try + { + if (triggerAction != null) + { + try { await triggerAction(); } + catch + { + tcs.TrySetResult(false); + } + } + return await tcs.Task; + } + finally + { + _logWatcher.AreaEntered -= Handler; + } + } + + public (bool[,] Grid, List Items, int Free) GetInventoryState() + { + return (Tracker.GetGrid(), Tracker.GetItems(), Tracker.FreeCells); + } +} diff --git a/src/Poe2Trade.Inventory/InventoryTracker.cs b/src/Poe2Trade.Inventory/InventoryTracker.cs new file mode 100644 index 0000000..255e92b --- /dev/null +++ b/src/Poe2Trade.Inventory/InventoryTracker.cs @@ -0,0 +1,126 @@ +using Poe2Trade.Core; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Inventory; + +public class PlacedItem +{ + public int Row { get; init; } + public int Col { get; init; } + public int W { get; init; } + public int H { get; init; } + public PostAction PostAction { get; init; } +} + +public class InventoryTracker +{ + private const int Rows = 5; + private const int Cols = 12; + + private readonly bool[,] _grid = new bool[Rows, Cols]; + private readonly List _items = []; + + public void InitFromScan(bool[,] cells, List items, PostAction defaultAction = PostAction.Stash) + { + Array.Clear(_grid); + _items.Clear(); + + var rowCount = Math.Min(cells.GetLength(0), Rows); + var colCount = Math.Min(cells.GetLength(1), Cols); + for (var r = 0; r < rowCount; r++) + for (var c = 0; c < colCount; c++) + _grid[r, c] = cells[r, c]; + + foreach (var item in items) + { + if (item.W > 2 || item.H > 4) + { + Log.Warning("Ignoring oversized item at ({Row},{Col}) {W}x{H}", item.Row, item.Col, item.W, item.H); + continue; + } + _items.Add(new PlacedItem { Row = item.Row, Col = item.Col, W = item.W, H = item.H, PostAction = defaultAction }); + } + + Log.Information("Inventory initialized: {Occupied} occupied, {Items} items, {Free} free", + Rows * Cols - FreeCells, _items.Count, FreeCells); + } + + public (int Row, int Col)? TryPlace(int w, int h, PostAction postAction = PostAction.Stash) + { + for (var col = 0; col <= Cols - w; col++) + for (var row = 0; row <= Rows - h; row++) + { + if (!Fits(row, col, w, h)) continue; + Place(row, col, w, h, postAction); + Log.Information("Item placed at ({Row},{Col}) {W}x{H} free={Free}", row, col, w, h, FreeCells); + return (row, col); + } + return null; + } + + public bool CanFit(int w, int h) + { + for (var col = 0; col <= Cols - w; col++) + for (var row = 0; row <= Rows - h; row++) + if (Fits(row, col, w, h)) return true; + return false; + } + + public List GetItems() => [.. _items]; + public List GetItemsByAction(PostAction action) => _items.Where(i => i.PostAction == action).ToList(); + public bool HasItemsWithAction(PostAction action) => _items.Any(i => i.PostAction == action); + + public void RemoveItem(PlacedItem item) + { + if (!_items.Remove(item)) return; + for (var r = item.Row; r < item.Row + item.H; r++) + for (var c = item.Col; c < item.Col + item.W; c++) + _grid[r, c] = false; + } + + public void RemoveItemsByAction(PostAction action) + { + var toRemove = _items.Where(i => i.PostAction == action).ToList(); + foreach (var item in toRemove) + RemoveItem(item); + Log.Information("Removed {Count} items with action {Action}", toRemove.Count, action); + } + + public bool[,] GetGrid() => (bool[,])_grid.Clone(); + + public void Clear() + { + Array.Clear(_grid); + _items.Clear(); + Log.Information("Inventory cleared"); + } + + public int FreeCells + { + get + { + var count = 0; + for (var r = 0; r < Rows; r++) + for (var c = 0; c < Cols; c++) + if (!_grid[r, c]) count++; + return count; + } + } + + private bool Fits(int row, int col, int w, int h) + { + for (var r = row; r < row + h; r++) + for (var c = col; c < col + w; c++) + if (_grid[r, c]) return false; + return true; + } + + private void Place(int row, int col, int w, int h, PostAction postAction) + { + for (var r = row; r < row + h; r++) + for (var c = col; c < col + w; c++) + _grid[r, c] = true; + _items.Add(new PlacedItem { Row = row, Col = col, W = w, H = h, PostAction = postAction }); + } +} diff --git a/src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj b/src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj new file mode 100644 index 0000000..d3c2a6f --- /dev/null +++ b/src/Poe2Trade.Inventory/Poe2Trade.Inventory.csproj @@ -0,0 +1,13 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + + + + + + + + diff --git a/src/Poe2Trade.Items/ItemReader.cs b/src/Poe2Trade.Items/ItemReader.cs new file mode 100644 index 0000000..e8d4654 --- /dev/null +++ b/src/Poe2Trade.Items/ItemReader.cs @@ -0,0 +1,59 @@ +using Poe2Trade.Core; +using Poe2Trade.Game; +using Serilog; + +namespace Poe2Trade.Items; + +/// +/// Reads item data by hovering and pressing Ctrl+C to copy item text to clipboard. +/// Will be wired up to Sidekick's ItemParser once the submodule is added. +/// +public class ItemReader +{ + private readonly GameController _game; + + public ItemReader(GameController game) + { + _game = game; + } + + /// + /// Hover over an item at (x, y), press Ctrl+C, and read clipboard text. + /// + public async Task ReadItemText(int x, int y) + { + // Move mouse to item position + await _game.MoveMouseTo(x, y); + await Helpers.Sleep(100); + + // Ctrl+C to copy item text + ClipboardHelper.Write(""); // Clear clipboard + await Helpers.Sleep(50); + + // Press Ctrl+C + var input = new InputSender(); + await input.KeyDown(InputSender.VK.CONTROL); + await Helpers.Sleep(30); + await input.PressKey(InputSender.VK.C); + await input.KeyUp(InputSender.VK.CONTROL); + await Helpers.Sleep(100); + + var text = ClipboardHelper.Read(); + if (string.IsNullOrWhiteSpace(text)) + { + Log.Warning("No item text in clipboard after Ctrl+C at ({X},{Y})", x, y); + return null; + } + + Log.Information("Read item text ({Length} chars) from ({X},{Y})", text.Length, x, y); + return text; + } + + // TODO: Wire up Sidekick's ItemParser + // public async Task ParseItem(int x, int y) + // { + // var text = await ReadItemText(x, y); + // if (text == null) return null; + // return SidekickItemParser.Parse(text); + // } +} diff --git a/src/Poe2Trade.Items/Poe2Trade.Items.csproj b/src/Poe2Trade.Items/Poe2Trade.Items.csproj new file mode 100644 index 0000000..5418aa8 --- /dev/null +++ b/src/Poe2Trade.Items/Poe2Trade.Items.csproj @@ -0,0 +1,11 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + + + + + + diff --git a/src/Poe2Trade.Screen/DaemonTypes.cs b/src/Poe2Trade.Screen/DaemonTypes.cs new file mode 100644 index 0000000..adc94c4 --- /dev/null +++ b/src/Poe2Trade.Screen/DaemonTypes.cs @@ -0,0 +1,171 @@ +using System.Text.Json.Serialization; + +namespace Poe2Trade.Screen; + +public class OcrWord +{ + [JsonPropertyName("text")] public string Text { get; set; } = ""; + [JsonPropertyName("x")] public int X { get; set; } + [JsonPropertyName("y")] public int Y { get; set; } + [JsonPropertyName("width")] public int Width { get; set; } + [JsonPropertyName("height")] public int Height { get; set; } +} + +public class OcrLine +{ + [JsonPropertyName("text")] public string Text { get; set; } = ""; + [JsonPropertyName("words")] public List Words { get; set; } = []; +} + +public class OcrResponse +{ + public string Text { get; set; } = ""; + public List Lines { get; set; } = []; +} + +public class GridItem +{ + [JsonPropertyName("row")] public int Row { get; set; } + [JsonPropertyName("col")] public int Col { get; set; } + [JsonPropertyName("w")] public int W { get; set; } + [JsonPropertyName("h")] public int H { get; set; } +} + +public class GridMatch +{ + [JsonPropertyName("row")] public int Row { get; set; } + [JsonPropertyName("col")] public int Col { get; set; } + [JsonPropertyName("similarity")] public double Similarity { get; set; } +} + +public class GridScanResult +{ + public bool[][] Cells { get; set; } = []; + public List Items { get; set; } = []; + public List? Matches { get; set; } +} + +public class DiffOcrResponse +{ + public string Text { get; set; } = ""; + public List Lines { get; set; } = []; + public Poe2Trade.Core.Region? Region { get; set; } +} + +public class TemplateMatchResult +{ + public int X { get; set; } + public int Y { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public double Confidence { get; set; } +} + +// -- Parameter types -- + +public sealed class DiffCropParams +{ + [JsonPropertyName("diffThresh")] + public int DiffThresh { get; set; } = 20; + + [JsonPropertyName("rowThreshDiv")] + public int RowThreshDiv { get; set; } = 40; + + [JsonPropertyName("colThreshDiv")] + public int ColThreshDiv { get; set; } = 8; + + [JsonPropertyName("maxGap")] + public int MaxGap { get; set; } = 20; + + [JsonPropertyName("trimCutoff")] + public double TrimCutoff { get; set; } = 0.4; + + [JsonPropertyName("ocrPad")] + public int OcrPad { get; set; } = 10; +} + +public sealed class OcrParams +{ + // preprocessing + [JsonPropertyName("kernelSize")] + public int KernelSize { get; set; } = 41; + + [JsonPropertyName("upscale")] + public int Upscale { get; set; } = 2; + + [JsonPropertyName("useBackgroundSub")] + public bool UseBackgroundSub { get; set; } = true; + + [JsonPropertyName("dimPercentile")] + public int DimPercentile { get; set; } = 40; + + [JsonPropertyName("textThresh")] + public int TextThresh { get; set; } = 60; + + [JsonPropertyName("softThreshold")] + public bool SoftThreshold { get; set; } = false; + + // EasyOCR tuning + [JsonPropertyName("mergeGap")] + public int MergeGap { get; set; } = 0; + + [JsonPropertyName("linkThreshold")] + public double? LinkThreshold { get; set; } + + [JsonPropertyName("textThreshold")] + public double? TextThreshold { get; set; } + + [JsonPropertyName("lowText")] + public double? LowText { get; set; } + + [JsonPropertyName("widthThs")] + public double? WidthThs { get; set; } + + [JsonPropertyName("paragraph")] + public bool? Paragraph { get; set; } +} + +public sealed class DiffOcrParams +{ + [JsonPropertyName("crop")] + public DiffCropParams Crop { get; set; } = new(); + + [JsonPropertyName("ocr")] + public OcrParams Ocr { get; set; } = new(); +} + +public sealed class EdgeCropParams +{ + [JsonPropertyName("darkThresh")] + public int DarkThresh { get; set; } = 40; + + [JsonPropertyName("minDarkRun")] + public int MinDarkRun { get; set; } = 200; + + [JsonPropertyName("runGapTolerance")] + public int RunGapTolerance { get; set; } = 15; + + [JsonPropertyName("rowThreshDiv")] + public int RowThreshDiv { get; set; } = 40; + + [JsonPropertyName("colThreshDiv")] + public int ColThreshDiv { get; set; } = 8; + + [JsonPropertyName("maxGap")] + public int MaxGap { get; set; } = 15; + + [JsonPropertyName("trimCutoff")] + public double TrimCutoff { get; set; } = 0.3; + + [JsonPropertyName("ocrPad")] + public int OcrPad { get; set; } = 10; +} + +public sealed class EdgeOcrParams +{ + [JsonPropertyName("crop")] + public EdgeCropParams Crop { get; set; } = new(); + + [JsonPropertyName("ocr")] + public OcrParams Ocr { get; set; } = new(); +} diff --git a/tools/OcrDaemon/DetectGridHandler.cs b/src/Poe2Trade.Screen/DetectGridHandler.cs similarity index 59% rename from tools/OcrDaemon/DetectGridHandler.cs rename to src/Poe2Trade.Screen/DetectGridHandler.cs index 7ed2274..7fca666 100644 --- a/tools/OcrDaemon/DetectGridHandler.cs +++ b/src/Poe2Trade.Screen/DetectGridHandler.cs @@ -1,21 +1,17 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; +using Serilog; +using Region = Poe2Trade.Core.Region; class DetectGridHandler { - public object HandleDetectGrid(Request req) + public DetectGridResult Detect(Region region, int minCellSize = 20, int maxCellSize = 70, + string? file = null, bool debug = false) { - if (req.Region == null) - return new ErrorResponse("detect-grid requires region"); - - int minCell = req.MinCellSize > 0 ? req.MinCellSize : 20; - int maxCell = req.MaxCellSize > 0 ? req.MaxCellSize : 70; - bool debug = req.Debug; - - Bitmap bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region); + Bitmap bitmap = ScreenCapture.CaptureOrLoad(file, region); int w = bitmap.Width; int h = bitmap.Height; @@ -39,20 +35,15 @@ class DetectGridHandler bitmap.Dispose(); - // ── Pass 1: Scan horizontal bands using "very dark pixel density" ── - // Grid lines are nearly all very dark (density ~0.9), cell interiors are - // partially dark (0.3-0.5), game world is mostly bright (density ~0.05). - // This creates clear periodic peaks at grid line positions. int bandH = 200; int bandStep = 40; - const int veryDarkPixelThresh = 12; // pixels below this brightness = "very dark" - const double gridSegThresh = 0.25; // density above this = potential grid column + const int veryDarkPixelThresh = 12; + const double gridSegThresh = 0.25; var candidates = new List<(int bandY, int cellW, double hAc, int hLeft, int hRight)>(); for (int by = 0; by + bandH <= h; by += bandStep) { - // "Very dark pixel density" per column: fraction of pixels below threshold double[] darkDensity = new double[w]; for (int x = 0; x < w; x++) { @@ -64,43 +55,30 @@ class DetectGridHandler darkDensity[x] = (double)count / bandH; } - // Find segments where density > gridSegThresh (grid panel regions) var gridSegs = SignalProcessing.FindDarkDensitySegments(darkDensity, gridSegThresh, 200); foreach (var (segLeft, segRight) in gridSegs) { - // Extract segment and run AC int segLen = segRight - segLeft; double[] segment = new double[segLen]; Array.Copy(darkDensity, segLeft, segment, 0, segLen); - var (period, acScore) = SignalProcessing.FindPeriodWithScore(segment, minCell, maxCell); - + var (period, acScore) = SignalProcessing.FindPeriodWithScore(segment, minCellSize, maxCellSize); if (period <= 0) continue; - // FindGridExtent within the segment var (extLeft, extRight) = SignalProcessing.FindGridExtent(segment, period); if (extLeft < 0) continue; - // Map back to full image coordinates int absLeft = segLeft + extLeft; int absRight = segLeft + extRight; int extent = absRight - absLeft; - // Require at least 8 cells wide AND 200px absolute minimum if (extent < period * 8 || extent < 200) continue; - if (debug) Console.Error.WriteLine( - $" Band y={by}: seg=[{segLeft}-{segRight}] period={period}, AC={acScore:F3}, " + - $"extent={absLeft}-{absRight}={extent}px ({extent / period} cells)"); - candidates.Add((by, period, acScore, absLeft, absRight)); } } - if (debug) Console.Error.WriteLine($"Pass 1: {candidates.Count} candidates"); - - // Sort by score = AC * extent (prefer large strongly-periodic areas) candidates.Sort((a, b) => { double sa = a.hAc * (a.hRight - a.hLeft); @@ -108,13 +86,12 @@ class DetectGridHandler return sb.CompareTo(sa); }); - // ── Pass 2: Verify vertical periodicity ── + // Pass 2: Verify vertical periodicity foreach (var cand in candidates.Take(10)) { int colSpan = cand.hRight - cand.hLeft; if (colSpan < cand.cellW * 3) continue; - // Row "very dark pixel density" within the detected column range double[] rowDensity = new double[h]; for (int y = 0; y < h; y++) { @@ -126,17 +103,15 @@ class DetectGridHandler rowDensity[y] = (double)count / colSpan; } - // Find grid panel vertical segment var vGridSegs = SignalProcessing.FindDarkDensitySegments(rowDensity, gridSegThresh, 100); if (vGridSegs.Count == 0) continue; - // Use the largest segment var (vSegTop, vSegBottom) = vGridSegs.OrderByDescending(s => s.end - s.start).First(); int vSegLen = vSegBottom - vSegTop; double[] vSegment = new double[vSegLen]; Array.Copy(rowDensity, vSegTop, vSegment, 0, vSegLen); - var (cellH, vAc) = SignalProcessing.FindPeriodWithScore(vSegment, minCell, maxCell); + var (cellH, vAc) = SignalProcessing.FindPeriodWithScore(vSegment, minCellSize, maxCellSize); if (cellH <= 0) continue; var (extTop, extBottom) = SignalProcessing.FindGridExtent(vSegment, cellH); @@ -146,37 +121,20 @@ class DetectGridHandler int bottom = vSegTop + extBottom; int vExtent = bottom - top; - // Require at least 3 rows tall AND 100px absolute minimum if (vExtent < cellH * 3 || vExtent < 100) continue; - if (debug) Console.Error.WriteLine( - $" 2D candidate: cellW={cand.cellW}, cellH={cellH}, " + - $"region=({cand.hLeft},{top})-({cand.hRight},{bottom}), " + - $"vAC={vAc:F3}, extent={vExtent}px ({vExtent / cellH} rows)"); - - // ── Found a valid 2D grid ── int gridW = cand.hRight - cand.hLeft; int gridH = bottom - top; int cols = Math.Max(2, (int)Math.Round((double)gridW / cand.cellW)); int rows = Math.Max(2, (int)Math.Round((double)gridH / cellH)); - // Snap grid dimensions to exact multiples of cell size gridW = cols * cand.cellW; gridH = rows * cellH; - if (debug) Console.Error.WriteLine( - $" => cols={cols}, rows={rows}, gridW={gridW}, gridH={gridH}"); - - return new DetectGridResponse + return new DetectGridResult { Detected = true, - Region = new RegionRect - { - X = req.Region.X + cand.hLeft, - Y = req.Region.Y + top, - Width = gridW, - Height = gridH, - }, + Region = new Region(region.X + cand.hLeft, region.Y + top, gridW, gridH), Cols = cols, Rows = rows, CellWidth = Math.Round((double)gridW / cols, 1), @@ -184,7 +142,16 @@ class DetectGridHandler }; } - if (debug) Console.Error.WriteLine(" No valid 2D grid found"); - return new DetectGridResponse { Detected = false }; + return new DetectGridResult { Detected = false }; } } + +public class DetectGridResult +{ + public bool Detected { get; set; } + public Region? Region { get; set; } + public int Cols { get; set; } + public int Rows { get; set; } + public double CellWidth { get; set; } + public double CellHeight { get; set; } +} diff --git a/src/Poe2Trade.Screen/DiffCropHandler.cs b/src/Poe2Trade.Screen/DiffCropHandler.cs new file mode 100644 index 0000000..e2e2865 --- /dev/null +++ b/src/Poe2Trade.Screen/DiffCropHandler.cs @@ -0,0 +1,368 @@ +namespace Poe2Trade.Screen; + +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using Serilog; +using Region = Poe2Trade.Core.Region; + +class DiffCropHandler +{ + private Bitmap? _referenceFrame; + private Region? _referenceRegion; + + public void HandleSnapshot(string? file = null, Region? region = null) + { + _referenceFrame?.Dispose(); + _referenceFrame = ScreenCapture.CaptureOrLoad(file, region); + _referenceRegion = region; + } + + public void HandleScreenshot(string path, Region? region = null) + { + var bitmap = _referenceFrame ?? ScreenCapture.CaptureOrLoad(null, region); + var format = ImageUtils.GetImageFormat(path); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + bitmap.Save(path, format); + if (bitmap != _referenceFrame) bitmap.Dispose(); + } + + public byte[] HandleCapture(Region? region = null) + { + using var bitmap = ScreenCapture.CaptureOrLoad(null, region); + using var ms = new MemoryStream(); + bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + return ms.ToArray(); + } + + /// + /// Diff detection + crop only. Returns the raw tooltip crop bitmap and region, + /// or null if no tooltip detected. Caller is responsible for disposing the bitmaps. + /// + public (Bitmap cropped, Bitmap refCropped, Bitmap current, Region region)? DiffCrop( + DiffCropParams c, string? file = null, Region? region = null) + { + if (_referenceFrame == null) + return null; + + var diffRegion = region ?? _referenceRegion; + int baseX = diffRegion?.X ?? 0; + int baseY = diffRegion?.Y ?? 0; + var current = ScreenCapture.CaptureOrLoad(file, diffRegion); + + Bitmap refForDiff = _referenceFrame; + bool disposeRef = false; + + if (diffRegion != null) + { + if (_referenceRegion == null) + { + var croppedRef = CropBitmap(_referenceFrame, diffRegion); + if (croppedRef == null) + { + current.Dispose(); + return null; + } + refForDiff = croppedRef; + disposeRef = true; + } + else if (!RegionsEqual(diffRegion, _referenceRegion)) + { + int offX = diffRegion.X - _referenceRegion.X; + int offY = diffRegion.Y - _referenceRegion.Y; + if (offX < 0 || offY < 0 || offX + diffRegion.Width > _referenceFrame.Width || offY + diffRegion.Height > _referenceFrame.Height) + { + current.Dispose(); + return null; + } + var croppedRef = CropBitmap(_referenceFrame, new Region(offX, offY, diffRegion.Width, diffRegion.Height)); + if (croppedRef == null) + { + current.Dispose(); + return null; + } + refForDiff = croppedRef; + disposeRef = true; + } + } + + int w = Math.Min(refForDiff.Width, current.Width); + int h = Math.Min(refForDiff.Height, current.Height); + + var refData = refForDiff.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + byte[] refPx = new byte[refData.Stride * h]; + Marshal.Copy(refData.Scan0, refPx, 0, refPx.Length); + refForDiff.UnlockBits(refData); + int stride = refData.Stride; + + var curData = current.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + byte[] curPx = new byte[curData.Stride * h]; + Marshal.Copy(curData.Scan0, curPx, 0, curPx.Length); + current.UnlockBits(curData); + + int diffThresh = c.DiffThresh; + + // Pass 1: parallel row diff + int[] rowCounts = new int[h]; + Parallel.For(0, h, y => + { + int count = 0; + int rowOffset = y * stride; + for (int x = 0; x < w; x++) + { + int i = rowOffset + x * 4; + int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]); + if (darker > diffThresh) + count++; + } + rowCounts[y] = count; + }); + + int totalChanged = 0; + for (int y = 0; y < h; y++) totalChanged += rowCounts[y]; + + if (totalChanged == 0) + { + current.Dispose(); + if (disposeRef) refForDiff.Dispose(); + return null; + } + + int maxGap = c.MaxGap; + int rowThresh = w / c.RowThreshDiv; + int bestRowStart = 0, bestRowEnd = 0, bestRowLen = 0; + int curRowStart = -1, lastActiveRow = -1; + for (int y = 0; y < h; y++) + { + if (rowCounts[y] >= rowThresh) + { + if (curRowStart < 0) curRowStart = y; + lastActiveRow = y; + } + else if (curRowStart >= 0 && y - lastActiveRow > maxGap) + { + int len = lastActiveRow - curRowStart + 1; + if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; } + curRowStart = -1; + } + } + if (curRowStart >= 0) + { + int len = lastActiveRow - curRowStart + 1; + if (len > bestRowLen) { bestRowStart = curRowStart; bestRowEnd = lastActiveRow; bestRowLen = len; } + } + + // Pass 2: parallel column diff + int[] colCounts = new int[w]; + int rowRangeLen = bestRowEnd - bestRowStart + 1; + if (rowRangeLen <= 200) + { + for (int y = bestRowStart; y <= bestRowEnd; y++) + { + int rowOffset = y * stride; + for (int x = 0; x < w; x++) + { + int i = rowOffset + x * 4; + int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]); + if (darker > diffThresh) + colCounts[x]++; + } + } + } + else + { + Parallel.For(bestRowStart, bestRowEnd + 1, + () => new int[w], + (y, _, localCols) => + { + int rowOffset = y * stride; + for (int x = 0; x < w; x++) + { + int i = rowOffset + x * 4; + int darker = (refPx[i] - curPx[i]) + (refPx[i + 1] - curPx[i + 1]) + (refPx[i + 2] - curPx[i + 2]); + if (darker > diffThresh) + localCols[x]++; + } + return localCols; + }, + localCols => + { + for (int x = 0; x < w; x++) + Interlocked.Add(ref colCounts[x], localCols[x]); + }); + } + + int tooltipHeight = bestRowEnd - bestRowStart + 1; + int colThresh = tooltipHeight / c.ColThreshDiv; + + int bestColStart = 0, bestColEnd = 0, bestColLen = 0; + int curColStart = -1, lastActiveCol = -1; + for (int x = 0; x < w; x++) + { + if (colCounts[x] >= colThresh) + { + if (curColStart < 0) curColStart = x; + lastActiveCol = x; + } + else if (curColStart >= 0 && x - lastActiveCol > maxGap) + { + int len = lastActiveCol - curColStart + 1; + if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; } + curColStart = -1; + } + } + if (curColStart >= 0) + { + int len = lastActiveCol - curColStart + 1; + if (len > bestColLen) { bestColStart = curColStart; bestColEnd = lastActiveCol; bestColLen = len; } + } + + Log.Debug("diff-crop: changed={Changed} rows={RowStart}-{RowEnd}({RowLen}) cols={ColStart}-{ColEnd}({ColLen})", + totalChanged, bestRowStart, bestRowEnd, bestRowLen, bestColStart, bestColEnd, bestColLen); + + if (bestRowLen < 50 || bestColLen < 50) + { + Log.Debug("diff-crop: no tooltip-sized region found"); + current.Dispose(); + if (disposeRef) refForDiff.Dispose(); + return null; + } + + int minX = bestColStart; + int minY = bestRowStart; + int maxX = Math.Min(bestColEnd, w - 1); + int maxY = Math.Min(bestRowEnd, h - 1); + + // Boundary extension + int extRowThresh = Math.Max(1, rowThresh / 4); + int extColThresh = Math.Max(1, colThresh / 4); + + int extTop = Math.Max(0, minY - maxGap); + for (int y = minY - 1; y >= extTop; y--) + { + if (rowCounts[y] >= extRowThresh) minY = y; + else break; + } + int extBottom = Math.Min(h - 1, maxY + maxGap); + for (int y = maxY + 1; y <= extBottom; y++) + { + if (rowCounts[y] >= extRowThresh) maxY = y; + else break; + } + int extLeft = Math.Max(0, minX - maxGap); + for (int x = minX - 1; x >= extLeft; x--) + { + if (colCounts[x] >= extColThresh) minX = x; + else break; + } + int extRight = Math.Min(w - 1, maxX + maxGap); + for (int x = maxX + 1; x <= extRight; x++) + { + if (colCounts[x] >= extColThresh) maxX = x; + else break; + } + + // Trim low-density edges + int colSpan = maxX - minX + 1; + if (colSpan > 50) + { + int q1 = minX + colSpan / 4; + int q3 = minX + colSpan * 3 / 4; + long midSum = 0; + int midCount = 0; + for (int x = q1; x <= q3; x++) { midSum += colCounts[x]; midCount++; } + double avgMidDensity = (double)midSum / Math.Max(1, midCount); + double cutoff = avgMidDensity * c.TrimCutoff; + + while (minX < maxX - 50 && colCounts[minX] < cutoff) + minX++; + while (maxX > minX + 50 && colCounts[maxX] < cutoff) + maxX--; + } + + int rowSpan = maxY - minY + 1; + if (rowSpan > 50) + { + int q1 = minY + rowSpan / 4; + int q3 = minY + rowSpan * 3 / 4; + long midSum = 0; + int midCount = 0; + for (int y = q1; y <= q3; y++) { midSum += rowCounts[y]; midCount++; } + double avgMidDensity = (double)midSum / Math.Max(1, midCount); + double cutoff = avgMidDensity * c.TrimCutoff; + + while (minY < maxY - 50 && rowCounts[minY] < cutoff) + minY++; + while (maxY > minY + 50 && rowCounts[maxY] < cutoff) + maxY--; + } + int rw = maxX - minX + 1; + int rh = maxY - minY + 1; + + var cropped = CropFromBytes(curPx, stride, minX, minY, rw, rh); + var refCropped = CropFromBytes(refPx, stride, minX, minY, rw, rh); + var resultRegion = new Region(baseX + minX, baseY + minY, rw, rh); + + Log.Debug("diff-crop: tooltip region ({X},{Y}) {W}x{H}", minX, minY, rw, rh); + + if (disposeRef) refForDiff.Dispose(); + return (cropped, refCropped, current, resultRegion); + } + + private static bool RegionsEqual(Region a, Region b) => + a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height; + + private static Bitmap? CropBitmap(Bitmap src, Region region) + { + int cx = Math.Max(0, region.X); + int cy = Math.Max(0, region.Y); + int cw = Math.Min(region.Width, src.Width - cx); + int ch = Math.Min(region.Height, src.Height - cy); + if (cw <= 0 || ch <= 0) + return null; + return src.Clone(new Rectangle(cx, cy, cw, ch), PixelFormat.Format32bppArgb); + } + + /// + /// Fast crop from raw pixel bytes. + /// + private static Bitmap CropFromBytes(byte[] px, int srcStride, int cropX, int cropY, int cropW, int cropH) + { + var bmp = new Bitmap(cropW, cropH, PixelFormat.Format32bppArgb); + var data = bmp.LockBits(new Rectangle(0, 0, cropW, cropH), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); + int dstStride = data.Stride; + int rowBytes = cropW * 4; + for (int y = 0; y < cropH; y++) + { + int srcOffset = (cropY + y) * srcStride + cropX * 4; + Marshal.Copy(px, srcOffset, data.Scan0 + y * dstStride, rowBytes); + } + bmp.UnlockBits(data); + return bmp; + } + + public static double LevenshteinSimilarity(string a, string b) + { + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + if (a == b) return 1.0; + + int la = a.Length, lb = b.Length; + if (la == 0 || lb == 0) return 0.0; + + var d = new int[la + 1, lb + 1]; + for (int i = 0; i <= la; i++) d[i, 0] = i; + for (int j = 0; j <= lb; j++) d[0, j] = j; + + for (int i = 1; i <= la; i++) + for (int j = 1; j <= lb; j++) + { + int cost = a[i - 1] == b[j - 1] ? 0 : 1; + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + } + + return 1.0 - (double)d[la, lb] / Math.Max(la, lb); + } +} diff --git a/tools/OcrDaemon/EdgeCropHandler.cs b/src/Poe2Trade.Screen/EdgeCropHandler.cs similarity index 65% rename from tools/OcrDaemon/EdgeCropHandler.cs rename to src/Poe2Trade.Screen/EdgeCropHandler.cs index 03334dc..ebe68df 100644 --- a/tools/OcrDaemon/EdgeCropHandler.cs +++ b/src/Poe2Trade.Screen/EdgeCropHandler.cs @@ -1,8 +1,10 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; +using Serilog; +using Region = Poe2Trade.Core.Region; class EdgeCropHandler { @@ -12,27 +14,28 @@ class EdgeCropHandler [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); - public (Bitmap cropped, Bitmap fullCapture, RegionRect region)? EdgeCrop(Request req, EdgeCropParams p) + public (Bitmap cropped, Bitmap fullCapture, Region region)? EdgeCrop( + EdgeCropParams p, int? cursorX = null, int? cursorY = null, string? file = null) { - int cursorX, cursorY; - if (req.CursorX.HasValue && req.CursorY.HasValue) + int cx, cy; + if (cursorX.HasValue && cursorY.HasValue) { - cursorX = req.CursorX.Value; - cursorY = req.CursorY.Value; + cx = cursorX.Value; + cy = cursorY.Value; } else { GetCursorPos(out var pt); - cursorX = pt.X; - cursorY = pt.Y; + cx = pt.X; + cy = pt.Y; } - var fullCapture = ScreenCapture.CaptureOrLoad(req.File, null); + var fullCapture = ScreenCapture.CaptureOrLoad(file, null); int w = fullCapture.Width; int h = fullCapture.Height; - cursorX = Math.Clamp(cursorX, 0, w - 1); - cursorY = Math.Clamp(cursorY, 0, h - 1); + cx = Math.Clamp(cx, 0, w - 1); + cy = Math.Clamp(cy, 0, h - 1); var bmpData = fullCapture.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); byte[] px = new byte[bmpData.Stride * h]; @@ -44,12 +47,10 @@ class EdgeCropHandler int colGap = p.RunGapTolerance; int maxGap = p.MaxGap; - // ── Phase 1: Per-row horizontal extent ── - // Scan left/right from cursorX per row. Gap tolerance bridges through text. - // Percentile-based filtering for robustness. - int bandHalf = p.MinDarkRun; // repurpose: half-height of horizontal scan band - int bandTop = Math.Max(0, cursorY - bandHalf); - int bandBot = Math.Min(h - 1, cursorY + bandHalf); + // Phase 1: Per-row horizontal extent + int bandHalf = p.MinDarkRun; + int bandTop = Math.Max(0, cy - bandHalf); + int bandBot = Math.Min(h - 1, cy + bandHalf); var leftExtents = new List(); var rightExtents = new List(); @@ -57,27 +58,30 @@ class EdgeCropHandler for (int y = bandTop; y <= bandBot; y++) { int rowOff = y * stride; - int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cursorX, darkThresh, seedRadius: 6); + int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cx, darkThresh, seedRadius: 6); if (seedX < 0) continue; - int leftEdge = seedX; + int leftEdge = cx; int gap = 0; - for (int x = seedX - 1; x >= 0; x--) + bool foundLeft = false; + int initialBridge = Math.Max(colGap * 4, 12); + for (int x = cx; x >= 0; x--) { int i = rowOff + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; - if (brightness < darkThresh) { leftEdge = x; gap = 0; } - else if (++gap > colGap) break; + if (brightness < darkThresh) { leftEdge = x; gap = 0; foundLeft = true; } + else if (++gap > (foundLeft ? colGap : initialBridge)) break; } - int rightEdge = seedX; + int rightEdge = cx; gap = 0; - for (int x = seedX + 1; x < w; x++) + bool foundRight = false; + for (int x = cx; x < w; x++) { int i = rowOff + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; - if (brightness < darkThresh) { rightEdge = x; gap = 0; } - else if (++gap > colGap) break; + if (brightness < darkThresh) { rightEdge = x; gap = 0; foundRight = true; } + else if (++gap > (foundRight ? colGap : initialBridge)) break; } leftExtents.Add(leftEdge); @@ -86,7 +90,7 @@ class EdgeCropHandler if (leftExtents.Count < 10) { - Console.Error.WriteLine($" edge-crop: too few dark rows ({leftExtents.Count})"); + Log.Debug("edge-crop: too few dark rows ({Count})", leftExtents.Count); fullCapture.Dispose(); return null; } @@ -94,8 +98,6 @@ class EdgeCropHandler leftExtents.Sort(); rightExtents.Sort(); - // Use RowThreshDiv/ColThreshDiv as percentile denominators - // e.g., RowThreshDiv=4 → 25th percentile for left, ColThreshDiv=4 → 75th for right int leftPctIdx = leftExtents.Count / p.RowThreshDiv; int rightPctIdx = rightExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv; leftPctIdx = Math.Clamp(leftPctIdx, 0, leftExtents.Count - 1); @@ -104,49 +106,50 @@ class EdgeCropHandler int bestColStart = leftExtents[leftPctIdx]; int bestColEnd = rightExtents[rightPctIdx]; - Console.Error.WriteLine($" edge-crop: horizontal: left={bestColStart} right={bestColEnd} ({bestColEnd - bestColStart + 1}px) samples={leftExtents.Count} pctL={leftPctIdx}/{leftExtents.Count} pctR={rightPctIdx}/{rightExtents.Count}"); - if (bestColEnd - bestColStart + 1 < 50) { - Console.Error.WriteLine($" edge-crop: horizontal extent too small"); + Log.Debug("edge-crop: horizontal extent too small"); fullCapture.Dispose(); return null; } - // ── Phase 2: Per-column vertical extent ── + // Phase 2: Per-column vertical extent int colBandHalf = (bestColEnd - bestColStart + 1) / 3; - int colBandLeft = Math.Max(bestColStart, cursorX - colBandHalf); - int colBandRight = Math.Min(bestColEnd, cursorX + colBandHalf); + int colBandLeft = Math.Max(bestColStart, cx - colBandHalf); + int colBandRight = Math.Min(bestColEnd, cx + colBandHalf); var topExtents = new List(); var bottomExtents = new List(); - // Asymmetric gap: larger upward to bridge header decorations (~30-40px bright) int maxGapUp = maxGap * 3; for (int x = colBandLeft; x <= colBandRight; x++) { - int seedY = FindDarkSeedInColumn(px, stride, h, x, cursorY, darkThresh, seedRadius: 6); + int seedY = FindDarkSeedInColumn(px, stride, h, x, cy, darkThresh, seedRadius: 6); if (seedY < 0) continue; - int topEdge = seedY; + int topEdge = cy; int gap = 0; - for (int y = seedY - 1; y >= 0; y--) + bool foundTop = false; + int initialBridgeUp = Math.Max(maxGapUp * 2, 12); + for (int y = cy; y >= 0; y--) { int i = y * stride + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; - if (brightness < darkThresh) { topEdge = y; gap = 0; } - else if (++gap > maxGapUp) break; + if (brightness < darkThresh) { topEdge = y; gap = 0; foundTop = true; } + else if (++gap > (foundTop ? maxGapUp : initialBridgeUp)) break; } - int bottomEdge = seedY; + int bottomEdge = cy; gap = 0; - for (int y = seedY + 1; y < h; y++) + bool foundBottom = false; + int initialBridgeDown = Math.Max(maxGap * 2, 12); + for (int y = cy; y < h; y++) { int i = y * stride + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; - if (brightness < darkThresh) { bottomEdge = y; gap = 0; } - else if (++gap > maxGap) break; + if (brightness < darkThresh) { bottomEdge = y; gap = 0; foundBottom = true; } + else if (++gap > (foundBottom ? maxGap : initialBridgeDown)) break; } topExtents.Add(topEdge); @@ -155,7 +158,7 @@ class EdgeCropHandler if (topExtents.Count < 10) { - Console.Error.WriteLine($" edge-crop: too few dark columns ({topExtents.Count})"); + Log.Debug("edge-crop: too few dark columns ({Count})", topExtents.Count); fullCapture.Dispose(); return null; } @@ -171,11 +174,9 @@ class EdgeCropHandler int bestRowStart = topExtents[topPctIdx]; int bestRowEnd = bottomExtents[botPctIdx]; - Console.Error.WriteLine($" edge-crop: vertical: top={bestRowStart} bottom={bestRowEnd} ({bestRowEnd - bestRowStart + 1}px) samples={topExtents.Count}"); - if (bestRowEnd - bestRowStart + 1 < 50) { - Console.Error.WriteLine($" edge-crop: vertical extent too small"); + Log.Debug("edge-crop: vertical extent too small"); fullCapture.Dispose(); return null; } @@ -188,18 +189,16 @@ class EdgeCropHandler int rw = maxX - minX + 1; int rh = maxY - minY + 1; - Console.Error.WriteLine($" edge-crop: result ({minX},{minY}) {rw}x{rh}"); - if (rw < 50 || rh < 50) { - Console.Error.WriteLine($" edge-crop: region too small ({rw}x{rh})"); + Log.Debug("edge-crop: region too small ({W}x{H})", rw, rh); fullCapture.Dispose(); return null; } var cropRect = new Rectangle(minX, minY, rw, rh); var cropped = fullCapture.Clone(cropRect, PixelFormat.Format32bppArgb); - var region = new RegionRect { X = minX, Y = minY, Width = rw, Height = rh }; + var region = new Region(minX, minY, rw, rh); return (cropped, fullCapture, region); } diff --git a/tools/OcrDaemon/GridHandler.cs b/src/Poe2Trade.Screen/GridHandler.cs similarity index 75% rename from tools/OcrDaemon/GridHandler.cs rename to src/Poe2Trade.Screen/GridHandler.cs index 12afdac..838e1a8 100644 --- a/tools/OcrDaemon/GridHandler.cs +++ b/src/Poe2Trade.Screen/GridHandler.cs @@ -1,12 +1,11 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; -using System.Drawing.Imaging; -using System.Runtime.InteropServices; +using Serilog; +using Region = Poe2Trade.Core.Region; -class GridHandler +public class GridHandler { - // Pre-loaded empty cell templates (loaded lazily on first grid scan) private byte[]? _emptyTemplate70Gray; private byte[]? _emptyTemplate70Argb; private int _emptyTemplate70W, _emptyTemplate70H, _emptyTemplate70Stride; @@ -14,16 +13,13 @@ class GridHandler private byte[]? _emptyTemplate35Argb; private int _emptyTemplate35W, _emptyTemplate35H, _emptyTemplate35Stride; - public object HandleGrid(Request req) + public GridScanResult Scan(Region region, int cols, int rows, + int threshold = 0, int? targetRow = null, int? targetCol = null, + string? file = null, bool debug = false) { - if (req.Region == null || req.Cols <= 0 || req.Rows <= 0) - return new ErrorResponse("grid command requires region, cols, rows"); - LoadTemplatesIfNeeded(); - using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region); - int cols = req.Cols; - int rows = req.Rows; + using var bitmap = ScreenCapture.CaptureOrLoad(file, region); float cellW = (float)bitmap.Width / cols; float cellH = (float)bitmap.Height / rows; @@ -50,14 +46,12 @@ class GridHandler } else { - return new ErrorResponse("Empty cell templates not found in assets/"); + throw new InvalidOperationException("Empty cell templates not found in assets/"); } - // Convert captured bitmap to grayscale + keep ARGB for border color comparison var (captureGray, captureArgb, captureStride) = ImageUtils.BitmapToGrayAndArgb(bitmap); int captureW = bitmap.Width; - // Border to skip (outer pixels may differ between cells) int border = Math.Max(2, nominalCell / 10); // Pre-compute template average for the inner region @@ -71,17 +65,15 @@ class GridHandler } double tmplMean = innerCount > 0 ? (double)templateSum / innerCount : 0; - // Threshold for brightness-normalized MAD - double diffThreshold = req.Threshold > 0 ? req.Threshold : 5; - bool debug = req.Debug; + double diffThreshold = threshold > 0 ? threshold : 5; - if (debug) Console.Error.WriteLine($"Grid: {cols}x{rows}, cellW={cellW:F1}, cellH={cellH:F1}, border={border}, threshold={diffThreshold}, tmplMean={tmplMean:F1}"); + if (debug) Log.Debug("Grid: {Cols}x{Rows}, cellW={CellW:F1}, cellH={CellH:F1}, border={Border}, threshold={Threshold}, tmplMean={TmplMean:F1}", + cols, rows, cellW, cellH, border, diffThreshold, tmplMean); var cells = new List>(); for (int row = 0; row < rows; row++) { var rowList = new List(); - var debugDiffs = new List(); for (int col = 0; col < cols; col++) { int cx0 = (int)(col * cellW); @@ -92,7 +84,6 @@ class GridHandler int innerW = Math.Min(cw, templateW) - border; int innerH = Math.Min(ch, templateH) - border; - // First pass: compute cell region mean brightness long cellSum = 0; int compared = 0; for (int py = border; py < innerH; py++) @@ -104,7 +95,6 @@ class GridHandler double cellMean = compared > 0 ? (double)cellSum / compared : 0; double offset = cellMean - tmplMean; - // Second pass: MAD on brightness-normalized values long diffSum = 0; for (int py = border; py < innerH; py++) for (int px = border; px < innerW; px++) @@ -116,16 +106,11 @@ class GridHandler double meanDiff = compared > 0 ? (double)diffSum / compared : 0; bool occupied = meanDiff > diffThreshold; rowList.Add(occupied); - if (debug) debugDiffs.Add($"{meanDiff,5:F1}{(occupied ? "*" : " ")}"); } cells.Add(rowList); - if (debug) Console.Error.WriteLine($" Row {row,2}: {string.Join(" ", debugDiffs)}"); } - // ── Item detection: compare border pixels to empty template (grayscale) ── - // Items have a colored tint behind them that shows through grid lines. - // Compare each cell's border strip against the template's border pixels. - // If they differ → item tint present → cells belong to same item. + // Item detection: union-find on border pixel comparison int[] parent = new int[rows * cols]; for (int i = 0; i < parent.Length; i++) parent[i] = i; @@ -164,7 +149,6 @@ class GridHandler } } double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0; - if (debug) Console.Error.WriteLine($" H ({row},{col})->({row},{col+1}): {meanDiff:F1}{(meanDiff > borderDiffThresh ? " SAME" : "")}"); if (meanDiff > borderDiffThresh) Union(row * cols + col, row * cols + col + 1); } @@ -189,7 +173,6 @@ class GridHandler } } double meanDiff = cnt > 0 ? (double)diffSum / cnt : 0; - if (debug) Console.Error.WriteLine($" V ({row},{col})->({row+1},{col}): {meanDiff:F1}{(meanDiff > borderDiffThresh ? " SAME" : "")}"); if (meanDiff > borderDiffThresh) Union(row * cols + col, (row + 1) * cols + col); } @@ -217,31 +200,24 @@ class GridHandler items.Add(new GridItem { Row = minR, Col = minC, W = maxC - minC + 1, H = maxR - minR + 1 }); } - if (debug) - { - Console.Error.WriteLine($" Items found: {items.Count}"); - foreach (var item in items) - Console.Error.WriteLine($" ({item.Row},{item.Col}) {item.W}x{item.H}"); - } - - // ── Visual matching: find cells similar to target ── + // Visual matching List? matches = null; - if (req.TargetRow >= 0 && req.TargetCol >= 0 && - req.TargetRow < rows && req.TargetCol < cols && - cells[req.TargetRow][req.TargetCol]) + int tRow = targetRow ?? -1; + int tCol = targetCol ?? -1; + if (tRow >= 0 && tCol >= 0 && tRow < rows && tCol < cols && cells[tRow][tCol]) { matches = FindMatchingCells( captureGray, captureW, bitmap.Height, cells, rows, cols, cellW, cellH, border, - req.TargetRow, req.TargetCol, debug); + tRow, tCol, debug); } - return new GridResponse { Cells = cells, Items = items, Matches = matches }; + // Convert cells to bool[][] + var cellsArr = cells.Select(r => r.ToArray()).ToArray(); + + return new GridScanResult { Cells = cellsArr, Items = items, Matches = matches }; } - /// - /// Find all occupied cells visually similar to the target cell using full-resolution NCC. - /// private List FindMatchingCells( byte[] gray, int imgW, int imgH, List> cells, int rows, int cols, @@ -260,7 +236,6 @@ class GridHandler int n = innerW * innerH; - // Pre-compute target cell pixels and stats double[] targetPixels = new double[n]; double tMean = 0; for (int py = 0; py < innerH; py++) @@ -277,7 +252,6 @@ class GridHandler tStd += (targetPixels[i] - tMean) * (targetPixels[i] - tMean); tStd = Math.Sqrt(tStd / n); - if (debug) Console.Error.WriteLine($" Match target ({targetRow},{targetCol}): {innerW}x{innerH} ({n}px), mean={tMean:F1}, std={tStd:F1}"); if (tStd < 3.0) return []; double matchThreshold = 0.70; @@ -296,7 +270,6 @@ class GridHandler int cInnerH = Math.Min(innerH, imgH - cy0); if (cInnerW < innerW || cInnerH < innerH) continue; - // Compute NCC at full resolution double cMean = 0; for (int py = 0; py < innerH; py++) for (int px = 0; px < innerW; px++) @@ -316,15 +289,11 @@ class GridHandler double ncc = (tStd > 0 && cStd > 0) ? cross / (n * tStd * cStd) : 0; - if (debug && ncc > 0.5) - Console.Error.WriteLine($" ({row},{col}): NCC={ncc:F3}"); - if (ncc >= matchThreshold) matches.Add(new GridMatch { Row = row, Col = col, Similarity = Math.Round(ncc, 3) }); } } - if (debug) Console.Error.WriteLine($" Matches for ({targetRow},{targetCol}): {matches.Count}"); return matches; } @@ -332,12 +301,9 @@ class GridHandler { if (_emptyTemplate70Gray != null) return; - // Look for templates relative to exe directory - var exeDir = AppContext.BaseDirectory; - // Templates are in assets/ at project root — walk up from bin/Release/net8.0-.../ - var projectRoot = Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "..", "..")); - var t70Path = Path.Combine(projectRoot, "assets", "empty70.png"); - var t35Path = Path.Combine(projectRoot, "assets", "empty35.png"); + // Templates are in assets/ at project root (working directory) + var t70Path = Path.Combine("assets", "empty70.png"); + var t35Path = Path.Combine("assets", "empty35.png"); if (File.Exists(t70Path)) { diff --git a/src/Poe2Trade.Screen/GridReader.cs b/src/Poe2Trade.Screen/GridReader.cs new file mode 100644 index 0000000..a2b3885 --- /dev/null +++ b/src/Poe2Trade.Screen/GridReader.cs @@ -0,0 +1,136 @@ +using Poe2Trade.Core; +using Serilog; + +namespace Poe2Trade.Screen; + +public class GridLayout +{ + public required Region Region { get; init; } + public required int Cols { get; init; } + public required int Rows { get; init; } +} + +public record CellCoord(int Row, int Col, int X, int Y); + +public class ScanResult +{ + public required GridLayout Layout { get; init; } + public required List Occupied { get; init; } + public required List Items { get; init; } + public List? Matches { get; init; } +} + +public static class GridLayouts +{ + public static readonly Dictionary All = new() + { + ["inventory"] = new GridLayout + { + Region = new Region(1696, 788, 840, 350), + Cols = 12, Rows = 5 + }, + ["stash12"] = new GridLayout + { + Region = new Region(23, 169, 840, 840), + Cols = 12, Rows = 12 + }, + ["stash12_folder"] = new GridLayout + { + Region = new Region(23, 216, 840, 840), + Cols = 12, Rows = 12 + }, + ["stash24"] = new GridLayout + { + Region = new Region(23, 169, 840, 840), + Cols = 24, Rows = 24 + }, + ["stash24_folder"] = new GridLayout + { + Region = new Region(23, 216, 840, 840), + Cols = 24, Rows = 24 + }, + ["seller"] = new GridLayout + { + Region = new Region(416, 299, 840, 840), + Cols = 12, Rows = 12 + }, + ["shop"] = new GridLayout + { + Region = new Region(23, 216, 840, 840), + Cols = 12, Rows = 12 + }, + ["vendor"] = new GridLayout + { + Region = new Region(416, 369, 840, 840), + Cols = 12, Rows = 12 + }, + }; + + public static readonly GridLayout Inventory = All["inventory"]; + public static readonly GridLayout Seller = All["seller"]; +} + +public class GridReader +{ + private readonly GridHandler _handler; + + public GridReader(GridHandler handler) + { + _handler = handler; + } + + public Task Scan(string layoutName, int threshold = 0, + int? targetRow = null, int? targetCol = null) + { + if (!GridLayouts.All.TryGetValue(layoutName, out var layout)) + throw new ArgumentException($"Unknown grid layout: {layoutName}"); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = _handler.Scan(layout.Region, layout.Cols, layout.Rows, + threshold, targetRow, targetCol); + + var occupied = new List(); + for (var row = 0; row < result.Cells.Length; row++) + for (var col = 0; col < result.Cells[row].Length; col++) + { + if (result.Cells[row][col]) + { + var center = GetCellCenter(layout, row, col); + occupied.Add(new CellCoord(row, col, center.X, center.Y)); + } + } + + Log.Information("Grid scan {Layout}: {Occupied} occupied, {Items} items, {Ms}ms", + layoutName, occupied.Count, result.Items.Count, sw.ElapsedMilliseconds); + + return Task.FromResult(new ScanResult + { + Layout = layout, + Occupied = occupied, + Items = result.Items, + Matches = result.Matches + }); + } + + public (int X, int Y) GetCellCenter(GridLayout layout, int row, int col) + { + var cellW = (double)layout.Region.Width / layout.Cols; + var cellH = (double)layout.Region.Height / layout.Rows; + return ( + (int)Math.Round(layout.Region.X + col * cellW + cellW / 2), + (int)Math.Round(layout.Region.Y + row * cellH + cellH / 2) + ); + } + + public List GetAllCells(GridLayout layout) + { + var cells = new List(); + for (var row = 0; row < layout.Rows; row++) + for (var col = 0; col < layout.Cols; col++) + { + var center = GetCellCenter(layout, row, col); + cells.Add(new CellCoord(row, col, center.X, center.Y)); + } + return cells; + } +} diff --git a/tools/OcrDaemon/ImagePreprocessor.cs b/src/Poe2Trade.Screen/ImagePreprocessor.cs similarity index 84% rename from tools/OcrDaemon/ImagePreprocessor.cs rename to src/Poe2Trade.Screen/ImagePreprocessor.cs index e2bf747..20e8978 100644 --- a/tools/OcrDaemon/ImagePreprocessor.cs +++ b/src/Poe2Trade.Screen/ImagePreprocessor.cs @@ -1,4 +1,4 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; using OpenCvSharp; @@ -9,7 +9,7 @@ static class ImagePreprocessor /// /// Pre-process an image for OCR using morphological white top-hat filtering. /// Isolates bright tooltip text, suppresses dim background text visible through overlay. - /// Pipeline: grayscale → morphological top-hat → Otsu binary → upscale + /// Pipeline: grayscale -> morphological top-hat -> Otsu binary -> upscale /// public static Bitmap PreprocessForOcr(Bitmap src, int kernelSize = 41, int upscale = 2) { @@ -41,7 +41,7 @@ static class ImagePreprocessor /// /// Background-subtraction preprocessing: uses the reference frame to remove /// background bleed-through from the semi-transparent tooltip overlay. - /// Pipeline: estimate dimming factor → subtract expected background → threshold → upscale + /// Pipeline: estimate dimming factor -> subtract expected background -> threshold -> upscale /// Returns the upscaled binary Mat directly (caller must dispose). /// public static Mat PreprocessWithBackgroundSubMat(Bitmap tooltipCrop, Bitmap referenceCrop, @@ -57,8 +57,6 @@ static class ImagePreprocessor int rows = curGray.Rows, cols = curGray.Cols; // Estimate the dimming factor of the tooltip overlay. - // For non-text pixels: current ≈ reference × dim_factor - // Collect ratios where reference is bright enough to be meaningful var ratios = new List(); unsafe { @@ -72,28 +70,23 @@ static class ImagePreprocessor { byte r = refPtr[y * refStep + x]; byte c = curPtr[y * curStep + x]; - if (r > 30) // skip very dark reference pixels (no signal) + if (r > 30) ratios.Add((double)c / r); } } if (ratios.Count == 0) { - // Fallback: use top-hat preprocessing, convert to Mat using var fallbackBmp = PreprocessForOcr(tooltipCrop, 41, upscale); return BitmapConverter.ToMat(fallbackBmp); } - // Use a low percentile of ratios as the dimming factor. - // Text pixels have high ratios (bright on dark), overlay pixels have low ratios. - // A low percentile captures the overlay dimming, ignoring text. ratios.Sort(); int idx = Math.Clamp(ratios.Count * dimPercentile / 100, 0, ratios.Count - 1); double dimFactor = ratios[idx]; - // Clamp to sane range dimFactor = Math.Clamp(dimFactor, 0.05, 0.95); - // Subtract expected background: text_signal = current - reference × dimFactor + // Subtract expected background: text_signal = current - reference * dimFactor using var textSignal = new Mat(rows, cols, MatType.CV_8UC1); unsafe { @@ -116,9 +109,6 @@ static class ImagePreprocessor Mat result; if (softThreshold) { - // Soft threshold: clip below textThresh, contrast-stretch, invert. - // Produces grayscale anti-aliased text on white background, - // matching the training data format (text2image renders). result = new Mat(rows, cols, MatType.CV_8UC1); unsafe { @@ -127,7 +117,6 @@ static class ImagePreprocessor int srcStep = (int)textSignal.Step(); int dstStep = (int)result.Step(); - // Find max signal above threshold for contrast stretch int maxClipped = 1; for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) @@ -136,26 +125,24 @@ static class ImagePreprocessor if (val > maxClipped) maxClipped = val; } - // Clip, stretch, invert: background → 255 (white), text → dark for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) { int clipped = srcPtr[y * srcStep + x] - textThresh; if (clipped <= 0) { - dstPtr[y * dstStep + x] = 255; // background + dstPtr[y * dstStep + x] = 255; } else { int stretched = clipped * 255 / maxClipped; - dstPtr[y * dstStep + x] = (byte)(255 - stretched); // invert + dstPtr[y * dstStep + x] = (byte)(255 - stretched); } } } } else { - // Hard binary threshold (original behavior) result = new Mat(); Cv2.Threshold(textSignal, result, textThresh, 255, ThresholdTypes.BinaryInv); } @@ -184,8 +171,6 @@ static class ImagePreprocessor { int rows = binary.Rows, cols = binary.Cols; - // Count dark (text) pixels per row — use < 128 threshold since - // cubic upscaling introduces anti-aliased intermediate values var rowCounts = new int[rows]; unsafe { @@ -197,7 +182,6 @@ static class ImagePreprocessor rowCounts[y]++; } - // Group into contiguous runs with gap tolerance var lines = new List<(int yStart, int yEnd)>(); int lineStart = -1, lastActive = -1; for (int y = 0; y < rows; y++) diff --git a/src/Poe2Trade.Screen/ImageUtils.cs b/src/Poe2Trade.Screen/ImageUtils.cs new file mode 100644 index 0000000..2004c4f --- /dev/null +++ b/src/Poe2Trade.Screen/ImageUtils.cs @@ -0,0 +1,39 @@ +namespace Poe2Trade.Screen; + +using System.Drawing; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; +using SdImageFormat = System.Drawing.Imaging.ImageFormat; + +static class ImageUtils +{ + public static (byte[] gray, byte[] argb, int stride) BitmapToGrayAndArgb(Bitmap bmp) + { + int w = bmp.Width, h = bmp.Height; + var data = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + byte[] argb = new byte[data.Stride * h]; + Marshal.Copy(data.Scan0, argb, 0, argb.Length); + bmp.UnlockBits(data); + int stride = data.Stride; + + byte[] gray = new byte[w * h]; + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + { + int i = y * stride + x * 4; + gray[y * w + x] = (byte)((argb[i] + argb[i + 1] + argb[i + 2]) / 3); + } + return (gray, argb, stride); + } + + public static SdImageFormat GetImageFormat(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".jpg" or ".jpeg" => SdImageFormat.Jpeg, + ".bmp" => SdImageFormat.Bmp, + _ => SdImageFormat.Png, + }; + } +} diff --git a/src/Poe2Trade.Screen/Poe2Trade.Screen.csproj b/src/Poe2Trade.Screen/Poe2Trade.Screen.csproj new file mode 100644 index 0000000..6ef586a --- /dev/null +++ b/src/Poe2Trade.Screen/Poe2Trade.Screen.csproj @@ -0,0 +1,17 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + true + + + + + + + + + + + diff --git a/tools/OcrDaemon/PythonOcrBridge.cs b/src/Poe2Trade.Screen/PythonOcrBridge.cs similarity index 66% rename from tools/OcrDaemon/PythonOcrBridge.cs rename to src/Poe2Trade.Screen/PythonOcrBridge.cs index 6ae919b..58f8db7 100644 --- a/tools/OcrDaemon/PythonOcrBridge.cs +++ b/src/Poe2Trade.Screen/PythonOcrBridge.cs @@ -1,15 +1,16 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Diagnostics; using System.Drawing; using System.Text.Json; using System.Text.Json.Serialization; +using Serilog; using SdImageFormat = System.Drawing.Imaging.ImageFormat; /// -/// Manages a persistent Python subprocess for EasyOCR / PaddleOCR. +/// Manages a persistent Python subprocess for EasyOCR. /// Lazy-starts on first request; reuses the process for subsequent calls. -/// Same stdin/stdout JSON-per-line protocol as the C# daemon itself. +/// Same stdin/stdout JSON-per-line protocol. /// class PythonOcrBridge : IDisposable { @@ -26,53 +27,17 @@ class PythonOcrBridge : IDisposable public PythonOcrBridge() { - // Resolve paths relative to this exe - var exeDir = AppContext.BaseDirectory; - // exeDir = tools/OcrDaemon/bin/Release/net8.0-.../ - // Walk up 4 levels to tools/ - var toolsDir = Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "..")); - _daemonScript = Path.GetFullPath(Path.Combine(toolsDir, "python-ocr", "daemon.py")); + // Resolve paths relative to working directory + _daemonScript = Path.GetFullPath(Path.Combine("tools", "python-ocr", "daemon.py")); - // Use the venv Python if it exists, otherwise fall back to system python - var venvPython = Path.GetFullPath(Path.Combine(toolsDir, "python-ocr", ".venv", "Scripts", "python.exe")); + var venvPython = Path.GetFullPath(Path.Combine("tools", "python-ocr", ".venv", "Scripts", "python.exe")); _pythonExe = File.Exists(venvPython) ? venvPython : "python"; } /// - /// Run OCR on a screen region using the specified Python engine. - /// Captures screenshot, saves to temp file, sends to Python, returns OcrResponse. + /// Run OCR on a bitmap via the Python EasyOCR engine (base64 PNG over pipe). /// - public object HandleOcr(Request req, string engine) - { - var tmpPath = Path.Combine(Path.GetTempPath(), $"ocr_{Guid.NewGuid():N}.png"); - try - { - using var bitmap = ScreenCapture.CaptureOrLoad(req.File, req.Region); - bitmap.Save(tmpPath, SdImageFormat.Png); - return OcrFromFile(tmpPath, engine); - } - finally - { - try { File.Delete(tmpPath); } catch { /* ignore */ } - } - } - - /// - /// Run OCR on an already-saved image file via the Python engine. - /// - public OcrResponse OcrFromFile(string imagePath, string engine, OcrParams? ocrParams = null) - { - EnsureRunning(); - - var pyReq = BuildPythonRequest(engine, ocrParams); - pyReq["imagePath"] = imagePath; - return SendPythonRequest(pyReq); - } - - /// - /// Run OCR on a bitmap via the Python engine (base64 PNG over pipe, no temp file). - /// - public OcrResponse OcrFromBitmap(Bitmap bitmap, string engine, OcrParams? ocrParams = null) + public OcrResponse OcrFromBitmap(Bitmap bitmap, OcrParams? ocrParams = null) { EnsureRunning(); @@ -80,14 +45,14 @@ class PythonOcrBridge : IDisposable bitmap.Save(ms, SdImageFormat.Png); var imageBase64 = Convert.ToBase64String(ms.ToArray()); - var pyReq = BuildPythonRequest(engine, ocrParams); + var pyReq = BuildPythonRequest(ocrParams); pyReq["imageBase64"] = imageBase64; return SendPythonRequest(pyReq); } - private static Dictionary BuildPythonRequest(string engine, OcrParams? ocrParams) + private static Dictionary BuildPythonRequest(OcrParams? ocrParams) { - var req = new Dictionary { ["cmd"] = "ocr", ["engine"] = engine }; + var req = new Dictionary { ["cmd"] = "ocr", ["engine"] = "easyocr" }; if (ocrParams == null) return req; if (ocrParams.MergeGap > 0) req["mergeGap"] = ocrParams.MergeGap; @@ -137,7 +102,7 @@ class PythonOcrBridge : IDisposable if (!File.Exists(_daemonScript)) throw new Exception($"Python OCR daemon not found at {_daemonScript}"); - Console.Error.WriteLine($"Spawning Python OCR daemon: {_pythonExe} {_daemonScript}"); + Log.Information("Spawning Python OCR daemon: {Python} {Script}", _pythonExe, _daemonScript); _proc = new Process { @@ -156,7 +121,7 @@ class PythonOcrBridge : IDisposable _proc.ErrorDataReceived += (_, e) => { if (!string.IsNullOrEmpty(e.Data)) - Console.Error.WriteLine($"[python-ocr] {e.Data}"); + Log.Debug("[python-ocr] {Line}", e.Data); }; _proc.Start(); @@ -171,7 +136,7 @@ class PythonOcrBridge : IDisposable if (ready?.Ready != true) throw new Exception($"Python OCR daemon did not send ready signal: {readyLine}"); - Console.Error.WriteLine("Python OCR daemon ready"); + Log.Information("Python OCR daemon ready"); } public void Dispose() @@ -202,7 +167,7 @@ class PythonOcrBridge : IDisposable public string? Text { get; set; } [JsonPropertyName("lines")] - public List? Lines { get; set; } + public List? Lines { get; set; } [JsonPropertyName("error")] public string? Error { get; set; } diff --git a/tools/OcrDaemon/ScreenCapture.cs b/src/Poe2Trade.Screen/ScreenCapture.cs similarity index 90% rename from tools/OcrDaemon/ScreenCapture.cs rename to src/Poe2Trade.Screen/ScreenCapture.cs index 949dcb4..586b749 100644 --- a/tools/OcrDaemon/ScreenCapture.cs +++ b/src/Poe2Trade.Screen/ScreenCapture.cs @@ -1,8 +1,9 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; +using Region = Poe2Trade.Core.Region; static class ScreenCapture { @@ -18,7 +19,7 @@ static class ScreenCapture /// Capture from screen, or load from file if specified. /// When file is set, loads the image and crops to region. /// - public static Bitmap CaptureOrLoad(string? file, RegionRect? region) + public static Bitmap CaptureOrLoad(string? file, Region? region) { if (!string.IsNullOrEmpty(file)) { @@ -38,7 +39,7 @@ static class ScreenCapture return CaptureScreen(region); } - public static Bitmap CaptureScreen(RegionRect? region) + public static Bitmap CaptureScreen(Region? region) { int x, y, w, h; if (region != null) diff --git a/src/Poe2Trade.Screen/ScreenReader.cs b/src/Poe2Trade.Screen/ScreenReader.cs new file mode 100644 index 0000000..d5f2f7f --- /dev/null +++ b/src/Poe2Trade.Screen/ScreenReader.cs @@ -0,0 +1,259 @@ +using Poe2Trade.Core; +using OpenCvSharp.Extensions; +using Serilog; + +namespace Poe2Trade.Screen; + +public class ScreenReader : IDisposable +{ + private readonly DiffCropHandler _diffCrop = new(); + private readonly GridHandler _gridHandler = new(); + private readonly TemplateMatchHandler _templateMatch = new(); + private readonly EdgeCropHandler _edgeCrop = new(); + private readonly PythonOcrBridge _pythonBridge = new(); + private bool _initialized; + + public GridReader Grid { get; } + + public ScreenReader() + { + Grid = new GridReader(_gridHandler); + } + + public Task Warmup() + { + if (!_initialized) + { + ScreenCapture.InitDpiAwareness(); + _initialized = true; + } + return Task.CompletedTask; + } + + // -- Capture -- + + public Task CaptureScreen() + { + return Task.FromResult(_diffCrop.HandleCapture()); + } + + public Task CaptureRegion(Region region) + { + return Task.FromResult(_diffCrop.HandleCapture(region)); + } + + // -- OCR -- + + public Task Ocr(Region? region = null, string? preprocess = null) + { + using var bitmap = ScreenCapture.CaptureOrLoad(null, region); + + if (preprocess == "tophat") + { + using var processed = ImagePreprocessor.PreprocessForOcr(bitmap); + return Task.FromResult(_pythonBridge.OcrFromBitmap(processed)); + } + + return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap)); + } + + public async Task<(int X, int Y)?> FindTextOnScreen(string searchText, bool fuzzy = false) + { + var result = await Ocr(); + var pos = FindWordInOcrResult(result, searchText, fuzzy); + if (pos.HasValue) + Log.Information("Found text '{Text}' at ({X},{Y})", searchText, pos.Value.X, pos.Value.Y); + else + Log.Information("Text '{Text}' not found on screen", searchText); + return pos; + } + + public async Task ReadFullScreen() + { + var result = await Ocr(); + return result.Text; + } + + public async Task<(int X, int Y)?> FindTextInRegion(Region region, string searchText) + { + var result = await Ocr(region); + var pos = FindWordInOcrResult(result, searchText); + if (pos.HasValue) + return (region.X + pos.Value.X, region.Y + pos.Value.Y); + return null; + } + + public async Task ReadRegionText(Region region) + { + var result = await Ocr(region); + return result.Text; + } + + public async Task CheckForText(Region region, string searchText) + { + var pos = await FindTextInRegion(region, searchText); + return pos.HasValue; + } + + // -- Snapshot / Diff OCR -- + + public Task Snapshot() + { + _diffCrop.HandleSnapshot(); + return Task.CompletedTask; + } + + public Task DiffOcr(string? savePath = null, Region? region = null) + { + var p = new DiffOcrParams(); + var cropResult = _diffCrop.DiffCrop(p.Crop, region: region); + if (cropResult == null) + return Task.FromResult(new DiffOcrResponse { Text = "", Lines = [] }); + + var (cropped, refCropped, current, cropRegion) = cropResult.Value; + using var _current = current; + using var _cropped = cropped; + using var _refCropped = refCropped; + + // Save raw crop if path is provided + if (!string.IsNullOrEmpty(savePath)) + { + var dir = Path.GetDirectoryName(savePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + cropped.Save(savePath, ImageUtils.GetImageFormat(savePath)); + } + + // Preprocess with background subtraction + var ocr = p.Ocr; + using var processedBmp = ocr.UseBackgroundSub + ? ImagePreprocessor.PreprocessWithBackgroundSub(cropped, refCropped, ocr.DimPercentile, ocr.TextThresh, 1, ocr.SoftThreshold) + : ImagePreprocessor.PreprocessForOcr(cropped, ocr.KernelSize, 1); + + var ocrResult = _pythonBridge.OcrFromBitmap(processedBmp, ocr); + + // Offset coordinates to screen space + foreach (var line in ocrResult.Lines) + foreach (var word in line.Words) + { + word.X += cropRegion.X; + word.Y += cropRegion.Y; + } + + return Task.FromResult(new DiffOcrResponse + { + Text = ocrResult.Text, + Lines = ocrResult.Lines, + Region = cropRegion, + }); + } + + // -- Template matching -- + + public Task TemplateMatch(string templatePath, Region? region = null) + { + var result = _templateMatch.Match(templatePath, region); + if (result != null) + Log.Information("Template match found: ({X},{Y}) confidence={Conf:F3}", result.X, result.Y, result.Confidence); + return Task.FromResult(result); + } + + // -- Save -- + + public Task SaveScreenshot(string path) + { + _diffCrop.HandleScreenshot(path); + return Task.CompletedTask; + } + + public Task SaveRegion(Region region, string path) + { + _diffCrop.HandleScreenshot(path, region); + return Task.CompletedTask; + } + + public void Dispose() => _pythonBridge.Dispose(); + + // -- OCR text matching -- + + private static (int X, int Y)? FindWordInOcrResult(OcrResponse result, string needle, bool fuzzy = false) + { + var lower = needle.ToLowerInvariant(); + const double fuzzyThreshold = 0.55; + + if (lower.Contains(' ')) + { + var needleNorm = Normalize(needle); + foreach (var line in result.Lines) + { + if (line.Words.Count == 0) continue; + if (line.Text.ToLowerInvariant().Contains(lower)) + return LineBoundsCenter(line); + + if (fuzzy) + { + var lineNorm = Normalize(line.Text); + var windowLen = needleNorm.Length; + for (var i = 0; i <= lineNorm.Length - windowLen + 2; i++) + { + var end = Math.Min(i + windowLen + 2, lineNorm.Length); + var window = lineNorm[i..end]; + if (BigramSimilarity(needleNorm, window) >= fuzzyThreshold) + return LineBoundsCenter(line); + } + } + } + return null; + } + + var needleN = Normalize(needle); + foreach (var line in result.Lines) + { + foreach (var word in line.Words) + { + if (word.Text.ToLowerInvariant().Contains(lower)) + return (word.X + word.Width / 2, word.Y + word.Height / 2); + + if (fuzzy && BigramSimilarity(needleN, Normalize(word.Text)) >= fuzzyThreshold) + return (word.X + word.Width / 2, word.Y + word.Height / 2); + } + } + return null; + } + + private static (int X, int Y) LineBoundsCenter(OcrLine line) + { + var first = line.Words[0]; + var last = line.Words[^1]; + var x1 = first.X; + var y1 = first.Y; + var x2 = last.X + last.Width; + var y2 = line.Words.Max(w => w.Y + w.Height); + return ((x1 + x2) / 2, (y1 + y2) / 2); + } + + private static string Normalize(string s) => + new(s.ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray()); + + private static double BigramSimilarity(string a, string b) + { + if (a.Length < 2 || b.Length < 2) return a == b ? 1 : 0; + var bigramsA = new Dictionary(); + for (var i = 0; i < a.Length - 1; i++) + { + var bg = a.Substring(i, 2); + bigramsA[bg] = bigramsA.GetValueOrDefault(bg) + 1; + } + var matches = 0; + for (var i = 0; i < b.Length - 1; i++) + { + var bg = b.Substring(i, 2); + if (bigramsA.TryGetValue(bg, out var count) && count > 0) + { + matches++; + bigramsA[bg] = count - 1; + } + } + return 2.0 * matches / (a.Length - 1 + b.Length - 1); + } +} diff --git a/tools/OcrDaemon/SignalProcessing.cs b/src/Poe2Trade.Screen/SignalProcessing.cs similarity index 84% rename from tools/OcrDaemon/SignalProcessing.cs rename to src/Poe2Trade.Screen/SignalProcessing.cs index 5ebf5c2..be9fce9 100644 --- a/tools/OcrDaemon/SignalProcessing.cs +++ b/src/Poe2Trade.Screen/SignalProcessing.cs @@ -1,4 +1,4 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; static class SignalProcessing { @@ -27,8 +27,6 @@ static class SignalProcessing ac[lag] = sum / variance; } - // Find the first significant peak — this is the fundamental period. - // Using "first" avoids picking harmonics (2x, 3x) or unrelated larger patterns. for (int lag = minPeriod + 1; lag < maxLag; lag++) { if (ac[lag] > 0.01 && ac[lag] >= ac[lag - 1] && ac[lag] >= ac[lag + 1]) @@ -40,8 +38,6 @@ static class SignalProcessing /// /// Find contiguous segments where values are ABOVE threshold. - /// Used to find grid panel regions by density of very dark pixels. - /// Allows brief gaps (up to 5px) to handle grid borders. /// public static List<(int start, int end)> FindDarkDensitySegments(double[] profile, double threshold, int minLength) { @@ -85,17 +81,14 @@ static class SignalProcessing } /// - /// Find the extent of the grid in a 1D profile using local autocorrelation - /// at the specific detected period. Only regions where the signal actually - /// repeats at the given period will score high — much more precise than variance. + /// Find the extent of the grid in a 1D profile using local autocorrelation. /// public static (int start, int end) FindGridExtent(double[] signal, int period) { int n = signal.Length; - int halfWin = period * 2; // window radius: 2 periods each side + int halfWin = period * 2; if (n < halfWin * 2 + period) return (-1, -1); - // Compute local AC at the specific lag=period in a sliding window double[] localAc = new double[n]; for (int center = halfWin; center < n - halfWin; center++) { @@ -103,20 +96,17 @@ static class SignalProcessing int wEnd = center + halfWin; int count = wEnd - wStart; - // Local mean double sum = 0; for (int i = wStart; i < wEnd; i++) sum += signal[i]; double mean = sum / count; - // Local variance double varSum = 0; for (int i = wStart; i < wEnd; i++) varSum += (signal[i] - mean) * (signal[i] - mean); if (varSum < 1.0) continue; - // AC at the specific lag=period double acSum = 0; for (int i = wStart; i < wEnd - period; i++) acSum += (signal[i] - mean) * (signal[i + period] - mean); @@ -124,7 +114,6 @@ static class SignalProcessing localAc[center] = Math.Max(0, acSum / varSum); } - // Find the longest contiguous run above threshold double maxAc = 0; for (int i = 0; i < n; i++) if (localAc[i] > maxAc) maxAc = localAc[i]; @@ -155,7 +144,6 @@ static class SignalProcessing } } } - // Handle run extending to end of signal if (curStartPos >= 0) { int len = n - curStartPos; @@ -168,7 +156,6 @@ static class SignalProcessing if (bestStart < 0) return (-1, -1); - // Small extension to include cell borders at edges bestStart = Math.Max(0, bestStart - period / 4); bestEnd = Math.Min(n - 1, bestEnd + period / 4); diff --git a/tools/OcrDaemon/TemplateMatchHandler.cs b/src/Poe2Trade.Screen/TemplateMatchHandler.cs similarity index 50% rename from tools/OcrDaemon/TemplateMatchHandler.cs rename to src/Poe2Trade.Screen/TemplateMatchHandler.cs index c6c3675..3aef223 100644 --- a/tools/OcrDaemon/TemplateMatchHandler.cs +++ b/src/Poe2Trade.Screen/TemplateMatchHandler.cs @@ -1,26 +1,24 @@ -namespace OcrDaemon; +namespace Poe2Trade.Screen; using System.Drawing; -using System.Drawing.Imaging; using OpenCvSharp; using OpenCvSharp.Extensions; +using Region = Poe2Trade.Core.Region; class TemplateMatchHandler { - public object HandleTemplateMatch(Request req) + public TemplateMatchResult? Match(string templatePath, Region? region = null, + string? file = null, double threshold = 0.7) { - if (string.IsNullOrEmpty(req.Path)) - return new ErrorResponse("match-template command requires 'path' (template image file)"); + if (!System.IO.File.Exists(templatePath)) + throw new FileNotFoundException($"Template file not found: {templatePath}"); - if (!System.IO.File.Exists(req.Path)) - return new ErrorResponse($"Template file not found: {req.Path}"); - - using var screenshot = ScreenCapture.CaptureOrLoad(req.File, req.Region); + using var screenshot = ScreenCapture.CaptureOrLoad(file, region); using var screenMat = BitmapConverter.ToMat(screenshot); - using var template = Cv2.ImRead(req.Path, ImreadModes.Color); + using var template = Cv2.ImRead(templatePath, ImreadModes.Color); if (template.Empty()) - return new ErrorResponse($"Failed to load template image: {req.Path}"); + throw new InvalidOperationException($"Failed to load template image: {templatePath}"); // Convert screenshot from BGRA to BGR if needed using var screenBgr = new Mat(); @@ -31,25 +29,21 @@ class TemplateMatchHandler // Template must fit within screenshot if (template.Rows > screenBgr.Rows || template.Cols > screenBgr.Cols) - return new TemplateMatchResponse { Found = false }; + return null; using var result = new Mat(); Cv2.MatchTemplate(screenBgr, template, result, TemplateMatchModes.CCoeffNormed); Cv2.MinMaxLoc(result, out _, out double maxVal, out _, out OpenCvSharp.Point maxLoc); - double threshold = req.Threshold > 0 ? req.Threshold / 100.0 : 0.7; - if (maxVal < threshold) - return new TemplateMatchResponse { Found = false, Confidence = maxVal }; + return null; - // Calculate center coordinates — offset by region origin if provided - int offsetX = req.Region?.X ?? 0; - int offsetY = req.Region?.Y ?? 0; + int offsetX = region?.X ?? 0; + int offsetY = region?.Y ?? 0; - return new TemplateMatchResponse + return new TemplateMatchResult { - Found = true, X = offsetX + maxLoc.X + template.Cols / 2, Y = offsetY + maxLoc.Y + template.Rows / 2, Width = template.Cols, diff --git a/src/Poe2Trade.Trade/Poe2Trade.Trade.csproj b/src/Poe2Trade.Trade/Poe2Trade.Trade.csproj new file mode 100644 index 0000000..fde513b --- /dev/null +++ b/src/Poe2Trade.Trade/Poe2Trade.Trade.csproj @@ -0,0 +1,11 @@ + + + net8.0-windows10.0.19041.0 + enable + enable + + + + + + diff --git a/src/Poe2Trade.Trade/Selectors.cs b/src/Poe2Trade.Trade/Selectors.cs new file mode 100644 index 0000000..74b0568 --- /dev/null +++ b/src/Poe2Trade.Trade/Selectors.cs @@ -0,0 +1,30 @@ +namespace Poe2Trade.Trade; + +public static class Selectors +{ + public const string LiveSearchButton = + "button.livesearch-btn, button:has-text(\"Activate Live Search\")"; + + public const string ListingRow = + ".resultset .row, [class*=\"result\"]"; + + public static string ListingById(string id) => $"[data-id=\"{id}\"]"; + + public const string TravelToHideoutButton = + "button:has-text(\"Travel to Hideout\"), button:has-text(\"Visit Hideout\"), a:has-text(\"Travel to Hideout\"), [class*=\"hideout\"]"; + + public const string WhisperButton = + ".whisper-btn, button[class*=\"whisper\"], [data-tooltip=\"Whisper\"], button:has-text(\"Whisper\")"; + + public const string ConfirmDialog = + "[class*=\"modal\"], [class*=\"dialog\"], [class*=\"confirm\"]"; + + public const string ConfirmYesButton = + "button:has-text(\"Yes\"), button:has-text(\"Confirm\"), button:has-text(\"OK\"), button:has-text(\"Accept\")"; + + public const string ConfirmNoButton = + "button:has-text(\"No\"), button:has-text(\"Cancel\"), button:has-text(\"Decline\")"; + + public const string ResultsContainer = + ".resultset, [class*=\"results\"]"; +} diff --git a/src/Poe2Trade.Trade/TradeMonitor.cs b/src/Poe2Trade.Trade/TradeMonitor.cs new file mode 100644 index 0000000..696b20c --- /dev/null +++ b/src/Poe2Trade.Trade/TradeMonitor.cs @@ -0,0 +1,296 @@ +using System.Text.Json; +using Microsoft.Playwright; +using Poe2Trade.Core; +using Serilog; + +namespace Poe2Trade.Trade; + +public class TradeMonitor : IAsyncDisposable +{ + private IBrowserContext? _context; + private readonly Dictionary _pages = new(); + private readonly HashSet _pausedSearches = new(); + private readonly AppConfig _config; + + private const string StealthScript = """ + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + Object.defineProperty(navigator, 'plugins', { + get: () => [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' }, + { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' }, + { name: 'Native Client', filename: 'internal-nacl-plugin' }, + ], + }); + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); + delete window.__playwright; + delete window.__pw_manual; + if (!window.chrome) window.chrome = {}; + if (!window.chrome.runtime) window.chrome.runtime = { id: undefined }; + const originalQuery = window.navigator.permissions?.query; + if (originalQuery) { + window.navigator.permissions.query = (params) => { + if (params.name === 'notifications') + return Promise.resolve({ state: Notification.permission }); + return originalQuery(params); + }; + } + """; + + public event Action, IPage>? NewListings; + + public TradeMonitor(AppConfig config) + { + _config = config; + } + + public async Task Start(string? dashboardUrl = null) + { + Log.Information("Launching Playwright browser (stealth mode)..."); + + var playwright = await Playwright.CreateAsync(); + _context = await playwright.Chromium.LaunchPersistentContextAsync( + _config.BrowserUserDataDir, + new BrowserTypeLaunchPersistentContextOptions + { + Headless = false, + ViewportSize = null, + Args = [ + "--disable-blink-features=AutomationControlled", + "--disable-features=AutomationControlled", + "--no-first-run", + "--no-default-browser-check", + "--disable-infobars", + ], + IgnoreDefaultArgs = ["--enable-automation"], + }); + + await _context.AddInitScriptAsync(StealthScript); + + if (dashboardUrl != null) + { + var pages = _context.Pages; + if (pages.Count > 0) + await pages[0].GotoAsync(dashboardUrl); + else + await (await _context.NewPageAsync()).GotoAsync(dashboardUrl); + Log.Information("Dashboard opened: {Url}", dashboardUrl); + } + + Log.Information("Browser launched (stealth active)"); + } + + public async Task AddSearch(string tradeUrl) + { + if (_context == null) throw new InvalidOperationException("Browser not started"); + + var searchId = ExtractSearchId(tradeUrl); + if (_pages.ContainsKey(searchId)) + { + Log.Information("Search already open: {SearchId}", searchId); + return; + } + + Log.Information("Adding trade search: {Url} ({SearchId})", tradeUrl, searchId); + + var page = await _context.NewPageAsync(); + _pages[searchId] = page; + + await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await Helpers.Sleep(2000); + + page.WebSocket += (_, ws) => HandleWebSocket(ws, searchId, page); + + try + { + var liveBtn = page.Locator(Selectors.LiveSearchButton).First; + await liveBtn.ClickAsync(new LocatorClickOptions { Timeout = 5000 }); + Log.Information("Live search activated: {SearchId}", searchId); + } + catch + { + Log.Warning("Could not click Activate Live Search: {SearchId}", searchId); + } + } + + public async Task PauseSearch(string searchId) + { + _pausedSearches.Add(searchId); + if (_pages.TryGetValue(searchId, out var page)) + { + await page.CloseAsync(); + _pages.Remove(searchId); + } + Log.Information("Search paused: {SearchId}", searchId); + } + + public async Task ClickTravelToHideout(IPage page, string? itemId = null) + { + try + { + if (itemId != null) + { + var row = page.Locator(Selectors.ListingById(itemId)); + if (await WaitForVisible(row, 5000)) + { + var travelBtn = row.Locator(Selectors.TravelToHideoutButton).First; + if (await WaitForVisible(travelBtn, 3000)) + { + await travelBtn.ClickAsync(); + Log.Information("Clicked Travel to Hideout for item {ItemId}", itemId); + await HandleConfirmDialog(page); + return true; + } + } + } + + var btn = page.Locator(Selectors.TravelToHideoutButton).First; + await btn.ClickAsync(new LocatorClickOptions { Timeout = 5000 }); + Log.Information("Clicked Travel to Hideout"); + await HandleConfirmDialog(page); + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to click Travel to Hideout"); + return false; + } + } + + public async Task<(IPage Page, List Items)> OpenScrapPage(string tradeUrl) + { + if (_context == null) throw new InvalidOperationException("Browser not started"); + + var page = await _context.NewPageAsync(); + var items = new List(); + + page.Response += async (_, response) => + { + if (!response.Url.Contains("/api/trade2/fetch/")) return; + try + { + var body = await response.TextAsync(); + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("result", out var results) && + results.ValueKind == JsonValueKind.Array) + { + foreach (var r in results.EnumerateArray()) + items.Add(ParseTradeItem(r)); + } + } + catch { /* Response may not be JSON */ } + }; + + await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await Helpers.Sleep(2000); + Log.Information("Scrap page opened: {Url} ({Count} items)", tradeUrl, items.Count); + return (page, items); + } + + public string ExtractSearchId(string url) + { + var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", ""); + var parts = cleaned.Split('/'); + return parts.Length > 0 ? parts[^1] : url; + } + + public static TradeItem ParseTradeItem(JsonElement r) + { + var id = r.GetProperty("id").GetString() ?? ""; + int w = 1, h = 1, stashX = 0, stashY = 0; + var account = ""; + + if (r.TryGetProperty("item", out var item)) + { + if (item.TryGetProperty("w", out var wProp)) w = wProp.GetInt32(); + if (item.TryGetProperty("h", out var hProp)) h = hProp.GetInt32(); + } + if (r.TryGetProperty("listing", out var listing)) + { + if (listing.TryGetProperty("stash", out var stash)) + { + if (stash.TryGetProperty("x", out var sx)) stashX = sx.GetInt32(); + if (stash.TryGetProperty("y", out var sy)) stashY = sy.GetInt32(); + } + if (listing.TryGetProperty("account", out var acc) && + acc.TryGetProperty("name", out var accName)) + account = accName.GetString() ?? ""; + } + return new TradeItem(id, w, h, stashX, stashY, account); + } + + public async ValueTask DisposeAsync() + { + foreach (var page in _pages.Values) + await page.CloseAsync(); + _pages.Clear(); + if (_context != null) + { + await _context.CloseAsync(); + _context = null; + } + Log.Information("Trade monitor stopped"); + } + + private void HandleWebSocket(IWebSocket ws, string searchId, IPage page) + { + if (!ws.Url.Contains("/api/trade") || !ws.Url.Contains("/live/")) + return; + + Log.Information("WebSocket connected for live search: {SearchId}", searchId); + + ws.FrameReceived += (_, frame) => + { + if (_pausedSearches.Contains(searchId)) return; + try + { + var payload = frame.Text ?? ""; + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("new", out var newItems) && + newItems.ValueKind == JsonValueKind.Array) + { + var ids = newItems.EnumerateArray() + .Select(e => e.GetString()!) + .Where(s => s != null) + .ToList(); + if (ids.Count > 0) + { + Log.Information("New listings: {SearchId} ({Count} items)", searchId, ids.Count); + NewListings?.Invoke(searchId, ids, page); + } + } + } + catch { /* Not all frames are JSON */ } + }; + + ws.Close += (_, _) => Log.Warning("WebSocket closed: {SearchId}", searchId); + } + + private async Task HandleConfirmDialog(IPage page) + { + await Helpers.Sleep(500); + try + { + var confirmBtn = page.Locator(Selectors.ConfirmYesButton).First; + if (await WaitForVisible(confirmBtn, 2000)) + { + await confirmBtn.ClickAsync(); + Log.Information("Confirmed dialog"); + } + } + catch { /* No dialog */ } + } + + private static async Task WaitForVisible(ILocator locator, int timeoutMs) + { + try + { + await locator.WaitForAsync(new LocatorWaitForOptions + { + State = WaitForSelectorState.Visible, + Timeout = timeoutMs + }); + return true; + } + catch (TimeoutException) { return false; } + } +} diff --git a/src/Poe2Trade.Ui/App.axaml b/src/Poe2Trade.Ui/App.axaml new file mode 100644 index 0000000..6ae79d7 --- /dev/null +++ b/src/Poe2Trade.Ui/App.axaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/src/Poe2Trade.Ui/App.axaml.cs b/src/Poe2Trade.Ui/App.axaml.cs new file mode 100644 index 0000000..793152b --- /dev/null +++ b/src/Poe2Trade.Ui/App.axaml.cs @@ -0,0 +1,44 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Poe2Trade.Bot; +using Poe2Trade.Core; +using Poe2Trade.Ui.ViewModels; +using Poe2Trade.Ui.Views; + +namespace Poe2Trade.Ui; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var store = new ConfigStore(); + var config = AppConfig.Load(); + var bot = new BotOrchestrator(store, config); + + var mainVm = new MainWindowViewModel(bot) + { + DebugVm = new DebugViewModel(bot), + SettingsVm = new SettingsViewModel(bot) + }; + + var window = new MainWindow { DataContext = mainVm }; + window.SetConfigStore(store); + desktop.MainWindow = window; + + desktop.ShutdownRequested += async (_, _) => + { + await bot.DisposeAsync(); + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/Poe2Trade.Ui/Converters/ValueConverters.cs b/src/Poe2Trade.Ui/Converters/ValueConverters.cs new file mode 100644 index 0000000..9706398 --- /dev/null +++ b/src/Poe2Trade.Ui/Converters/ValueConverters.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Poe2Trade.Core; +using Poe2Trade.Ui.ViewModels; + +namespace Poe2Trade.Ui.Converters; + +public class LogLevelToBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value?.ToString()?.ToUpperInvariant() switch + { + "INFO" => new SolidColorBrush(Color.Parse("#58a6ff")), + "WARN" or "WARNING" => new SolidColorBrush(Color.Parse("#d29922")), + "ERROR" => new SolidColorBrush(Color.Parse("#f85149")), + "DEBUG" => new SolidColorBrush(Color.Parse("#8b949e")), + _ => new SolidColorBrush(Color.Parse("#e6edf3")), + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class BoolToOccupiedBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var occupied = value is true; + return new SolidColorBrush(occupied ? Color.Parse("#238636") : Color.Parse("#161b22")); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class LinkModeToColorConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value switch + { + LinkMode.Live => new SolidColorBrush(Color.Parse("#1f6feb")), + LinkMode.Scrap => new SolidColorBrush(Color.Parse("#9e6a03")), + _ => new SolidColorBrush(Color.Parse("#30363d")), + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class StatusDotBrushConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var state = value?.ToString() ?? "Idle"; + return state switch + { + "Idle" => new SolidColorBrush(Color.Parse("#8b949e")), + "Paused" => new SolidColorBrush(Color.Parse("#d29922")), + _ => new SolidColorBrush(Color.Parse("#3fb950")), + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class ActiveOpacityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? 1.0 : 0.5; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +public class CellBorderConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is CellState cell) + { + return new Thickness( + cell.BorderLeft ? 2 : 0, + cell.BorderTop ? 2 : 0, + cell.BorderRight ? 2 : 0, + cell.BorderBottom ? 2 : 0); + } + return new Thickness(0); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj new file mode 100644 index 0000000..f7ebf31 --- /dev/null +++ b/src/Poe2Trade.Ui/Poe2Trade.Ui.csproj @@ -0,0 +1,19 @@ + + + WinExe + net8.0-windows10.0.19041.0 + enable + enable + app.manifest + + + + + + + + + + + + diff --git a/src/Poe2Trade.Ui/Program.cs b/src/Poe2Trade.Ui/Program.cs new file mode 100644 index 0000000..246076c --- /dev/null +++ b/src/Poe2Trade.Ui/Program.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Poe2Trade.Core; + +namespace Poe2Trade.Ui; + +class Program +{ + [STAThread] + public static void Main(string[] args) + { + Logging.Setup(); + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs new file mode 100644 index 0000000..f9fa122 --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -0,0 +1,201 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Poe2Trade.Bot; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Ui.ViewModels; + +public partial class DebugViewModel : ObservableObject +{ + private readonly BotOrchestrator _bot; + + [ObservableProperty] private string _findText = ""; + [ObservableProperty] private string _debugResult = ""; + [ObservableProperty] private string _selectedGridLayout = "inventory"; + [ObservableProperty] private decimal? _clickX; + [ObservableProperty] private decimal? _clickY; + + public string[] GridLayoutNames { get; } = + [ + "inventory", "stash12", "stash12_folder", "stash24", + "stash24_folder", "seller", "shop", "vendor" + ]; + + public DebugViewModel(BotOrchestrator bot) + { + _bot = bot; + } + + private bool EnsureReady() + { + if (_bot.IsReady) return true; + DebugResult = "Bot not started yet. Press Start first."; + return false; + } + + [RelayCommand] + private async Task TakeScreenshot() + { + if (!EnsureReady()) return; + try + { + var path = Path.Combine("debug", $"screenshot-{DateTime.Now:yyyyMMdd-HHmmss}.png"); + Directory.CreateDirectory("debug"); + await _bot.Screen.SaveScreenshot(path); + DebugResult = $"Screenshot saved: {path}"; + } + catch (Exception ex) + { + DebugResult = $"Screenshot failed: {ex.Message}"; + Log.Error(ex, "Screenshot failed"); + } + } + + [RelayCommand] + private async Task RunOcr() + { + if (!EnsureReady()) return; + try + { + var text = await _bot.Screen.ReadFullScreen(); + DebugResult = string.IsNullOrWhiteSpace(text) ? "(no text detected)" : text; + } + catch (Exception ex) + { + DebugResult = $"OCR failed: {ex.Message}"; + Log.Error(ex, "OCR failed"); + } + } + + [RelayCommand] + private async Task GoHideout() + { + if (!EnsureReady()) return; + try + { + await _bot.Game.FocusGame(); + await _bot.Game.GoToHideout(); + DebugResult = "Sent /hideout command"; + } + catch (Exception ex) + { + DebugResult = $"Go hideout failed: {ex.Message}"; + Log.Error(ex, "Go hideout failed"); + } + } + + [RelayCommand] + private async Task FindTextOnScreen() + { + if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return; + try + { + var pos = await _bot.Screen.FindTextOnScreen(FindText, fuzzy: true); + DebugResult = pos.HasValue + ? $"Found '{FindText}' at ({pos.Value.X}, {pos.Value.Y})" + : $"Text '{FindText}' not found"; + } + catch (Exception ex) + { + DebugResult = $"Find text failed: {ex.Message}"; + Log.Error(ex, "Find text failed"); + } + } + + [RelayCommand] + private async Task FindAndClick() + { + if (!EnsureReady() || string.IsNullOrWhiteSpace(FindText)) return; + try + { + await _bot.Game.FocusGame(); + var pos = await _bot.Inventory.FindAndClickNameplate(FindText); + DebugResult = pos.HasValue + ? $"Clicked '{FindText}' at ({pos.Value.X}, {pos.Value.Y})" + : $"Text '{FindText}' not found"; + } + catch (Exception ex) + { + DebugResult = $"Find & click failed: {ex.Message}"; + Log.Error(ex, "Find & click failed"); + } + } + + [RelayCommand] + private async Task ClickAt() + { + if (!EnsureReady()) return; + var x = (int)(ClickX ?? 0); + var y = (int)(ClickY ?? 0); + try + { + await _bot.Game.FocusGame(); + await _bot.Game.LeftClickAt(x, y); + DebugResult = $"Clicked at ({x}, {y})"; + } + catch (Exception ex) + { + DebugResult = $"Click failed: {ex.Message}"; + Log.Error(ex, "Click at failed"); + } + } + + [RelayCommand] + private async Task ScanGrid() + { + if (!EnsureReady()) return; + try + { + var result = await _bot.Screen.Grid.Scan(SelectedGridLayout); + DebugResult = $"Grid scan '{SelectedGridLayout}': " + + $"{result.Layout.Cols}x{result.Layout.Rows}, " + + $"{result.Occupied.Count} occupied, " + + $"{result.Items.Count} items"; + } + catch (Exception ex) + { + DebugResult = $"Grid scan failed: {ex.Message}"; + Log.Error(ex, "Grid scan failed"); + } + } + + [RelayCommand] + private async Task ClickAnge() + { + if (!EnsureReady()) return; + try + { + await _bot.Game.FocusGame(); + var pos = await _bot.Inventory.FindAndClickNameplate("ANGE"); + DebugResult = pos.HasValue ? $"Clicked ANGE at ({pos.Value.X}, {pos.Value.Y})" : "ANGE not found"; + } + catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } + } + + [RelayCommand] + private async Task ClickStash() + { + if (!EnsureReady()) return; + try + { + await _bot.Game.FocusGame(); + var pos = await _bot.Inventory.FindAndClickNameplate("STASH"); + DebugResult = pos.HasValue ? $"Clicked STASH at ({pos.Value.X}, {pos.Value.Y})" : "STASH not found"; + } + catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } + } + + [RelayCommand] + private async Task ClickSalvage() + { + if (!EnsureReady()) return; + try + { + await _bot.Game.FocusGame(); + var pos = await _bot.Inventory.FindAndClickNameplate("SALVAGE BENCH"); + DebugResult = pos.HasValue ? $"Clicked SALVAGE at ({pos.Value.X}, {pos.Value.Y})" : "SALVAGE BENCH not found"; + } + catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } + } +} diff --git a/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..1276f21 --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,183 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Poe2Trade.Bot; +using Poe2Trade.Core; +using Serilog; + +namespace Poe2Trade.Ui.ViewModels; + +public class LogEntry +{ + public string Time { get; init; } = ""; + public string Level { get; init; } = ""; + public string Message { get; init; } = ""; +} + +public partial class CellState : ObservableObject +{ + [ObservableProperty] private bool _isOccupied; + [ObservableProperty] private bool _borderTop; + [ObservableProperty] private bool _borderBottom; + [ObservableProperty] private bool _borderLeft; + [ObservableProperty] private bool _borderRight; +} + +public partial class MainWindowViewModel : ObservableObject +{ + private readonly BotOrchestrator _bot; + + [ObservableProperty] + private string _state = "Idle"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PauseButtonText))] + private bool _isPaused; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(StartCommand))] + [NotifyCanExecuteChangedFor(nameof(PauseCommand))] + private bool _isStarted; + + [ObservableProperty] private string _newUrl = ""; + [ObservableProperty] private string _newLinkName = ""; + [ObservableProperty] private LinkMode _newLinkMode = LinkMode.Live; + [ObservableProperty] private int _tradesCompleted; + [ObservableProperty] private int _tradesFailed; + [ObservableProperty] private int _activeLinksCount; + + public static LinkMode[] LinkModes { get; } = [LinkMode.Live, LinkMode.Scrap]; + + public MainWindowViewModel(BotOrchestrator bot) + { + _bot = bot; + _isPaused = bot.IsPaused; + + for (var i = 0; i < 60; i++) + InventoryCells.Add(new CellState()); + + bot.StatusUpdated += () => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + State = bot.State; + IsPaused = bot.IsPaused; + var status = bot.GetStatus(); + TradesCompleted = status.TradesCompleted; + TradesFailed = status.TradesFailed; + ActiveLinksCount = status.Links.Count(l => l.Active); + OnPropertyChanged(nameof(Links)); + UpdateInventoryGrid(); + }); + }; + + bot.LogMessage += (level, message) => + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + Logs.Add(new LogEntry + { + Time = DateTime.Now.ToString("HH:mm:ss"), + Level = level.ToUpperInvariant(), + Message = message + }); + if (Logs.Count > 500) Logs.RemoveAt(0); + }); + }; + } + + public string PauseButtonText => IsPaused ? "Resume" : "Pause"; + public List Links => _bot.Links.GetLinks(); + public ObservableCollection Logs { get; } = []; + public ObservableCollection InventoryCells { get; } = []; + public int InventoryFreeCells => _bot.IsReady ? _bot.Inventory.Tracker.FreeCells : 60; + + // Sub-ViewModels for tabs + public DebugViewModel? DebugVm { get; set; } + public SettingsViewModel? SettingsVm { get; set; } + + [RelayCommand(CanExecute = nameof(CanStart))] + private async Task Start() + { + try + { + await _bot.Start(_bot.Config.TradeUrls); + IsStarted = true; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to start bot"); + Logs.Add(new LogEntry + { + Time = DateTime.Now.ToString("HH:mm:ss"), + Level = "ERROR", + Message = $"Start failed: {ex.Message}" + }); + } + } + private bool CanStart() => !IsStarted; + + [RelayCommand(CanExecute = nameof(CanPause))] + private void Pause() + { + if (_bot.IsPaused) _bot.Resume(); + else _bot.Pause(); + } + private bool CanPause() => IsStarted; + + [RelayCommand] + private void AddLink() + { + if (string.IsNullOrWhiteSpace(NewUrl)) return; + _bot.AddLink(NewUrl, NewLinkName, NewLinkMode); + NewUrl = ""; + NewLinkName = ""; + } + + [RelayCommand] + private void RemoveLink(string? id) + { + if (id != null) _bot.RemoveLink(id); + } + + [RelayCommand] + private void ToggleLink(string? id) + { + if (id == null) return; + var link = _bot.Links.GetLink(id); + if (link != null) _bot.ToggleLink(id, !link.Active); + } + + private void UpdateInventoryGrid() + { + if (!_bot.IsReady) return; + var (grid, items, _) = _bot.Inventory.GetInventoryState(); + + for (var r = 0; r < 5; r++) + for (var c = 0; c < 12; c++) + { + var cell = InventoryCells[r * 12 + c]; + cell.IsOccupied = grid[r, c]; + cell.BorderTop = false; + cell.BorderBottom = false; + cell.BorderLeft = false; + cell.BorderRight = false; + } + + foreach (var item in items) + { + for (var r = item.Row; r < item.Row + item.H; r++) + for (var c = item.Col; c < item.Col + item.W; c++) + { + if (r >= 5 || c >= 12) continue; + var cell = InventoryCells[r * 12 + c]; + if (r == item.Row) cell.BorderTop = true; + if (r == item.Row + item.H - 1) cell.BorderBottom = true; + if (c == item.Col) cell.BorderLeft = true; + if (c == item.Col + item.W - 1) cell.BorderRight = true; + } + } + + OnPropertyChanged(nameof(InventoryFreeCells)); + } +} diff --git a/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..4a6a67e --- /dev/null +++ b/src/Poe2Trade.Ui/ViewModels/SettingsViewModel.cs @@ -0,0 +1,65 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Poe2Trade.Bot; + +namespace Poe2Trade.Ui.ViewModels; + +public partial class SettingsViewModel : ObservableObject +{ + private readonly BotOrchestrator _bot; + + [ObservableProperty] private string _poe2LogPath = ""; + [ObservableProperty] private string _windowTitle = ""; + [ObservableProperty] private decimal? _travelTimeoutMs = 15000; + [ObservableProperty] private decimal? _stashScanTimeoutMs = 10000; + [ObservableProperty] private decimal? _waitForMoreItemsMs = 20000; + [ObservableProperty] private decimal? _betweenTradesDelayMs = 5000; + [ObservableProperty] private bool _isSaved; + + public SettingsViewModel(BotOrchestrator bot) + { + _bot = bot; + LoadFromConfig(); + } + + private void LoadFromConfig() + { + var s = _bot.Store.Settings; + Poe2LogPath = s.Poe2LogPath; + WindowTitle = s.Poe2WindowTitle; + TravelTimeoutMs = s.TravelTimeoutMs; + StashScanTimeoutMs = s.StashScanTimeoutMs; + WaitForMoreItemsMs = s.WaitForMoreItemsMs; + BetweenTradesDelayMs = s.BetweenTradesDelayMs; + } + + [RelayCommand] + private void SaveSettings() + { + _bot.Store.UpdateSettings(s => + { + s.Poe2LogPath = Poe2LogPath; + s.Poe2WindowTitle = WindowTitle; + s.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000); + s.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000); + s.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000); + s.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000); + }); + + _bot.Config.Poe2LogPath = Poe2LogPath; + _bot.Config.Poe2WindowTitle = WindowTitle; + _bot.Config.TravelTimeoutMs = (int)(TravelTimeoutMs ?? 15000); + _bot.Config.StashScanTimeoutMs = (int)(StashScanTimeoutMs ?? 10000); + _bot.Config.WaitForMoreItemsMs = (int)(WaitForMoreItemsMs ?? 20000); + _bot.Config.BetweenTradesDelayMs = (int)(BetweenTradesDelayMs ?? 5000); + + IsSaved = true; + } + + partial void OnPoe2LogPathChanged(string value) => IsSaved = false; + partial void OnWindowTitleChanged(string value) => IsSaved = false; + partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false; + partial void OnStashScanTimeoutMsChanged(decimal? value) => IsSaved = false; + partial void OnWaitForMoreItemsMsChanged(decimal? value) => IsSaved = false; + partial void OnBetweenTradesDelayMsChanged(decimal? value) => IsSaved = false; +} diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml new file mode 100644 index 0000000..4fa6f88 --- /dev/null +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +