diff --git a/CHANGELOG.md b/CHANGELOG.md
index 140f8ace..20e0b8f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,7 +16,7 @@
- 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。
- AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。
-- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带原创的系列通用要塞图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。
+- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,沿用 0.2.x 的薄荷绿城堡并精修,精简包也自带该图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(#77、PR #408,感谢 @Siornya)。这是复刻版便捷功能。
- 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。
diff --git a/docs/ASSETS.md b/docs/ASSETS.md
index 94635acf..0d934e25 100644
--- a/docs/ASSETS.md
+++ b/docs/ASSETS.md
@@ -8,15 +8,17 @@ The original project icons below are shipped static files, not downloaded game a
## Original project application icons (#413)
-`public/icons/app.svg` is the original vector master for the **卫戍协议 series**, independent of any season.
-Three battlements lead into a shield-shaped wall around one square tactical cell: defending a position on a board.
-The only colours are charcoal `#242424` and off-white `#f2f2f2`; the mark contains no text, gradients or fine texture.
-It contains no 「盟约」 lettering, Alliance badge or season-exclusive colours / elements.
+By the maintainer's revised decision of 2026-10-10, **沿用 0.2.x 的薄荷绿城堡并精修**.
+`public/icons/app.svg` retains the existing mint rook's three battlements, inset tower and splayed base.
+The selected refinement (B) narrows the tower, balances the battlements, reduces the base thickness and adds breathing
+room. On the 512 px canvas the mark spans x=128–384 and y=88–424; the tower is 176 px wide and the base is 32 px thick.
+The original colours remain mint `#4ed8af` and dark `#0c0f0e`, with no text, gradients, gloss or texture.
-**Originality statement (2026-10-10):** the SVG geometry was drawn from basic rectangles and polygons for this
-project. No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn to make it.
+**Provenance / originality statement (2026-10-10):** this is a proportion refinement of this project's existing
+`public/icons/app.svg` from before `ba5b998a`, expressed as basic rectangles and polygons.
+No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn for this refinement.
The vector and exports are project-owned work under the repository's GPL-3.0-or-later licence, separate from the
-Hypergryph / Yostar game assets below. The abstract fortification / tactical-board idea does not assert official branding.
+Hypergryph / Yostar game assets below. The castle mark does not assert official branding.
Rebuild offline with Python 3 and Pillow (the same image library used by local-extract):
@@ -28,6 +30,8 @@ python3 tools/export-app-icons.py --out /tmp/stronghold-icons
The script reads only the master SVG's flat rectangle / polygon geometry, renders at 8× and downsamples; unsupported
SVG elements fail rather than silently disappearing. It needs no game art, fonts, browser or network.
+ICO frames use the same native-size renders as the PNG favicons, with explicit 24-bit DIBs and opaque 1-bit masks
+whose rows are padded to 4 bytes. This avoids malformed transparency masks in Pillow's RGB ICO export.
| Files in `public/icons/` | Use |
|---|---|
@@ -37,7 +41,7 @@ SVG elements fail rather than silently disappearing. It needs no game art, fonts
| `apple-touch-icon.png` | 180 px home-screen icon |
| `app.svg` | Scalable favicon and editable source |
-The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 34.8% of the canvas
+The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 36.1% of the canvas
width from that centre; even antialiased edge pixels stay inside the **40%-radius safe circle** specified by
[Web Application Manifest §2.3](https://www.w3.org/TR/appmanifest/#icon-masks).
The background remains opaque to every edge; launcher masks may remove the background outside the safe zone.
@@ -46,8 +50,9 @@ favicons and Apple icon; `public/manifest.json` lists all four install icons. Al
and included by the full and lite package allowlist; no changes to `data/assets.json` are needed.
`test/ui/pwa-install-023.test.js` checks decoded pixels, mask safety and references; `test/package.test.js` checks
-packaging inclusion. The local handoff holds old/new light/dark, small-size and phone-layout previews with their
-render script. These are offline review renders, not evidence of browser or native OS installation.
+packaging inclusion. The local handoff `claude-review-023/icon-v2/` holds the original and A/B/C SVGs, light/dark
+256 px and 16/32/48/96 px comparisons, and phone-layout / mask previews with their render script.
+These are offline review renders, not evidence of browser or native OS installation.
## Running
diff --git a/docs/design/client.md b/docs/design/client.md
index 40abede9..487d43e2 100644
--- a/docs/design/client.md
+++ b/docs/design/client.md
@@ -115,8 +115,11 @@ refused recovery can return to the previous identity and nickname. It does not i
Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
-`manifest.json` enables browser installation. The original season-independent fortress / shield mark in
-`public/icons/app.svg` uses neutral greys and no official art, logos, icons, fonts or season-specific insignia.
+`manifest.json` enables browser installation. The maintainer's revised decision of 2026-10-10 is to
+**沿用 0.2.x 的薄荷绿城堡并精修**: `public/icons/app.svg` keeps the existing three-battlement rook, inset tower
+and splayed base in mint `#4ed8af` on dark `#0c0f0e`. Refinement B narrows the tower, balances the battlements,
+thins the base and increases breathing room. It has no lettering, gradients or gloss and uses no official art,
+logos, icons or fonts.
`tools/export-app-icons.py` exports 192/512 px PNGs for `any` and separate opaque `maskable` icons: the latter scale
only the mark to 87.5%, keeping every foreground pixel inside the central circle of radius 40% of the canvas.
The same master supplies 16/32/48 px PNG / ICO favicons and the 180 px Apple touch icon linked from `index.html`.
diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md
index ed3510da..f695f7a1 100644
--- a/docs/history/0.2.3.md
+++ b/docs/history/0.2.3.md
@@ -275,19 +275,27 @@ Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appe
standalone/fullscreen and installed sessions hide them. Accept, dismiss, failure and duplicate clicks all consume the
same offer once. See [the prompt API](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt)
and [installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
-The 2026-10-10 icon revision replaces the previous mint rook with an original, season-independent fortress / shield
-and defended square. Its neutral grey palette and geometry use no official logos, artwork, icons or fonts and no
-Alliance lettering or seasonal insignia. `public/icons/app.svg` is the master; `tools/export-app-icons.py` exports
+The maintainer revised the icon direction on 2026-10-10: **沿用 0.2.x 的薄荷绿城堡并精修**.
+The selected B refinement keeps the previous mint rook's three battlements, inset tower and splayed base,
+with a narrower tower, balanced battlements, thinner base and more breathing room. It retains mint `#4ed8af`
+on dark `#0c0f0e`, without text, gradients, gloss or official logos, artwork, icons or fonts.
+The source is the project's `public/icons/app.svg` from before `ba5b998a`; the local `icon-v2` review includes
+the original and A/B/C proposals at large and small sizes, on light/dark backgrounds and simulated home screens.
+`public/icons/app.svg` is the selected B master; `tools/export-app-icons.py` exports
192/512 px `any` and separate opaque `maskable` PNGs (the mark stays inside the central 40%-radius safe circle),
16/32/48 px PNG / ICO favicons and a 180 px Apple touch icon. All ship outside optional game art, including lite
packages. See ASSETS.md for the source, reproduction command and originality statement.
+The offline revision also corrects the ICO writer's transparency masks: each 16/32/48 px frame now uses its own
+native-size render and 4-byte-aligned opaque mask rows. Decoding previously exposed transparent holes in the 48 px
+frame; the regression checks mask layout / opacity and compares every ICO colour pixel with its PNG counterpart.
No service worker or offline cache is installed; the server is still required.
The lobby keeps Settings immediately before How to Play even when the optional install/recovery buttons appear;
the full browser run caught and corrected an initial insertion between that established pair.
The settings-entry check tests the absent board-style/background-music option labels, allowing the text-size help
to explain that the board keeps its scale.
-Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / maskable safe zone, favicon references and packaging inclusion.
+Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / mint palette / maskable safe zone,
+three distinct battlements and an inset tower at 16/32/48 px, favicon references and packaging inclusion.
The initial feature also had real Chrome manifest and installability checks, and a synthetic
dismissed offer through the actual button. Initial incognito rejection was a test-environment limitation; the real
installability check runs in Chrome's ordinary temporary profile. Native OS installation itself is not performed.
diff --git a/public/icons/app-192.png b/public/icons/app-192.png
index 4db28a88..2639b4e3 100644
Binary files a/public/icons/app-192.png and b/public/icons/app-192.png differ
diff --git a/public/icons/app-512.png b/public/icons/app-512.png
index 6facc901..30d1f10f 100644
Binary files a/public/icons/app-512.png and b/public/icons/app-512.png differ
diff --git a/public/icons/app-maskable-192.png b/public/icons/app-maskable-192.png
index 28a1338f..f0587b5d 100644
Binary files a/public/icons/app-maskable-192.png and b/public/icons/app-maskable-192.png differ
diff --git a/public/icons/app-maskable-512.png b/public/icons/app-maskable-512.png
index 9752fb4e..207b7bd8 100644
Binary files a/public/icons/app-maskable-512.png and b/public/icons/app-maskable-512.png differ
diff --git a/public/icons/app.svg b/public/icons/app.svg
index eebb6f02..3680a014 100644
--- a/public/icons/app.svg
+++ b/public/icons/app.svg
@@ -1,10 +1,8 @@
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
index 066f6cdc..9165f095 100644
Binary files a/public/icons/apple-touch-icon.png and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/favicon-16.png b/public/icons/favicon-16.png
index caf86ffd..94fbf4f2 100644
Binary files a/public/icons/favicon-16.png and b/public/icons/favicon-16.png differ
diff --git a/public/icons/favicon-32.png b/public/icons/favicon-32.png
index 4d0f5fbe..7bc3815e 100644
Binary files a/public/icons/favicon-32.png and b/public/icons/favicon-32.png differ
diff --git a/public/icons/favicon-48.png b/public/icons/favicon-48.png
index 22cac1e4..0b84e371 100644
Binary files a/public/icons/favicon-48.png and b/public/icons/favicon-48.png differ
diff --git a/public/icons/favicon.ico b/public/icons/favicon.ico
index 1b3ebeee..dc41051c 100644
Binary files a/public/icons/favicon.ico and b/public/icons/favicon.ico differ
diff --git a/test/ui/pwa-install-023.test.js b/test/ui/pwa-install-023.test.js
index dad758cd..9d0e1c0a 100644
--- a/test/ui/pwa-install-023.test.js
+++ b/test/ui/pwa-install-023.test.js
@@ -43,7 +43,7 @@ test('installed / standalone hides stale offers; disposing removes listeners', a
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
-test('manifest provides opaque 192/512 icons for both purposes with a safe maskable mark', () => {
+test('manifest provides opaque mint-rook 192/512 icons for both purposes with a safe maskable mark', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
assert.deepEqual(m.icons.map((i) => `${i.purpose}:${i.sizes}`).sort(),
@@ -55,13 +55,14 @@ test('manifest provides opaque 192/512 icons for both purposes with a safe maska
const size = Number(icon.sizes.split('x')[0]);
const { w, h, rgba } = decodePng(bytes);
assert.equal(w, size); assert.equal(h, size);
- let foreground = 0;
+ let foreground = 0, solidMint = 0;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
assert.equal(rgba[i + 3], 255, 'opaque edge-to-edge');
- assert.equal(rgba[i], rgba[i + 1], 'season-independent neutral palette');
- assert.equal(rgba[i], rgba[i + 2]);
- if (rgba[i] === 36) continue; // #242424 background
+ const [r, g, b] = rgba.subarray(i, i + 3);
+ if (r === 12 && g === 15 && b === 14) continue; // Original #0c0f0e background
+ assert.ok(g >= b && b > r, 'mint palette, including antialiased edges');
+ if (r === 78 && g === 216 && b === 175) solidMint++; // Original #4ed8af
foreground++;
if (icon.purpose === 'maskable') {
// W3C safe zone is a circle, not merely a centred 80% square.
@@ -70,6 +71,7 @@ test('manifest provides opaque 192/512 icons for both purposes with a safe maska
}
}
assert.ok(foreground > size * size * 0.12, 'the exported mark is not empty or tiny');
+ assert.ok(solidMint > size * size * 0.12, 'the original flat mint identity is retained');
}
});
@@ -86,7 +88,7 @@ test('favicon and Apple links resolve to real sizes, including all legacy ICO fr
assert.ok(link.includes(`sizes="${w}x${h}"`), href);
} else if (href.endsWith('.svg')) {
assert.match(bytes.toString(), /viewBox="0 0 512 512"/);
- assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject)\b|(?:href|font-family)=/);
+ assert.doesNotMatch(bytes.toString(), /<(?:image|text|use|foreignObject|linearGradient|radialGradient|filter)\b|(?:href|font-family)=/);
} else {
assert.equal(bytes.readUInt16LE(2), 1);
assert.equal(bytes.readUInt16LE(4), 3);
@@ -95,11 +97,37 @@ test('favicon and Apple links resolve to real sizes, including all legacy ICO fr
assert.equal(bytes[entry], size); assert.equal(bytes[entry + 1], size);
const length = bytes.readUInt32LE(entry + 8), offset = bytes.readUInt32LE(entry + 12);
assert.ok(length > 0 && offset >= 54 && offset + length <= bytes.length);
+ // Decode the legacy 24-bit DIB and its 1-bit transparency mask. Both row
+ // types need 4-byte alignment; malformed masks can punch holes in an icon.
+ assert.equal(bytes.readUInt32LE(offset), 40);
+ assert.equal(bytes.readInt32LE(offset + 4), size);
+ assert.equal(bytes.readInt32LE(offset + 8), size * 2);
+ assert.equal(bytes.readUInt16LE(offset + 14), 24);
+ assert.equal(bytes.readUInt32LE(offset + 16), 0);
+ const colorStride = Math.ceil(size * 3 / 4) * 4, maskStride = Math.ceil(size / 32) * 4;
+ assert.equal(length, 40 + (colorStride + maskStride) * size, `${size}px aligned ICO frame`);
+ const mask = bytes.subarray(offset + 40 + colorStride * size, offset + length);
+ assert.ok(mask.every((byte) => byte === 0), `${size}px ICO is fully opaque`);
+ const { rgba } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
+ const bmp = offset + 40 + (size - 1 - y) * colorStride + x * 3;
+ const png = (y * size + x) * 4;
+ for (let c = 0; c < 3; c++) assert.equal(bytes[bmp + 2 - c], rgba[png + c], `${size}px ICO matches PNG`);
+ }
}
}
}
for (const size of [16, 32, 48]) {
- const { w, h } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
+ const { w, h, rgba } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url)));
assert.equal(w, size); assert.equal(h, size);
+ // At actual favicon sizes, the three battlements must stay separated and the
+ // tower must remain narrower than its base. Sample visible raster features.
+ const row = (y) => Array.from({ length: w }, (_, x) => rgba[(Math.floor(y * h) * w + x) * 4 + 1] > 100);
+ const runs = (pixels) => pixels.filter((on, x) => on && !pixels[x - 1]).length;
+ const crown = row(0.19), tower = row(0.56), base = row(0.79);
+ assert.equal(runs(crown), 3, `${size}px battlement gaps stay open`);
+ assert.equal(runs(tower), 1, `${size}px tower stays connected`);
+ assert.equal(runs(base), 1, `${size}px base stays connected`);
+ assert.ok(tower.filter(Boolean).length < base.filter(Boolean).length, `${size}px inset tower remains legible`);
}
});
diff --git a/tools/export-app-icons.py b/tools/export-app-icons.py
index 58d08366..d2284a0d 100644
--- a/tools/export-app-icons.py
+++ b/tools/export-app-icons.py
@@ -6,7 +6,9 @@ only this source's flat rect/polygon vocabulary; unsupported SVG fails loudly.
No fonts, game art, browser, network or platform image service is involved.
"""
import argparse
+from io import BytesIO
from pathlib import Path
+import struct
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw
@@ -54,6 +56,26 @@ def render(size, maskable=False):
return image.resize((size, size), Image.Resampling.BOX)
+def write_ico(path):
+ # Native-size renders preserve the same pixels as each PNG favicon. Write
+ # the DIBs explicitly: Pillow's RGB ICO writer does not pad 1-bit mask rows
+ # to 4 bytes, and can reuse the last size's mask for every smaller frame.
+ sizes = (16, 32, 48)
+ directory, frames = bytearray(), bytearray()
+ offset = 6 + 16 * len(sizes)
+ for size in sizes:
+ dib = BytesIO()
+ render(size).save(dib, format="DIB")
+ frame = bytearray(dib.getvalue())
+ # ICO DIB height includes both the colour bitmap and the AND mask.
+ struct.pack_into("