From 904212543053247e641294ed23daf5e5b6df9f96 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 10 Oct 2026 02:11:23 +0800 Subject: [PATCH] =?UTF-8?q?0.2.3:=20=E9=87=8D=E7=BB=98=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E5=AE=89=E8=A3=85=E5=9B=BE=E6=A0=87=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=E5=AE=A1=E6=A0=B8=E5=86=B3?= =?UTF-8?q?=E5=AE=9A=EF=BC=88#413=E3=80=81#77/#408=E3=80=81#444=E3=80=81#4?= =?UTF-8?q?07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +- docs/ASSETS.md | 48 ++++++++++++++++++- docs/design/client.md | 9 +++- docs/design/overview.md | 4 ++ docs/history/0.2.3.md | 20 +++++++- public/icons/app-192.png | Bin 886 -> 1254 bytes public/icons/app-512.png | Bin 2562 -> 3302 bytes public/icons/app-maskable-192.png | Bin 0 -> 1366 bytes public/icons/app-maskable-512.png | Bin 0 -> 3150 bytes public/icons/app.svg | 11 ++++- public/icons/apple-touch-icon.png | Bin 0 -> 1356 bytes public/icons/favicon-16.png | Bin 0 -> 308 bytes public/icons/favicon-32.png | Bin 0 -> 404 bytes public/icons/favicon-48.png | Bin 0 -> 569 bytes public/icons/favicon.ico | Bin 0 -> 11790 bytes public/index.html | 6 ++- public/manifest.json | 4 +- test/package.test.js | 12 +++++ test/ui/followup-023.e2e.test.js | 14 ++++++ test/ui/pwa-install-023.test.js | 55 +++++++++++++++++++++- tools/export-app-icons.py | 75 ++++++++++++++++++++++++++++++ 21 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 public/icons/app-maskable-192.png create mode 100644 public/icons/app-maskable-512.png create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/favicon-16.png create mode 100644 public/icons/favicon-32.png create mode 100644 public/icons/favicon-48.png create mode 100644 public/icons/favicon.ico create mode 100644 tools/export-app-icons.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2036731..140f8ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ - 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。 - 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。 - AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。 -- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带安装图标(PR #413,感谢 @Cromemadnd)。 -- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(PR #408,感谢 @Siornya)。这是复刻版便捷功能。 +- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带原创的系列通用要塞图标、maskable 安全区版本和 favicon(PR #413,感谢 @Cromemadnd)。 +- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(#77、PR #408,感谢 @Siornya)。这是复刻版便捷功能。 - 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。 ## 0.2.2 — 2026-10-09 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 303f48eb..94635acf 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -2,8 +2,52 @@ Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`. -All art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored. -Everything the client needs is listed in **`data/assets.json`**. The client should only request URLs that appear in that manifest. +All official game art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored. +All downloaded game assets the client needs are listed in **`data/assets.json`**. Game-art requests must use that manifest. +The original project icons below are shipped static files, not downloaded game assets. + +## 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. + +**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. +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. + +Rebuild offline with Python 3 and Pillow (the same image library used by local-extract): + +```bash +python3 tools/export-app-icons.py +# Optional review export without replacing shipped files: +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. + +| Files in `public/icons/` | Use | +|---|---| +| `app-192.png`, `app-512.png` | Manifest `purpose: any`, 192 / 512 px | +| `app-maskable-192.png`, `app-maskable-512.png` | Manifest `purpose: maskable`, opaque full-bleed background | +| `favicon-16.png`, `favicon-32.png`, `favicon-48.png`, `favicon.ico` | Tab / legacy favicon; ICO contains 16 / 32 / 48 px frames | +| `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 +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. +Normal icons and favicons retain the larger mark for readability. `public/index.html` links the SVG / ICO / PNG +favicons and Apple icon; `public/manifest.json` lists all four install icons. All these static files must be tracked +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. ## Running diff --git a/docs/design/client.md b/docs/design/client.md index 572ff17e..40abede9 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -115,8 +115,13 @@ 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; its original fortress favicon is packaged as actual 192/512 px icons in -`public/icons/`, including lite packages. The install button appears only for a live browser install offer and hides +`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. +`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`. +These project-owned assets live in `public/icons/` and ship in full and lite packages; ASSETS.md records their provenance. +The install button appears only for a live browser install offer and hides in standalone/fullscreen windows or after installation. Every offer is consumed before awaiting the prompt, including cancellation or failure. No service worker, offline cache or persistent server is introduced; HTTPS or localhost and browser support still govern installation. diff --git a/docs/design/overview.md b/docs/design/overview.md index f00b972f..875b7e69 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -15,6 +15,10 @@ In scope: - Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker. - Reconnect, AI take-over of disconnected players, robust validation of every client intent. +New features require explicit maintainer approval before implementation. The 2026-10-10 review approved retaining +the existing PWA installation, unanimous opening reroll, teammate emotes and cooperative purchasing heuristic +(DESIGN §28.15, §28.16, §28.19, §28.21); this is not blanket approval for future additions. + Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the shared pool — 0.2.0 plays them as 自选编队: the picks of the 干员调配 overlay's 自选编队 tab, each sold only in its player's shop; DATA.md §18, shared/diy.js, server/match/player/diy.js), trophies/progression persistence, reporting. --- diff --git a/docs/history/0.2.3.md b/docs/history/0.2.3.md index a39d971e..ed3510da 100644 --- a/docs/history/0.2.3.md +++ b/docs/history/0.2.3.md @@ -215,6 +215,8 @@ English overflow; the corrected browser suite passes. Current behavior is in des ### 28.15 Cooperative card purchasing — PR #407 +维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。 + Adapted from @IceCodeNew. [ASSUMED] A tier V/VI shared-pool card matching another living teammate's active opening bond receives one 18-point buying-score penalty. It is a soft preference, not reserved stock; normal/elite and DIY stock semantics remain unchanged. Humans and bots receive the same cooperation. This does not add persona presets. @@ -225,6 +227,8 @@ measurement of the purchasing heuristic. Current rule: design/match.md §6.6 and ### 28.16 AI teammate emotes — PR #444 +维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。 + Event wiring from @DDDarkstar, with corrected dispatch semantics. [ASSUMED] Cosmetic reactions are enabled only in mixed human/AI matches, under the existing emote whitelist and cooldown; `SP_BOT_EMOTES=0` disables all hooks. Responses skip cooling bots and stop after one reply. MVP uses this round's kills and damage, with stable order for @@ -265,20 +269,30 @@ a real phone-sized browser changes the control, reloads and resets it. Current c ### 28.19 Browser installation — PR #413 +维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。 + Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appear only after a browser install offer; 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 existing original fortress favicon supplies correctly sized 192/512 px icons, shipped outside optional game art. +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 +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. 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, actual PNG dimensions, real Chrome manifest and installability checks, and a synthetic +Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / maskable safe zone, 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. +The icon revision is verified offline; browser / OS validation of these replacement assets is deferred to the follow-up +review environment (this sandbox cannot listen on a port). ### 28.20 Recover a closed local match window — #431 @@ -294,6 +308,8 @@ the match tab. The existing identity/static suite also passes. Current contract: ### 28.21 Unanimous opening reroll — #77 / PR #408 +维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。 + Adapted from @Siornya, excluding the unrelated result-screen redesign. [ASSUMED] This remake feature is host-proposed, requires every connected human's consent, and pauses confirmation while voting. Bots/spectators do not vote; autoplay humans still do. Single-human matches refresh immediately. Refusal, cancellation, disconnect or leave restores the diff --git a/public/icons/app-192.png b/public/icons/app-192.png index 7dfdf60011813b1e3aa1be900e5a76b3fab2ae56..4db28a8859cfb2bc51644f8c0d86504261524e97 100644 GIT binary patch literal 1254 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 diff --git a/public/icons/app-maskable-192.png b/public/icons/app-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..28a1338f29cecc52deb00dc13aeb9e7c50719d4b GIT binary patch literal 1366 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfmPPi#WAE}&fB}T-pbhwZ4b-+ z`A&$f+``48;}pcn{7aZAOl%u-89S?X_ZP#dzBwY@8S)!G@h|DS8q&02YN!^oQ$mwd zf+K%X#J3$YD~)b`f4uLNitypH#w|XNwnwc^YuNMeRIPtQ!-Awn1w{o94h|uvrj`ae zGk@NWSQfR`EpW>3caJ|-Sje2K|ET1@z!?t#Ib~ZL=>VKzg{`vR!_dD&o_wKE& zt1~m(sJB3)%WAIQyWQ%ZlQb9Hy?b}=+_`t!Uw^Hd$z)|^wJJnwC;#^S`}3n6<{ZN3 zo4Q&tHLdBVqobog->)kz4YiC}8g#N`$JD8!kHzmZt-Aht`t<4E)_2ZX3mf<)P7n}K zVPWB9baZkclG&Mm-KGEdt4g1VIi~ac5d=&AUJ#0_pu;`!Ta|1 zp4Yt`erNgRmk9p>$CkIQ;bf2 znUduH`lIoJ8s1bcjmbCeSJ~LK-YnDW-n;Jj^M;%ZjcG?QI+!ZEc&{CEV%NiN`_;_jy5qP zSLs&Xe&Me>1OpcGKApG7sr|S`P`@#w=&gW0O|6oO9_z0=L;^1Ip0#v!{KA-!i>2>+bpFe+o{km0gaB@S; z%tc40Km2H{FnhVi*X)WtyeH0SdhL-0hX1DYt|0&Rdva%P|ALM)&fK5Yx6j(& zT4%5I?S1ySxIb>Mlj8-%xMlS zm6Z|~xfd9H_DY^94dqYpKO1fAG`AW`QQBWoe+!#yd*;6#mrpo1DYXfvr|M`Nik@j3 zo1dRAVxl7>BSnH>a(^-ez12qlDXgp{tOm(~mFAh|6t&uv6fFPXU}KhxtBbZ6@6`5+w!fe7eh;byS%s5 zy?({NEI;Hve{TAxOY1N-v~6p-e?Ql3T1=DXo$(FHw0s}~_qOW?7EN;s!P4ux-cpZb zUhe!zLSu5L!H{;Egg%Qp6V7uyMYIgU{s9E;@4?FNU)P1t=-7EDMX6BnKV&wwHMk$T zm9GR861x82!Z0tMa@zO{PcY?-97}f3&?>6;H`GE*B5<;>5AJb~VQxgG}u!bv)>?cZW@!FHANw z5@dJ4G|!IehYhqX@293fkNfEcqmN3rka$%B)BHnJP1wMKZt z8Z$&y5s#`Z2@N6~ft%maFR#K81=1=C;kz!PQEp{zTo)k%7mlOJ?Kumx%i2!Mzn`)@ z7pJT3oZW93l3uLOw75aXpMaApP4c7^x@#<$ls&M+m0gF7hjbF)mJS%s_TiPbs>=vG zbOSQ3>Tw9asuffz8Ro$KJKUN1CXHOl$Tbeh3bC_anYS>uv(t3P;N0~y73|12FV5`j zFis3q(cg^KT;{)neTFFO1n6NkD$ovA9dE&?JJBIdr^fK%*~ z^(j$MkpeZXk5~PZY^Dz4xcpl54%u8&2%~zI5XM4pA|S4QaEV$sGfV|>eQrVGZmEA0 z2(pV;8L)}iVs?vhax8NV6%79DW~37Zg9@DL7E36Y(xt5>f!}HHc(zgINkC4Eu|^$E zgPQJ4yJZ<=FbQlY#6f~d#U2i}UyooadErJt>c#n1w1I#ORApL`w-ser5tV>wHso1R zkQIGpMGOK06$fH6D(=E@flVwVV%ppW>-#I}kZ!4(1B!VvOf8B+Dk!*n4HHM&M#bB4isNS7#1zKNZUW*Sel13=;sDmfDl3&o1*zFZR_cc&AWO`}vQ}Mz z(Yqy5Y&p}Z5UqZ*HP`Dz@L1b`5!e@C)HEA_#XZFV7e+%dFl+@h4B!*NuyryOqWnwU zEmBCrmN6~m8`UDEz>=r&usciv-~c9zw|7(c^8@mBFDUO;S~TjZ0D`(S2xIP}YV1D& N#6-nKHvFA??mrnPM&JMd literal 0 HcmV?d00001 diff --git a/public/icons/app.svg b/public/icons/app.svg index 2dab0e6c..eebb6f02 100644 --- a/public/icons/app.svg +++ b/public/icons/app.svg @@ -1 +1,10 @@ - + + Stronghold Protocol — fortress and defended square + Original geometric project mark. No official artwork, lettering or seasonal insignia. + + + + + + + diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..066f6cdc4e7de2759455dbf691e094d8885c59ff GIT binary patch literal 1356 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofmPVk#WAE}&fB~89?F>vZ6EWm zuV!*ea7a{`pfn-ug4Wt?8%=s!#a{ZPCr=mYYF#Qei8)(Wr)TZjsbOK4mwJgh3b3>` zvWnZ}<;UkAPf@!+|9LpqI z%SG|JRpG^TnTs|pmUedxPJ_hFXI zRIjH$e%$zEw@BmF_uoehnZ3Qe&AtonogTP0tatOBI@{yt?aIo^;^N}|{aJA^p`gBg z{!5p|7d5sR&FO!fUE6c->32=nxG4`m*tTyyuRl>gwL+-fw$?>%>O+4SvoC@_Lyw(# zQaxl;QLD^YgJNOiPfBo7upS-BG*Nz@-U7sqEyzgq(R>^tyB!bt5 z{r+1g;%aI8@9f#L|Ni~EeEG77ym8;-xb@S&UVnB+>u^{7sb{r{Vd3G+trgdPKlouE zkM^|1Gycvv)pEp=zb!O1VxvrJ>=aJ*B~w2q+z4xPJso(Xt|rmx(I581hm3Th`jqqo zmT_i2wU;QHmUy>FWT*UpYsEsv=EPjdYr5_S#a~b6%oH_}Fq@wEvPfi){C*#g#OA~| zdWTd zN#&Sq-_gV#&8c2<{gz+Nl5}5^`Rn`c$h{&a3oP!;nsDUc!2&zJ-|8x*Wd=1Jho5nJ z#>ny1KiZJ%6!e|_@M?=UGbbGBKKQ|)@1~!}tBVOgB#*CAQ~8)}P}8^g1gB@M4A1$; z8fi{K{_M&f7N2HMxbpfShiab$m6!4uqM~((g8N* zI~JS#JcRBgw8$Nw@8co$DxrqMyzjia3TK6Z9Z;-Zz2)E!2buq~COm(h&i0tO@Xz89 zt-bs9u~oM}d?->(@H{We zGC~LdrBqQA<2b%9oCN@ckUYG0hjX7jdq$EVVGG2?#kXwP0_PeT8IdG72!aR= zG&VLeFfed&aS;?G(*j>#Um^vEX-;OrFa6=ehyMP4JPII@LB0jwzJ1%gc{7A9ARqvV z)z4r+qy;1dQDI@>7BGm4iXvXuKu0n!FR!q$FfLgI1qFf@Fk;qdQc_aL1;B#`4^mT8 z-@bi|Y5MHhvyrVQwy1da?Afwq%g_u`S64??V`5^0W_nLg53xRgfRd6Dgwt@ yZ{AFvJo($VZ$#=M!2=G`P)JhV|?GTTCLXW_4QiU^;WADDzPk!v-}kyjB%}2yKfLn zo}QYf6^q5h*HEccavZ1A>738!>!JyQfN3)r3<`z9n-kja_kSBJm5Spy5jkCLLTAN@ z0R@D{czJI+{6y@gv zGd$OIncwXdA$aTr0Nd@>vaC!dW81c27|cGLHUdHr=|m|_r_+yC6~B#`5CBxG)o!=T ze=sG4*tWgdY?68pEf$M-7$YgXeja(wL*YLFm1F{_!JezCzFVCiIMNLhyuNyn4lztM-!-00000NkvXX Hu0mjf40r~| literal 0 HcmV?d00001 diff --git a/public/icons/favicon.ico b/public/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1b3ebeee8a9b58c6173766c6a524ac96e08a2f20 GIT binary patch literal 11790 zcmeI1`%6_>6vt0AKZwo@4O00Uz1fUP$!IDg21z!o$OZgM)vzNk~Wtw+2nZ!os$1-yRzq+uhxL^XAQ{s3>kR zI%^UT5KvH1Fg`y1k4>IDd6Jix=kM?D=nv$eprGdFW*k3#`gGyKg^rF6X;oWW8yFaf zgK7mxKR>_H(o!5|W@d;9e6l!o>eTG)EXJauA{9zA*#2mQc7FBU*D2V|6SCP7%m z$Rj=fG7z}oAa)c4yWXhAYgNjZbzuHonE`7ZvG7afr|QV}-=dDJYS>I?w;7Oi$U+ju z?KmqoWLdI?ckI|h_>r>s;K75FCr?t&$VJMHi_!FM!n44pN-r%fQAz){3B`9|Vd2`fYi=h( z@SQt%0=uxV5ZKi8SFc{Zc=3XIO`)fgxw$!vk_a0fz-Xd}XP~R#7cX9v4S;~JU%w_` zZ*Om3Umwc+{5(byA+MU68re8q=>nh7&`=<;RS=vwz$VVimoM3sVSD%P-Rz)m-n@Y# z=~s0@$_B7ij<|uGafAfI(#z1$(2*lY7z>%7OrG4_+((Zd5g7+kgEX?>sd0R!9LNtG zIPm=W^S5u`3UapIef#ziJTfx!H=B?GyV}8n2gk<77<6&tP?C_4Alsg^UL^jZLx(0N zCO}0OoN0)|`3BNpjEt}Ym6=J8nVFd&!2(yKqoc{m$u7p9oSc-bIYvZBM}vokSFc`W zk4KuCnwVPj2;h;C5rQM+LYk|J5515RA0ID+4t}U$qMkp09{dbbYHI4khY!~hAN1kd7n3}xo{;u3y1o^s+pRaq8H|8N=gblBUAn9)2A2_LeVM4!-o%x zCvO+LCnP2&8m}+bDXWI*O&4T=?BBmXA|isN%9XkyMk)rC1f!$pS4*gLK^7D`1Vx%~ z6^j_9f?;&@lt;!_;3-j_%7S8SR8LLC;|%b6V2S|)*7qpuCz=!d)O%E*`5xu*iE9Ed z888|6A2Sda7sr=Tj)}aKc|1+|J}u{Cj;c91Ii7+fw5XH1QanPGo)(2fDH}Jte|ma)e}BKiV5h7v zBxhit<=nY*DtEq6@JYoPQR@ZC85p5Ofl-P9QEP|9Khx}(ms<+5!538uHD4H4Y{-XB zh1?CLtE^sK)QGDp8hHNy5j+WLW^R6 zho|&z;?XmpA1{yala-al98 卫戍协议:盟约 · STRONGHOLD PROTOCOL - + + + + + diff --git a/public/manifest.json b/public/manifest.json index 816286f6..94e37dad 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -11,6 +11,8 @@ "theme_color": "#0c0f0e", "icons": [ { "src": "/icons/app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, - { "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" } + { "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/app-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/app-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } diff --git a/test/package.test.js b/test/package.test.js index 00409acf..73149f9c 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -20,6 +20,18 @@ const FAKE_HOME = ['', 'Users', 'someone'].join('/'); const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const TOOL = path.join(ROOT, 'tools', 'package.mjs'); + +test('all install and favicon assets ship independently of optional game art', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/manifest.json'), 'utf8')); + const html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'); + const urls = [...manifest.icons.map((i) => i.src), + ...[...html.matchAll(/]+href="([^"]+)"/g)].map((m) => m[1])]; + const files = [...new Set(['public/manifest.json', ...urls.map((url) => `public${url}`), + ...[16, 32, 48].map((size) => `public/icons/favicon-${size}.png`)])].sort(); + for (const f of files) assert.ok(fs.statSync(path.join(ROOT, f)).size > 0, f); + assert.deepEqual(selectTracked([...files, 'tools/export-app-icons.py', 'handoff/claude-review-023/icon/light.png']).keep, + files, 'full and lite share the same tracked static-file allowlist'); +}); // hermetic scans: not this machine's account name, but a planted one const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' }; diff --git a/test/ui/followup-023.e2e.test.js b/test/ui/followup-023.e2e.test.js index df4f0f3f..457cf795 100644 --- a/test/ui/followup-023.e2e.test.js +++ b/test/ui/followup-023.e2e.test.js @@ -170,6 +170,20 @@ describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => { const client = await page.createCDPSession(); const manifest = await client.send('Page.getAppManifest'); assert.deepEqual(manifest.errors, []); + const icons = await page.evaluate(async () => { + const m = await (await fetch('/manifest.json')).json(); + const entries = [...m.icons, ...[...document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]')] + .map((link) => ({ src: link.getAttribute('href'), purpose: link.rel }))]; + return Promise.all(entries.map(async (entry) => { + const response = await fetch(entry.src); + if (!response.ok) throw new Error(`${entry.src}: ${response.status}`); + const img = new Image(); img.src = entry.src; await img.decode(); + return { purpose: entry.purpose, width: img.naturalWidth, height: img.naturalHeight }; + })); + }); + assert.deepEqual(icons.filter((i) => i.purpose === 'maskable').map((i) => i.width).sort((a, b) => a - b), [192, 512]); + assert.ok(icons.every((i) => i.width > 0 && i.width === i.height)); + assert.ok(icons.some((i) => i.purpose === 'apple-touch-icon' && i.width === 180)); const install = await client.send('Page.getInstallabilityErrors'); assert.deepEqual(install.installabilityErrors, []); await page.evaluate(() => { diff --git a/test/ui/pwa-install-023.test.js b/test/ui/pwa-install-023.test.js index d183241a..dad758cd 100644 --- a/test/ui/pwa-install-023.test.js +++ b/test/ui/pwa-install-023.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createPwaInstall } from '../../public/js/pwa.js'; +import { decodePng } from '../../tools/crop-board-atlas.mjs'; function rig(standalone = false) { const win = new EventTarget(), display = new EventTarget(); display.matches = standalone; @@ -42,13 +43,63 @@ 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 uses actual 192/512 icons shipped outside optional game assets', () => { +test('manifest provides opaque 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(), + ['any:192x192', 'any:512x512', 'maskable:192x192', 'maskable:512x512']); for (const icon of m.icons) { assert.ok(icon.src.startsWith('/icons/')); + assert.equal(icon.type, 'image/png'); const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url)); const size = Number(icon.sizes.split('x')[0]); - assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size); + const { w, h, rgba } = decodePng(bytes); + assert.equal(w, size); assert.equal(h, size); + let foreground = 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 + foreground++; + if (icon.purpose === 'maskable') { + // W3C safe zone is a circle, not merely a centred 80% square. + const dx = x + 0.5 - w / 2, dy = y + 0.5 - h / 2; + assert.ok(dx * dx + dy * dy <= (size * 0.4) ** 2, `clipped mark at ${x},${y}`); + } + } + assert.ok(foreground > size * size * 0.12, 'the exported mark is not empty or tiny'); + } +}); + +test('favicon and Apple links resolve to real sizes, including all legacy ICO frames', () => { + const html = readFileSync(new URL('../../public/index.html', import.meta.url), 'utf8'); + const links = [...html.matchAll(/]+>/g)].map(([s]) => s); + assert.equal(links.length, 5); + for (const link of links) { + const href = link.match(/href="([^"]+)"/)[1]; + assert.ok(href.startsWith('/icons/'), href); + const bytes = readFileSync(new URL('../../public' + href, import.meta.url)); + if (href.endsWith('.png')) { + const { w, h } = decodePng(bytes); + 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)=/); + } else { + assert.equal(bytes.readUInt16LE(2), 1); + assert.equal(bytes.readUInt16LE(4), 3); + for (const [i, size] of [16, 32, 48].entries()) { + const entry = 6 + i * 16; + 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); + } + } + } + for (const size of [16, 32, 48]) { + const { w, h } = decodePng(readFileSync(new URL(`../../public/icons/favicon-${size}.png`, import.meta.url))); + assert.equal(w, size); assert.equal(h, size); } }); diff --git a/tools/export-app-icons.py b/tools/export-app-icons.py new file mode 100644 index 00000000..58d08366 --- /dev/null +++ b/tools/export-app-icons.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Export the original app.svg offline: python3 tools/export-app-icons.py. + +Requires Python 3 + Pillow (also used by local-extract). This deliberately renders +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 pathlib import Path +import xml.etree.ElementTree as ET + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "public/icons/app.svg" +MASK_SCALE = 0.875 + + +def render(size, maskable=False): + svg = ET.parse(SOURCE).getroot() + if svg.attrib != {"width": "512", "height": "512", "viewBox": "0 0 512 512"}: + raise ValueError("Expected the 512-square SVG master") + # Eight samples per output pixel, then area averaging; no ringing beyond the mark. + scale = size * 8 / 512 + image = Image.new("RGB", (size * 8, size * 8)) + draw = ImageDraw.Draw(image) + + def paint(element, inset=1): + tag = element.tag.rsplit("}", 1)[-1] + attrs = element.attrib + if tag in ("title", "desc"): + if attrs or len(element): + raise ValueError("Unsupported metadata") + return + if tag == "g" and attrs == {"id": "mark"}: + for child in element: + paint(child, MASK_SCALE if maskable else 1) + return + if tag == "rect" and set(attrs) <= {"x", "y", "width", "height", "fill"}: + x, y = float(attrs.get("x", 0)), float(attrs.get("y", 0)) + w, h = float(attrs["width"]), float(attrs["height"]) + points = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] + elif tag == "polygon" and set(attrs) == {"fill", "points"}: + points = [tuple(map(float, p.split(","))) for p in attrs["points"].split()] + else: + raise ValueError(f"Unsupported SVG element/attributes: {tag} {attrs}") + # Pillow addresses pixel centres; shift the vector edges by half a sample. + points = [((256 + (x - 256) * inset) * scale - 0.5, + (256 + (y - 256) * inset) * scale - 0.5) for x, y in points] + draw.polygon(points, fill=attrs["fill"]) + + for element in svg: + paint(element) + return image.resize((size, size), Image.Resampling.BOX) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ROOT / "public/icons") + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + for size in (192, 512): + for maskable in (False, True): + name = f"app-{'maskable-' if maskable else ''}{size}.png" + render(size, maskable).save(args.out / name) + for size in (16, 32, 48): + render(size).save(args.out / f"favicon-{size}.png") + render(180).save(args.out / "apple-touch-icon.png") + # BMP-backed frames also work in older ICO readers; PNG favicons ship separately. + render(48).save(args.out / "favicon.ico", sizes=[(16, 16), (32, 32), (48, 48)], bitmap_format="bmp") + print(f"Exported 9 icon files to {args.out}") + + +if __name__ == "__main__": + main()