diff --git a/.gitignore b/.gitignore
index af66c94..c219519 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
node_modules/
dist/
+/tmp/
+__pycache__/
.dev-server.log
.dev-server.err
*.local
diff --git a/docs/battle-map-terrain-atlas-pipeline.md b/docs/battle-map-terrain-atlas-pipeline.md
index 20e3555..a13cd9b 100644
--- a/docs/battle-map-terrain-atlas-pipeline.md
+++ b/docs/battle-map-terrain-atlas-pipeline.md
@@ -1,12 +1,14 @@
# Hand-Painted Tactical Map Pipeline
+> Production V2 note (2026-07-15): battle 1 keeps the approved direct repaint, while battles 2-66 use the deterministic atlas compositor documented in `docs/visual-asset-all-battle-maps-v2-report.md`. The older per-map V1 commands and resolution table below remain only as historical reference.
+
## Scope
-All sixty-six campaign battles now use the same hand-painted tactical map pipeline. The renderer still reads the original scenario tile coordinates and battle logic; only the map texture asset changes from flat/vector map art to low-contrast raster terrain.
+All sixty-six campaign battles now use the same hand-painted tactical map art direction. Battle 1 is a direct repaint and battles 2-66 use the V2 atlas compositor. The renderer still reads the original scenario tile coordinates and battle logic; only the map texture asset changes from the earlier flat raster treatment to low-contrast painted terrain.
-## V2 Pilot
+## V2 Rollout
-The first battle now uses `src/assets/images/battle/first-battle-map-v2.webp` as the approved V2 vertical slice. It is an original 96 px/tile repaint with reduced runtime tint and grid weight. The procedural generator still targets the retired `first-battle-map.webp` filename for controlled V1 comparisons; do not promote that output over the V2 asset without a new runtime visual review.
+The first battle uses `src/assets/images/battle/first-battle-map-v2.webp` as the approved direct-repaint vertical slice. Battles 2-66 are generated from nine original V2 material sources with runtime-corrected scenario terrain. Run `pnpm run generate:battle-maps-v2`; the compositor preserves production filenames, emits a manifest and contact sheet, and caps every raster edge at 8,192 px for desktop WebGL reliability.
## Art Direction
diff --git a/docs/battle-map-v2-contact-sheet.jpg b/docs/battle-map-v2-contact-sheet.jpg
new file mode 100644
index 0000000..5386851
Binary files /dev/null and b/docs/battle-map-v2-contact-sheet.jpg differ
diff --git a/docs/battle-map-v2-manifest.json b/docs/battle-map-v2-manifest.json
new file mode 100644
index 0000000..23fbcc6
--- /dev/null
+++ b/docs/battle-map-v2-manifest.json
@@ -0,0 +1,1127 @@
+{
+ "pipeline": "battle-map-v2-atlas-compositor",
+ "maps": [
+ {
+ "number": 1,
+ "id": "first-battle-zhuo-commandery",
+ "mapTextureKey": "battle-map-first",
+ "assetPath": "src/assets/images/battle/first-battle-map-v2.webp",
+ "grid": [
+ 20,
+ 18
+ ],
+ "image": [
+ 1920,
+ 1728
+ ],
+ "tilePixels": 96,
+ "bytes": 1067410,
+ "sha256": "86197881010a47a675e37aa5b9dca7931a79a1a683c2aac97ef0bab42da727e4"
+ },
+ {
+ "number": 2,
+ "id": "second-battle-yellow-turban-pursuit",
+ "mapTextureKey": "battle-map-second",
+ "assetPath": "src/assets/images/battle/second-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 513450,
+ "sha256": "ceba9164d6802fca866d05bc1048b644ab6323dca7a3ace08eebb3064f1e6fb2"
+ },
+ {
+ "number": 3,
+ "id": "third-battle-guangzong-road",
+ "mapTextureKey": "battle-map-third",
+ "assetPath": "src/assets/images/battle/third-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 517832,
+ "sha256": "d15265dc71f32bec7f95a9893cee13e608e612ed363a1df589061a1b7383f6e7"
+ },
+ {
+ "number": 4,
+ "id": "fourth-battle-guangzong-camp",
+ "mapTextureKey": "battle-map-fourth",
+ "assetPath": "src/assets/images/battle/fourth-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 546116,
+ "sha256": "bdc7a09c23f2780ee09fc25c011c78ad41445d6309d7073bd5d6f22912c7bff3"
+ },
+ {
+ "number": 5,
+ "id": "fifth-battle-sishui-vanguard",
+ "mapTextureKey": "battle-map-fifth",
+ "assetPath": "src/assets/images/battle/fifth-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 527614,
+ "sha256": "9eb771070cdd316302f750af2a6de7d61bcfe4e4775b4beee434d3b9976a671e"
+ },
+ {
+ "number": 6,
+ "id": "sixth-battle-jieqiao-relief",
+ "mapTextureKey": "battle-map-sixth",
+ "assetPath": "src/assets/images/battle/sixth-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 506798,
+ "sha256": "5445b1e07aad687b47f4960c0deb930ddb0655cf2e19631b7a4564e75511a360"
+ },
+ {
+ "number": 7,
+ "id": "seventh-battle-xuzhou-rescue",
+ "mapTextureKey": "battle-map-seventh",
+ "assetPath": "src/assets/images/battle/seventh-battle-map.webp",
+ "grid": [
+ 24,
+ 20
+ ],
+ "image": [
+ 2304,
+ 1920
+ ],
+ "tilePixels": 96,
+ "bytes": 491430,
+ "sha256": "de40bc858db42ade76aad9b9edd6ed6e0bf80ccf30898c2609fb1cda127cf9c9"
+ },
+ {
+ "number": 8,
+ "id": "eighth-battle-xiaopei-supply-road",
+ "mapTextureKey": "battle-map-eighth",
+ "assetPath": "src/assets/images/battle/eighth-battle-map.webp",
+ "grid": [
+ 26,
+ 22
+ ],
+ "image": [
+ 2496,
+ 2112
+ ],
+ "tilePixels": 96,
+ "bytes": 621642,
+ "sha256": "361436acd8f6cfbde0ba28e10c664465bf215156a20f5d00dbff15d6d454fa83"
+ },
+ {
+ "number": 9,
+ "id": "ninth-battle-xuzhou-gate-night-raid",
+ "mapTextureKey": "battle-map-ninth",
+ "assetPath": "src/assets/images/battle/ninth-battle-map.webp",
+ "grid": [
+ 26,
+ 22
+ ],
+ "image": [
+ 2496,
+ 2112
+ ],
+ "tilePixels": 96,
+ "bytes": 332804,
+ "sha256": "4007e9306025e827eb9c49fe1c05dd06686a8f9e90343bb20e904571d7c466f0"
+ },
+ {
+ "number": 10,
+ "id": "tenth-battle-xuzhou-breakout",
+ "mapTextureKey": "battle-map-tenth",
+ "assetPath": "src/assets/images/battle/tenth-battle-map.webp",
+ "grid": [
+ 28,
+ 22
+ ],
+ "image": [
+ 2688,
+ 2112
+ ],
+ "tilePixels": 96,
+ "bytes": 673526,
+ "sha256": "89ed7777c573be7a43b323b447cc2a0b4193de2adc6cfdaa74ca5f16ceeab7f2"
+ },
+ {
+ "number": 11,
+ "id": "eleventh-battle-xudu-refuge-road",
+ "mapTextureKey": "battle-map-eleventh",
+ "assetPath": "src/assets/images/battle/eleventh-battle-map.webp",
+ "grid": [
+ 28,
+ 22
+ ],
+ "image": [
+ 2688,
+ 2112
+ ],
+ "tilePixels": 96,
+ "bytes": 735004,
+ "sha256": "4755ae318e38e56923547b938d5c18f1bc354a05a01f706b1d6dc4d2bed173fa"
+ },
+ {
+ "number": 12,
+ "id": "twelfth-battle-xiapi-outer-siege",
+ "mapTextureKey": "battle-map-twelfth",
+ "assetPath": "src/assets/images/battle/twelfth-battle-map.webp",
+ "grid": [
+ 30,
+ 24
+ ],
+ "image": [
+ 2880,
+ 2304
+ ],
+ "tilePixels": 96,
+ "bytes": 770078,
+ "sha256": "1ced32840b97e3439ce4681787f557df128b201cb034cab37815fb2e5d3d13e9"
+ },
+ {
+ "number": 13,
+ "id": "thirteenth-battle-xiapi-final",
+ "mapTextureKey": "battle-map-thirteenth",
+ "assetPath": "src/assets/images/battle/thirteenth-battle-map.webp",
+ "grid": [
+ 32,
+ 24
+ ],
+ "image": [
+ 3072,
+ 2304
+ ],
+ "tilePixels": 96,
+ "bytes": 836746,
+ "sha256": "ae86219e934beb03b5f61c55b9c5139406caee7fa473efff0bd2a7dfafa1b8be"
+ },
+ {
+ "number": 14,
+ "id": "fourteenth-battle-cao-break",
+ "mapTextureKey": "battle-map-fourteenth",
+ "assetPath": "src/assets/images/battle/fourteenth-battle-map.webp",
+ "grid": [
+ 32,
+ 24
+ ],
+ "image": [
+ 3072,
+ 2304
+ ],
+ "tilePixels": 96,
+ "bytes": 843772,
+ "sha256": "7e5506949b6f71f361ce96c2d7ca5a516f1b4678b6da72202bdff6b03f9d56af"
+ },
+ {
+ "number": 15,
+ "id": "fifteenth-battle-yuan-refuge-road",
+ "mapTextureKey": "battle-map-fifteenth",
+ "assetPath": "src/assets/images/battle/fifteenth-battle-map.webp",
+ "grid": [
+ 34,
+ 24
+ ],
+ "image": [
+ 3264,
+ 2304
+ ],
+ "tilePixels": 96,
+ "bytes": 846684,
+ "sha256": "2927b66ef22238e3d1bafd56777dfe36e35c5dc01ff62b152281e46b9665f7ce"
+ },
+ {
+ "number": 16,
+ "id": "sixteenth-battle-liu-biao-refuge",
+ "mapTextureKey": "battle-map-sixteenth",
+ "assetPath": "src/assets/images/battle/sixteenth-battle-map.webp",
+ "grid": [
+ 34,
+ 26
+ ],
+ "image": [
+ 3264,
+ 2496
+ ],
+ "tilePixels": 96,
+ "bytes": 1014916,
+ "sha256": "f2aa6dd8442aafe27d7ab7a9d266d31bc55a9d67e0b3a211c1102942f2ab7f72"
+ },
+ {
+ "number": 17,
+ "id": "seventeenth-battle-wolong-visit-road",
+ "mapTextureKey": "battle-map-seventeenth",
+ "assetPath": "src/assets/images/battle/seventeenth-battle-map.webp",
+ "grid": [
+ 34,
+ 26
+ ],
+ "image": [
+ 3264,
+ 2496
+ ],
+ "tilePixels": 96,
+ "bytes": 1024198,
+ "sha256": "0d9cff874cf46191608770f2257fe8db7f62b91d3acd597be639fb78a42ec482"
+ },
+ {
+ "number": 18,
+ "id": "eighteenth-battle-bowang-ambush",
+ "mapTextureKey": "battle-map-eighteenth",
+ "assetPath": "src/assets/images/battle/eighteenth-battle-map.webp",
+ "grid": [
+ 36,
+ 26
+ ],
+ "image": [
+ 3456,
+ 2496
+ ],
+ "tilePixels": 96,
+ "bytes": 1027884,
+ "sha256": "c0597f662a4b9f538cda1de8b5a784b8f20b1b033ba51c2ac1cb40c71ac7a82a"
+ },
+ {
+ "number": 19,
+ "id": "nineteenth-battle-changban-refuge",
+ "mapTextureKey": "battle-map-nineteenth",
+ "assetPath": "src/assets/images/battle/nineteenth-battle-map.webp",
+ "grid": [
+ 38,
+ 28
+ ],
+ "image": [
+ 3648,
+ 2688
+ ],
+ "tilePixels": 96,
+ "bytes": 1211262,
+ "sha256": "b34966a5e4a1dc3d6f7828eb2c46df0eb46015b62a23e9fbb436822243b587ad"
+ },
+ {
+ "number": 20,
+ "id": "twentieth-battle-jiangdong-envoy",
+ "mapTextureKey": "battle-map-twentieth",
+ "assetPath": "src/assets/images/battle/twentieth-battle-map.webp",
+ "grid": [
+ 40,
+ 28
+ ],
+ "image": [
+ 3840,
+ 2688
+ ],
+ "tilePixels": 96,
+ "bytes": 1172750,
+ "sha256": "1a878aa3cb4f7296a47e6de46b984780fe046d7a4a4f7260450f826484a3d71c"
+ },
+ {
+ "number": 21,
+ "id": "twenty-first-battle-red-cliffs-vanguard",
+ "mapTextureKey": "battle-map-twenty-first",
+ "assetPath": "src/assets/images/battle/twenty-first-battle-map.webp",
+ "grid": [
+ 42,
+ 30
+ ],
+ "image": [
+ 4032,
+ 2880
+ ],
+ "tilePixels": 96,
+ "bytes": 1333176,
+ "sha256": "6e417b6dda701053ce855a244d7bdc1c190d50d7e7e5d699839ac2a97b12d824"
+ },
+ {
+ "number": 22,
+ "id": "twenty-second-battle-red-cliffs-fire",
+ "mapTextureKey": "battle-map-twenty-second",
+ "assetPath": "src/assets/images/battle/twenty-second-battle-map.webp",
+ "grid": [
+ 44,
+ 30
+ ],
+ "image": [
+ 4224,
+ 2880
+ ],
+ "tilePixels": 96,
+ "bytes": 1366536,
+ "sha256": "7e842b8a361360c0ad5b571fa731bbecef9a5784f62f73bb743db218f79beed4"
+ },
+ {
+ "number": 23,
+ "id": "twenty-third-battle-jingzhou-south-entry",
+ "mapTextureKey": "battle-map-twenty-third",
+ "assetPath": "src/assets/images/battle/twenty-third-battle-map.webp",
+ "grid": [
+ 46,
+ 32
+ ],
+ "image": [
+ 4416,
+ 3072
+ ],
+ "tilePixels": 96,
+ "bytes": 1604818,
+ "sha256": "5024152561822ffafc9e773be199c48e16c4e7d5003f896d50c0d2cfa3971b18"
+ },
+ {
+ "number": 24,
+ "id": "twenty-fourth-battle-guiyang-persuasion",
+ "mapTextureKey": "battle-map-twenty-fourth",
+ "assetPath": "src/assets/images/battle/twenty-fourth-battle-map.webp",
+ "grid": [
+ 48,
+ 32
+ ],
+ "image": [
+ 4608,
+ 3072
+ ],
+ "tilePixels": 96,
+ "bytes": 1686812,
+ "sha256": "97b4639138a54e496d63c8e7286c5955f40e8fc934b23f90fe36f2c2d12dc7a8"
+ },
+ {
+ "number": 25,
+ "id": "twenty-fifth-battle-wuling-mountain-road",
+ "mapTextureKey": "battle-map-twenty-fifth",
+ "assetPath": "src/assets/images/battle/twenty-fifth-battle-map.webp",
+ "grid": [
+ 50,
+ 34
+ ],
+ "image": [
+ 4800,
+ 3264
+ ],
+ "tilePixels": 96,
+ "bytes": 1988038,
+ "sha256": "0229e02f0d24513a9834357234d162fda430d44b6fc3360921b60a0a0d9a0b86"
+ },
+ {
+ "number": 26,
+ "id": "twenty-sixth-battle-changsha-veteran",
+ "mapTextureKey": "battle-map-twenty-sixth",
+ "assetPath": "src/assets/images/battle/twenty-sixth-battle-map.webp",
+ "grid": [
+ 52,
+ 34
+ ],
+ "image": [
+ 4992,
+ 3264
+ ],
+ "tilePixels": 96,
+ "bytes": 2247918,
+ "sha256": "1468b6fe0c89bd41adf81738b67ae3b259bff7f1967bc8b17348879593643778"
+ },
+ {
+ "number": 27,
+ "id": "twenty-seventh-battle-yizhou-relief-road",
+ "mapTextureKey": "battle-map-twenty-seventh",
+ "assetPath": "src/assets/images/battle/twenty-seventh-battle-map.webp",
+ "grid": [
+ 54,
+ 36
+ ],
+ "image": [
+ 5184,
+ 3456
+ ],
+ "tilePixels": 96,
+ "bytes": 2525176,
+ "sha256": "d4bdae7133f73f31853e0d4f2a25473f51e6dd0634b6a4498e8483a01379fba2"
+ },
+ {
+ "number": 28,
+ "id": "twenty-eighth-battle-fu-pass-entry",
+ "mapTextureKey": "battle-map-twenty-eighth",
+ "assetPath": "src/assets/images/battle/twenty-eighth-battle-map.webp",
+ "grid": [
+ 56,
+ 38
+ ],
+ "image": [
+ 5376,
+ 3648
+ ],
+ "tilePixels": 96,
+ "bytes": 2784124,
+ "sha256": "d40c84d679103e2a67ae8fc278585130c7e131077cb9e5cf7d8f3a9973648e4a"
+ },
+ {
+ "number": 29,
+ "id": "twenty-ninth-battle-luo-outer-wall",
+ "mapTextureKey": "battle-map-twenty-ninth",
+ "assetPath": "src/assets/images/battle/twenty-ninth-battle-map.webp",
+ "grid": [
+ 58,
+ 40
+ ],
+ "image": [
+ 5568,
+ 3840
+ ],
+ "tilePixels": 96,
+ "bytes": 3124688,
+ "sha256": "04d30ae9a9054e9e8c9cffcfc74ae030ca71285a95dafa15aacf533fdc2d30bb"
+ },
+ {
+ "number": 30,
+ "id": "thirtieth-battle-luofeng-ambush",
+ "mapTextureKey": "battle-map-thirtieth",
+ "assetPath": "src/assets/images/battle/thirtieth-battle-map.webp",
+ "grid": [
+ 60,
+ 42
+ ],
+ "image": [
+ 5760,
+ 4032
+ ],
+ "tilePixels": 96,
+ "bytes": 3236750,
+ "sha256": "39e7be285053e0bc359203ecf936ce51dd146b35717ff5eac2566678b8b5faf8"
+ },
+ {
+ "number": 31,
+ "id": "thirty-first-battle-luo-main-gate",
+ "mapTextureKey": "battle-map-thirty-first",
+ "assetPath": "src/assets/images/battle/thirty-first-battle-map.webp",
+ "grid": [
+ 62,
+ 44
+ ],
+ "image": [
+ 5952,
+ 4224
+ ],
+ "tilePixels": 96,
+ "bytes": 3540912,
+ "sha256": "6c2eaef2c74d02368c52c5a61519a3cf24f51ff3501f4e8545dcaf5ed8eae39e"
+ },
+ {
+ "number": 32,
+ "id": "thirty-second-battle-mianzhu-gate",
+ "mapTextureKey": "battle-map-thirty-second",
+ "assetPath": "src/assets/images/battle/thirty-second-battle-map.webp",
+ "grid": [
+ 64,
+ 46
+ ],
+ "image": [
+ 6144,
+ 4416
+ ],
+ "tilePixels": 96,
+ "bytes": 3440330,
+ "sha256": "109b0c21da85f9b325ee92a0e02601ed3cadd51bb139324dcda3c5805dc97241"
+ },
+ {
+ "number": 33,
+ "id": "thirty-third-battle-chengdu-surrender",
+ "mapTextureKey": "battle-map-thirty-third",
+ "assetPath": "src/assets/images/battle/thirty-third-battle-map.webp",
+ "grid": [
+ 66,
+ 48
+ ],
+ "image": [
+ 6336,
+ 4608
+ ],
+ "tilePixels": 96,
+ "bytes": 3822282,
+ "sha256": "741f319e3722e81694599f6c7020449f0ba3da0cde3c8d23d2260c05812328a7"
+ },
+ {
+ "number": 34,
+ "id": "thirty-fourth-battle-jiameng-pass",
+ "mapTextureKey": "battle-map-thirty-fourth",
+ "assetPath": "src/assets/images/battle/thirty-fourth-battle-map.webp",
+ "grid": [
+ 68,
+ 50
+ ],
+ "image": [
+ 6528,
+ 4800
+ ],
+ "tilePixels": 96,
+ "bytes": 4085718,
+ "sha256": "33d91ed0be9059140957cfa3f9e31299ea426066c1b3eb148fe32cfe56474327"
+ },
+ {
+ "number": 35,
+ "id": "thirty-fifth-battle-yangping-scout",
+ "mapTextureKey": "battle-map-thirty-fifth",
+ "assetPath": "src/assets/images/battle/thirty-fifth-battle-map.webp",
+ "grid": [
+ 70,
+ 52
+ ],
+ "image": [
+ 6720,
+ 4992
+ ],
+ "tilePixels": 96,
+ "bytes": 4424708,
+ "sha256": "9540bcc30870778ee9f5e3c595f624be5f0ba75ee66d1730c9c5072cf22c887f"
+ },
+ {
+ "number": 36,
+ "id": "thirty-sixth-battle-dingjun-vanguard",
+ "mapTextureKey": "battle-map-thirty-sixth",
+ "assetPath": "src/assets/images/battle/thirty-sixth-battle-map.webp",
+ "grid": [
+ 72,
+ 54
+ ],
+ "image": [
+ 6912,
+ 5184
+ ],
+ "tilePixels": 96,
+ "bytes": 5054016,
+ "sha256": "1e963fcb5b85dcf985512aa73de7781cbfda52faa6a361cc586ea8aa966bd0d6"
+ },
+ {
+ "number": 37,
+ "id": "thirty-seventh-battle-hanzhong-decisive",
+ "mapTextureKey": "battle-map-thirty-seventh",
+ "assetPath": "src/assets/images/battle/thirty-seventh-battle-map.webp",
+ "grid": [
+ 74,
+ 56
+ ],
+ "image": [
+ 7104,
+ 5376
+ ],
+ "tilePixels": 96,
+ "bytes": 5016020,
+ "sha256": "1fdbb194327bc91eae899e773cd76ca925105fa16675a2b1bea193874904d4ec"
+ },
+ {
+ "number": 38,
+ "id": "thirty-eighth-battle-jing-defense",
+ "mapTextureKey": "battle-map-thirty-eighth",
+ "assetPath": "src/assets/images/battle/thirty-eighth-battle-map.webp",
+ "grid": [
+ 76,
+ 58
+ ],
+ "image": [
+ 7296,
+ 5568
+ ],
+ "tilePixels": 96,
+ "bytes": 4978326,
+ "sha256": "fae40f651ed8b13a81b0c5ddb70553b74a6fe7924f73a817e0b2a81fc1f4e70a"
+ },
+ {
+ "number": 39,
+ "id": "thirty-ninth-battle-fan-castle-vanguard",
+ "mapTextureKey": "battle-map-thirty-ninth",
+ "assetPath": "src/assets/images/battle/thirty-ninth-battle-map.webp",
+ "grid": [
+ 78,
+ 60
+ ],
+ "image": [
+ 7488,
+ 5760
+ ],
+ "tilePixels": 96,
+ "bytes": 5260768,
+ "sha256": "4ba6d7d0304139ca5b4d07dda91cfdde345368387d9c5615549648b9bf81a714"
+ },
+ {
+ "number": 40,
+ "id": "fortieth-battle-han-river-flood",
+ "mapTextureKey": "battle-map-fortieth",
+ "assetPath": "src/assets/images/battle/fortieth-battle-map.webp",
+ "grid": [
+ 80,
+ 62
+ ],
+ "image": [
+ 7680,
+ 5952
+ ],
+ "tilePixels": 96,
+ "bytes": 4623920,
+ "sha256": "0e146a87b853634d57bac26120ddae1b994dfbdec9d91a53c23aacd673d0832d"
+ },
+ {
+ "number": 41,
+ "id": "forty-first-battle-fan-castle-siege",
+ "mapTextureKey": "battle-map-forty-first",
+ "assetPath": "src/assets/images/battle/forty-first-battle-map.webp",
+ "grid": [
+ 82,
+ 64
+ ],
+ "image": [
+ 7216,
+ 5632
+ ],
+ "tilePixels": 88,
+ "bytes": 4843358,
+ "sha256": "88d74ebba589a2bd75fc393c50cc642dd02cdfb32e5a0336ab42c42e02a34f8e"
+ },
+ {
+ "number": 42,
+ "id": "forty-second-battle-jing-rear-crisis",
+ "mapTextureKey": "battle-map-forty-second",
+ "assetPath": "src/assets/images/battle/forty-second-battle-map.webp",
+ "grid": [
+ 84,
+ 66
+ ],
+ "image": [
+ 7392,
+ 5808
+ ],
+ "tilePixels": 88,
+ "bytes": 5051814,
+ "sha256": "8c49f1566757b84bef673b22d0c2c90ecb475b0391060a77978fa992214dbcc9"
+ },
+ {
+ "number": 43,
+ "id": "forty-third-battle-gongan-collapse",
+ "mapTextureKey": "battle-map-forty-third",
+ "assetPath": "src/assets/images/battle/forty-third-battle-map.webp",
+ "grid": [
+ 86,
+ 68
+ ],
+ "image": [
+ 7568,
+ 5984
+ ],
+ "tilePixels": 88,
+ "bytes": 5316416,
+ "sha256": "10af1a2d6f32d0a925b594d86fb85af7704b9312682ff8d724def9e85b2d628c"
+ },
+ {
+ "number": 44,
+ "id": "forty-fourth-battle-maicheng-isolation",
+ "mapTextureKey": "battle-map-forty-fourth",
+ "assetPath": "src/assets/images/battle/forty-fourth-battle-map.webp",
+ "grid": [
+ 88,
+ 70
+ ],
+ "image": [
+ 7744,
+ 6160
+ ],
+ "tilePixels": 88,
+ "bytes": 5762352,
+ "sha256": "de6767fd0c3bec101e87d340ebcb136ebf2a7f40f22d365f68d2575d3103e926"
+ },
+ {
+ "number": 45,
+ "id": "forty-fifth-battle-yiling-vanguard",
+ "mapTextureKey": "battle-map-forty-fifth",
+ "assetPath": "src/assets/images/battle/forty-fifth-battle-map.webp",
+ "grid": [
+ 90,
+ 72
+ ],
+ "image": [
+ 7920,
+ 6336
+ ],
+ "tilePixels": 88,
+ "bytes": 6413984,
+ "sha256": "74f397ba8ba60b1a96b9e4a68e8090f02c43703256011e7fc2db01040d0aaf01"
+ },
+ {
+ "number": 46,
+ "id": "forty-sixth-battle-yiling-fire",
+ "mapTextureKey": "battle-map-forty-sixth",
+ "assetPath": "src/assets/images/battle/forty-sixth-battle-map.webp",
+ "grid": [
+ 92,
+ 74
+ ],
+ "image": [
+ 7360,
+ 5920
+ ],
+ "tilePixels": 80,
+ "bytes": 5581432,
+ "sha256": "a2af30ab3fd9bf2b438016937c5b0986a8f23c2c2de1b72a200f1638cbdf6d31"
+ },
+ {
+ "number": 47,
+ "id": "forty-seventh-battle-nanzhong-stabilization",
+ "mapTextureKey": "battle-map-forty-seventh",
+ "assetPath": "src/assets/images/battle/forty-seventh-battle-map.webp",
+ "grid": [
+ 94,
+ 76
+ ],
+ "image": [
+ 7520,
+ 6080
+ ],
+ "tilePixels": 80,
+ "bytes": 6372960,
+ "sha256": "8ba909404d1802f1fdb0903f9f26a420bb541701e6388b97a1a32f465bfd30fa"
+ },
+ {
+ "number": 48,
+ "id": "forty-eighth-battle-meng-huo-main-force",
+ "mapTextureKey": "battle-map-forty-eighth",
+ "assetPath": "src/assets/images/battle/forty-eighth-battle-map.webp",
+ "grid": [
+ 96,
+ 78
+ ],
+ "image": [
+ 7680,
+ 6240
+ ],
+ "tilePixels": 80,
+ "bytes": 6787706,
+ "sha256": "48a1da64985ec70c397bc7f0f9cb1b4ea4e156913ba0f0f1855cd71b54b113ea"
+ },
+ {
+ "number": 49,
+ "id": "forty-ninth-battle-meng-huo-second-capture",
+ "mapTextureKey": "battle-map-forty-ninth",
+ "assetPath": "src/assets/images/battle/forty-ninth-battle-map.webp",
+ "grid": [
+ 98,
+ 80
+ ],
+ "image": [
+ 7840,
+ 6400
+ ],
+ "tilePixels": 80,
+ "bytes": 6534896,
+ "sha256": "1e6060a60bfbf823da7d293bea60a70a079183bff739940367f1fe19b7932c6f"
+ },
+ {
+ "number": 50,
+ "id": "fiftieth-battle-meng-huo-third-capture",
+ "mapTextureKey": "battle-map-fiftieth",
+ "assetPath": "src/assets/images/battle/fiftieth-battle-map.webp",
+ "grid": [
+ 100,
+ 82
+ ],
+ "image": [
+ 8000,
+ 6560
+ ],
+ "tilePixels": 80,
+ "bytes": 6827456,
+ "sha256": "349bd0bc4bde0245e0562184cb70092e1c9e0340fd9e8f0093b49903c9904a65"
+ },
+ {
+ "number": 51,
+ "id": "fifty-first-battle-meng-huo-fourth-capture",
+ "mapTextureKey": "battle-map-fifty-first",
+ "assetPath": "src/assets/images/battle/fifty-first-battle-map.webp",
+ "grid": [
+ 102,
+ 84
+ ],
+ "image": [
+ 7344,
+ 6048
+ ],
+ "tilePixels": 72,
+ "bytes": 5660984,
+ "sha256": "fcf6d910e86ad51d5a2ded7e2fa72b80ce390112c8015b6864689e1aa7cf4e03"
+ },
+ {
+ "number": 52,
+ "id": "fifty-second-battle-meng-huo-fifth-capture",
+ "mapTextureKey": "battle-map-fifty-second",
+ "assetPath": "src/assets/images/battle/fifty-second-battle-map.webp",
+ "grid": [
+ 104,
+ 86
+ ],
+ "image": [
+ 7488,
+ 6192
+ ],
+ "tilePixels": 72,
+ "bytes": 5951236,
+ "sha256": "2f344e6d7dd58f0eee355ab13853ffa4f44210ec3c9b1611af3a8a7d454aa575"
+ },
+ {
+ "number": 53,
+ "id": "fifty-third-battle-meng-huo-sixth-capture",
+ "mapTextureKey": "battle-map-fifty-third",
+ "assetPath": "src/assets/images/battle/fifty-third-battle-map.webp",
+ "grid": [
+ 106,
+ 88
+ ],
+ "image": [
+ 7632,
+ 6336
+ ],
+ "tilePixels": 72,
+ "bytes": 6320154,
+ "sha256": "45c18b80f93245719c1fdf57b6f6d7f0c3dcb0f2699e2b2fd8dbaabfa17608e7"
+ },
+ {
+ "number": 54,
+ "id": "fifty-fourth-battle-meng-huo-final-capture",
+ "mapTextureKey": "battle-map-fifty-fourth",
+ "assetPath": "src/assets/images/battle/fifty-fourth-battle-map.webp",
+ "grid": [
+ 108,
+ 90
+ ],
+ "image": [
+ 7776,
+ 6480
+ ],
+ "tilePixels": 72,
+ "bytes": 6318906,
+ "sha256": "2a79a07974cb591f361ad17c7b8e487610623a45fedc259f8781002f3b4165bb"
+ },
+ {
+ "number": 55,
+ "id": "fifty-fifth-battle-northern-qishan-road",
+ "mapTextureKey": "battle-map-fifty-fifth",
+ "assetPath": "src/assets/images/battle/fifty-fifth-battle-map.webp",
+ "grid": [
+ 110,
+ 92
+ ],
+ "image": [
+ 7920,
+ 6624
+ ],
+ "tilePixels": 72,
+ "bytes": 7426356,
+ "sha256": "1e72dbe98170cdd742ed7fd1e6058ad2d012192667a6e95bcb8302e4f41bb862"
+ },
+ {
+ "number": 56,
+ "id": "fifty-sixth-battle-tianshui-advance",
+ "mapTextureKey": "battle-map-fifty-sixth",
+ "assetPath": "src/assets/images/battle/fifty-sixth-battle-map.webp",
+ "grid": [
+ 112,
+ 94
+ ],
+ "image": [
+ 8064,
+ 6768
+ ],
+ "tilePixels": 72,
+ "bytes": 7955276,
+ "sha256": "c1c020d431df32e0d0aff52ff569b5d7dadbe8c6b5e9749784703831940016d6"
+ },
+ {
+ "number": 57,
+ "id": "fifty-seventh-battle-jieting-crisis",
+ "mapTextureKey": "battle-map-fifty-seventh",
+ "assetPath": "src/assets/images/battle/fifty-seventh-battle-map.webp",
+ "grid": [
+ 114,
+ 96
+ ],
+ "image": [
+ 7296,
+ 6144
+ ],
+ "tilePixels": 64,
+ "bytes": 6096990,
+ "sha256": "95183e0bd87daced32b97381273ddb45164a54d61e83536528c56e7b08181f04"
+ },
+ {
+ "number": 58,
+ "id": "fifty-eighth-battle-qishan-retreat",
+ "mapTextureKey": "battle-map-fifty-eighth",
+ "assetPath": "src/assets/images/battle/fifty-eighth-battle-map.webp",
+ "grid": [
+ 116,
+ 98
+ ],
+ "image": [
+ 7424,
+ 6272
+ ],
+ "tilePixels": 64,
+ "bytes": 6251010,
+ "sha256": "a4236f8ea9bc5c9f5ebce531fd57a126ee0634ed878c39f4f6022fc6f1b42b43"
+ },
+ {
+ "number": 59,
+ "id": "fifty-ninth-battle-chencang-siege",
+ "mapTextureKey": "battle-map-fifty-ninth",
+ "assetPath": "src/assets/images/battle/fifty-ninth-battle-map.webp",
+ "grid": [
+ 118,
+ 100
+ ],
+ "image": [
+ 7552,
+ 6400
+ ],
+ "tilePixels": 64,
+ "bytes": 6833920,
+ "sha256": "a71f5555df1876bde46c97f7b0c6e9aefb2d8ba0e47da763991934a8282f56d9"
+ },
+ {
+ "number": 60,
+ "id": "sixtieth-battle-wudu-yinping",
+ "mapTextureKey": "battle-map-sixtieth",
+ "assetPath": "src/assets/images/battle/sixtieth-battle-map.webp",
+ "grid": [
+ 120,
+ 102
+ ],
+ "image": [
+ 7680,
+ 6528
+ ],
+ "tilePixels": 64,
+ "bytes": 7055836,
+ "sha256": "53d2c9cac3233edd44f4e6fbf10134ecc8f8212a62f9ed6fec1d71e42ebd7492"
+ },
+ {
+ "number": 61,
+ "id": "sixty-first-battle-hanzhong-rain-defense",
+ "mapTextureKey": "battle-map-sixty-first",
+ "assetPath": "src/assets/images/battle/sixty-first-battle-map.webp",
+ "grid": [
+ 122,
+ 104
+ ],
+ "image": [
+ 7808,
+ 6656
+ ],
+ "tilePixels": 64,
+ "bytes": 5335318,
+ "sha256": "7c77c8558818cc596dd8bacfaebee20c8cc3620c36f768219b7eb0f3c2293690"
+ },
+ {
+ "number": 62,
+ "id": "sixty-second-battle-qishan-renewed-offensive",
+ "mapTextureKey": "battle-map-sixty-second",
+ "assetPath": "src/assets/images/battle/sixty-second-battle-map.webp",
+ "grid": [
+ 124,
+ 106
+ ],
+ "image": [
+ 7936,
+ 6784
+ ],
+ "tilePixels": 64,
+ "bytes": 7436704,
+ "sha256": "60386ad4c5024104962504fd9e12fa31dd577e42b96c17b5035b41bba176f000"
+ },
+ {
+ "number": 63,
+ "id": "sixty-third-battle-lucheng-pursuit",
+ "mapTextureKey": "battle-map-sixty-third",
+ "assetPath": "src/assets/images/battle/sixty-third-battle-map.webp",
+ "grid": [
+ 126,
+ 108
+ ],
+ "image": [
+ 8064,
+ 6912
+ ],
+ "tilePixels": 64,
+ "bytes": 8070400,
+ "sha256": "250745ac80ce916f634c38c5262b5b42c8e615fb4e4bea61f150da42780ced1a"
+ },
+ {
+ "number": 64,
+ "id": "sixty-fourth-battle-weishui-camps",
+ "mapTextureKey": "battle-map-sixty-fourth",
+ "assetPath": "src/assets/images/battle/sixty-fourth-battle-map.webp",
+ "grid": [
+ 128,
+ 110
+ ],
+ "image": [
+ 8192,
+ 7040
+ ],
+ "tilePixels": 64,
+ "bytes": 7302094,
+ "sha256": "ce0b2eaa1264ac62347ac3496f56150b3d7d4193c26a38cde9451917be1d439a"
+ },
+ {
+ "number": 65,
+ "id": "sixty-fifth-battle-weishui-northbank",
+ "mapTextureKey": "battle-map-sixty-fifth",
+ "assetPath": "src/assets/images/battle/sixty-fifth-battle-map.webp",
+ "grid": [
+ 130,
+ 112
+ ],
+ "image": [
+ 7800,
+ 6720
+ ],
+ "tilePixels": 60,
+ "bytes": 7143688,
+ "sha256": "76eed17950d396687a0a85690eee57073b529eda8a6a7ed8b3c9a937a8725485"
+ },
+ {
+ "number": 66,
+ "id": "sixty-sixth-battle-wuzhang-final",
+ "mapTextureKey": "battle-map-sixty-sixth",
+ "assetPath": "src/assets/images/battle/sixty-sixth-battle-map.webp",
+ "grid": [
+ 132,
+ 114
+ ],
+ "image": [
+ 7920,
+ 6840
+ ],
+ "tilePixels": 60,
+ "bytes": 7178220,
+ "sha256": "4f8199945b61d0167e64f4efb928946b83a350b21ac729dae4e0d5fa91652cc4"
+ }
+ ]
+}
diff --git a/docs/visual-asset-all-battle-maps-v2-report.md b/docs/visual-asset-all-battle-maps-v2-report.md
new file mode 100644
index 0000000..c1ba699
--- /dev/null
+++ b/docs/visual-asset-all-battle-maps-v2-report.md
@@ -0,0 +1,75 @@
+# All Battle Maps V2 Rollout
+
+Date: 2026-07-15
+
+## Scope
+
+- Battle 1 retains the approved direct repaint at `src/assets/images/battle/first-battle-map-v2.webp`.
+- Battles 2-66 use the same original V2 terrain language through a deterministic atlas compositor.
+- Scenario coordinates, terrain arrays, units, goals, movement rules, and map keys are unchanged.
+- Runtime-corrected terrain is exported from the loaded `battleScenarios` data, so secure terrain-center overrides are included before rendering.
+
+## Original V2 Sources
+
+The reusable source set lives in `src/assets/images/battle/terrain-v2/`:
+
+- `plain-v2-source.png`
+- `road-v2-source.png`
+- `hill-v2-source.png`
+- `forest-v2-source.png`
+- `river-v2-source.png`
+- `cliff-v2-source.png`
+- `village-v2-source.png`
+- `fort-v2-source.png`
+- `camp-v2-source.png`
+
+The sources were created with the built-in image generation workflow. The prompt set requested original orthographic late-Han battlefield materials with muted olive, ochre, stone, timber, and blue-green water; hand-painted tactical readability; restrained lighting; and no grid, UI, units, text, logos, watermarks, modern objects, or reproduction of copyrighted game assets.
+
+## Production Pipeline
+
+1. `scripts/export-battle-map-v2-data.mjs` loads the production scenario and map-asset modules through Vite SSR.
+2. It exports the 66 runtime map grids, asset paths, dimensions, map keys, and unit positions to `tmp/battle-map-v2-runtime-data.json`.
+3. `scripts/generate-battle-maps-v2.py` composites battles 2-66 with seeded texture quilting, organic visual masks, sparse terrain features, meandering wide-map rivers, non-grid road paths, component-spaced structures, scenario-era grading, and event atmosphere.
+4. The generator writes `docs/battle-map-v2-manifest.json` and `docs/battle-map-v2-contact-sheet.jpg` after a full run.
+
+The output is deterministic for a fixed source set and seed. The adaptive tile policy uses 96, 88, 80, 72, 64, or 60 source pixels per logical tile and guarantees that neither image edge exceeds 8,192 px.
+
+## Reproduction
+
+```powershell
+pnpm run generate:battle-maps-v2
+pnpm run verify:battle-map-assets
+pnpm run audit:visual-assets
+pnpm run qa:battle-maps:webgl
+```
+
+For a targeted preview:
+
+```powershell
+node scripts/export-battle-map-v2-data.mjs
+python scripts/generate-battle-maps-v2.py --maps 2,66 --quality 84 --skip-artifacts
+```
+
+## Visual Quality Controls
+
+- Overlapping feathered source crops replace hard rectangular tiling.
+- Forest and structure feature masks remove visible source-sheet boxes.
+- Large road components become a small number of natural routes instead of a tactical-looking lattice.
+- Thick late-game river blocks become meandering water channels inside a subtly graded logical floodplain.
+- Structure placement is capped and spatially separated by connected component.
+- Noise, feature selection, theme grading, and placements use stable seeded hashes.
+- The first battle remains untouched, providing the approved quality and palette reference for the rollout.
+
+## Verification
+
+Local release-candidate verification completed on 2026-07-15:
+
+- `verify:battle-map-assets`: 66 scenario map keys and 66 battle-map assets matched, including exact image axes and the 8,192 px edge ceiling.
+- `audit:visual-assets`: all 66 maps passed the V2 density and maximum-edge audit.
+- `verify:static-data`: all 20 static-data gates passed for the 66-battle campaign.
+- `build`: TypeScript and the production Vite build passed (546 modules transformed).
+- `qa:battle-maps:webgl`: 12 representative battles passed in the real Phaser WebGL renderer, with 12 victories, zero failures, and 71/71 objectives achieved. The set includes battles 40, 54, 59, and 61 for flood, river, and late-game large-map coverage.
+- `verify:performance`: all 8 budgets passed; first battle became playable 5,717 ms after the story transition in the measured local run.
+- `verify:release`: the release-candidate browser flow passed, including new-game auto-save, reload/continue, defeat retry, victory-to-camp return, FHD input mapping, and WebGL battle smoke coverage.
+
+The tracked release pipeline re-runs the full local verification and all-battle QA before NAS publication, then checks the public release manifest.
diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md
index 4714d6e..89f1740 100644
--- a/docs/visual-asset-quality-audit.md
+++ b/docs/visual-asset-quality-audit.md
@@ -6,9 +6,9 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
- Target desktop viewport: 1920x1080
- Battle map assets: 66
-- Flagged battle map review rows: 43
-- Flagged battle map rows with repaint reports: 4
-- Battle map source pixel density range: 64.0-224.0 px/tile
+- Flagged battle map review rows: 10
+- Flagged battle map rows with repaint reports: 3
+- Battle map source pixel density range: 60.0-96.0 px/tile
- Story source backgrounds: 136
- Story page background references: 466
- Flagged story background review rows: 120
@@ -16,15 +16,15 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
## Main Findings
- Existing verification already gates missing files and minimum dimensions; this audit ranks visual-review risk instead of failing the build.
-- Battle maps are scored by source pixels per scenario tile, source/image aspect match, and WebP byte density.
+- Battle maps are scored by the V2 adaptive 60 px/tile floor, the 8,192 px WebGL edge budget, source/image aspect match, and WebP byte density.
- Battle maps with repaint reports stay in the inventory, but are skipped for the next-batch recommendation.
- Story backgrounds are scored by standard 1672x941 dimensions, repeat usage, and PNG byte density.
- The audit cannot judge composition or painterly quality by itself; top rows are candidates for desktop browser screenshot review.
## Recommended Next Batch
-- Battle map candidate: `sixty-third-battle-lucheng-pursuit` / `battle-map-sixty-third`
-- Battle reason: low tile pixel density
+- Battle map candidate: `sixty-first-battle-hanzhong-rain-defense` / `battle-map-sixty-first`
+- Battle reason: watch tile pixel density
- Story background candidate: `src/assets/images/story/25-yizhou-luo-road.png`
- Story reason: high-repeat story background
- Suggested workflow: capture the top battle candidate in a 1920x1080 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.
@@ -33,18 +33,13 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
| score | battle id | map key | grid | image | px/tile | bytes/px | concerns |
| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |
-| 6 | `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 0.1200 | low tile pixel density |
-| 6 | `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 14400x12240 | 120.0 | 0.1116 | low tile pixel density |
-| 6 | `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 14640x12480 | 120.0 | 0.1162 | low tile pixel density |
-| 6 | `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 14880x12720 | 120.0 | 0.1222 | low tile pixel density |
-| 4 | `fifty-sixth-battle-tianshui-advance` | `battle-map-fifty-sixth` | 112x94 | 14336x12032 | 128.0 | 0.0939 | reduced tile pixel density |
-| 4 | `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 14592x12288 | 128.0 | 0.0987 | reduced tile pixel density |
-| 4 | `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 14848x12544 | 128.0 | 0.1003 | reduced tile pixel density |
-| 4 | `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 15104x12800 | 128.0 | 0.1049 | reduced tile pixel density |
-| 4 | `fifty-fifth-battle-northern-qishan-road` | `battle-map-fifty-fifth` | 110x92 | 14080x11776 | 128.0 | 0.1146 | reduced tile pixel density |
-| 4 | `fifty-third-battle-meng-huo-sixth-capture` | `battle-map-fifty-third` | 106x88 | 14416x11968 | 136.0 | 0.1155 | reduced tile pixel density |
-| 4 | `fifty-fourth-battle-meng-huo-final-capture` | `battle-map-fifty-fourth` | 108x90 | 14688x12240 | 136.0 | 0.1168 | reduced tile pixel density |
-| 4 | `fifth-battle-sishui-vanguard` | `battle-map-fifth` | 24x20 | 5376x4480 | 224.0 | 0.0270 | very low WebP byte density |
+| 2 | `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 7808x6656 | 64.0 | 0.1027 | watch tile pixel density |
+| 2 | `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 7424x6272 | 64.0 | 0.1342 | watch tile pixel density |
+| 2 | `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 7296x6144 | 64.0 | 0.1360 | watch tile pixel density |
+| 2 | `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 7936x6784 | 64.0 | 0.1381 | watch tile pixel density |
+| 2 | `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 7680x6528 | 64.0 | 0.1407 | watch tile pixel density |
+| 2 | `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 7552x6400 | 64.0 | 0.1414 | watch tile pixel density |
+| 2 | `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 8064x6912 | 64.0 | 0.1448 | watch tile pixel density |
## Top Story Background Review Queue
@@ -67,72 +62,72 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
| battle id | map key | grid | image | px/tile | MiB | concerns |
| --- | --- | ---: | ---: | ---: | ---: | --- |
-| `eighteenth-battle-bowang-ambush` | `battle-map-eighteenth` | 36x26 | 8064x5824 | 224.0 | 1.99 | low WebP byte density |
-| `eighth-battle-xiaopei-supply-road` | `battle-map-eighth` | 26x22 | 5824x4928 | 224.0 | 0.99 | low WebP byte density |
-| `eleventh-battle-xudu-refuge-road` | `battle-map-eleventh` | 28x22 | 6272x4928 | 224.0 | 1.11 | low WebP byte density |
-| `fifteenth-battle-yuan-refuge-road` | `battle-map-fifteenth` | 34x24 | 7616x5376 | 224.0 | 1.33 | very low WebP byte density |
-| `fifth-battle-sishui-vanguard` | `battle-map-fifth` | 24x20 | 5376x4480 | 224.0 | 0.62 | very low WebP byte density |
-| `fiftieth-battle-meng-huo-third-capture` | `battle-map-fiftieth` | 100x82 | 15200x12464 | 152.0 | 19.13 | watch tile pixel density |
-| `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 14848x12544 | 128.0 | 17.82 | reduced tile pixel density |
-| `fifty-fifth-battle-northern-qishan-road` | `battle-map-fifty-fifth` | 110x92 | 14080x11776 | 128.0 | 18.11 | reduced tile pixel density |
-| `fifty-first-battle-meng-huo-fourth-capture` | `battle-map-fifty-first` | 102x84 | 14688x12096 | 144.0 | 18.73 | watch tile pixel density |
-| `fifty-fourth-battle-meng-huo-final-capture` | `battle-map-fifty-fourth` | 108x90 | 14688x12240 | 136.0 | 20.03 | reduced tile pixel density |
-| `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 15104x12800 | 128.0 | 19.33 | reduced tile pixel density |
-| `fifty-second-battle-meng-huo-fifth-capture` | `battle-map-fifty-second` | 104x86 | 14976x12384 | 144.0 | 18.84 | watch tile pixel density |
-| `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 14592x12288 | 128.0 | 16.89 | reduced tile pixel density |
-| `fifty-sixth-battle-tianshui-advance` | `battle-map-fifty-sixth` | 112x94 | 14336x12032 | 128.0 | 15.45 | reduced tile pixel density |
-| `fifty-third-battle-meng-huo-sixth-capture` | `battle-map-fifty-third` | 106x88 | 14416x11968 | 136.0 | 19.00 | reduced tile pixel density |
-| `first-battle-zhuo-commandery` | `battle-map-first` | 20x18 | 1920x1728 | 96.0 | 1.02 | low tile pixel density
repaint report exists |
-| `fortieth-battle-han-river-flood` | `battle-map-fortieth` | 80x62 | 15360x11904 | 192.0 | 11.76 | none |
-| `forty-eighth-battle-meng-huo-main-force` | `battle-map-forty-eighth` | 96x78 | 15360x12480 | 160.0 | 16.44 | none |
-| `forty-fifth-battle-yiling-vanguard` | `battle-map-forty-fifth` | 90x72 | 14400x11520 | 160.0 | 13.98 | none |
-| `forty-first-battle-fan-castle-siege` | `battle-map-forty-first` | 82x64 | 15744x12288 | 192.0 | 12.58 | none |
-| `forty-fourth-battle-maicheng-isolation` | `battle-map-forty-fourth` | 88x70 | 15488x12320 | 176.0 | 15.70 | none |
-| `forty-ninth-battle-meng-huo-second-capture` | `battle-map-forty-ninth` | 98x80 | 14896x12160 | 152.0 | 17.61 | watch tile pixel density |
-| `forty-second-battle-jing-rear-crisis` | `battle-map-forty-second` | 84x66 | 14784x11616 | 176.0 | 12.54 | none |
-| `forty-seventh-battle-nanzhong-stabilization` | `battle-map-forty-seventh` | 94x76 | 15040x12160 | 160.0 | 16.22 | none |
-| `forty-sixth-battle-yiling-fire` | `battle-map-forty-sixth` | 92x74 | 14720x11840 | 160.0 | 14.87 | none |
-| `forty-third-battle-gongan-collapse` | `battle-map-forty-third` | 86x68 | 15136x11968 | 176.0 | 13.34 | none |
-| `fourteenth-battle-cao-break` | `battle-map-fourteenth` | 32x24 | 7168x5376 | 224.0 | 1.31 | low WebP byte density |
-| `fourth-battle-guangzong-camp` | `battle-map-fourth` | 24x20 | 5376x4480 | 224.0 | 0.63 | very low WebP byte density |
-| `nineteenth-battle-changban-refuge` | `battle-map-nineteenth` | 38x28 | 8512x6272 | 224.0 | 2.08 | low WebP byte density |
-| `ninth-battle-xuzhou-gate-night-raid` | `battle-map-ninth` | 26x22 | 5824x4928 | 224.0 | 1.00 | low WebP byte density |
-| `second-battle-yellow-turban-pursuit` | `battle-map-second` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
-| `seventeenth-battle-wolong-visit-road` | `battle-map-seventeenth` | 34x26 | 7616x5824 | 224.0 | 1.81 | low WebP byte density |
-| `seventh-battle-xuzhou-rescue` | `battle-map-seventh` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
-| `sixteenth-battle-liu-biao-refuge` | `battle-map-sixteenth` | 34x26 | 7616x5824 | 224.0 | 1.49 | low WebP byte density |
-| `sixth-battle-jieqiao-relief` | `battle-map-sixth` | 24x20 | 5376x4480 | 224.0 | 0.64 | very low WebP byte density |
-| `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 14400x12240 | 120.0 | 18.76 | low tile pixel density |
-| `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 8320x7168 | 64.0 | 8.00 | low tile pixel density
repaint report exists |
-| `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 14640x12480 | 120.0 | 20.24 | low tile pixel density |
-| `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 8192x7040 | 64.0 | 7.51 | low tile pixel density
repaint report exists |
-| `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 14880x12720 | 120.0 | 22.05 | low tile pixel density |
-| `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 8448x7296 | 64.0 | 7.91 | low tile pixel density
repaint report exists |
-| `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 19.53 | low tile pixel density |
-| `tenth-battle-xuzhou-breakout` | `battle-map-tenth` | 28x22 | 6272x4928 | 224.0 | 1.16 | low WebP byte density |
-| `third-battle-guangzong-road` | `battle-map-third` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
-| `thirteenth-battle-xiapi-final` | `battle-map-thirteenth` | 32x24 | 7168x5376 | 224.0 | 1.32 | low WebP byte density |
-| `thirtieth-battle-luofeng-ambush` | `battle-map-thirtieth` | 60x42 | 11520x8064 | 192.0 | 5.18 | none |
-| `thirty-eighth-battle-jing-defense` | `battle-map-thirty-eighth` | 76x58 | 14592x11136 | 192.0 | 10.88 | none |
-| `thirty-fifth-battle-yangping-scout` | `battle-map-thirty-fifth` | 70x52 | 13440x9984 | 192.0 | 8.67 | none |
-| `thirty-first-battle-luo-main-gate` | `battle-map-thirty-first` | 62x44 | 11904x8448 | 192.0 | 6.17 | none |
-| `thirty-fourth-battle-jiameng-pass` | `battle-map-thirty-fourth` | 68x50 | 13056x9600 | 192.0 | 7.99 | none |
-| `thirty-ninth-battle-fan-castle-vanguard` | `battle-map-thirty-ninth` | 78x60 | 14976x11520 | 192.0 | 11.19 | none |
-| `thirty-second-battle-mianzhu-gate` | `battle-map-thirty-second` | 64x46 | 12288x8832 | 192.0 | 6.75 | none |
-| `thirty-seventh-battle-hanzhong-decisive` | `battle-map-thirty-seventh` | 74x56 | 14208x10752 | 192.0 | 9.92 | none |
-| `thirty-sixth-battle-dingjun-vanguard` | `battle-map-thirty-sixth` | 72x54 | 13824x10368 | 192.0 | 9.26 | none |
-| `thirty-third-battle-chengdu-surrender` | `battle-map-thirty-third` | 66x48 | 12672x9216 | 192.0 | 7.44 | none |
-| `twelfth-battle-xiapi-outer-siege` | `battle-map-twelfth` | 30x24 | 6720x5376 | 224.0 | 1.25 | low WebP byte density |
-| `twentieth-battle-jiangdong-envoy` | `battle-map-twentieth` | 40x28 | 8960x6272 | 224.0 | 2.05 | low WebP byte density |
-| `twenty-eighth-battle-fu-pass-entry` | `battle-map-twenty-eighth` | 56x38 | 10752x7296 | 192.0 | 4.51 | none |
-| `twenty-fifth-battle-wuling-mountain-road` | `battle-map-twenty-fifth` | 50x34 | 11200x7616 | 224.0 | 3.69 | none |
-| `twenty-first-battle-red-cliffs-vanguard` | `battle-map-twenty-first` | 42x30 | 9408x6720 | 224.0 | 2.19 | low WebP byte density |
-| `twenty-fourth-battle-guiyang-persuasion` | `battle-map-twenty-fourth` | 48x32 | 10752x7168 | 224.0 | 2.81 | low WebP byte density |
-| `twenty-ninth-battle-luo-outer-wall` | `battle-map-twenty-ninth` | 58x40 | 11136x7680 | 192.0 | 4.74 | none |
-| `twenty-second-battle-red-cliffs-fire` | `battle-map-twenty-second` | 44x30 | 9856x6720 | 224.0 | 2.47 | low WebP byte density |
-| `twenty-seventh-battle-yizhou-relief-road` | `battle-map-twenty-seventh` | 54x36 | 10368x6912 | 192.0 | 3.89 | none |
-| `twenty-sixth-battle-changsha-veteran` | `battle-map-twenty-sixth` | 52x34 | 9984x6528 | 192.0 | 2.73 | low WebP byte density |
-| `twenty-third-battle-jingzhou-south-entry` | `battle-map-twenty-third` | 46x32 | 10304x7168 | 224.0 | 2.66 | low WebP byte density |
+| `eighteenth-battle-bowang-ambush` | `battle-map-eighteenth` | 36x26 | 3456x2496 | 96.0 | 0.98 | none |
+| `eighth-battle-xiaopei-supply-road` | `battle-map-eighth` | 26x22 | 2496x2112 | 96.0 | 0.59 | none |
+| `eleventh-battle-xudu-refuge-road` | `battle-map-eleventh` | 28x22 | 2688x2112 | 96.0 | 0.70 | none |
+| `fifteenth-battle-yuan-refuge-road` | `battle-map-fifteenth` | 34x24 | 3264x2304 | 96.0 | 0.81 | none |
+| `fifth-battle-sishui-vanguard` | `battle-map-fifth` | 24x20 | 2304x1920 | 96.0 | 0.50 | none |
+| `fiftieth-battle-meng-huo-third-capture` | `battle-map-fiftieth` | 100x82 | 8000x6560 | 80.0 | 6.51 | none |
+| `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 7424x6272 | 64.0 | 5.96 | watch tile pixel density |
+| `fifty-fifth-battle-northern-qishan-road` | `battle-map-fifty-fifth` | 110x92 | 7920x6624 | 72.0 | 7.08 | none |
+| `fifty-first-battle-meng-huo-fourth-capture` | `battle-map-fifty-first` | 102x84 | 7344x6048 | 72.0 | 5.40 | none |
+| `fifty-fourth-battle-meng-huo-final-capture` | `battle-map-fifty-fourth` | 108x90 | 7776x6480 | 72.0 | 6.03 | none |
+| `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 7552x6400 | 64.0 | 6.52 | watch tile pixel density |
+| `fifty-second-battle-meng-huo-fifth-capture` | `battle-map-fifty-second` | 104x86 | 7488x6192 | 72.0 | 5.68 | none |
+| `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 7296x6144 | 64.0 | 5.81 | watch tile pixel density |
+| `fifty-sixth-battle-tianshui-advance` | `battle-map-fifty-sixth` | 112x94 | 8064x6768 | 72.0 | 7.59 | none |
+| `fifty-third-battle-meng-huo-sixth-capture` | `battle-map-fifty-third` | 106x88 | 7632x6336 | 72.0 | 6.03 | none |
+| `first-battle-zhuo-commandery` | `battle-map-first` | 20x18 | 1920x1728 | 96.0 | 1.02 | repaint report exists |
+| `fortieth-battle-han-river-flood` | `battle-map-fortieth` | 80x62 | 7680x5952 | 96.0 | 4.41 | none |
+| `forty-eighth-battle-meng-huo-main-force` | `battle-map-forty-eighth` | 96x78 | 7680x6240 | 80.0 | 6.47 | none |
+| `forty-fifth-battle-yiling-vanguard` | `battle-map-forty-fifth` | 90x72 | 7920x6336 | 88.0 | 6.12 | none |
+| `forty-first-battle-fan-castle-siege` | `battle-map-forty-first` | 82x64 | 7216x5632 | 88.0 | 4.62 | none |
+| `forty-fourth-battle-maicheng-isolation` | `battle-map-forty-fourth` | 88x70 | 7744x6160 | 88.0 | 5.50 | none |
+| `forty-ninth-battle-meng-huo-second-capture` | `battle-map-forty-ninth` | 98x80 | 7840x6400 | 80.0 | 6.23 | none |
+| `forty-second-battle-jing-rear-crisis` | `battle-map-forty-second` | 84x66 | 7392x5808 | 88.0 | 4.82 | none |
+| `forty-seventh-battle-nanzhong-stabilization` | `battle-map-forty-seventh` | 94x76 | 7520x6080 | 80.0 | 6.08 | none |
+| `forty-sixth-battle-yiling-fire` | `battle-map-forty-sixth` | 92x74 | 7360x5920 | 80.0 | 5.32 | none |
+| `forty-third-battle-gongan-collapse` | `battle-map-forty-third` | 86x68 | 7568x5984 | 88.0 | 5.07 | none |
+| `fourteenth-battle-cao-break` | `battle-map-fourteenth` | 32x24 | 3072x2304 | 96.0 | 0.80 | none |
+| `fourth-battle-guangzong-camp` | `battle-map-fourth` | 24x20 | 2304x1920 | 96.0 | 0.52 | none |
+| `nineteenth-battle-changban-refuge` | `battle-map-nineteenth` | 38x28 | 3648x2688 | 96.0 | 1.16 | none |
+| `ninth-battle-xuzhou-gate-night-raid` | `battle-map-ninth` | 26x22 | 2496x2112 | 96.0 | 0.32 | none |
+| `second-battle-yellow-turban-pursuit` | `battle-map-second` | 24x20 | 2304x1920 | 96.0 | 0.49 | none |
+| `seventeenth-battle-wolong-visit-road` | `battle-map-seventeenth` | 34x26 | 3264x2496 | 96.0 | 0.98 | none |
+| `seventh-battle-xuzhou-rescue` | `battle-map-seventh` | 24x20 | 2304x1920 | 96.0 | 0.47 | none |
+| `sixteenth-battle-liu-biao-refuge` | `battle-map-sixteenth` | 34x26 | 3264x2496 | 96.0 | 0.97 | none |
+| `sixth-battle-jieqiao-relief` | `battle-map-sixth` | 24x20 | 2304x1920 | 96.0 | 0.48 | none |
+| `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 7680x6528 | 64.0 | 6.73 | watch tile pixel density |
+| `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 7800x6720 | 60.0 | 6.81 | reduced tile pixel density
repaint report exists |
+| `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 7808x6656 | 64.0 | 5.09 | watch tile pixel density |
+| `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 8192x7040 | 64.0 | 6.96 | watch tile pixel density
repaint report exists |
+| `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 7936x6784 | 64.0 | 7.09 | watch tile pixel density |
+| `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 7920x6840 | 60.0 | 6.85 | reduced tile pixel density
repaint report exists |
+| `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 8064x6912 | 64.0 | 7.70 | watch tile pixel density |
+| `tenth-battle-xuzhou-breakout` | `battle-map-tenth` | 28x22 | 2688x2112 | 96.0 | 0.64 | none |
+| `third-battle-guangzong-road` | `battle-map-third` | 24x20 | 2304x1920 | 96.0 | 0.49 | none |
+| `thirteenth-battle-xiapi-final` | `battle-map-thirteenth` | 32x24 | 3072x2304 | 96.0 | 0.80 | none |
+| `thirtieth-battle-luofeng-ambush` | `battle-map-thirtieth` | 60x42 | 5760x4032 | 96.0 | 3.09 | none |
+| `thirty-eighth-battle-jing-defense` | `battle-map-thirty-eighth` | 76x58 | 7296x5568 | 96.0 | 4.75 | none |
+| `thirty-fifth-battle-yangping-scout` | `battle-map-thirty-fifth` | 70x52 | 6720x4992 | 96.0 | 4.22 | none |
+| `thirty-first-battle-luo-main-gate` | `battle-map-thirty-first` | 62x44 | 5952x4224 | 96.0 | 3.38 | none |
+| `thirty-fourth-battle-jiameng-pass` | `battle-map-thirty-fourth` | 68x50 | 6528x4800 | 96.0 | 3.90 | none |
+| `thirty-ninth-battle-fan-castle-vanguard` | `battle-map-thirty-ninth` | 78x60 | 7488x5760 | 96.0 | 5.02 | none |
+| `thirty-second-battle-mianzhu-gate` | `battle-map-thirty-second` | 64x46 | 6144x4416 | 96.0 | 3.28 | none |
+| `thirty-seventh-battle-hanzhong-decisive` | `battle-map-thirty-seventh` | 74x56 | 7104x5376 | 96.0 | 4.78 | none |
+| `thirty-sixth-battle-dingjun-vanguard` | `battle-map-thirty-sixth` | 72x54 | 6912x5184 | 96.0 | 4.82 | none |
+| `thirty-third-battle-chengdu-surrender` | `battle-map-thirty-third` | 66x48 | 6336x4608 | 96.0 | 3.65 | none |
+| `twelfth-battle-xiapi-outer-siege` | `battle-map-twelfth` | 30x24 | 2880x2304 | 96.0 | 0.73 | none |
+| `twentieth-battle-jiangdong-envoy` | `battle-map-twentieth` | 40x28 | 3840x2688 | 96.0 | 1.12 | none |
+| `twenty-eighth-battle-fu-pass-entry` | `battle-map-twenty-eighth` | 56x38 | 5376x3648 | 96.0 | 2.66 | none |
+| `twenty-fifth-battle-wuling-mountain-road` | `battle-map-twenty-fifth` | 50x34 | 4800x3264 | 96.0 | 1.90 | none |
+| `twenty-first-battle-red-cliffs-vanguard` | `battle-map-twenty-first` | 42x30 | 4032x2880 | 96.0 | 1.27 | none |
+| `twenty-fourth-battle-guiyang-persuasion` | `battle-map-twenty-fourth` | 48x32 | 4608x3072 | 96.0 | 1.61 | none |
+| `twenty-ninth-battle-luo-outer-wall` | `battle-map-twenty-ninth` | 58x40 | 5568x3840 | 96.0 | 2.98 | none |
+| `twenty-second-battle-red-cliffs-fire` | `battle-map-twenty-second` | 44x30 | 4224x2880 | 96.0 | 1.30 | none |
+| `twenty-seventh-battle-yizhou-relief-road` | `battle-map-twenty-seventh` | 54x36 | 5184x3456 | 96.0 | 2.41 | none |
+| `twenty-sixth-battle-changsha-veteran` | `battle-map-twenty-sixth` | 52x34 | 4992x3264 | 96.0 | 2.14 | none |
+| `twenty-third-battle-jingzhou-south-entry` | `battle-map-twenty-third` | 46x32 | 4416x3072 | 96.0 | 1.53 | none |
## Story Background Inventory
diff --git a/package.json b/package.json
index 1c2c6dd..ba45eeb 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"generate:audio": "node scripts/generate-bgm.mjs",
"generate:camp-audio": "node scripts/generate-camp-audio.mjs",
"generate:sfx": "node scripts/generate-sfx.mjs",
+ "export:battle-map-v2-data": "node scripts/export-battle-map-v2-data.mjs",
+ "generate:battle-maps-v2": "node scripts/export-battle-map-v2-data.mjs && python scripts/generate-battle-maps-v2.py",
"audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
"audit:visual-assets": "node scripts/audit-visual-asset-quality.mjs",
"preview": "vite preview --host 0.0.0.0",
@@ -42,6 +44,7 @@
"verify:local-release": "pnpm run verify:static-data && tsc --noEmit && pnpm run build && pnpm run verify:release && pnpm run verify:performance",
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs",
+ "qa:battle-maps:webgl": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,9,18,22,33,40,46,54,59,61,64,66 --renderer=webgl --report=dist/qa-battle-maps-webgl.json",
"qa:smoke": "node scripts/qa-representative-battles.mjs --set=smoke",
"qa:early": "node scripts/qa-representative-battles.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66",
"qa:early:repeat": "node scripts/qa-repeat.mjs --set=campaign --battles=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66",
diff --git a/scripts/audit-visual-asset-quality.mjs b/scripts/audit-visual-asset-quality.mjs
index 0578ac2..f3f11b3 100644
--- a/scripts/audit-visual-asset-quality.mjs
+++ b/scripts/audit-visual-asset-quality.mjs
@@ -75,13 +75,16 @@ function battleConcerns({ image, pxPerTile, aspectDelta }) {
concerns.push({ label: 'missing readable image metadata', weight: 8 });
return concerns;
}
- if (pxPerTile < 128) {
+ if (pxPerTile < 60) {
concerns.push({ label: 'low tile pixel density', weight: 6 });
- } else if (pxPerTile < 144) {
+ } else if (pxPerTile < 64) {
concerns.push({ label: 'reduced tile pixel density', weight: 4 });
- } else if (pxPerTile < 160) {
+ } else if (pxPerTile < 72) {
concerns.push({ label: 'watch tile pixel density', weight: 2 });
}
+ if (Math.max(image.width, image.height) > 8192) {
+ concerns.push({ label: 'exceeds WebGL texture edge budget', weight: 8 });
+ }
if (image.bytes / (image.width * image.height) < 0.035) {
concerns.push({ label: 'very low WebP byte density', weight: 4 });
} else if (image.bytes / (image.width * image.height) < 0.045) {
@@ -259,7 +262,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) {
'## Main Findings',
'',
'- Existing verification already gates missing files and minimum dimensions; this audit ranks visual-review risk instead of failing the build.',
- '- Battle maps are scored by source pixels per scenario tile, source/image aspect match, and WebP byte density.',
+ '- Battle maps are scored by the V2 adaptive 60 px/tile floor, the 8,192 px WebGL edge budget, source/image aspect match, and WebP byte density.',
'- Battle maps with repaint reports stay in the inventory, but are skipped for the next-batch recommendation.',
'- Story backgrounds are scored by standard 1672x941 dimensions, repeat usage, and PNG byte density.',
'- The audit cannot judge composition or painterly quality by itself; top rows are candidates for desktop browser screenshot review.',
diff --git a/scripts/export-battle-map-v2-data.mjs b/scripts/export-battle-map-v2-data.mjs
new file mode 100644
index 0000000..13c1dc7
--- /dev/null
+++ b/scripts/export-battle-map-v2-data.mjs
@@ -0,0 +1,49 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join, sep } from 'node:path';
+import { createServer } from 'vite';
+
+const outputPath = process.argv[2] ?? join('tmp', 'battle-map-v2-runtime-data.json');
+const battleAssetMarker = '/src/assets/images/battle/';
+
+const server = await createServer({
+ logLevel: 'error',
+ server: { middlewareMode: true },
+ appType: 'custom'
+});
+
+try {
+ const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
+ const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts');
+
+ const battles = Object.values(battleScenarios).map((scenario, index) => {
+ const assetUrl = battleMapAssets[scenario.mapTextureKey];
+ const markerIndex = typeof assetUrl === 'string' ? assetUrl.indexOf(battleAssetMarker) : -1;
+ if (markerIndex < 0) {
+ throw new Error(`${scenario.id}: cannot resolve battle map asset URL ${JSON.stringify(assetUrl)}`);
+ }
+
+ const fileName = assetUrl.slice(markerIndex + battleAssetMarker.length);
+ return {
+ number: index + 1,
+ id: scenario.id,
+ title: scenario.title,
+ mapTextureKey: scenario.mapTextureKey,
+ assetPath: join('src', 'assets', 'images', 'battle', fileName).split(sep).join('/'),
+ width: scenario.map.width,
+ height: scenario.map.height,
+ terrain: scenario.map.terrain,
+ units: scenario.units.map((unit) => ({
+ id: unit.id,
+ faction: unit.faction,
+ x: unit.x,
+ y: unit.y
+ }))
+ };
+ });
+
+ mkdirSync(dirname(outputPath), { recursive: true });
+ writeFileSync(outputPath, `${JSON.stringify({ generatedAt: new Date().toISOString(), battles }, null, 2)}\n`, 'utf8');
+ console.log(`Wrote ${outputPath} with ${battles.length} runtime battle maps.`);
+} finally {
+ await server.close();
+}
diff --git a/scripts/generate-battle-maps-v2.py b/scripts/generate-battle-maps-v2.py
new file mode 100644
index 0000000..9d700f7
--- /dev/null
+++ b/scripts/generate-battle-maps-v2.py
@@ -0,0 +1,1752 @@
+from __future__ import annotations
+
+import argparse
+import gc
+import hashlib
+import json
+import math
+import random
+from collections import deque
+from heapq import heappop, heappush
+from pathlib import Path
+
+from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOps
+
+
+Image.MAX_IMAGE_PIXELS = None
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_DATA = ROOT / "tmp" / "battle-map-v2-runtime-data.json"
+SOURCE_DIR = ROOT / "src" / "assets" / "images" / "battle" / "terrain-v2"
+DOCS_DIR = ROOT / "docs"
+
+SOURCE_PATHS = {
+ kind: SOURCE_DIR / f"{kind}-v2-source.png"
+ for kind in ("plain", "road", "hill", "forest", "river", "cliff", "village", "fort", "camp")
+}
+
+REGION_KINDS = ("hill", "forest", "cliff")
+STRUCTURE_KINDS = {"village", "fort", "camp"}
+CONNECTABLE_ROAD = {"road", "village", "fort", "camp"}
+MAX_TEXTURE_EDGE = 8192
+WATER_LAYER_REASSERT_BATTLES = {40, 41, 42, 43, 51, 52, 53, 54, 59, 60, 61, 62, 63}
+GRID_CARDINAL_NEIGHBORS = ((1, 0), (-1, 0), (0, 1), (0, -1))
+GRID_EIGHT_NEIGHBORS = ((0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1))
+
+
+def coordinate_hash(x: int, y: int, seed: int, salt: int = 0) -> int:
+ value = (x * 0x1F123BB5) ^ (y * 0x5F356495) ^ (seed * 0x9E3779B1) ^ salt
+ value &= 0xFFFFFFFF
+ value ^= value >> 16
+ value = (value * 0x7FEB352D) & 0xFFFFFFFF
+ value ^= value >> 15
+ value = (value * 0x846CA68B) & 0xFFFFFFFF
+ value ^= value >> 16
+ return value
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Generate topology-safe V2 battle maps from AI-authored terrain sources.")
+ parser.add_argument("--data", type=Path, default=DEFAULT_DATA)
+ parser.add_argument("--maps", default="2-66", help="Battle numbers, ranges, or comma-separated values (default: 2-66).")
+ parser.add_argument("--quality", type=int, default=84)
+ parser.add_argument("--contact-sheet", type=Path, default=DOCS_DIR / "battle-map-v2-contact-sheet.jpg")
+ parser.add_argument("--manifest", type=Path, default=DOCS_DIR / "battle-map-v2-manifest.json")
+ parser.add_argument("--skip-artifacts", action="store_true", help="Skip manifest/contact sheet generation during a targeted preview.")
+ return parser.parse_args()
+
+
+def parse_number_set(value: str) -> set[int]:
+ result: set[int] = set()
+ for part in value.split(","):
+ token = part.strip()
+ if not token:
+ continue
+ if "-" in token:
+ start_text, end_text = token.split("-", 1)
+ start, end = int(start_text), int(end_text)
+ result.update(range(min(start, end), max(start, end) + 1))
+ else:
+ result.add(int(token))
+ return result
+
+
+def load_sources() -> dict[str, Image.Image]:
+ missing = [str(path) for path in SOURCE_PATHS.values() if not path.is_file()]
+ if missing:
+ raise FileNotFoundError(f"Missing V2 terrain sources: {', '.join(missing)}")
+ return {kind: Image.open(path).convert("RGB") for kind, path in SOURCE_PATHS.items()}
+
+
+def output_tile_size(columns: int, rows: int) -> int:
+ longest = max(columns, rows)
+ if longest <= 80:
+ tile = 96
+ elif longest <= 90:
+ tile = 88
+ elif longest <= 100:
+ tile = 80
+ elif longest <= 112:
+ tile = 72
+ elif longest <= 128:
+ tile = 64
+ else:
+ tile = 60
+ if longest * tile > MAX_TEXTURE_EDGE:
+ tile = MAX_TEXTURE_EDGE // longest
+ return tile
+
+
+def feathered_patch_mask(side: int, alpha: int = 236) -> Image.Image:
+ fade = max(24, side // 7)
+ mask = Image.new("L", (side, side), 0)
+ draw = ImageDraw.Draw(mask)
+ inset = max(4, fade // 3)
+ draw.rounded_rectangle(
+ (inset, inset, side - inset, side - inset),
+ radius=max(16, fade),
+ fill=alpha,
+ )
+ return mask.filter(ImageFilter.GaussianBlur(max(8, fade // 2)))
+
+
+def fill_texture(source: Image.Image, size: tuple[int, int], tile: int, kind: str, seed: int) -> Image.Image:
+ """Synthesize a full material without visible square tiling.
+
+ A low-frequency color field supplies continuity while overlapping, feathered
+ source crops restore game-scale detail. This avoids stamping the source's
+ macro composition across large late-game maps.
+ """
+
+ rng = random.Random(seed)
+ width, height = size
+ average_sample = source.resize((1, 1), Image.Resampling.BOX)
+ average = average_sample.getpixel((0, 0))
+ average_sample.close()
+ dark = tuple(max(0, channel - (31 if kind == "river" else 26)) for channel in average)
+ light = tuple(min(255, channel + (23 if kind == "river" else 29)) for channel in average)
+ noise_side = 768
+ noise_rng = random.Random(seed ^ 0x5F3759DF)
+ noise = Image.frombytes("L", (noise_side, noise_side), noise_rng.randbytes(noise_side * noise_side))
+ noise = noise.filter(ImageFilter.GaussianBlur(2.2 if kind == "river" else 3.5))
+ noise = noise.resize(size, Image.Resampling.BICUBIC)
+ result = ImageOps.colorize(noise, dark, light)
+ noise.close()
+
+ patch_side = min(1152, max(512, tile * (17 if kind == "river" else 14)))
+ stride = max(192, round(patch_side * 0.58))
+ variants: list[Image.Image] = []
+ for index in range(18):
+ crop_side = round(source.width * rng.uniform(0.46, 0.82))
+ left = rng.randint(0, max(0, source.width - crop_side))
+ top = rng.randint(0, max(0, source.height - crop_side))
+ crop = source.crop((left, top, left + crop_side, top + crop_side))
+ variant = ImageOps.fit(crop, (patch_side, patch_side), method=Image.Resampling.LANCZOS)
+ crop.close()
+ if index % 4 == 1:
+ variant = ImageOps.mirror(variant)
+ elif index % 4 == 2:
+ variant = variant.transpose(Image.Transpose.ROTATE_180)
+ elif index % 4 == 3 and kind != "river":
+ variant = ImageOps.flip(variant)
+ if kind == "river":
+ variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.95, 1.05))
+ variant = ImageEnhance.Color(variant).enhance(rng.uniform(0.95, 1.04))
+ else:
+ variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.985, 1.018))
+ variant = ImageEnhance.Color(variant).enhance(rng.uniform(0.98, 1.02))
+ variants.append(variant)
+
+ patch_mask = feathered_patch_mask(patch_side, 228 if kind == "river" else 148)
+ row = 0
+ for base_y in range(-patch_side // 2, height + patch_side // 2, stride):
+ offset = stride // 2 if row % 2 else 0
+ for base_x in range(-patch_side // 2 - offset, width + patch_side // 2, stride):
+ jitter = max(12, stride // 7)
+ x = base_x + rng.randint(-jitter, jitter)
+ y = base_y + rng.randint(-jitter, jitter)
+ result.paste(variants[rng.randrange(len(variants))], (x, y), patch_mask)
+ row += 1
+
+ patch_mask.close()
+ for variant in variants:
+ variant.close()
+ return result
+
+
+def is_kind(terrain: list[list[str]], x: int, y: int, kind: str) -> bool:
+ return 0 <= y < len(terrain) and 0 <= x < len(terrain[0]) and terrain[y][x] == kind
+
+
+def region_mask(terrain: list[list[str]], kind: str, tile: int, seed: int) -> Image.Image:
+ width = len(terrain[0]) * tile
+ height = len(terrain) * tile
+ rng = random.Random(seed * 97 + sum(ord(char) for char in kind))
+ mask = Image.new("L", (width, height), 0)
+ draw = ImageDraw.Draw(mask)
+ for y, row in enumerate(terrain):
+ for x, terrain_kind in enumerate(row):
+ if terrain_kind != kind:
+ continue
+ jitter = tile // (14 if kind == "river" else (10 if kind == "cliff" else 7))
+ cx = x * tile + tile // 2 + rng.randint(-jitter, jitter)
+ cy = y * tile + tile // 2 + rng.randint(-jitter, jitter)
+ if kind == "river":
+ radius_min, radius_max = (0.63, 0.74)
+ elif kind == "cliff":
+ radius_min, radius_max = (0.51, 0.63)
+ else:
+ radius_min, radius_max = (0.49, 0.67)
+ rx = round(tile * rng.uniform(radius_min, radius_max))
+ ry = round(tile * rng.uniform(radius_min, radius_max))
+ draw.ellipse((cx - rx, cy - ry, cx + rx, cy + ry), fill=255)
+ core = max(4, round(tile * 0.22))
+ center_x = x * tile + tile // 2
+ center_y = y * tile + tile // 2
+ draw.ellipse((center_x - core, center_y - core, center_x + core, center_y + core), fill=255)
+
+ for dx, dy in ((1, 0), (0, 1)):
+ if not is_kind(terrain, x + dx, y + dy, kind):
+ continue
+ ncx = (x + dx) * tile + tile // 2
+ ncy = (y + dy) * tile + tile // 2
+ if kind == "river":
+ half_width = round(tile * rng.uniform(0.57, 0.68))
+ else:
+ half_width = round(tile * rng.uniform(0.40, 0.57))
+ if dx:
+ draw.polygon(
+ [
+ (cx, cy - half_width),
+ (ncx, ncy - half_width + rng.randint(-tile // 14, tile // 14)),
+ (ncx, ncy + half_width + rng.randint(-tile // 14, tile // 14)),
+ (cx, cy + half_width),
+ ],
+ fill=255,
+ )
+ else:
+ draw.polygon(
+ [
+ (cx - half_width, cy),
+ (ncx - half_width + rng.randint(-tile // 14, tile // 14), ncy),
+ (ncx + half_width + rng.randint(-tile // 14, tile // 14), ncy),
+ (cx + half_width, cy),
+ ],
+ fill=255,
+ )
+
+ blur = max(1.0, tile * (0.035 if kind == "river" else 0.045))
+ return mask.filter(ImageFilter.GaussianBlur(blur))
+
+
+def terrain_components(terrain: list[list[str]], kind: str) -> list[list[tuple[int, int]]]:
+ cells = {(x, y) for y, row in enumerate(terrain) for x, value in enumerate(row) if value == kind}
+ components: list[list[tuple[int, int]]] = []
+ while cells:
+ start = cells.pop()
+ component = [start]
+ stack = [start]
+ while stack:
+ x, y = stack.pop()
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
+ neighbor = (x + dx, y + dy)
+ if neighbor in cells:
+ cells.remove(neighbor)
+ component.append(neighbor)
+ stack.append(neighbor)
+ components.append(component)
+ return components
+
+
+def component_grid_distances(component: list[tuple[int, int]]) -> dict[tuple[int, int], int]:
+ """Return a cheap Manhattan distance-to-bank field on terrain cells."""
+
+ cells = set(component)
+ distances: dict[tuple[int, int], int] = {}
+ pending: deque[tuple[int, int]] = deque()
+ for x, y in cells:
+ if any((x + dx, y + dy) not in cells for dx, dy in GRID_CARDINAL_NEIGHBORS):
+ distances[(x, y)] = 1
+ pending.append((x, y))
+
+ while pending:
+ x, y = pending.popleft()
+ next_distance = distances[(x, y)] + 1
+ for dx, dy in GRID_CARDINAL_NEIGHBORS:
+ neighbor = (x + dx, y + dy)
+ if neighbor in cells and neighbor not in distances:
+ distances[neighbor] = next_distance
+ pending.append(neighbor)
+ return distances
+
+
+def thin_grid_component(component: list[tuple[int, int]]) -> set[tuple[int, int]]:
+ """Topology-preserving Zhang-Suen thinning on the small terrain grid."""
+
+ skeleton = set(component)
+ changed = True
+ while changed:
+ changed = False
+ for phase in (0, 1):
+ removed: list[tuple[int, int]] = []
+ for x, y in skeleton:
+ neighbors = [(x + dx, y + dy) in skeleton for dx, dy in GRID_EIGHT_NEIGHBORS]
+ neighbor_count = sum(neighbors)
+ transitions = sum(
+ not neighbors[index] and neighbors[(index + 1) % 8]
+ for index in range(8)
+ )
+ if not 2 <= neighbor_count <= 6 or transitions != 1:
+ continue
+
+ north, _, east, _, south, _, west, _ = neighbors
+ if phase == 0:
+ if (north and east and south) or (east and south and west):
+ continue
+ elif (north and east and west) or (north and south and west):
+ continue
+ removed.append((x, y))
+
+ if removed:
+ skeleton.difference_update(removed)
+ changed = True
+ return skeleton
+
+
+def skeleton_adjacency(
+ skeleton: set[tuple[int, int]],
+) -> dict[tuple[int, int], tuple[tuple[int, int], ...]]:
+ return {
+ point: tuple(
+ sorted(
+ (point[0] + dx, point[1] + dy)
+ for dx, dy in GRID_EIGHT_NEIGHBORS
+ if (point[0] + dx, point[1] + dy) in skeleton
+ )
+ )
+ for point in skeleton
+ }
+
+
+def skeleton_junction_clusters(
+ adjacency: dict[tuple[int, int], tuple[tuple[int, int], ...]],
+) -> list[set[tuple[int, int]]]:
+ remaining = {point for point, neighbors in adjacency.items() if len(neighbors) >= 3}
+ clusters: list[set[tuple[int, int]]] = []
+ while remaining:
+ start = remaining.pop()
+ cluster = {start}
+ pending = [start]
+ while pending:
+ x, y = pending.pop()
+ for dx, dy in GRID_EIGHT_NEIGHBORS:
+ neighbor = (x + dx, y + dy)
+ if neighbor in remaining:
+ remaining.remove(neighbor)
+ cluster.add(neighbor)
+ pending.append(neighbor)
+ clusters.append(cluster)
+ return clusters
+
+
+def extend_skeleton_endpoint(
+ path: list[tuple[int, int]],
+ component_cells: set[tuple[int, int]],
+ from_start: bool,
+) -> tuple[float, float]:
+ endpoint = path[0] if from_start else path[-1]
+ inner_index = min(3, len(path) - 1)
+ inner = path[inner_index] if from_start else path[-inner_index - 1]
+ dx = (endpoint[0] > inner[0]) - (endpoint[0] < inner[0])
+ dy = (endpoint[1] > inner[1]) - (endpoint[1] < inner[1])
+ if dx == 0 and dy == 0 and len(path) >= 2:
+ adjacent = path[1] if from_start else path[-2]
+ dx = (endpoint[0] > adjacent[0]) - (endpoint[0] < adjacent[0])
+ dy = (endpoint[1] > adjacent[1]) - (endpoint[1] < adjacent[1])
+
+ current = endpoint
+ while dx or dy:
+ candidate = (current[0] + dx, current[1] + dy)
+ if candidate not in component_cells:
+ break
+ current = candidate
+ return current[0] + 0.5 + dx * 0.5, current[1] + 0.5 + dy * 0.5
+
+
+def trace_skeleton_chains(
+ skeleton: set[tuple[int, int]],
+ component: list[tuple[int, int]],
+) -> tuple[
+ list[tuple[list[tuple[float, float]], bool, bool, bool]],
+ list[tuple[float, float]],
+ int,
+]:
+ """Contract junction pixels and return endpoint-to-junction skeleton chains."""
+
+ adjacency = skeleton_adjacency(skeleton)
+ endpoint_cells = {point for point, neighbors in adjacency.items() if len(neighbors) <= 1}
+ junction_clusters = skeleton_junction_clusters(adjacency)
+ junction_index: dict[tuple[int, int], int] = {}
+ junction_points: list[tuple[float, float]] = []
+ for index, cluster in enumerate(junction_clusters):
+ for point in cluster:
+ junction_index[point] = index
+ junction_points.append(
+ (
+ sum(x + 0.5 for x, _ in cluster) / len(cluster),
+ sum(y + 0.5 for _, y in cluster) / len(cluster),
+ )
+ )
+
+ critical = endpoint_cells | set(junction_index)
+ visited_edges: set[tuple[tuple[int, int], tuple[int, int]]] = set()
+ component_cells = set(component)
+ chains: list[tuple[list[tuple[float, float]], bool, bool, bool]] = []
+
+ def edge_key(
+ first: tuple[int, int], second: tuple[int, int]
+ ) -> tuple[tuple[int, int], tuple[int, int]]:
+ return (first, second) if first <= second else (second, first)
+
+ for start in sorted(critical):
+ for neighbor in adjacency[start]:
+ first_edge = edge_key(start, neighbor)
+ if first_edge in visited_edges:
+ continue
+ visited_edges.add(first_edge)
+ path = [start, neighbor]
+ previous, current = start, neighbor
+ while current not in critical:
+ candidates = [candidate for candidate in adjacency[current] if candidate != previous]
+ if not candidates:
+ break
+ following = candidates[0]
+ next_edge = edge_key(current, following)
+ if next_edge in visited_edges:
+ break
+ visited_edges.add(next_edge)
+ path.append(following)
+ previous, current = current, following
+
+ start_junction = junction_index.get(path[0])
+ end_junction = junction_index.get(path[-1])
+ if start_junction is not None and start_junction == end_junction and len(path) <= 2:
+ continue
+
+ points = [(x + 0.5, y + 0.5) for x, y in path]
+ if start_junction is not None:
+ points[0] = junction_points[start_junction]
+ if end_junction is not None:
+ points[-1] = junction_points[end_junction]
+ start_is_endpoint = path[0] in endpoint_cells
+ end_is_endpoint = path[-1] in endpoint_cells
+ if start_is_endpoint:
+ points[0] = extend_skeleton_endpoint(path, component_cells, True)
+ if end_is_endpoint:
+ points[-1] = extend_skeleton_endpoint(path, component_cells, False)
+ if len(points) >= 2 and points[0] != points[-1]:
+ chains.append((points, start_is_endpoint, end_is_endpoint, False))
+
+ # A ring has no critical cell, so trace any remaining degree-two cycle once.
+ for start in sorted(skeleton):
+ for neighbor in adjacency[start]:
+ first_edge = edge_key(start, neighbor)
+ if first_edge in visited_edges:
+ continue
+ visited_edges.add(first_edge)
+ path = [start, neighbor]
+ previous, current = start, neighbor
+ while current != start:
+ candidates = [
+ candidate
+ for candidate in adjacency[current]
+ if candidate != previous and edge_key(current, candidate) not in visited_edges
+ ]
+ if not candidates:
+ break
+ following = candidates[0]
+ visited_edges.add(edge_key(current, following))
+ path.append(following)
+ previous, current = current, following
+ closed = path[-1] == start
+ grid_points = [(x + 0.5, y + 0.5) for x, y in (path[:-1] if closed else path)]
+ if len(grid_points) >= 2:
+ chains.append((grid_points, False, False, closed))
+
+ return chains, junction_points, len(endpoint_cells)
+
+
+def polyline_tortuosity(points: list[tuple[float, float]]) -> float:
+ if len(points) < 2:
+ return math.inf
+ length = polyline_length(points)
+ chord = math.hypot(points[-1][0] - points[0][0], points[-1][1] - points[0][1])
+ return length / chord if chord > 1e-6 else math.inf
+
+
+def polyline_length(points: list[tuple[float, float]]) -> float:
+ return sum(
+ math.hypot(second[0] - first[0], second[1] - first[1])
+ for first, second in zip(points, points[1:])
+ )
+
+
+def skeleton_node_key(point: tuple[float, float]) -> tuple[float, float]:
+ return round(point[0], 6), round(point[1], 6)
+
+
+def skeleton_chain_graph(
+ chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
+) -> tuple[
+ dict[tuple[float, float], list[tuple[tuple[float, float], int, float]]],
+ dict[tuple[float, float], tuple[float, float]],
+]:
+ adjacency: dict[
+ tuple[float, float], list[tuple[tuple[float, float], int, float]]
+ ] = {}
+ node_points: dict[tuple[float, float], tuple[float, float]] = {}
+ for index, (points, _, _, closed) in enumerate(chains):
+ if closed or len(points) < 2:
+ continue
+ start = skeleton_node_key(points[0])
+ end = skeleton_node_key(points[-1])
+ if start == end:
+ continue
+ node_points.setdefault(start, points[0])
+ node_points.setdefault(end, points[-1])
+ weight = max(1e-6, polyline_length(points))
+ adjacency.setdefault(start, []).append((end, index, weight))
+ adjacency.setdefault(end, []).append((start, index, weight))
+ for edges in adjacency.values():
+ edges.sort(key=lambda edge: (edge[0], edge[1]))
+ return adjacency, node_points
+
+
+def weighted_skeleton_paths(
+ adjacency: dict[
+ tuple[float, float], list[tuple[tuple[float, float], int, float]]
+ ],
+ start: tuple[float, float],
+) -> tuple[
+ dict[tuple[float, float], float],
+ dict[tuple[float, float], tuple[tuple[float, float], int]],
+]:
+ distances = {start: 0.0}
+ parents: dict[tuple[float, float], tuple[tuple[float, float], int]] = {}
+ pending = [(0.0, start)]
+ while pending:
+ distance, current = heappop(pending)
+ if distance > distances[current] + 1e-9:
+ continue
+ for neighbor, edge_index, weight in adjacency.get(current, []):
+ candidate = distance + weight
+ if candidate + 1e-9 >= distances.get(neighbor, math.inf):
+ continue
+ distances[neighbor] = candidate
+ parents[neighbor] = current, edge_index
+ heappush(pending, (candidate, neighbor))
+ return distances, parents
+
+
+def skeleton_path_to(
+ parents: dict[tuple[float, float], tuple[tuple[float, float], int]],
+ start: tuple[float, float],
+ end: tuple[float, float],
+) -> tuple[list[tuple[float, float]], list[int]]:
+ nodes = [end]
+ edges: list[int] = []
+ current = end
+ while current != start:
+ parent = parents.get(current)
+ if parent is None:
+ return [], []
+ current, edge_index = parent
+ nodes.append(current)
+ edges.append(edge_index)
+ nodes.reverse()
+ edges.reverse()
+ return nodes, edges
+
+
+def active_skeleton_chains(
+ chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
+ kept_indices: set[int] | None = None,
+) -> tuple[
+ list[tuple[list[tuple[float, float]], bool, bool, bool]],
+ list[tuple[float, float]],
+ int,
+]:
+ kept = set(range(len(chains))) if kept_indices is None else kept_indices
+ adjacency, node_points = skeleton_chain_graph(
+ [chain for index, chain in enumerate(chains) if index in kept]
+ )
+ degrees = {node: len(edges) for node, edges in adjacency.items()}
+ active_chains: list[tuple[list[tuple[float, float]], bool, bool, bool]] = []
+ for index, (points, _, _, closed) in enumerate(chains):
+ if index not in kept:
+ continue
+ if closed:
+ active_chains.append((points, False, False, True))
+ continue
+ start = skeleton_node_key(points[0])
+ end = skeleton_node_key(points[-1])
+ active_chains.append((points, degrees.get(start) == 1, degrees.get(end) == 1, False))
+ junction_points = sorted(
+ (node_points[node] for node, degree in degrees.items() if degree >= 3),
+ key=lambda point: (point[1], point[0]),
+ )
+ endpoint_count = sum(degree == 1 for degree in degrees.values())
+ return active_chains, junction_points, endpoint_count
+
+
+def prune_skeleton_branches(
+ chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
+ raw_endpoint_count: int,
+) -> tuple[
+ list[tuple[list[tuple[float, float]], bool, bool, bool]],
+ list[tuple[float, float]],
+ int,
+ dict[str, object],
+]:
+ """Keep the widest direct backbone for an over-connected flood grid.
+
+ Synthetic flood grids can have a very long U-shaped graph diameter. Choosing
+ the endpoint pair with the widest straight-line span (then the shorter graph
+ path) avoids that artificial U while preserving an edge-to-edge backbone.
+ The shortest-path formulation also deterministically resolves the occasional
+ one-cycle artifact produced by grid thinning.
+ """
+
+ adjacency, _ = skeleton_chain_graph(chains)
+ endpoints = sorted(node for node, edges in adjacency.items() if len(edges) == 1)
+ if raw_endpoint_count <= 4 or len(endpoints) <= 4:
+ active_chains, junction_points, endpoint_count = active_skeleton_chains(chains)
+ return active_chains, junction_points, endpoint_count, {
+ "pruned": False,
+ "diameter_weight": None,
+ "tributary_weights": [],
+ }
+
+ path_cache = {
+ endpoint: weighted_skeleton_paths(adjacency, endpoint)
+ for endpoint in endpoints
+ }
+ diameter_start: tuple[float, float] | None = None
+ diameter_end: tuple[float, float] | None = None
+ diameter_span = -1.0
+ diameter_weight = math.inf
+ for start_index, start in enumerate(endpoints):
+ distances, _ = path_cache[start]
+ for end in endpoints[start_index + 1 :]:
+ distance = distances.get(end)
+ if distance is None:
+ continue
+ span = math.hypot(end[0] - start[0], end[1] - start[1])
+ pair = (start, end)
+ best_pair = (diameter_start, diameter_end)
+ if span > diameter_span + 1e-9 or (
+ abs(span - diameter_span) <= 1e-9
+ and (
+ distance < diameter_weight - 1e-9
+ or (
+ abs(distance - diameter_weight) <= 1e-9
+ and (diameter_start is None or pair < best_pair)
+ )
+ )
+ ):
+ diameter_start, diameter_end = pair
+ diameter_span = span
+ diameter_weight = distance
+
+ if diameter_start is None or diameter_end is None:
+ active_chains, junction_points, endpoint_count = active_skeleton_chains(chains)
+ return active_chains, junction_points, endpoint_count, {
+ "pruned": False,
+ "diameter_weight": None,
+ "tributary_weights": [],
+ }
+
+ _, diameter_parents = path_cache[diameter_start]
+ diameter_nodes, diameter_edges = skeleton_path_to(
+ diameter_parents,
+ diameter_start,
+ diameter_end,
+ )
+ diameter_node_set = set(diameter_nodes)
+ kept_indices = set(diameter_edges)
+ tributaries: list[tuple[float, tuple[float, float], list[int]]] = []
+ for endpoint in endpoints:
+ if endpoint in diameter_node_set:
+ continue
+ distances, parents = path_cache[endpoint]
+ reachable = [node for node in diameter_node_set if node in distances]
+ if not reachable:
+ continue
+ attachment = min(reachable, key=lambda node: (distances[node], node))
+ _, branch_edges = skeleton_path_to(parents, endpoint, attachment)
+ if branch_edges:
+ tributaries.append((distances[attachment], endpoint, branch_edges))
+
+ tributaries.sort(key=lambda branch: (-branch[0], branch[1], tuple(branch[2])))
+ # Extra terminal branches in the synthetic 40-43 flood grids still read as
+ # a comb/U-shaped canal after cycle pruning. The direct backbone already
+ # spans the encounter, so omit optional tributaries from the visual water.
+ selected_tributaries = []
+ for _, _, branch_edges in selected_tributaries:
+ kept_indices.update(branch_edges)
+
+ active_chains, junction_points, endpoint_count = active_skeleton_chains(
+ chains,
+ kept_indices,
+ )
+ return active_chains, junction_points, endpoint_count, {
+ "pruned": len(kept_indices) < len(chains),
+ "diameter_weight": diameter_weight,
+ "tributary_weights": [weight for weight, _, _ in selected_tributaries],
+ }
+
+
+def sparse_branched_river_layout(
+ component: list[tuple[int, int]],
+ component_width: int,
+ component_height: int,
+ thick_floodplain: bool,
+) -> dict[str, object] | None:
+ """Return a medial layout only for sparse, band-like complex components."""
+
+ if not thick_floodplain:
+ return None
+ major = max(component_width, component_height)
+ minor = min(component_width, component_height)
+ density = len(component) / max(1, component_width * component_height)
+ if density > 0.55:
+ return None
+
+ distances = component_grid_distances(component)
+ max_radius = max(distances.values(), default=0)
+ if max_radius > max(6, round(major * 0.14)):
+ return None
+
+ skeleton = thin_grid_component(component)
+ raw_chains, raw_junction_points, raw_endpoint_count = trace_skeleton_chains(
+ skeleton,
+ component,
+ )
+ aspect = major / max(1, minor)
+ raw_open_chains = [points for points, _, _, closed in raw_chains if not closed]
+ raw_tortuosity = max(
+ (polyline_tortuosity(points) for points in raw_open_chains),
+ default=math.inf,
+ )
+ complex_topology = (
+ aspect < 1.45
+ or raw_endpoint_count != 2
+ or bool(raw_junction_points)
+ or len(raw_chains) != 1
+ or raw_tortuosity > 1.22
+ )
+ if not complex_topology:
+ return None
+ chains, junction_points, endpoint_count, pruning = prune_skeleton_branches(
+ raw_chains,
+ raw_endpoint_count,
+ )
+ open_chains = [points for points, _, _, closed in chains if not closed]
+ tortuosity = max((polyline_tortuosity(points) for points in open_chains), default=math.inf)
+ return {
+ "chains": chains,
+ "density": density,
+ "distances": distances,
+ "endpoint_count": endpoint_count,
+ "junction_points": junction_points,
+ "max_radius": max_radius,
+ "raw_chain_count": len(raw_chains),
+ "raw_endpoint_count": raw_endpoint_count,
+ "raw_junction_count": len(raw_junction_points),
+ "skeleton": skeleton,
+ "tortuosity": tortuosity,
+ **pruning,
+ }
+
+
+def chaikin_open(points: list[tuple[float, float]], rounds: int = 2) -> list[tuple[float, float]]:
+ result = points
+ for _ in range(rounds):
+ if len(result) < 3:
+ break
+ smoothed = [result[0]]
+ for first, second in zip(result, result[1:]):
+ smoothed.extend(
+ (
+ (first[0] * 0.75 + second[0] * 0.25, first[1] * 0.75 + second[1] * 0.25),
+ (first[0] * 0.25 + second[0] * 0.75, first[1] * 0.25 + second[1] * 0.75),
+ )
+ )
+ smoothed.append(result[-1])
+ result = smoothed
+ return result
+
+
+def organic_skeleton_chain(
+ points: list[tuple[float, float]],
+ distances: dict[tuple[int, int], int],
+ width_tiles: float,
+ rng: random.Random,
+) -> list[tuple[float, float]]:
+ anchors = [points[0]]
+ anchors.extend(points[index] for index in range(3, len(points) - 1, 3))
+ if points[-1] != anchors[-1]:
+ anchors.append(points[-1])
+ if len(anchors) <= 2:
+ return anchors
+
+ organic = [anchors[0]]
+ wander = 0.0
+ for index in range(1, len(anchors) - 1):
+ previous, point, following = anchors[index - 1], anchors[index], anchors[index + 1]
+ tangent_x = following[0] - previous[0]
+ tangent_y = following[1] - previous[1]
+ tangent_length = max(1e-6, math.hypot(tangent_x, tangent_y))
+ normal_x, normal_y = -tangent_y / tangent_length, tangent_x / tangent_length
+ cell = (math.floor(point[0]), math.floor(point[1]))
+ clearance = max(0.0, distances.get(cell, 1) - width_tiles / 2 - 0.5)
+ # Broad synthetic bands need a visible low-frequency shoreline drift;
+ # sub-tile noise alone still reads as a ruler-straight L/T junction at
+ # overview scale. Clearance keeps the curve safely inside gameplay's
+ # logical river cells before the final mask clip.
+ limit = min(1.35, clearance * 0.70)
+ wander = max(-limit, min(limit, wander * 0.72 + rng.uniform(-0.70, 0.70)))
+ organic.append((point[0] + normal_x * wander, point[1] + normal_y * wander))
+ organic.append(anchors[-1])
+ return chaikin_open(organic)
+
+
+def inset_polyline_endpoint(
+ points: list[tuple[float, float]],
+ distance: float,
+ from_start: bool,
+) -> tuple[float, float]:
+ if len(points) < 2 or distance <= 0:
+ return points[0] if from_start else points[-1]
+ total_length = polyline_length(points)
+ remaining = min(distance, total_length * 0.45)
+ ordered = points if from_start else list(reversed(points))
+ for first, second in zip(ordered, ordered[1:]):
+ segment_length = math.hypot(second[0] - first[0], second[1] - first[1])
+ if segment_length <= 1e-9:
+ continue
+ if remaining <= segment_length:
+ fraction = remaining / segment_length
+ return (
+ first[0] + (second[0] - first[0]) * fraction,
+ first[1] + (second[1] - first[1]) * fraction,
+ )
+ remaining -= segment_length
+ return ordered[-1]
+
+
+def endpoint_touches_map_edge(
+ point: tuple[float, float],
+ grid_width: int,
+ grid_height: int,
+) -> bool:
+ epsilon = 1e-6
+ return (
+ point[0] <= epsilon
+ or point[0] >= grid_width - epsilon
+ or point[1] <= epsilon
+ or point[1] >= grid_height - epsilon
+ )
+
+
+def draw_medial_river_component(
+ channel_draw: ImageDraw.ImageDraw,
+ component: list[tuple[int, int]],
+ layout: dict[str, object],
+ tile: int,
+ seed: int,
+ grid_width: int,
+ grid_height: int,
+) -> None:
+ skeleton = layout["skeleton"]
+ distances = layout["distances"]
+ chains = layout["chains"]
+ junction_points = layout["junction_points"]
+ assert isinstance(skeleton, set)
+ assert isinstance(distances, dict)
+ assert isinstance(chains, list)
+ assert isinstance(junction_points, list)
+
+ radii = sorted(distances[point] for point in skeleton)
+ middle = len(radii) // 2
+ median_radius = (
+ radii[middle]
+ if len(radii) % 2
+ else (radii[middle - 1] + radii[middle]) / 2
+ )
+ width_tiles = max(2.0, min(2.8, median_radius * 2 * 0.34))
+ channel_width = round(width_tiles * tile)
+ component_salt = len(component) * 0x45D9F3B
+ for x, y in component:
+ component_salt ^= coordinate_hash(x, y, 0, 0xA24BAED5)
+ min_x = min(x for x, _ in component)
+ min_y = min(y for _, y in component)
+ rng = random.Random(coordinate_hash(min_x, min_y, seed, component_salt))
+
+ radius = channel_width / 2
+ for points, start_is_endpoint, end_is_endpoint, closed in chains:
+ if closed:
+ organic = points[::3] or points
+ if organic[-1] != organic[0]:
+ organic = organic + [organic[0]]
+ else:
+ organic = organic_skeleton_chain(points, distances, width_tiles, rng)
+ original = organic
+ organic = list(organic)
+ # Leave enough logical-bank clearance for the full round cap.
+ # Narrow five-cell flood bands otherwise clip the cap back into a
+ # flat plateau even though wider late-game channels look correct.
+ endpoint_inset = width_tiles * 0.72
+ if start_is_endpoint and not endpoint_touches_map_edge(
+ points[0],
+ grid_width,
+ grid_height,
+ ):
+ organic[0] = inset_polyline_endpoint(original, endpoint_inset, True)
+ if end_is_endpoint and not endpoint_touches_map_edge(
+ points[-1],
+ grid_width,
+ grid_height,
+ ):
+ organic[-1] = inset_polyline_endpoint(original, endpoint_inset, False)
+ pixel_points = [(round(x * tile), round(y * tile)) for x, y in organic]
+ if len(pixel_points) < 2:
+ continue
+ channel_draw.line(pixel_points, fill=255, width=channel_width, joint="curve")
+ if start_is_endpoint:
+ x, y = pixel_points[0]
+ channel_draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
+ if end_is_endpoint:
+ x, y = pixel_points[-1]
+ channel_draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
+
+ for x, y in junction_points:
+ pixel_x, pixel_y = round(x * tile), round(y * tile)
+ channel_draw.ellipse(
+ (pixel_x - radius, pixel_y - radius, pixel_x + radius, pixel_y + radius),
+ fill=255,
+ )
+
+
+def river_masks(terrain: list[list[str]], tile: int, seed: int) -> tuple[Image.Image, Image.Image]:
+ """Return logical floodplain and visual water masks.
+
+ Normal one- or two-cell rivers keep their full footprint. Very thick
+ late-game river blocks become either a simple axial channel or a medial
+ skeleton that preserves sparse L/T/U-shaped branch topology.
+ """
+
+ logical = region_mask(terrain, "river", tile, seed)
+ visual = logical.copy()
+ channels = Image.new("L", logical.size, 0)
+ channel_draw = ImageDraw.Draw(channels)
+ visual_draw = ImageDraw.Draw(visual)
+ rng = random.Random(seed * 227)
+
+ for component in terrain_components(terrain, "river"):
+ xs = [x for x, _ in component]
+ ys = [y for _, y in component]
+ min_x, max_x = min(xs), max(xs)
+ min_y, max_y = min(ys), max(ys)
+ component_width = max_x - min_x + 1
+ component_height = max_y - min_y + 1
+ major = max(component_width, component_height)
+ minor = min(component_width, component_height)
+ aspect = major / max(1, minor)
+ thick_floodplain = minor >= 5 and len(component) >= major * 3
+ elongated_channel = aspect >= 1.45 and major >= 4
+ if not thick_floodplain and not elongated_channel:
+ continue
+
+ medial_layout = sparse_branched_river_layout(
+ component,
+ component_width,
+ component_height,
+ thick_floodplain,
+ )
+ component_density = len(component) / max(1, component_width * component_height)
+ compact_lake = aspect < 1.45 and medial_layout is None and component_density >= 0.55
+ # Sparse band networks use the topology-preserving medial layout. A
+ # genuinely compact near-square water body can still read as a lake;
+ # irregular sparse shapes must never fall back to the bbox ellipse.
+ if aspect < 1.45 and medial_layout is None and not compact_lake:
+ continue
+
+ clear_padding = max(4, tile // 3)
+ for x, y in component:
+ visual_draw.rectangle(
+ (
+ x * tile - clear_padding,
+ y * tile - clear_padding,
+ (x + 1) * tile + clear_padding,
+ (y + 1) * tile + clear_padding,
+ ),
+ fill=0,
+ )
+
+ if medial_layout is not None:
+ draw_medial_river_component(
+ channel_draw,
+ component,
+ medial_layout,
+ tile,
+ seed,
+ len(terrain[0]),
+ len(terrain),
+ )
+ continue
+
+ if compact_lake:
+ center_x = (min_x + max_x + 1) * tile / 2
+ center_y = (min_y + max_y + 1) * tile / 2
+ radius_x = component_width * tile * rng.uniform(0.25, 0.37)
+ radius_y = component_height * tile * rng.uniform(0.25, 0.37)
+ channel_draw.ellipse(
+ (center_x - radius_x, center_y - radius_y, center_x + radius_x, center_y + radius_y),
+ fill=255,
+ )
+ continue
+
+ horizontal = component_width >= component_height
+ cells_by_axis: dict[int, list[int]] = {}
+ for x, y in component:
+ axis, cross = (x, y) if horizontal else (y, x)
+ cells_by_axis.setdefault(axis, []).append(cross)
+ start_axis = min(cells_by_axis)
+ end_axis = max(cells_by_axis)
+ points: list[tuple[int, int]] = []
+ wander = 0.0
+ axis_values = list(range(start_axis, end_axis + 1, 3))
+ if not axis_values or axis_values[-1] != end_axis:
+ axis_values.append(end_axis)
+ for axis in axis_values:
+ available = cells_by_axis.get(axis)
+ if not available:
+ nearest_axis = min(cells_by_axis, key=lambda candidate: abs(candidate - axis))
+ available = cells_by_axis[nearest_axis]
+ low, high = min(available), max(available)
+ center_cross = (low + high + 1) * tile / 2
+ span = max(tile, (high - low + 1) * tile)
+ wander = wander * 0.62 + rng.uniform(-tile * 0.72, tile * 0.72)
+ wander = max(-span * 0.24, min(span * 0.24, wander))
+ axis_pixel = axis * tile + tile // 2
+ cross_pixel = round(center_cross + wander)
+ points.append((axis_pixel, cross_pixel) if horizontal else (cross_pixel, axis_pixel))
+ if len(points) == 1:
+ points.append((points[0][0] + (tile if horizontal else 0), points[0][1] + (0 if horizontal else tile)))
+ if thick_floodplain:
+ channel_width = max(round(tile * 1.35), min(round(tile * 2.8), round(minor * tile * 0.28)))
+ else:
+ channel_width = max(round(tile * 0.88), min(round(tile * 2.25), round(minor * tile * 0.88)))
+
+ # Normalize axial endpoints to the logical floodplain edge. Internal
+ # ends then move roughly one cap radius inward so clipping cannot turn
+ # the round cap into a flat wall; map-edge ends stay flush off-map.
+ if horizontal:
+ points[0] = (start_axis * tile, points[0][1])
+ points[-1] = ((end_axis + 1) * tile, points[-1][1])
+ start_off_map = start_axis == 0
+ end_off_map = end_axis == len(terrain[0]) - 1
+ else:
+ points[0] = (points[0][0], start_axis * tile)
+ points[-1] = (points[-1][0], (end_axis + 1) * tile)
+ start_off_map = start_axis == 0
+ end_off_map = end_axis == len(terrain) - 1
+ original_points = list(points)
+ endpoint_inset = channel_width * 0.45
+ if not start_off_map:
+ points[0] = inset_polyline_endpoint(original_points, endpoint_inset, True)
+ if not end_off_map:
+ points[-1] = inset_polyline_endpoint(original_points, endpoint_inset, False)
+ points = [(round(x), round(y)) for x, y in points]
+ channel_draw.line(points, fill=255, width=channel_width, joint="curve")
+ cap_radius = channel_width / 2
+ for cap_x, cap_y in (points[0], points[-1]):
+ channel_draw.ellipse(
+ (
+ cap_x - cap_radius,
+ cap_y - cap_radius,
+ cap_x + cap_radius,
+ cap_y + cap_radius,
+ ),
+ fill=255,
+ )
+
+ softened_channels = channels.filter(ImageFilter.GaussianBlur(max(2, tile * 0.055)))
+ clipped_channels = ImageChops.darker(softened_channels, logical)
+ combined = ImageChops.lighter(visual, clipped_channels)
+ visual.close()
+ channels.close()
+ softened_channels.close()
+ clipped_channels.close()
+ return logical, combined
+
+
+def selected_road_edges(terrain: list[list[str]], seed: int) -> list[tuple[tuple[int, int], tuple[int, int]]]:
+ rows = len(terrain)
+ columns = len(terrain[0])
+
+ def road_degree(x: int, y: int) -> int:
+ return sum(
+ 1
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
+ if 0 <= y + dy < rows and 0 <= x + dx < columns and terrain[y + dy][x + dx] == "road"
+ )
+
+ def lane_selected(index: int, salt: int) -> bool:
+ value = (index * 1_103_515_245 + seed * 12_345 + salt * 2_654_435_761) & 0xFFFFFFFF
+ return value % 100 < 26
+
+ edges: list[tuple[tuple[int, int], tuple[int, int]]] = []
+ for y, row in enumerate(terrain):
+ for x, kind in enumerate(row):
+ if kind != "road":
+ continue
+ current_degree = road_degree(x, y)
+ for dx, dy in ((1, 0), (0, 1)):
+ nx, ny = x + dx, y + dy
+ if not (0 <= ny < rows and 0 <= nx < columns):
+ continue
+ neighbor_kind = terrain[ny][nx]
+ if neighbor_kind in STRUCTURE_KINDS:
+ edges.append(((x, y), (nx, ny)))
+ continue
+ if neighbor_kind != "road":
+ continue
+ neighbor_degree = road_degree(nx, ny)
+ narrow_route = current_degree <= 2 or neighbor_degree <= 2
+ broad_lane = lane_selected(y, 17) if dx else lane_selected(x, 31)
+ edge_variation = ((x * 928_371 + y * 689_287 + seed * 97) % 100) < 12
+ if narrow_route or broad_lane or edge_variation:
+ edges.append(((x, y), (nx, ny)))
+ return edges
+
+
+def road_paths(terrain: list[list[str]], tile: int, seed: int) -> list[list[tuple[int, int]]]:
+ rng = random.Random(seed * 239)
+ broad_cells: set[tuple[int, int]] = set()
+ paths: list[list[tuple[int, int]]] = []
+
+ def component_path(
+ component: list[tuple[int, int]],
+ horizontal: bool,
+ fraction: float,
+ salt: int,
+ ) -> list[tuple[int, int]]:
+ cells_by_axis: dict[int, list[int]] = {}
+ for x, y in component:
+ axis, cross = (x, y) if horizontal else (y, x)
+ cells_by_axis.setdefault(axis, []).append(cross)
+ start_axis, end_axis = min(cells_by_axis), max(cells_by_axis)
+ axes = list(range(start_axis, end_axis + 1, 3))
+ if axes[-1] != end_axis:
+ axes.append(end_axis)
+ local_rng = random.Random(seed * 257 + salt)
+ points: list[tuple[int, int]] = []
+ wander = 0.0
+ for axis in axes:
+ available = cells_by_axis.get(axis)
+ if not available:
+ nearest_axis = min(cells_by_axis, key=lambda candidate: abs(candidate - axis))
+ available = cells_by_axis[nearest_axis]
+ ordered = sorted(available)
+ base = ordered[min(len(ordered) - 1, round((len(ordered) - 1) * fraction))]
+ wander = wander * 0.58 + local_rng.uniform(-0.55, 0.55)
+ cross = max(min(ordered) + 0.25, min(max(ordered) + 0.75, base + 0.5 + wander))
+ axis_pixel = axis * tile + tile // 2
+ cross_pixel = round(cross * tile)
+ points.append((axis_pixel, cross_pixel) if horizontal else (cross_pixel, axis_pixel))
+ return points
+
+ for component_index, component in enumerate(terrain_components(terrain, "road")):
+ xs = [x for x, _ in component]
+ ys = [y for _, y in component]
+ width = max(xs) - min(xs) + 1
+ height = max(ys) - min(ys) + 1
+ density = len(component) / max(1, width * height)
+ broad = len(component) >= 90 and min(width, height) >= 6 and (density >= 0.24 or len(component) >= 500)
+ if not broad:
+ continue
+ broad_cells.update(component)
+ if width >= height:
+ fractions = (0.28, 0.62, 0.82) if len(component) >= 1200 else (0.34, 0.7)
+ for offset, fraction in enumerate(fractions):
+ paths.append(component_path(component, True, fraction, component_index * 17 + offset))
+ paths.append(component_path(component, False, 0.52, component_index * 17 + 11))
+ else:
+ fractions = (0.28, 0.62, 0.82) if len(component) >= 1200 else (0.34, 0.7)
+ for offset, fraction in enumerate(fractions):
+ paths.append(component_path(component, False, fraction, component_index * 17 + offset))
+ paths.append(component_path(component, True, 0.52, component_index * 17 + 11))
+
+ for (x, y), (nx, ny) in selected_road_edges(terrain, seed):
+ if (x, y) in broad_cells and (nx, ny) in broad_cells:
+ continue
+ x1, y1 = x * tile + tile // 2, y * tile + tile // 2
+ x2, y2 = nx * tile + tile // 2, ny * tile + tile // 2
+ mid = (
+ round((x1 + x2) / 2 + rng.randint(-tile // 7, tile // 7)),
+ round((y1 + y2) / 2 + rng.randint(-tile // 7, tile // 7)),
+ )
+ paths.append([(x1, y1), mid, (x2, y2)])
+
+ return [path for path in paths if len(path) >= 2]
+
+
+def road_mask(terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
+ width = len(terrain[0]) * tile
+ height = len(terrain) * tile
+ mask = Image.new("L", (width, height), 0)
+ draw = ImageDraw.Draw(mask)
+ road_width = max(11, round(tile * 0.24))
+ for path in road_paths(terrain, tile, seed):
+ draw.line(path, fill=255, width=road_width, joint="curve")
+
+ return mask.filter(ImageFilter.GaussianBlur(max(1.0, tile * 0.055)))
+
+
+def shifted_mask(mask: Image.Image, dx: int, dy: int) -> Image.Image:
+ shifted = Image.new("L", mask.size, 0)
+ shifted.paste(mask, (dx, dy))
+ return shifted
+
+
+def composite_texture(canvas: Image.Image, texture: Image.Image, mask: Image.Image) -> Image.Image:
+ canvas.paste(texture, (0, 0), mask)
+ texture.close()
+ return canvas
+
+
+def apply_tint(canvas: Image.Image, mask: Image.Image, color: tuple[int, int, int], strength: float) -> Image.Image:
+ tint_mask = mask.point(lambda value: round(value * strength))
+ canvas.paste(color, (0, 0, canvas.width, canvas.height), tint_mask)
+ tint_mask.close()
+ return canvas
+
+
+def paste_region_features(
+ canvas: Image.Image,
+ source: Image.Image,
+ terrain: list[list[str]],
+ kind: str,
+ region: Image.Image,
+ tile: int,
+ seed: int,
+) -> Image.Image:
+ """Scatter large feathered material crops instead of one crop per cell."""
+
+ rng = random.Random(seed * 181 + len(kind) * 37)
+ cells = [(x, y) for y, row in enumerate(terrain) for x, value in enumerate(row) if value == kind]
+ if not cells:
+ return canvas
+
+ density = {
+ "forest": (31, 43),
+ "hill": (12, 34),
+ "cliff": (17, 48),
+ }[kind]
+ multipliers = {
+ "forest": (2.15, 2.65, 3.15),
+ "hill": (2.75, 3.35, 3.95),
+ "cliff": (2.35, 2.9, 3.45),
+ }[kind]
+
+ variants: list[tuple[Image.Image, Image.Image]] = []
+ for multiplier in multipliers:
+ side = max(tile, round(tile * multiplier))
+ for index in range(6):
+ crop_side = round(source.width * rng.uniform(0.36, 0.62))
+ left = rng.randint(0, max(0, source.width - crop_side))
+ top = rng.randint(0, max(0, source.height - crop_side))
+ crop = source.crop((left, top, left + crop_side, top + crop_side))
+ feature_mask = None
+ if kind == "forest":
+ feature_mask = ImageOps.grayscale(crop).point(
+ lambda value: max(0, min(255, (82 - value) * 5))
+ )
+ feature_mask = ImageOps.fit(feature_mask, (side, side), method=Image.Resampling.LANCZOS)
+ variant = ImageOps.fit(crop, (side, side), method=Image.Resampling.LANCZOS)
+ crop.close()
+ if index % 3 == 1:
+ variant = ImageOps.mirror(variant)
+ if feature_mask is not None:
+ feature_mask = ImageOps.mirror(feature_mask)
+ elif index % 3 == 2:
+ variant = variant.transpose(Image.Transpose.ROTATE_180)
+ if feature_mask is not None:
+ feature_mask = feature_mask.transpose(Image.Transpose.ROTATE_180)
+ variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.94, 1.05))
+ soft_mask = feathered_patch_mask(side, 242 if kind == "cliff" else 232)
+ if feature_mask is not None:
+ feature_mask = feature_mask.filter(ImageFilter.GaussianBlur(max(1.5, tile * 0.035)))
+ combined_mask = ImageChops.multiply(soft_mask, feature_mask)
+ soft_mask.close()
+ feature_mask.close()
+ soft_mask = combined_mask
+ variants.append((variant, soft_mask))
+
+ candidates: list[tuple[int, int, int]] = []
+ for x, y in cells:
+ boundary = any(not is_kind(terrain, x + dx, y + dy, kind) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
+ threshold = density[1] if boundary else density[0]
+ value = coordinate_hash(x, y, seed, 0xA511E9B3)
+ if value % 100 < threshold:
+ candidates.append((value, x, y))
+
+ candidates.sort()
+ exclusion = {"forest": 1, "hill": 2, "cliff": 2}[kind]
+ selected: list[tuple[int, int]] = []
+ selected_set: set[tuple[int, int]] = set()
+ for _, x, y in candidates:
+ if any(
+ (x + dx, y + dy) in selected_set
+ for dx in range(-exclusion, exclusion + 1)
+ for dy in range(-exclusion, exclusion + 1)
+ if dx * dx + dy * dy <= exclusion * exclusion
+ ):
+ continue
+ selected.append((x, y))
+ selected_set.add((x, y))
+ if not selected:
+ selected.append(cells[len(cells) // 2])
+
+ max_jitter = max(2, round(tile * 0.34))
+ for x, y in selected:
+ index = coordinate_hash(x, y, seed, 0x63D83595) % len(variants)
+ variant, soft_mask = variants[index]
+ side = variant.width
+ left = x * tile + (tile - side) // 2 + rng.randint(-max_jitter, max_jitter)
+ top = y * tile + (tile - side) // 2 + rng.randint(-max_jitter, max_jitter)
+ clip = region.crop((left, top, left + side, top + side))
+ bleed_size = min(side - 1, max(5, round(tile * 0.72)) | 1)
+ expanded_clip = clip.filter(ImageFilter.MaxFilter(bleed_size))
+ combined = ImageChops.multiply(soft_mask, expanded_clip)
+ canvas.paste(variant, (left, top), combined)
+ clip.close()
+ expanded_clip.close()
+ combined.close()
+
+ for variant, soft_mask in variants:
+ variant.close()
+ soft_mask.close()
+ return canvas
+
+
+def structure_variant_mask(variant: Image.Image, tile: int) -> Image.Image:
+ side = variant.width
+ blurred = variant.filter(ImageFilter.GaussianBlur(max(5, round(side * 0.085))))
+ detail = ImageOps.grayscale(ImageChops.difference(variant, blurred))
+ blurred.close()
+ detail = detail.point(lambda value: max(0, min(255, (value - 5) * 18)))
+ dilation = min(side - 1, max(7, round(side * 0.13)) | 1)
+ detail = detail.filter(ImageFilter.MaxFilter(dilation))
+ detail = detail.filter(ImageFilter.GaussianBlur(max(2, round(side * 0.035))))
+
+ radial = Image.new("L", (side, side), 0)
+ radial_draw = ImageDraw.Draw(radial)
+ inset = max(2, round(tile * 0.08))
+ radial_draw.ellipse((inset, inset, side - inset, side - inset), fill=255)
+ radial = radial.filter(ImageFilter.GaussianBlur(max(8, round(side * 0.11))))
+ ground_halo = radial.point(lambda value: round(value * 0.16))
+ foreground = ImageChops.multiply(detail, radial)
+ result = ImageChops.lighter(foreground, ground_halo)
+ detail.close()
+ radial.close()
+ ground_halo.close()
+ foreground.close()
+ return result
+
+
+def add_region(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], kind: str, tile: int, seed: int) -> Image.Image:
+ mask = region_mask(terrain, kind, tile, seed)
+ if mask.getbbox() is None:
+ mask.close()
+ return canvas
+
+ if kind in {"forest", "hill", "cliff"}:
+ shifted = shifted_mask(mask, max(2, tile // 18), max(3, tile // 14))
+ shadow_mask = ImageChops.subtract(shifted, mask).filter(ImageFilter.GaussianBlur(max(2, tile // 11)))
+ shadow = Image.new("RGB", canvas.size, (24, 25, 18))
+ shadow_strength = shadow_mask.point(lambda value: round(value * (0.38 if kind == "cliff" else 0.24)))
+ shadowed = Image.composite(shadow, canvas, shadow_strength)
+ canvas.close()
+ canvas = shadowed
+ shadow.close()
+ shifted.close()
+ shadow_mask.close()
+ shadow_strength.close()
+
+ tint, strength = {
+ "forest": ((45, 60, 34), 0.035),
+ "hill": ((113, 96, 63), 0.02),
+ "cliff": ((111, 101, 79), 0.08),
+ }[kind]
+ canvas = apply_tint(canvas, mask, tint, strength)
+ canvas = paste_region_features(canvas, sources[kind], terrain, kind, mask, tile, seed + 17)
+ mask.close()
+ return canvas
+
+
+def add_river(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
+ logical_mask, mask = river_masks(terrain, tile, seed)
+ if mask.getbbox() is None:
+ logical_mask.close()
+ mask.close()
+ return canvas
+ canvas = apply_tint(canvas, logical_mask, (82, 86, 62), 0.075)
+ bank_size = max(5, round(tile * 0.32)) | 1
+ expanded = mask.filter(ImageFilter.MaxFilter(bank_size))
+ bank = ImageChops.subtract(expanded, mask).filter(ImageFilter.GaussianBlur(max(1, tile // 24)))
+ bank_layer = Image.new("RGB", canvas.size, (67, 64, 43))
+ bank_strength = bank.point(lambda value: round(value * 0.78))
+ banked = Image.composite(bank_layer, canvas, bank_strength)
+ canvas.close()
+ canvas = banked
+ bank_layer.close()
+ bank_strength.close()
+ texture = fill_texture(sources["river"], canvas.size, tile, "river", seed + 29)
+ water_tint = Image.new("RGB", canvas.size, (43, 71, 73))
+ tinted_texture = Image.blend(texture, water_tint, 0.22)
+ texture.close()
+ water_tint.close()
+ texture = tinted_texture
+ canvas = composite_texture(canvas, texture, mask)
+ expanded.close()
+ bank.close()
+ logical_mask.close()
+ mask.close()
+ return canvas
+
+
+def add_roads(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
+ mask = road_mask(terrain, tile, seed)
+ if mask.getbbox() is None:
+ mask.close()
+ return canvas
+ shadow_mask = shifted_mask(mask, max(2, tile // 25), max(2, tile // 20)).filter(ImageFilter.GaussianBlur(max(2, tile // 14)))
+ shadow_layer = Image.new("RGB", canvas.size, (61, 48, 32))
+ shadow_strength = shadow_mask.point(lambda value: round(value * 0.20))
+ shadowed = Image.composite(shadow_layer, canvas, shadow_strength)
+ canvas.close()
+ canvas = shadowed
+ shadow_layer.close()
+ shadow_mask.close()
+ shadow_strength.close()
+
+ packed_earth = Image.new("RGB", canvas.size, (154, 127, 84))
+ packed_route = Image.blend(canvas, packed_earth, 0.38)
+ routed = Image.composite(packed_route, canvas, mask)
+ canvas.close()
+ canvas = routed
+ packed_earth.close()
+ packed_route.close()
+ mask.close()
+ return canvas
+
+
+def add_structures(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
+ rng = random.Random(seed * 149)
+ for kind in ("village", "fort", "camp"):
+ cells = {(x, y) for y, row in enumerate(terrain) for x, terrain_kind in enumerate(row) if terrain_kind == kind}
+ if not cells:
+ continue
+
+ components: list[list[tuple[int, int]]] = []
+ remaining = set(cells)
+ while remaining:
+ start = remaining.pop()
+ component = [start]
+ stack = [start]
+ while stack:
+ x, y = stack.pop()
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
+ neighbor = (x + dx, y + dy)
+ if neighbor in remaining:
+ remaining.remove(neighbor)
+ component.append(neighbor)
+ stack.append(neighbor)
+ components.append(component)
+
+ selected_cells: set[tuple[int, int]] = set()
+ for component in components:
+ center_x = sum(x for x, _ in component) / len(component)
+ center_y = sum(y for _, y in component) / len(component)
+ representative = min(component, key=lambda point: (point[0] - center_x) ** 2 + (point[1] - center_y) ** 2)
+ selected_cells.add(representative)
+ if len(component) <= 3:
+ selected_cells.update(component)
+ else:
+ target = min(
+ {"village": 6, "camp": 6, "fort": 8}[kind],
+ max(2, 1 + round(math.sqrt(len(component)) / (2.5 if kind == "fort" else 2.1))),
+ )
+ minimum_distance = 3.1 if kind == "fort" else 2.35
+ candidates = component[:]
+ rng.shuffle(candidates)
+ component_selected = [representative]
+ for candidate in candidates:
+ if len(component_selected) >= target:
+ break
+ if all(
+ (candidate[0] - point[0]) ** 2 + (candidate[1] - point[1]) ** 2 >= minimum_distance**2
+ for point in component_selected
+ ):
+ component_selected.append(candidate)
+ selected_cells.update(component_selected)
+
+ source = sources[kind]
+ half_w, half_h = source.width // 2, source.height // 2
+ quadrants = [
+ source.crop((0, 0, half_w, half_h)),
+ source.crop((half_w, 0, source.width, half_h)),
+ source.crop((0, half_h, half_w, source.height)),
+ source.crop((half_w, half_h, source.width, source.height)),
+ ]
+ variant_size = max(
+ tile,
+ round(tile * {"village": 1.9, "camp": 2.0, "fort": 2.35}[kind]),
+ )
+ variants = [ImageOps.fit(variant, (variant_size, variant_size), method=Image.Resampling.LANCZOS) for variant in quadrants]
+ for variant in quadrants:
+ variant.close()
+
+ variant_masks = [structure_variant_mask(variant, tile) for variant in variants]
+
+ for x, y in sorted(selected_cells, key=lambda point: (point[1], point[0])):
+ index = coordinate_hash(x, y, seed, 0xC2B2AE35) % len(variants)
+ variant = variants[index]
+ left = x * tile + (tile - variant_size) // 2 + rng.randint(-max(1, tile // 24), max(1, tile // 24))
+ top = y * tile + (tile - variant_size) // 2 + rng.randint(-max(1, tile // 24), max(1, tile // 24))
+ canvas.paste(variant, (left, top), variant_masks[index])
+
+ for variant in variants:
+ variant.close()
+ for variant_mask in variant_masks:
+ variant_mask.close()
+
+ return canvas
+
+
+def add_route_ruts(canvas: Image.Image, terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
+ overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ draw = ImageDraw.Draw(overlay)
+
+ for path in road_paths(terrain, tile, seed):
+ horizontal = abs(path[-1][0] - path[0][0]) >= abs(path[-1][1] - path[0][1])
+ normal_x, normal_y = (0, 1) if horizontal else (1, 0)
+ offset = max(3, tile // 11)
+ for sign in (-1, 1):
+ jx = normal_x * offset * sign
+ jy = normal_y * offset * sign
+ draw.line(
+ [(x + jx, y + jy) for x, y in path],
+ fill=(70, 52, 34, 58),
+ width=max(1, tile // 34),
+ joint="curve",
+ )
+ composited = Image.alpha_composite(canvas.convert("RGBA"), overlay.filter(ImageFilter.GaussianBlur(0.55))).convert("RGB")
+ canvas.close()
+ canvas = composited
+ overlay.close()
+ return canvas
+
+
+def theme_settings(number: int) -> tuple[tuple[int, int, int], float, float, float, float]:
+ if number <= 4:
+ return (120, 105, 66), 0.035, 0.97, 0.94, 0.98
+ if number <= 6:
+ return (126, 108, 77), 0.055, 0.96, 0.94, 1.0
+ if number <= 15:
+ return (74, 96, 76), 0.045, 0.95, 0.93, 0.98
+ if number <= 19:
+ return (61, 93, 57), 0.06, 0.96, 0.98, 0.98
+ if number <= 24:
+ return (49, 87, 88), 0.055, 0.95, 0.93, 0.99
+ if number <= 37:
+ return (125, 100, 69), 0.07, 0.96, 0.9, 1.01
+ if number <= 44:
+ return (52, 82, 80), 0.065, 0.94, 0.88, 0.98
+ if number <= 46:
+ return (117, 76, 48), 0.075, 0.93, 0.88, 1.0
+ if number <= 54:
+ return (57, 87, 50), 0.085, 0.96, 1.03, 1.0
+ if number <= 63:
+ return (139, 111, 72), 0.08, 0.96, 0.86, 1.01
+ return (130, 106, 68), 0.09, 0.96, 0.83, 1.01
+
+
+def add_event_atmosphere(canvas: Image.Image, terrain: list[list[str]], tile: int, number: int, seed: int) -> Image.Image:
+ tint, tint_alpha, brightness, saturation, contrast = theme_settings(number)
+ canvas = Image.blend(canvas, Image.new("RGB", canvas.size, tint), tint_alpha)
+ canvas = ImageEnhance.Color(canvas).enhance(saturation)
+ canvas = ImageEnhance.Contrast(canvas).enhance(contrast)
+ canvas = ImageEnhance.Brightness(canvas).enhance(brightness)
+
+ if number == 9:
+ canvas = Image.blend(canvas, Image.new("RGB", canvas.size, (25, 36, 48)), 0.28)
+ canvas = ImageEnhance.Brightness(canvas).enhance(0.78)
+ elif number in {22, 46}:
+ rng = random.Random(seed * 211)
+ glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ draw = ImageDraw.Draw(glow)
+ candidates = [(x, y) for y, row in enumerate(terrain) for x, kind in enumerate(row) if kind in {"camp", "fort", "forest"}]
+ rng.shuffle(candidates)
+ for x, y in candidates[: max(5, min(18, len(candidates) // 20))]:
+ cx, cy = x * tile + tile // 2, y * tile + tile // 2
+ radius = rng.randint(tile // 2, tile * 2)
+ draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=(198, 77, 24, rng.randint(22, 42)))
+ canvas = Image.alpha_composite(canvas.convert("RGBA"), glow.filter(ImageFilter.GaussianBlur(max(8, tile // 2)))).convert("RGB")
+ glow.close()
+ elif number in {40, 61}:
+ canvas = Image.blend(canvas, Image.new("RGB", canvas.size, (47, 64, 70)), 0.1 if number == 40 else 0.15)
+ canvas = ImageEnhance.Brightness(canvas).enhance(0.91 if number == 61 else 0.96)
+
+ return canvas
+
+
+def add_finish(canvas: Image.Image, seed: int) -> Image.Image:
+ width, height = canvas.size
+ noise_side = 512
+ noise_rng = random.Random(seed ^ 0x9E3779B9)
+ noise = Image.frombytes("L", (noise_side, noise_side), noise_rng.randbytes(noise_side * noise_side))
+ noise = noise.filter(ImageFilter.GaussianBlur(1.1)).resize(canvas.size, Image.Resampling.BILINEAR)
+ noise_color = ImageOps.colorize(noise, (49, 47, 35), (153, 143, 101))
+ canvas = Image.blend(canvas, noise_color, 0.02)
+ noise.close()
+ noise_color.close()
+
+ vignette_small = Image.new("L", (512, 512), 0)
+ vdraw = ImageDraw.Draw(vignette_small)
+ vdraw.ellipse((-55, -40, 567, 570), fill=255)
+ vignette_small = vignette_small.filter(ImageFilter.GaussianBlur(52))
+ vignette = vignette_small.resize((width, height), Image.Resampling.BILINEAR)
+ dark = Image.new("RGB", canvas.size, (22, 22, 17))
+ edge_mask = ImageOps.invert(vignette).point(lambda value: round(value * 0.11))
+ canvas = Image.composite(dark, canvas, edge_mask)
+ vignette_small.close()
+ vignette.close()
+ dark.close()
+ edge_mask.close()
+ return canvas
+
+
+def build_map(battle: dict[str, object], sources: dict[str, Image.Image]) -> tuple[Image.Image, int]:
+ number = int(battle["number"])
+ terrain = battle["terrain"]
+ columns = int(battle["width"])
+ rows = int(battle["height"])
+ tile = output_tile_size(columns, rows)
+ size = (columns * tile, rows * tile)
+ seed = 260715 + number * 1009
+
+ canvas = fill_texture(sources["plain"], size, tile, "plain", seed)
+ canvas = add_river(canvas, sources, terrain, tile, seed)
+ for index, kind in enumerate(REGION_KINDS):
+ canvas = add_region(canvas, sources, terrain, kind, tile, seed + index * 31)
+ canvas = add_roads(canvas, sources, terrain, tile, seed)
+ canvas = add_route_ruts(canvas, terrain, tile, seed)
+ canvas = add_structures(canvas, sources, terrain, tile, seed)
+ if number in WATER_LAYER_REASSERT_BATTLES:
+ # Reviewed late-game water fields use intentionally generous feature
+ # bleed. Reassert water last so roads and terrain stamps cannot read as
+ # floating tiles inside the channel.
+ canvas = add_river(canvas, sources, terrain, tile, seed)
+ canvas = add_event_atmosphere(canvas, terrain, tile, number, seed)
+ canvas = add_finish(canvas, seed)
+ return canvas, tile
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as file:
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def write_contact_sheet(battles: list[dict[str, object]], output: Path) -> None:
+ card_width, card_height = 360, 310
+ columns = 6
+ rows = math.ceil(len(battles) / columns)
+ sheet = Image.new("RGB", (columns * card_width, rows * card_height), (24, 25, 20))
+ draw = ImageDraw.Draw(sheet)
+ for index, battle in enumerate(battles):
+ path = ROOT / str(battle["assetPath"])
+ if not path.is_file():
+ continue
+ with Image.open(path) as image:
+ preview = ImageOps.contain(image.convert("RGB"), (card_width - 20, card_height - 44), Image.Resampling.LANCZOS)
+ x = (index % columns) * card_width
+ y = (index // columns) * card_height
+ sheet.paste(preview, (x + (card_width - preview.width) // 2, y + 30 + (card_height - 44 - preview.height) // 2))
+ draw.text((x + 10, y + 8), f"{int(battle['number']):02d} {battle['id']}", fill=(231, 219, 180))
+ output.parent.mkdir(parents=True, exist_ok=True)
+ sheet.save(output, "JPEG", quality=90, optimize=True)
+
+
+def write_manifest(battles: list[dict[str, object]], output: Path) -> None:
+ rows = []
+ for battle in battles:
+ path = ROOT / str(battle["assetPath"])
+ with Image.open(path) as image:
+ width, height = image.size
+ rows.append(
+ {
+ "number": battle["number"],
+ "id": battle["id"],
+ "mapTextureKey": battle["mapTextureKey"],
+ "assetPath": battle["assetPath"],
+ "grid": [battle["width"], battle["height"]],
+ "image": [width, height],
+ "tilePixels": min(width // int(battle["width"]), height // int(battle["height"])),
+ "bytes": path.stat().st_size,
+ "sha256": sha256(path),
+ }
+ )
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(json.dumps({"pipeline": "battle-map-v2-atlas-compositor", "maps": rows}, indent=2) + "\n", encoding="utf-8")
+
+
+def main() -> None:
+ args = parse_args()
+ selected_numbers = parse_number_set(args.maps)
+ payload = json.loads(args.data.read_text(encoding="utf-8"))
+ battles = payload["battles"]
+ sources = load_sources()
+
+ for battle in battles:
+ number = int(battle["number"])
+ if number not in selected_numbers:
+ continue
+ output = ROOT / str(battle["assetPath"])
+ image, tile = build_map(battle, sources)
+ if image.width > MAX_TEXTURE_EDGE or image.height > MAX_TEXTURE_EDGE:
+ raise RuntimeError(f"{battle['id']}: generated texture {image.size} exceeds {MAX_TEXTURE_EDGE}px budget")
+ output.parent.mkdir(parents=True, exist_ok=True)
+ image.save(output, "WEBP", quality=args.quality, method=4)
+ image.close()
+ print(f"[{number:02d}/66] {battle['id']} -> {output.name} ({int(battle['width']) * tile}x{int(battle['height']) * tile}, {tile}px/tile)")
+ gc.collect()
+
+ for source in sources.values():
+ source.close()
+ if not args.skip_artifacts:
+ write_manifest(battles, args.manifest)
+ write_contact_sheet(battles, args.contact_sheet)
+ print(f"Wrote {args.manifest}")
+ print(f"Wrote {args.contact_sheet}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs
index 5059d3a..afb2c66 100644
--- a/scripts/qa-representative-battles.mjs
+++ b/scripts/qa-representative-battles.mjs
@@ -6,7 +6,8 @@ import { chromium } from 'playwright';
const cliOptions = parseCliOptions(process.argv.slice(2));
const defaultQaPort = process.env.QA_PORT ?? '41737';
-const targetUrl = withCanvasRenderer(process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`);
+const qaRenderer = cliOptions.renderer ?? process.env.QA_RENDERER ?? 'canvas';
+const targetUrl = withRenderer(process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`, qaRenderer);
const headless = process.env.QA_HEADLESS !== '0';
const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80);
const cumulativeMode = process.env.QA_CUMULATIVE === '1';
@@ -17,9 +18,12 @@ const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH;
const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1';
const baselineViewport = { width: 1920, height: 1080 };
-function withCanvasRenderer(url) {
+function withRenderer(url, renderer) {
+ if (!['canvas', 'webgl'].includes(renderer)) {
+ throw new Error(`Unknown QA renderer "${renderer}". Use canvas or webgl.`);
+ }
const parsed = new URL(url);
- parsed.searchParams.set('renderer', 'canvas');
+ parsed.searchParams.set('renderer', renderer);
return parsed.toString();
}
const supplyLabels = {
@@ -1268,6 +1272,7 @@ try {
async function setupBattle(page, battle) {
await gotoTargetPage(page);
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
+ await assertRequestedRenderer(page);
await page.evaluate((selected) => {
const now = new Date().toISOString();
const state = {
@@ -1317,6 +1322,7 @@ async function setupCumulativeBattle(page, battle, firstBattle) {
: undefined;
await gotoTargetPage(page);
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
+ await assertRequestedRenderer(page);
const preparation = await page.evaluate(
({ firstBattle, initialSnapshot, labels, protectedUnitIds, selected, slotKey, storageKey }) => {
const now = new Date().toISOString();
@@ -1467,6 +1473,17 @@ async function gotoTargetPage(page) {
throw lastError;
}
+async function assertRequestedRenderer(page) {
+ const probe = await page.evaluate(() => ({
+ type: window.__HEROS_GAME__?.renderer?.type ?? null,
+ name: window.__HEROS_GAME__?.renderer?.constructor?.name ?? null
+ }));
+ const expectedType = qaRenderer === 'webgl' ? 2 : 1;
+ if (probe.type !== expectedType) {
+ throw new Error(`Expected ${qaRenderer} renderer type ${expectedType}, received ${JSON.stringify(probe)}`);
+ }
+}
+
async function restartLocalServer() {
if (serverProcess) {
serverProcess.kill();
diff --git a/scripts/verify-battle-map-asset-data.mjs b/scripts/verify-battle-map-asset-data.mjs
index 2befce5..041688d 100644
--- a/scripts/verify-battle-map-asset-data.mjs
+++ b/scripts/verify-battle-map-asset-data.mjs
@@ -6,6 +6,7 @@ const battleMapDir = join('src', 'assets', 'images', 'battle');
const battleMapAssetsSourcePath = join('src', 'game', 'data', 'battleMapAssets.ts');
const minimumRasterMapWidth = 1024;
const minimumRasterMapHeight = 768;
+const maximumRasterMapEdge = 8192;
const server = await createServer({
logLevel: 'error',
@@ -21,7 +22,7 @@ try {
validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAssets);
validateScenarioMapKeys(errors, battleScenarios, battleMapAssets);
- validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets);
+ validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets, battleScenarios);
validateBattleDirectoryCoverage(errors, staticBattleMapAssets);
if (errors.length) {
@@ -80,11 +81,24 @@ function validateRuntimeMatchesSource(errors, battleMapAssets, staticBattleMapAs
});
}
-function validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets) {
+function validateMapAssetFiles(errors, battleMapAssets, staticBattleMapAssets, battleScenarios) {
+ const scenariosByMapKey = new Map(
+ Object.values(battleScenarios).map((scenario) => [scenario.mapTextureKey, scenario])
+ );
Object.entries(staticBattleMapAssets)
.sort(([left], [right]) => left.localeCompare(right))
.forEach(([mapKey, path]) => {
- validateMapFile(errors, mapKey, path);
+ const dimensions = validateMapFile(errors, mapKey, path);
+ const scenario = scenariosByMapKey.get(mapKey);
+ if (dimensions && scenario) {
+ const widthPerTile = dimensions.width / scenario.map.width;
+ const heightPerTile = dimensions.height / scenario.map.height;
+ if (Math.abs(widthPerTile - heightPerTile) > 0.01) {
+ errors.push(
+ `${mapKey}: "${path}" is ${dimensions.width}x${dimensions.height} for a ${scenario.map.width}x${scenario.map.height} grid; expected equal source pixels per tile on both axes`
+ );
+ }
+ }
const url = battleMapAssets[mapKey];
assertNonEmptyString(errors, url, `${mapKey} runtime asset URL`);
@@ -178,7 +192,7 @@ function validateMapFile(errors, context, path) {
if (!/viewBox=["'][^"']+["']/.test(text) && !/width=["'][^"']+["']/.test(text)) {
errors.push(`${context}: "${path}" should declare SVG dimensions or a viewBox`);
}
- return;
+ return undefined;
}
if (path.toLowerCase().endsWith('.webp')) {
@@ -187,23 +201,29 @@ function validateMapFile(errors, context, path) {
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
) {
errors.push(`${context}: "${path}" does not have a WebP RIFF header`);
- return;
+ return undefined;
}
const dimensions = readWebpDimensions(bytes);
if (!dimensions) {
errors.push(`${context}: "${path}" does not expose readable WebP dimensions`);
- return;
+ return undefined;
}
if (dimensions.width < minimumRasterMapWidth || dimensions.height < minimumRasterMapHeight) {
errors.push(
`${context}: "${path}" is ${dimensions.width}x${dimensions.height}, expected at least ${minimumRasterMapWidth}x${minimumRasterMapHeight}`
);
}
- return;
+ if (dimensions.width > maximumRasterMapEdge || dimensions.height > maximumRasterMapEdge) {
+ errors.push(
+ `${context}: "${path}" is ${dimensions.width}x${dimensions.height}, exceeding the ${maximumRasterMapEdge}px WebGL texture budget`
+ );
+ }
+ return dimensions;
}
errors.push(`${context}: "${path}" uses an unsupported map asset extension`);
+ return undefined;
}
function readWebpDimensions(bytes) {
diff --git a/src/assets/images/battle/eighteenth-battle-map.webp b/src/assets/images/battle/eighteenth-battle-map.webp
index 7ece4c1..4e570c0 100644
Binary files a/src/assets/images/battle/eighteenth-battle-map.webp and b/src/assets/images/battle/eighteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/eighth-battle-map.webp b/src/assets/images/battle/eighth-battle-map.webp
index 9716648..6c9c5c1 100644
Binary files a/src/assets/images/battle/eighth-battle-map.webp and b/src/assets/images/battle/eighth-battle-map.webp differ
diff --git a/src/assets/images/battle/eleventh-battle-map.webp b/src/assets/images/battle/eleventh-battle-map.webp
index 48a24f8..2eb4427 100644
Binary files a/src/assets/images/battle/eleventh-battle-map.webp and b/src/assets/images/battle/eleventh-battle-map.webp differ
diff --git a/src/assets/images/battle/fifteenth-battle-map.webp b/src/assets/images/battle/fifteenth-battle-map.webp
index 55b8bd6..da9d15a 100644
Binary files a/src/assets/images/battle/fifteenth-battle-map.webp and b/src/assets/images/battle/fifteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifth-battle-map.webp b/src/assets/images/battle/fifth-battle-map.webp
index 429aaa3..0fe3bfd 100644
Binary files a/src/assets/images/battle/fifth-battle-map.webp and b/src/assets/images/battle/fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/fiftieth-battle-map.webp b/src/assets/images/battle/fiftieth-battle-map.webp
index 6f4b672..f34c17f 100644
Binary files a/src/assets/images/battle/fiftieth-battle-map.webp and b/src/assets/images/battle/fiftieth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-eighth-battle-map.webp b/src/assets/images/battle/fifty-eighth-battle-map.webp
index a6ec7f6..ade0e36 100644
Binary files a/src/assets/images/battle/fifty-eighth-battle-map.webp and b/src/assets/images/battle/fifty-eighth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-fifth-battle-map.webp b/src/assets/images/battle/fifty-fifth-battle-map.webp
index 2a62f7b..3b3f359 100644
Binary files a/src/assets/images/battle/fifty-fifth-battle-map.webp and b/src/assets/images/battle/fifty-fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-first-battle-map.webp b/src/assets/images/battle/fifty-first-battle-map.webp
index abde24a..b917cfb 100644
Binary files a/src/assets/images/battle/fifty-first-battle-map.webp and b/src/assets/images/battle/fifty-first-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-fourth-battle-map.webp b/src/assets/images/battle/fifty-fourth-battle-map.webp
index 79a47a2..e78dbbd 100644
Binary files a/src/assets/images/battle/fifty-fourth-battle-map.webp and b/src/assets/images/battle/fifty-fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-ninth-battle-map.webp b/src/assets/images/battle/fifty-ninth-battle-map.webp
index de583e7..6f0313d 100644
Binary files a/src/assets/images/battle/fifty-ninth-battle-map.webp and b/src/assets/images/battle/fifty-ninth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-second-battle-map.webp b/src/assets/images/battle/fifty-second-battle-map.webp
index 108f2e4..aabb1e6 100644
Binary files a/src/assets/images/battle/fifty-second-battle-map.webp and b/src/assets/images/battle/fifty-second-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-seventh-battle-map.webp b/src/assets/images/battle/fifty-seventh-battle-map.webp
index 1f18be6..f6d53bb 100644
Binary files a/src/assets/images/battle/fifty-seventh-battle-map.webp and b/src/assets/images/battle/fifty-seventh-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-sixth-battle-map.webp b/src/assets/images/battle/fifty-sixth-battle-map.webp
index 69a3d09..3501f4e 100644
Binary files a/src/assets/images/battle/fifty-sixth-battle-map.webp and b/src/assets/images/battle/fifty-sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/fifty-third-battle-map.webp b/src/assets/images/battle/fifty-third-battle-map.webp
index 4e7e93e..cb78991 100644
Binary files a/src/assets/images/battle/fifty-third-battle-map.webp and b/src/assets/images/battle/fifty-third-battle-map.webp differ
diff --git a/src/assets/images/battle/fortieth-battle-map.webp b/src/assets/images/battle/fortieth-battle-map.webp
index 11436c0..9d9324b 100644
Binary files a/src/assets/images/battle/fortieth-battle-map.webp and b/src/assets/images/battle/fortieth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-eighth-battle-map.webp b/src/assets/images/battle/forty-eighth-battle-map.webp
index 1150294..83f6638 100644
Binary files a/src/assets/images/battle/forty-eighth-battle-map.webp and b/src/assets/images/battle/forty-eighth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-fifth-battle-map.webp b/src/assets/images/battle/forty-fifth-battle-map.webp
index b7b1dc1..4790d33 100644
Binary files a/src/assets/images/battle/forty-fifth-battle-map.webp and b/src/assets/images/battle/forty-fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-first-battle-map.webp b/src/assets/images/battle/forty-first-battle-map.webp
index a2e873a..10756e6 100644
Binary files a/src/assets/images/battle/forty-first-battle-map.webp and b/src/assets/images/battle/forty-first-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-fourth-battle-map.webp b/src/assets/images/battle/forty-fourth-battle-map.webp
index 7903a4d..769fd16 100644
Binary files a/src/assets/images/battle/forty-fourth-battle-map.webp and b/src/assets/images/battle/forty-fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-ninth-battle-map.webp b/src/assets/images/battle/forty-ninth-battle-map.webp
index 9234c37..35e0f8f 100644
Binary files a/src/assets/images/battle/forty-ninth-battle-map.webp and b/src/assets/images/battle/forty-ninth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-second-battle-map.webp b/src/assets/images/battle/forty-second-battle-map.webp
index 39d9615..554405e 100644
Binary files a/src/assets/images/battle/forty-second-battle-map.webp and b/src/assets/images/battle/forty-second-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-seventh-battle-map.webp b/src/assets/images/battle/forty-seventh-battle-map.webp
index 19ce90f..0215b3e 100644
Binary files a/src/assets/images/battle/forty-seventh-battle-map.webp and b/src/assets/images/battle/forty-seventh-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-sixth-battle-map.webp b/src/assets/images/battle/forty-sixth-battle-map.webp
index 6d4e115..62f668e 100644
Binary files a/src/assets/images/battle/forty-sixth-battle-map.webp and b/src/assets/images/battle/forty-sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/forty-third-battle-map.webp b/src/assets/images/battle/forty-third-battle-map.webp
index 9a30ebd..8378d4f 100644
Binary files a/src/assets/images/battle/forty-third-battle-map.webp and b/src/assets/images/battle/forty-third-battle-map.webp differ
diff --git a/src/assets/images/battle/fourteenth-battle-map.webp b/src/assets/images/battle/fourteenth-battle-map.webp
index 3781fe7..72495f5 100644
Binary files a/src/assets/images/battle/fourteenth-battle-map.webp and b/src/assets/images/battle/fourteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/fourth-battle-map.webp b/src/assets/images/battle/fourth-battle-map.webp
index cc5bd72..8c0c9c9 100644
Binary files a/src/assets/images/battle/fourth-battle-map.webp and b/src/assets/images/battle/fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/nineteenth-battle-map.webp b/src/assets/images/battle/nineteenth-battle-map.webp
index e154e52..959db84 100644
Binary files a/src/assets/images/battle/nineteenth-battle-map.webp and b/src/assets/images/battle/nineteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/ninth-battle-map.webp b/src/assets/images/battle/ninth-battle-map.webp
index d1f98c3..4fc5aad 100644
Binary files a/src/assets/images/battle/ninth-battle-map.webp and b/src/assets/images/battle/ninth-battle-map.webp differ
diff --git a/src/assets/images/battle/second-battle-map.webp b/src/assets/images/battle/second-battle-map.webp
index e119083..88397b2 100644
Binary files a/src/assets/images/battle/second-battle-map.webp and b/src/assets/images/battle/second-battle-map.webp differ
diff --git a/src/assets/images/battle/seventeenth-battle-map.webp b/src/assets/images/battle/seventeenth-battle-map.webp
index 83bcc5a..139663d 100644
Binary files a/src/assets/images/battle/seventeenth-battle-map.webp and b/src/assets/images/battle/seventeenth-battle-map.webp differ
diff --git a/src/assets/images/battle/seventh-battle-map.webp b/src/assets/images/battle/seventh-battle-map.webp
index 052f546..1f9cd3b 100644
Binary files a/src/assets/images/battle/seventh-battle-map.webp and b/src/assets/images/battle/seventh-battle-map.webp differ
diff --git a/src/assets/images/battle/sixteenth-battle-map.webp b/src/assets/images/battle/sixteenth-battle-map.webp
index c71f90f..06c9c1e 100644
Binary files a/src/assets/images/battle/sixteenth-battle-map.webp and b/src/assets/images/battle/sixteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixth-battle-map.webp b/src/assets/images/battle/sixth-battle-map.webp
index 162fc59..b85a0da 100644
Binary files a/src/assets/images/battle/sixth-battle-map.webp and b/src/assets/images/battle/sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixtieth-battle-map.webp b/src/assets/images/battle/sixtieth-battle-map.webp
index 11ed070..b97b7de 100644
Binary files a/src/assets/images/battle/sixtieth-battle-map.webp and b/src/assets/images/battle/sixtieth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-fifth-battle-map.webp b/src/assets/images/battle/sixty-fifth-battle-map.webp
index 0d617f7..0d29f74 100644
Binary files a/src/assets/images/battle/sixty-fifth-battle-map.webp and b/src/assets/images/battle/sixty-fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-first-battle-map.webp b/src/assets/images/battle/sixty-first-battle-map.webp
index b2b1598..19029cd 100644
Binary files a/src/assets/images/battle/sixty-first-battle-map.webp and b/src/assets/images/battle/sixty-first-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-fourth-battle-map.webp b/src/assets/images/battle/sixty-fourth-battle-map.webp
index 5756b3d..3694a01 100644
Binary files a/src/assets/images/battle/sixty-fourth-battle-map.webp and b/src/assets/images/battle/sixty-fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-second-battle-map.webp b/src/assets/images/battle/sixty-second-battle-map.webp
index e483d3f..aca1495 100644
Binary files a/src/assets/images/battle/sixty-second-battle-map.webp and b/src/assets/images/battle/sixty-second-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-sixth-battle-map.webp b/src/assets/images/battle/sixty-sixth-battle-map.webp
index 5bb05d3..8372b0f 100644
Binary files a/src/assets/images/battle/sixty-sixth-battle-map.webp and b/src/assets/images/battle/sixty-sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/sixty-third-battle-map.webp b/src/assets/images/battle/sixty-third-battle-map.webp
index c914c74..89a6317 100644
Binary files a/src/assets/images/battle/sixty-third-battle-map.webp and b/src/assets/images/battle/sixty-third-battle-map.webp differ
diff --git a/src/assets/images/battle/tenth-battle-map.webp b/src/assets/images/battle/tenth-battle-map.webp
index 08d42f2..e12a003 100644
Binary files a/src/assets/images/battle/tenth-battle-map.webp and b/src/assets/images/battle/tenth-battle-map.webp differ
diff --git a/src/assets/images/battle/terrain-v2/camp-v2-source.png b/src/assets/images/battle/terrain-v2/camp-v2-source.png
new file mode 100644
index 0000000..6a657b1
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/camp-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/cliff-v2-source.png b/src/assets/images/battle/terrain-v2/cliff-v2-source.png
new file mode 100644
index 0000000..b2f0b72
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/cliff-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/forest-v2-source.png b/src/assets/images/battle/terrain-v2/forest-v2-source.png
new file mode 100644
index 0000000..e355f59
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/forest-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/fort-v2-source.png b/src/assets/images/battle/terrain-v2/fort-v2-source.png
new file mode 100644
index 0000000..e6cdd29
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/fort-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/hill-v2-source.png b/src/assets/images/battle/terrain-v2/hill-v2-source.png
new file mode 100644
index 0000000..0b0ce01
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/hill-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/plain-v2-source.png b/src/assets/images/battle/terrain-v2/plain-v2-source.png
new file mode 100644
index 0000000..86c77eb
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/plain-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/river-v2-source.png b/src/assets/images/battle/terrain-v2/river-v2-source.png
new file mode 100644
index 0000000..41505f0
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/river-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/road-v2-source.png b/src/assets/images/battle/terrain-v2/road-v2-source.png
new file mode 100644
index 0000000..c6e9ff8
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/road-v2-source.png differ
diff --git a/src/assets/images/battle/terrain-v2/village-v2-source.png b/src/assets/images/battle/terrain-v2/village-v2-source.png
new file mode 100644
index 0000000..12a53ab
Binary files /dev/null and b/src/assets/images/battle/terrain-v2/village-v2-source.png differ
diff --git a/src/assets/images/battle/third-battle-map.webp b/src/assets/images/battle/third-battle-map.webp
index 56e8921..80ff8c8 100644
Binary files a/src/assets/images/battle/third-battle-map.webp and b/src/assets/images/battle/third-battle-map.webp differ
diff --git a/src/assets/images/battle/thirteenth-battle-map.webp b/src/assets/images/battle/thirteenth-battle-map.webp
index 63e28a6..3d8f7f1 100644
Binary files a/src/assets/images/battle/thirteenth-battle-map.webp and b/src/assets/images/battle/thirteenth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirtieth-battle-map.webp b/src/assets/images/battle/thirtieth-battle-map.webp
index 86bbd3a..8010a5a 100644
Binary files a/src/assets/images/battle/thirtieth-battle-map.webp and b/src/assets/images/battle/thirtieth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-eighth-battle-map.webp b/src/assets/images/battle/thirty-eighth-battle-map.webp
index 5150642..178ffcc 100644
Binary files a/src/assets/images/battle/thirty-eighth-battle-map.webp and b/src/assets/images/battle/thirty-eighth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-fifth-battle-map.webp b/src/assets/images/battle/thirty-fifth-battle-map.webp
index a02ad4a..5f1371e 100644
Binary files a/src/assets/images/battle/thirty-fifth-battle-map.webp and b/src/assets/images/battle/thirty-fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-first-battle-map.webp b/src/assets/images/battle/thirty-first-battle-map.webp
index 10a8535..b4ae44c 100644
Binary files a/src/assets/images/battle/thirty-first-battle-map.webp and b/src/assets/images/battle/thirty-first-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-fourth-battle-map.webp b/src/assets/images/battle/thirty-fourth-battle-map.webp
index 16b1b18..ff7cda5 100644
Binary files a/src/assets/images/battle/thirty-fourth-battle-map.webp and b/src/assets/images/battle/thirty-fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-ninth-battle-map.webp b/src/assets/images/battle/thirty-ninth-battle-map.webp
index d354c3c..7a74211 100644
Binary files a/src/assets/images/battle/thirty-ninth-battle-map.webp and b/src/assets/images/battle/thirty-ninth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-second-battle-map.webp b/src/assets/images/battle/thirty-second-battle-map.webp
index b892a1a..d3fd392 100644
Binary files a/src/assets/images/battle/thirty-second-battle-map.webp and b/src/assets/images/battle/thirty-second-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-seventh-battle-map.webp b/src/assets/images/battle/thirty-seventh-battle-map.webp
index e4752bf..cab43c2 100644
Binary files a/src/assets/images/battle/thirty-seventh-battle-map.webp and b/src/assets/images/battle/thirty-seventh-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-sixth-battle-map.webp b/src/assets/images/battle/thirty-sixth-battle-map.webp
index be203e3..25413d2 100644
Binary files a/src/assets/images/battle/thirty-sixth-battle-map.webp and b/src/assets/images/battle/thirty-sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/thirty-third-battle-map.webp b/src/assets/images/battle/thirty-third-battle-map.webp
index 378cc06..2cab565 100644
Binary files a/src/assets/images/battle/thirty-third-battle-map.webp and b/src/assets/images/battle/thirty-third-battle-map.webp differ
diff --git a/src/assets/images/battle/twelfth-battle-map.webp b/src/assets/images/battle/twelfth-battle-map.webp
index ef9a4a0..49d12f5 100644
Binary files a/src/assets/images/battle/twelfth-battle-map.webp and b/src/assets/images/battle/twelfth-battle-map.webp differ
diff --git a/src/assets/images/battle/twentieth-battle-map.webp b/src/assets/images/battle/twentieth-battle-map.webp
index 1487633..848a49a 100644
Binary files a/src/assets/images/battle/twentieth-battle-map.webp and b/src/assets/images/battle/twentieth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-eighth-battle-map.webp b/src/assets/images/battle/twenty-eighth-battle-map.webp
index 6246b95..1143964 100644
Binary files a/src/assets/images/battle/twenty-eighth-battle-map.webp and b/src/assets/images/battle/twenty-eighth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-fifth-battle-map.webp b/src/assets/images/battle/twenty-fifth-battle-map.webp
index ea2a421..8bf28f6 100644
Binary files a/src/assets/images/battle/twenty-fifth-battle-map.webp and b/src/assets/images/battle/twenty-fifth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-first-battle-map.webp b/src/assets/images/battle/twenty-first-battle-map.webp
index 9fbdafe..046479b 100644
Binary files a/src/assets/images/battle/twenty-first-battle-map.webp and b/src/assets/images/battle/twenty-first-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-fourth-battle-map.webp b/src/assets/images/battle/twenty-fourth-battle-map.webp
index 3c0a1d2..8e72375 100644
Binary files a/src/assets/images/battle/twenty-fourth-battle-map.webp and b/src/assets/images/battle/twenty-fourth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-ninth-battle-map.webp b/src/assets/images/battle/twenty-ninth-battle-map.webp
index aa11be5..e7d2812 100644
Binary files a/src/assets/images/battle/twenty-ninth-battle-map.webp and b/src/assets/images/battle/twenty-ninth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-second-battle-map.webp b/src/assets/images/battle/twenty-second-battle-map.webp
index 7238195..c321404 100644
Binary files a/src/assets/images/battle/twenty-second-battle-map.webp and b/src/assets/images/battle/twenty-second-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-seventh-battle-map.webp b/src/assets/images/battle/twenty-seventh-battle-map.webp
index 49be961..f8bc99a 100644
Binary files a/src/assets/images/battle/twenty-seventh-battle-map.webp and b/src/assets/images/battle/twenty-seventh-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-sixth-battle-map.webp b/src/assets/images/battle/twenty-sixth-battle-map.webp
index e54af41..7215a30 100644
Binary files a/src/assets/images/battle/twenty-sixth-battle-map.webp and b/src/assets/images/battle/twenty-sixth-battle-map.webp differ
diff --git a/src/assets/images/battle/twenty-third-battle-map.webp b/src/assets/images/battle/twenty-third-battle-map.webp
index b21c213..2b98bfb 100644
Binary files a/src/assets/images/battle/twenty-third-battle-map.webp and b/src/assets/images/battle/twenty-third-battle-map.webp differ