diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md
index 10ec28e..d4f975b 100644
--- a/docs/visual-asset-quality-audit.md
+++ b/docs/visual-asset-quality-audit.md
@@ -7,6 +7,7 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
- Target desktop viewport: 1280x720
- Battle map assets: 66
- Flagged battle map review rows: 43
+- Flagged battle map rows with repaint reports: 2
- Battle map source pixel density range: 64.0-224.0 px/tile
- Story source backgrounds: 136
- Story page background references: 466
@@ -16,12 +17,13 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
- 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 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-fourth-battle-weishui-camps` / `battle-map-sixty-fourth`
+- Battle map candidate: `sixty-fifth-battle-weishui-northbank` / `battle-map-sixty-fifth`
- Battle reason: low tile pixel density
- Story background candidate: `src/assets/images/story/25-yizhou-luo-road.png`
- Story reason: high-repeat story background
@@ -31,8 +33,6 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
| score | battle id | map key | grid | image | px/tile | bytes/px | concerns |
| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |
-| 6 | `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 8192x7040 | 64.0 | 0.1366 | low tile pixel density |
-| 6 | `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 14784x12768 | 112.0 | 0.1058 | low tile pixel density |
| 6 | `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 14560x12544 | 112.0 | 0.1059 | low tile pixel density |
| 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 |
@@ -43,6 +43,8 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
| 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 |
## Top Story Background Review Queue
@@ -103,9 +105,9 @@ Generated by `scripts/audit-visual-asset-quality.mjs`.
| `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 | 14560x12544 | 112.0 | 18.44 | low tile pixel density |
| `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 |
+| `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 | 14784x12768 | 112.0 | 19.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 |
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-beacon.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-beacon.png
new file mode 100644
index 0000000..5b77850
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-beacon.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-before-after.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-before-after.png
new file mode 100644
index 0000000..5173d38
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-before-after.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-deploy.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-deploy.png
new file mode 100644
index 0000000..86a4a1f
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-deploy.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-detail-crops.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-detail-crops.png
new file mode 100644
index 0000000..96447c8
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-detail-crops.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-enemy-camp.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-enemy-camp.png
new file mode 100644
index 0000000..129599d
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-enemy-camp.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-main-camp.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-main-camp.png
new file mode 100644
index 0000000..647f04f
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-main-camp.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-report.md b/docs/visual-asset-sixty-sixth-map-repaint-v1-report.md
new file mode 100644
index 0000000..241aadd
--- /dev/null
+++ b/docs/visual-asset-sixty-sixth-map-repaint-v1-report.md
@@ -0,0 +1,39 @@
+# Sixty-Sixth Battle Map Repaint v1
+
+Date: 2026-07-10
+
+Target: `sixty-sixth-battle-wuzhang-final` / `battle-map-sixty-sixth`
+
+## Changes
+
+- Added the `wuzhang-final` detail profile to `scripts/generate-handpaint-map-sample.py`.
+- Regenerated `src/assets/images/battle/sixty-sixth-battle-map.webp` from the scenario terrain with desktop runtime output at 64 px/tile.
+- Added late-campaign Wuzhang details: dry ridge strokes, road ruts, footstep marks, return-route supply carts, ledger tents, beacon towers, smoke wisps, and camp clutter.
+- Kept the original 132x114 terrain grid and aspect ratio.
+
+## Asset Result
+
+- Previous image: 14,784 x 12,768, 19,973,582 bytes.
+- New image: 8,448 x 7,296, 8,289,494 bytes.
+- Source render density remains 112 px/tile before downsampling; runtime asset density is 64 px/tile for desktop browser performance.
+
+## Evidence
+
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-before-after.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-source-preview.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-detail-crops.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-deploy.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-start.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-main-camp.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-beacon.png`
+- `docs/visual-asset-sixty-sixth-map-repaint-v1-enemy-camp.png`
+
+## Validation
+
+- `python -m py_compile scripts\generate-handpaint-map-sample.py`
+- `pnpm run verify:static-data`
+- `pnpm run build`
+- `pnpm run audit:visual-assets`
+- `git diff --check`
+
+Browser QA reached the 66th battle deployment screen and active battle views around the start area, main camp, beacon line, and enemy camp. The in-app browser log buffer still contained an older 64th-map Phaser image-processing error timestamped before this pass; the 66th battle map rendered successfully in all captured views.
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-source-preview.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-source-preview.png
new file mode 100644
index 0000000..16a7c68
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-source-preview.png differ
diff --git a/docs/visual-asset-sixty-sixth-map-repaint-v1-start.png b/docs/visual-asset-sixty-sixth-map-repaint-v1-start.png
new file mode 100644
index 0000000..8187146
Binary files /dev/null and b/docs/visual-asset-sixty-sixth-map-repaint-v1-start.png differ
diff --git a/scripts/audit-visual-asset-quality.mjs b/scripts/audit-visual-asset-quality.mjs
index 92c36e0..467696c 100644
--- a/scripts/audit-visual-asset-quality.mjs
+++ b/scripts/audit-visual-asset-quality.mjs
@@ -21,7 +21,8 @@ try {
const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
- const battleRows = collectBattleRows(battleScenarios, battleMapAssets);
+ const reviewedBattleMapPrefixes = collectReviewedBattleMapPrefixes();
+ const battleRows = collectBattleRows(battleScenarios, battleMapAssets, reviewedBattleMapPrefixes);
const storyUsage = collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor);
const storyRows = collectStoryRows(storyBackgroundAssets, storyUsage);
const report = renderReport({ battleRows, storyRows, storyUsage });
@@ -32,7 +33,7 @@ try {
await server.close();
}
-function collectBattleRows(battleScenarios, battleMapAssets) {
+function collectBattleRows(battleScenarios, battleMapAssets, reviewedBattleMapPrefixes) {
return Object.values(battleScenarios)
.map((scenario) => {
const assetUrl = battleMapAssets[scenario.mapTextureKey];
@@ -61,6 +62,7 @@ function collectBattleRows(battleScenarios, battleMapAssets) {
pxPerTile,
aspectDelta,
concerns: concerns.map((concern) => concern.label),
+ hasMapRepaintReport: reviewedBattleMapPrefixes.has(battleOrdinalPrefix(scenario.id)),
score
};
})
@@ -100,6 +102,22 @@ function battleOrder(left, right) {
);
}
+function collectReviewedBattleMapPrefixes() {
+ try {
+ return new Set(
+ readdirSync('docs')
+ .map((fileName) => fileName.match(/^visual-asset-(.+)-map-repaint-v\d+-report\.md$/)?.[1])
+ .filter(Boolean)
+ );
+ } catch {
+ return new Set();
+ }
+}
+
+function battleOrdinalPrefix(battleId) {
+ return battleId.match(/^(.+)-battle-/)?.[1] ?? battleId;
+}
+
function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor) {
const usageByPath = new Map();
const pageReferences = [];
@@ -210,13 +228,17 @@ function storyOrder(left, right) {
function renderReport({ battleRows, storyRows, storyUsage }) {
const flaggedBattleRows = battleRows.filter((row) => row.concerns.length > 0);
+ const reviewedBattleRows = flaggedBattleRows.filter((row) => row.hasMapRepaintReport);
+ const pendingBattleRows = flaggedBattleRows.filter((row) => !row.hasMapRepaintReport);
const flaggedStoryRows = storyRows.filter((row) => row.concerns.length > 0);
- const topBattleRows = flaggedBattleRows.slice(0, 12);
+ const topBattleRows = pendingBattleRows.slice(0, 12);
const topStoryRows = flaggedStoryRows.slice(0, 12);
const nextBattle = topBattleRows[0];
const nextStory = topStoryRows[0];
const battlePxPerTileValues = battleRows.map((row) => row.pxPerTile);
const storyPageReferenceCount = storyUsage.pageReferences.length;
+ const battleConcernText = (row) =>
+ [...row.concerns, row.hasMapRepaintReport ? 'repaint report exists' : undefined].filter(Boolean).join('
') || 'none';
return `${[
'# Visual Asset Quality Audit',
@@ -228,6 +250,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) {
`- Target desktop viewport: ${targetViewport.width}x${targetViewport.height}`,
`- Battle map assets: ${battleRows.length}`,
`- Flagged battle map review rows: ${flaggedBattleRows.length}`,
+ `- Flagged battle map rows with repaint reports: ${reviewedBattleRows.length}`,
`- Battle map source pixel density range: ${formatNumber(Math.min(...battlePxPerTileValues))}-${formatNumber(Math.max(...battlePxPerTileValues))} px/tile`,
`- Story source backgrounds: ${storyRows.length}`,
`- Story page background references: ${storyPageReferenceCount}`,
@@ -237,6 +260,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) {
'',
'- 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 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.',
'',
@@ -254,7 +278,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) {
'| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |',
...topBattleRows.map(
(row) =>
- `| ${row.score} | \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytesPerPixel, 4)} | ${row.concerns.join('
')} |`
+ `| ${row.score} | \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytesPerPixel, 4)} | ${battleConcernText(row)} |`
),
'',
'## Top Story Background Review Queue',
@@ -275,7 +299,7 @@ function renderReport({ battleRows, storyRows, storyUsage }) {
.sort((left, right) => left.id.localeCompare(right.id))
.map(
(row) =>
- `| \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${row.concerns.join('
') || 'none'} |`
+ `| \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${battleConcernText(row)} |`
),
'',
'## Story Background Inventory',
diff --git a/scripts/generate-handpaint-map-sample.py b/scripts/generate-handpaint-map-sample.py
index 3786098..3ba16b6 100644
--- a/scripts/generate-handpaint-map-sample.py
+++ b/scripts/generate-handpaint-map-sample.py
@@ -9,6 +9,8 @@ from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOps
+Image.MAX_IMAGE_PIXELS = None
+
ROOT = Path(__file__).resolve().parents[1]
SCENARIO_FILE = ROOT / "src" / "game" / "data" / "scenario.ts"
@@ -666,6 +668,8 @@ MAP_CONFIGS = {
"before": BATTLE_IMAGE_DIR / "sixty-sixth-battle-map.svg",
"seed": 241354,
"tile_size": VAST_LARGE_MAP_TILE_SIZE,
+ "output_tile_size": DESKTOP_RUNTIME_MAP_TILE_SIZE,
+ "detail_profile": "wuzhang-final",
},
}
@@ -1373,6 +1377,176 @@ def add_weishui_camps_details(canvas: Image.Image, terrain: list[list[str]]) ->
canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.04)))
+def draw_wuzhang_ledger_tent(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
+ cx, cy = center(x, y)
+ tent_w = int(TILE * 0.62)
+ tent_h = int(TILE * 0.44)
+ base_y = cy + int(TILE * 0.24)
+ draw.polygon(
+ [(cx - tent_w, base_y), (cx - int(TILE * 0.08), cy - tent_h), (cx + tent_w, base_y)],
+ fill=(118, 86, 47, 154),
+ )
+ draw.polygon(
+ [(cx - int(tent_w * 0.7), base_y - 3), (cx - int(TILE * 0.08), cy - int(tent_h * 0.72)), (cx + int(tent_w * 0.18), base_y - 3)],
+ fill=(205, 162, 86, 136),
+ )
+ draw.line((cx - tent_w, base_y, cx + tent_w, base_y), fill=(58, 39, 25, 142), width=max(3, TILE // 32))
+ table_y = cy + int(TILE * 0.38)
+ draw.rounded_rectangle(
+ (cx - int(TILE * 0.36), table_y - int(TILE * 0.11), cx + int(TILE * 0.38), table_y + int(TILE * 0.12)),
+ radius=max(4, TILE // 28),
+ fill=(92, 56, 30, 144),
+ )
+ draw.rectangle(
+ (cx - int(TILE * 0.28), table_y - int(TILE * 0.07), cx + int(TILE * 0.22), table_y + int(TILE * 0.05)),
+ fill=(222, 194, 129, 132),
+ )
+ for offset in (-int(TILE * 0.18), 0, int(TILE * 0.18)):
+ draw.line(
+ (cx + offset, table_y - int(TILE * 0.05), cx + offset + int(TILE * 0.16), table_y + int(TILE * 0.04)),
+ fill=(99, 68, 42, 98),
+ width=2,
+ )
+ for lx in (cx - int(TILE * 0.48), cx + int(TILE * 0.5)):
+ ly = cy - int(TILE * 0.08)
+ draw.ellipse((lx - 8, ly - 8, lx + 8, ly + 8), fill=(238, 177, 76, 132))
+ draw.arc((lx - 22, ly - 36, lx + 28, ly + 20), 205, 330, fill=(93, 75, 48, 52), width=2)
+
+
+def draw_wuzhang_supply_cart(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
+ cx, cy = center(x, y)
+ cart_w = int(TILE * 0.5)
+ cart_h = int(TILE * 0.22)
+ body_y = cy + int(TILE * 0.14)
+ draw.rectangle((cx - cart_w, body_y - cart_h, cx + cart_w, body_y + cart_h), fill=(111, 71, 39, 138))
+ draw.line((cx - cart_w - 10, body_y - cart_h - 6, cx + cart_w + 8, body_y + cart_h + 6), fill=(213, 168, 86, 76), width=3)
+ for wx in (cx - int(TILE * 0.38), cx + int(TILE * 0.38)):
+ draw.ellipse((wx - 12, body_y + cart_h - 4, wx + 12, body_y + cart_h + 20), outline=(42, 29, 21, 138), width=3)
+ for offset in (-int(TILE * 0.22), 0, int(TILE * 0.23)):
+ draw.rectangle(
+ (cx + offset - int(TILE * 0.12), body_y - cart_h - int(TILE * 0.14), cx + offset + int(TILE * 0.1), body_y - cart_h + int(TILE * 0.02)),
+ fill=(153, 107, 55, 124),
+ )
+
+
+def draw_wuzhang_beacon(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
+ cx, cy = center(x, y)
+ base_y = cy + int(TILE * 0.28)
+ top_y = cy - int(TILE * 0.28)
+ draw.line((cx - int(TILE * 0.22), base_y, cx - 5, top_y), fill=(61, 43, 30, 150), width=max(4, TILE // 28))
+ draw.line((cx + int(TILE * 0.22), base_y, cx + 5, top_y), fill=(61, 43, 30, 150), width=max(4, TILE // 28))
+ draw.line((cx - int(TILE * 0.28), cy, cx + int(TILE * 0.28), cy - 5), fill=(128, 88, 48, 132), width=max(4, TILE // 34))
+ draw.rectangle((cx - 18, top_y - 8, cx + 18, top_y + 14), fill=(88, 55, 32, 166))
+ draw.polygon(
+ [(cx - 18, top_y - 8), (cx - 6, top_y - 38), (cx + 4, top_y - 14), (cx + 16, top_y - 42), (cx + 18, top_y - 4)],
+ fill=(222, 103, 33, 170),
+ )
+ draw.polygon([(cx - 6, top_y - 5), (cx + 1, top_y - 26), (cx + 8, top_y - 5)], fill=(244, 181, 70, 152))
+ for offset in (-26, -2, 24):
+ draw.arc((cx + offset - 18, top_y - 74, cx + offset + 48, top_y - 18), 205, 340, fill=(77, 72, 58, 54), width=2)
+
+
+def add_wuzhang_final_details(canvas: Image.Image, terrain: list[list[str]]) -> None:
+ details = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ draw = ImageDraw.Draw(details)
+ sharp = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
+ sharp_draw = ImageDraw.Draw(sharp)
+
+ for y, row in enumerate(terrain):
+ for x, kind in enumerate(row):
+ left, top, right, bottom = tile_box(x, y)
+ cx, cy = center(x, y)
+
+ if kind in {"hill", "cliff"} and RNG.random() < 0.7:
+ for _ in range(3):
+ px = RNG.randint(left + 10, right - 12)
+ py = RNG.randint(top + 12, bottom - 12)
+ draw.line(
+ (px - RNG.randint(12, 42), py + RNG.randint(-6, 8), px + RNG.randint(22, 58), py + RNG.randint(-10, 10)),
+ fill=(73, 63, 46, RNG.randint(34, 62)),
+ width=RNG.choice([2, 2, 3]),
+ )
+
+ if kind == "road" and (y >= 78 or x <= 70) and RNG.random() < 0.62:
+ for _ in range(2):
+ py = cy + RNG.randint(-30, 30)
+ draw.line(
+ (left + RNG.randint(10, 26), py, right - RNG.randint(10, 26), py + RNG.randint(-7, 7)),
+ fill=(91, 67, 44, RNG.randint(40, 68)),
+ width=RNG.choice([2, 3]),
+ )
+ if RNG.random() < 0.28:
+ for step in range(3):
+ px = left + 24 + step * int(TILE * 0.24) + RNG.randint(-6, 6)
+ py = cy + RNG.randint(-18, 22)
+ draw.ellipse((px - 4, py - 2, px + 5, py + 3), fill=(59, 45, 33, 54))
+
+ if kind == "river":
+ for _ in range(3):
+ sx = RNG.randint(left + 12, max(left + 13, right - 58))
+ sy = RNG.randint(top + 16, bottom - 16)
+ draw.arc(
+ (sx, sy - RNG.randint(6, 16), sx + RNG.randint(38, 86), sy + RNG.randint(10, 22)),
+ 192,
+ 350,
+ fill=(150, 178, 181, RNG.randint(32, 62)),
+ width=RNG.choice([2, 2, 3]),
+ )
+
+ if kind == "forest" and (x >= 76 or y >= 82 or RNG.random() < 0.2):
+ for _ in range(2):
+ bx = cx + RNG.randint(-52, 52)
+ by = cy + RNG.randint(-44, 44)
+ draw.polygon(organic_ellipse_points(bx, by, RNG.randint(22, 46), RNG.randint(14, 32), 12, 0.32), fill=(15, 44, 30, RNG.randint(40, 70)))
+ if RNG.random() < 0.38:
+ draw.line(
+ (cx + RNG.randint(-22, 20), cy + RNG.randint(8, 42), cx + RNG.randint(-18, 24), cy + RNG.randint(34, 62)),
+ fill=(58, 39, 25, 86),
+ width=RNG.randint(4, 7),
+ )
+
+ if kind in {"camp", "fort"} and RNG.random() < 0.45:
+ for _ in range(2):
+ px = RNG.randint(left + 22, right - 24)
+ py = RNG.randint(top + 26, bottom - 22)
+ draw.rectangle((px - 10, py - 7, px + 12, py + 8), fill=(137, 91, 47, RNG.randint(84, 122)))
+ if (x + y) % 7 == 0:
+ pole_x = cx + RNG.randint(-36, 36)
+ pole_y = cy + RNG.randint(-34, 22)
+ sharp_draw.line((pole_x, pole_y + 34, pole_x, pole_y - 36), fill=(70, 47, 29, 152), width=max(3, TILE // 40))
+ sharp_draw.polygon(
+ [(pole_x + 4, pole_y - 34), (pole_x + 42, pole_y - 22), (pole_x + 4, pole_y - 10)],
+ fill=(174, 58, 45, 148),
+ )
+
+ if kind == "village" and RNG.random() < 0.58:
+ lx = cx + RNG.randint(-36, 36)
+ ly = cy + RNG.randint(-22, 28)
+ draw.ellipse((lx - 9, ly - 9, lx + 9, ly + 9), fill=(229, 168, 76, 90))
+ draw.arc((lx - 18, ly - 42, lx + 28, ly + 10), 205, 330, fill=(84, 74, 55, 44), width=2)
+
+ for start, end, width in [
+ ((14, 103), (25, 101), 120),
+ ((25, 101), (46, 98), 118),
+ ((36, 104), (64, 96), 108),
+ ((64, 96), (90, 84), 96),
+ ]:
+ paint_path_segment(draw, center(*start), center(*end), width, (190, 154, 91), 52, 14)
+ paint_path_segment(draw, center(*start), center(*end), max(34, width // 2), (226, 190, 123), 30, 9)
+
+ for marker in [(20, 102), (38, 96), (46, 98), (64, 64), (86, 74)]:
+ draw_wuzhang_ledger_tent(sharp_draw, *marker)
+
+ for marker in [(14, 103), (25, 101), (36, 104), (57, 101), (63, 106)]:
+ draw_wuzhang_supply_cart(sharp_draw, *marker)
+
+ for beacon in [(84, 78), (90, 84), (103, 33), (121, 34), (124, 52)]:
+ draw_wuzhang_beacon(sharp_draw, *beacon)
+
+ canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.16)))
+ canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.035)))
+
+
def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) -> Image.Image:
width, height = canvas.size
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
@@ -1415,6 +1589,8 @@ def build_map(config: dict[str, object]) -> Image.Image:
add_route_and_battlefield_details(canvas, terrain, units)
if config.get("detail_profile") == "weishui-camps":
add_weishui_camps_details(canvas, terrain)
+ elif config.get("detail_profile") == "wuzhang-final":
+ add_wuzhang_final_details(canvas, terrain)
return add_subtle_grid_and_grade(canvas, terrain)
diff --git a/src/assets/images/battle/sixty-sixth-battle-map.webp b/src/assets/images/battle/sixty-sixth-battle-map.webp
index d37e1f6..5bb05d3 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