Skip to content

feat(pokemon): add the pokemon centre (Discuz X5 plugin integration) - #150

Merged
Carinoasd merged 37 commits into
Carinoasd:masterfrom
bbtu1:feat/pokemon-center
Sep 30, 2026
Merged

Carinoasd merged 37 commits into
Carinoasd:masterfrom
bbtu1:feat/pokemon-center

Conversation

@bbtu1

@bbtu1 bbtu1 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

宠物中心(插件 pokemon:pokemon 的 JSON 接口)做成原生页面了。宠物列表、治疗、背包、商店、冒险地图、战斗、详情、装备、仓库、进化路径都在,入口放在首页标题栏、首页工具卡和个人页,路由是 /pokemon/*。

顺带动了几个公共文件:

  • 插件写操作是 JSON POST,客户端原来只有表单和 multipart,所以加了 postJson(Dart 和 Android 的 Kotlin 通道都加了)。插件接口和图片请求单独用一个超时更宽(15/30/60)的 client,论坛和其它请求保持原来的 10s。
  • 写请求、以及返回非 2xx 的请求,会把 method 和 url 打进日志。
  • recover 的 404 和图片的 404 只记 debug,不再当成网络错误弹提示。
  • 图片缓存目录被系统清掉时会重建;写缓存失败只丢缓存条目,不再算图片加载失败。
  • snack bar 在页面临近销毁时跳过,之前会踩到 messenger 的断言。
  • 每次插件调用有 deadline(读 20s、写 35s,地图列表 60s),请求在传输层失败时会重读一次战斗状态。
  • 另外是注册 AdventureCache、加路由和路由回调、三个入口、三语文案,.gitignore 里补了 coverage。i18n 的三个 json 只加了 key。

几处是按插件的实际行为处理的:defeat 不代表战斗结束(有替补时插件会保留战斗,客户端用 heal_and_flee 清掉);heal 免费且会回满 HP 和 PP,但插件自己不会回血,所以离开战斗页的每条路径都会治一次;技能只有 4 个槽且遗忘要求 PP 满,学技能走「先遗忘再学」;背包和仓库共用 boxnum;写操作带 session 的 formhash。

测试补了 8 个(test_176 到 test_183),覆盖模型、文案映射、弹窗、仓库层(客户端复用、formhash 只读一次、治疗并发、请求超时)、cubit,以及横屏布局(宠物中心 4 个 tab、冒险页、战斗页,792x368 带挖孔)。本地 flutter test 全部通过,strict analyze 干净。

The DTOs of the plugin's JSON API (dart_mappable with a tolerant fromMap), the api base url, the exception its error envelope is reported with, and the strings of the new pages in all three locales.
Adds the JSON post the plugin's write calls need (dart and the android side), logs the method and url of every write and of every answer that is not ok, keeps a 404 out of the network error banner, and relaxes the android http timeouts the slow heal and sprite downloads hit.
One place attaches the session formhash, retries once when the server calls it stale and runs every call under a deadline (20s, 60s for the map list), so a request the platform client parked is reported as a network failure instead of holding a page in its busy state.
State and actions of the centre and of a battle, including the re-read of the battle after an action that lost its answer on the transport.
The centre with its four tabs, the adventure map list, the battle page and view, the detail/equipment/storage pages, and the shared widgets and helpers (state and type styling, dialogs, item paging).
Registers the adventure cache, adds the /pokemon routes and their observer, and puts the centre behind the home app bar, the home tools card and the profile page.
Android can clear the cache directory while the app runs, so it is created again before a write and a failing write only costs the cache entry, not the image.
A bar queued on a messenger whose page was just torn down trips an assertion inside it; skip it instead.
flutter test --coverage writes a coverage directory that is easy to add by accident.
Models and url building, the adventure/battle/skill/shop models and the error text mapping, the dialogs, the repository (client reuse, one formhash read per page load, party healing, a call that never answers), the cubits (lifecycle, status bar switch, item actions, the battle re-read after a lost answer), the pure helpers, and the landscape layout of the centre, the adventure page and the battle page (renumbered to 176-183).
What each folder holds, the conventions the module follows, the plugin behaviour that is easy to get wrong (site, defeat, the free heal, skill slots, the formhash, recover's 404), the test list and the coverage gaps.
… bool

The analyzer of the flutter version CI runs flags
ullable == true (use_if_null_to_convert_nulls_to_bools), and ?? false says the same thing.
@Carinoasd

Copy link
Copy Markdown
Owner

@claude

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @Carinoasd's task in 8s —— View job


Hi @Carinoasd, your comment only contained the @claude mention with no request, so I haven't changed any code or reviewed anything.

Here are some things I can do on this PR (#150, the pokemon centre):

  • Code review: read the diff against origin/master and give feedback on correctness, concurrency (e.g. the heal and battle re-read paths), the postJson changes on the Dart and Kotlin sides, and the image cache changes.
  • Answer questions about a specific file or behaviour.
  • Make changes: fix something, add tests, or adjust docs. I'd push these straight to feat/pokemon-center.

Reply with @claude and what you'd like, for example @claude review this PR or @claude explain how heal_and_flee is handled in battle_cubit.dart.

@Carinoasd

Copy link
Copy Markdown
Owner

@claude 請審查這個 PR,重點看:正確性與潛在 bug、並發問題(heal 與 battle 的重讀路徑)、Dart / Kotlin 兩端 postJson 的改動、圖片快取的改動,以及安全性。請用繁體中文回覆。

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @Carinoasd's task in 1m 5s —— View job


PR #150 審查:宠物中心(pokemon)

我讀了 battle_cubit.dart、pokemon_repository.dart,以及 Dart / Kotlin 兩端的公共檔案改動(HttpClient.kt、MainActivity.kt、net_client_provider*.dart、image_cache_provider.dart、show_toast.dart)。沒有執行 flutter test 或 analyze,以下都是靜態閱讀的結論。views、models 和 8 個測試檔只掃過,沒有逐行看。

整體結構清楚,註解把插件的實際行為(defeat 不結束戰鬥、heal 免費、formhash)都講明白了。下面是我認為值得處理的問題,依重要性排序。


需要處理

1. Kotlin postJson 走的是會自動重試的 client,寫請求可能被重放
android/.../HttpClient.kt(新增的 postJson)用的是 client,OkHttp 預設 retryOnConnectionFailure(true)。同一檔案裡 postForm 為了「不重放非冪等交易」,另外準備了 singleAttemptClient,而 postJson 沒有這樣做。

  • 情境:shop&action=buy、buy_pet、battle&action=turn、capture 在連線中途失敗(例如收到 stale connection 的 reset)時,OkHttp 會靜默重送,伺服器可能重複扣款、重複出手回合或重複丟球。
  • 影響最大的是 buy 類。heal 冪等,不受影響。
  • 建議:JSON 寫請求也走 singleAttemptClient(或至少對非冪等 action 這樣做)。順帶一提,singleAttemptClient 是從 client.newBuilder() 衍生的,所以新的 15/30/60 秒超時會一併繼承,這點沒問題。
  • 另外 Dart 側 _send 的 20 秒 deadline 只是不再等待,不會取消底層請求。買東西逾時後玩家再點一次,同樣有重複購買的可能。這是個較小的問題,可考慮在 UI 上逾時後先重讀背包/錢再讓玩家重試。

2. 圖片快取:寫檔失敗後 DB 記錄仍然留著
image_cache_provider.dart:_saveCache 的順序是:先 updateImageCache(imageUrl, fileName: ...) 寫 DB,再寫檔。現在 writeAsBytes 失敗只記 warning,然後繼續往下走,ImageUsageInfoUserAvatar 分支還會再寫一次頭像快取表。

  • 結果:DB 裡有一筆指向不存在檔案的記錄。PR 描述說「寫緩存失敗只丟緩存條目」,但實際上條目沒有被丟掉。
  • 下次載入同一張圖時,取決於讀取路徑有沒有處理「DB 有、檔案沒有」。如果沒有,會拿到讀檔錯誤或空圖。
  • 建議:把 DB 寫入移到檔案寫成功之後,或在 catch 裡直接 return,避免後面的 ref table 更新。
  • 好的部分:cache.parent.create(recursive: true) 處理目錄被清掉的情況是對的,log 只記 url 長度也避免了洩漏。

3. resync() 的競態:可能用舊場景覆蓋新場景(battle_cubit.dart:303)
_runAction 在傳輸層失敗(_messageOf(e) == null)時 unawaited(resync())。

  • 問題 A:Dart 端 20 秒 deadline 到期時,請求可能仍在伺服器上處理中。這時 recoverBattle 讀到的是動作生效前的場景,之後動作才落地,畫面就停在過期狀態。
  • 問題 B:resync 沒有像 onAppResumed 那樣遞增 _generation,也沒有檢查在它等待期間玩家是否已開始新的動作。玩家在重讀進行中又點了技能,較晚回來的舊 recover 結果會覆蓋這次動作的場景,同時 _turn 也沒有同步。
  • 建議:在 resync 開頭捕獲 _generation 並用 _emitSceneIfCurrent;有新動作進行中(state.actionInProgress)時丟棄結果;必要時延遲一小段再讀。

較小的問題

  • _formHash 是 static 且不分帳號(pokemon_repository.dart:60)。_client 有按 uid 區分,formhash 沒有。換帳號或登出後第一個請求會帶舊 hash,靠 _send 的「formhash 錯誤 → 重讀重試一次」補救,所以功能上能運作,但每次換帳號會多一次失敗請求。建議把 hash 與 uid 綁在一起,或在登出/換帳號時清掉。
  • _ensureFormHash(refresh: true) 與 _readFormHash 的 finally:refresh 分支先把 _formHashRead = null 再新開讀取;舊的讀取結束時 finally 會把 _formHashRead 清成 null,可能把新讀取的共享 future 也清掉。多個請求同時遇到 stale formhash 時會各自 refresh,多讀幾次頁面。無害,只是浪費。
  • healParty 在 onAppResumed「gone」路徑被 unawaited(battle_cubit.dart:291)。玩家若馬上點「再戰」,start 與這批 heal 會並行。註解說伺服器只在戰鬥結束後允許 heal,所以多半只是 heal 被拒而已,但值得確認插件在「新戰鬥已開始」時對 heal 的行為,避免把剛開打的寵物回滿。
  • 全域 404 行為改變(net_client_provider.dart _ErrorHandler.onError):所有 badResponse + 404 現在都不寫 error log、也不進 NetErrorSaver,不限於頭像或 recover。例如論壇帖子頁真的 404 時,使用者原本會看到的網路錯誤提示會消失。如果這是有意的,建議註解寫明;否則可限縮到圖片請求和 pokemonApiBase。
  • BattleState.copyWith 無法清除欄位:failureMessage 一旦設定,之後成功也不會被清掉(?? this.failureMessage)。目前 UI 大概只在 failure 狀態讀取它,所以看不出問題,但之後容易踩到。
  • show_toast.dart:新增的區域函式 show() 上面用了 /// 文件註解,且與前一個 ); 之間沒有空行,格式化/lint 可能會抱怨,改成 // 即可。功能上 mounted 檢查是對的。
  • Kotlin MainActivity 的 postJson 分支:val headers、val body 在 try 區塊內遮蔽了外層同名變數(其他分支應該也是同樣寫法),可以跑,只是編譯器會給 warning。HttpClient.postJson 裡的 try { ... } catch (e) { throw e } 是多餘的,但與既有程式碼風格一致。

已檢查、沒問題的部分

  • Dart ↔ Kotlin 通道的一致性:_post 以 Content-Type 的 ; 前半段分派,Headers.jsonContentType(application/json; charset=utf-8)能正確匹配 'application/json';body 是 jsonEncode 的 String,body as String 成立;MainActivity 的 method 名稱 postJson 兩端一致。Kotlin 端 body 自帶 application/json; charset=utf-8,OkHttp 會以 body 的 content type 為準,不會與 header 衝突。
  • healParty 並發:Future.wait 對每隻寵物各發一個 heal,_formHash 的共享讀取避免了 N 次讀頁;heal 冪等,失敗只計數略過,這條路徑是安全的。
  • formhash 重試:只在伺服器明確回 formhash 錯誤(請求被拒絕、未執行動作)時重試一次,不會造成寫操作重複。
  • defeat / finishDefeat:heal_and_flee → 「戰鬥已結束」時直接 heal → 最後才是隨機 flee,各分支都有 isClosed 檢查,邏輯合理。
  • 安全性:formhash 只送往同一主機的 pokemonApiBase;伺服器的 error 字串只當純文字顯示;解析失敗的 log 只截取 200 字元。沒有看到注入、路徑或憑證外洩的問題。唯一可留意的是 talker.error 會記錄回應資料預覽,內含玩家寵物資料,匯出 log 時屬低敏感度資訊。
  • mapException 的訊息回退(error.error ?? error.type.name)避免了 <unknown error>,改得好。

建議的處理順序

  1. Kotlin postJson 改用不重試的 client(第 1 點)。
  2. 調整 _saveCache 的寫入順序(第 2 點)。
  3. 給 resync 加上 generation/in-flight 檢查(第 3 點)。
  4. 其餘小項視情況處理。

需要我直接針對 1–3 點提交修正到 feat/pokemon-center 嗎?回覆 @claude 並說明即可。

…p asked for

OkHttp retries a request on a dropped connection, which would buy something twice or add a battle turn, so the json writes go out on the non-retrying client with a one-shot body (as postForm already does). The 404 exemption is narrowed to image requests and the plugin api: a forum page that is really gone still reports.
…ed formhash to the account

A write the server already carried out must not be sent again, and the formhash belongs to one account's session, so another account reads it instead of sending the previous one's. A refresh no longer clears a read that another request started.
A row written ahead of a failed file would point at a file that is not there, so the file goes first and a failed write ends the save.
The read-back after a lost answer now keeps the generation it started with, is dropped while another action runs, and every action starts a new one, so its answer cannot paint over the action that followed. A scene that arrives also drops an older failure message.
…st its answer

The server may have carried the write out after all, so the money and the bag are read again before the player tries it a second time.
@Carinoasd Carinoasd closed this Sep 30, 2026
@Carinoasd Carinoasd reopened this Sep 30, 2026
@github-actions

Copy link
Copy Markdown

審查總結

我讀了網路層(Kotlin / NetClientProvider)、PokemonRepository、BattleCubit、AdventureCache。整體結構清楚、錯誤處理也很用心。以下是我有把握的問題:

1. useItem 只捕獲 Exception,解析錯誤會逃逸(正確性)

lib/features/pokemon/cubit/battle_cubit.dart 的 useItem 裡:

final scene = BattleScene.fromMap(data);
...
} on Exception {

PokemonRepository._decode 的註解已說明:欄位型別錯誤時 dart_mappable 拋出的是 TypeError(Error,不是 Exception)。因此插件回傳非預期欄位時,useItem 會直接拋出未處理的錯誤,而不是回傳 BattleUseItemResult(message: ...)。外層的 on Exception catch (e) 也一樣攔不到。建議改成 on Object,與 repository 一致。

2. 寫操作逾時後仍在背景執行,UI 顯示失敗但伺服器可能已成功(正確性)

PokemonRepository._withDeadline 用 Future.timeout,逾時只是讓 Future 提早回傳 HttpRequestFailedException,底層的 Dio / OkHttp 請求並沒有被取消(沒有 CancelToken)。對 buy、buyPet、release、startBattle、turn 這類寫操作,使用者看到「網路失敗」後很可能再點一次,而第一次其實已被伺服器處理,導致重複購買或重複出招。singleAttempt 只防止傳輸層重送,防不了這種情況。建議:

  • 對寫操作傳入 CancelToken 並在逾時時取消;或
  • 逾時後對寫操作提示「結果未知,請重新整理確認」,而不是當一般失敗。

_runAction 有對傳輸失敗做 resync(),戰鬥這邊已部分緩解;但商店、放生等路徑沒有。

3. AdventureCache.battleKey 可能誤判新戰鬥為「已結束」(邊界情況)

battleKey 由 mapId-wildPokemon.id-myPokemon.instanceId 組成。連續刷同一張地圖時,同種野生寶可夢加上同一隻我方寶可夢很常見。_finishedBattles 只在冒險頁確認「無進行中戰鬥」時才清空。若玩家用 fightAgain 連打(不經過冒險頁的檢查),再中途離開回到冒險頁,recover 回來的新戰鬥會與先前結束的那場產生相同 key,被當成已結束而直接 flee/放棄,而不是恢復。建議在 start 成功時清除或使該 key 失效(例如 fightAgain / start 成功後從 _finishedBattles 移除對應 key)。

4. 測試缺口

  • 新增的 Android 通道路徑(HttpClient.postJson、singleAttempt + application/json 的分派、KotlinHttpClient 對 JSON 的檢查)沒有任何測試。
  • NetClientProvider.postJson 的 singleAttempt(不跟隨重導、不重試)沒有直接測試。
  • 沒有針對 battleKey 碰撞(第 3 點)的測試。
  • _decodeEnvelope 對「非 JSON 的 404(例如 HTML 頁面)」的行為沒有覆蓋:recover 會得到 ServerRespFailure 而不是「沒有戰鬥」。

小備註(非阻擋)

  • OkHttp 的 readTimeout 由預設 10s 放寬為 30s、connectTimeout 15s,這是全域生效,會影響所有 Android 請求(不只寶可夢),建議在 PR 說明中明確這個範圍。
  • HttpClient.postJson 內的 try { ... } catch (e: Exception) { throw e } 沒有作用,可移除。

以上第 1、2 點我認為建議在合併前處理;第 3 點視實際玩法而定,建議至少補一個測試確認。

@bbtu1

bbtu1 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

感谢两轮审查。

第一轮的 1–3 和小项都处理了(已经推到分支,追加 7 个提交):

  • Kotlin postJson 改用不重试的 client,body 用 isOneShot,Dart 侧把 singleAttempt 一起传下去,插件的写操作都标成单次尝试。
  • _saveCache 改成先写文件再写 DB 行,写文件失败不会留下指向不存在文件的记录。
  • resync 沿用发起时的 generation、改用 _emitSceneIfCurrent,actionInProgress 时丢弃;每个动作也重新起算 generation。
  • formhash 绑定 uid,换账号会重读;refresh 不再清掉别的请求正在用的共享 future。
  • 404 的豁免收窄到图片请求和 pokemonApiBase。
  • copyWith 加了清掉 failureMessage 的开关;show_toast 的局部函数注释改回 //。
  • 写操作在传输层失败时,先重读 profile 和背包再让玩家重试。

第二轮这三点:

  1. on Exception 拦不到 dart_mappable 的 TypeError,我改成 on Object。
  2. 超时后底层请求还在跑这件事:singleAttempt 挡得住重送,挡不住「其实已经送到服务器」;我会保留超时后重读 money/背包 的做法,CancelToken 在 Android 走 method channel 时没法真正中止原生请求,所以不当作解法。
  3. battleKey 碰撞(同地图、同种野生、同一只出战宠,又用 fightAgain 连打)我会在 start 成功时把该 key 从已结束清单里移除,并补一个测试。

另外 OkHttp 的 15/30/60 秒是全局生效(不只宠物),这点我补进 PR 描述。

…rts again

dart_mappable throws a TypeError (an Error, not an Exception) on an unexpected field type, so the cubits' on Exception handlers let it escape; they catch Object now, like the repository does. Starting a battle also drops its finished-battle key, which repeats on the same map against the same species with the same own pokemon, so a fight the client just began is not taken for one that already ended.
@github-actions

Copy link
Copy Markdown

PR #150 審查總結

這個 PR 很大,我主要看了 PokemonRepository、BattleCubit、AdventureCache、網路層(Dart 與 Kotlin)和圖片快取的改動。整體寫得很仔細:single-attempt 寫入、formhash 依帳號隔離、generation 防止過期回應覆蓋畫面,這幾處都處理得不錯。以下是我比較有把握的問題。

1. 逾時後寫入操作可能已在伺服器端生效,卻被回報為網路失敗(正確性)
pokemon_repository.dart 的 _withDeadline 只是讓 Future 逾時,並不會取消底層請求。Android 的讀取逾時是 30s,而 deadline 是 20s,所以 buy、buyPet、heal、learnSkill 這類 singleAttempt 寫入,可能在 20s 時被回報為 HttpRequestFailedException,但伺服器其實還在處理,之後才成功。

  • 使用者看到「失敗」後會再按一次,購買可能重複扣款。
  • 只有 BattleCubit._runAction 會在這種情況呼叫 resync()。商店、背包、裝備、技能這些頁面沒有對應處理。
  • 建議:寫入類操作逾時後,文案改成「結果未知,請重新整理確認」並重新載入資料;或讓寫入的 deadline 不短於傳輸層逾時。

2. finishDefeat 缺少 healAndFlee 已有的 instanceId 保護(邊界情況)

  • healAndFlee() 在 instanceId <= 0 時會提前失敗,註解也說明了原因(否則會要求伺服器治療 pokemon 0)。
  • finishDefeat() 直接使用 scene.myPokemon.instanceId,沒有同樣的檢查。
  • 備援路徑 flee(scene.battleId) 用的 battleId,依 AdventureCache.battleKey 的註解,在戰鬥結束的回應裡是空字串。
  • 所以當結束畫面缺少 instanceId 時,兩條路徑都會失敗,戰鬥會一直留在伺服器端。這正是這個函式要解決的問題。
  • 建議:加上同樣的保護,並補一個測試,涵蓋結束畫面 instanceId == 0 且 battleId 為空的情況。

3. 全域 Android 逾時變更影響範圍大(效能/行為)
HttpClient.kt 把連線、讀取、寫入逾時改為 15/30/60s,作用於所有 Android 請求,不只寵物中心。這個 PR 已經說明了這點。弱網下所有頁面的失敗回饋會從約 10s 延長到約 30s。建議把這個改動拆成獨立 PR,或只對寵物與圖片請求使用較寬鬆的 client。

4. 測試缺口

  • 沒有測試覆蓋 NetClientProvider.postJson:singleAttempt 時 followRedirects 與 extra 的設定、非 2xx 的日誌。
  • 沒有測試覆蓋 Kotlin/Dart 的 postJson 通道,以及新增的 application/json 分支。
  • 沒有測試覆蓋 _ErrorHandler._isExpectedMissing。目前 id=pokemon:pokemon 的所有 404 都不再顯示錯誤提示,這條規則值得有測試保護。
  • 沒有測試覆蓋 ImageCacheProvider 的新流程:目錄被清掉後重建、寫檔失敗時不寫 DB。
  • 沒有測試覆蓋 showSnackBar 的 mounted 判斷。

5. 小風險(可選)
AdventureCache.battleKey 由 mapId-wildId-myInstanceId 組成。如果使用者在網站端用同一張地圖、同一隻野生寶可夢、同一隻自己的寶可夢開了新戰鬥,而本機還記著上一場「已結束」的 key,isBattleFinished 會是 true,App 可能把這場真實的戰鬥直接結束。機率低,但後果是誤逃跑。

我沒有執行測試,以上都是靜態閱讀的結果。

@github-actions

Copy link
Copy Markdown

審查摘要

整體結構清楚,postJson、單次嘗試(singleAttempt)與 formhash 快取的設計也算周全。以下只列我有把握的問題,我沒有實際執行測試。

1. buy / buyPet 在「回應遺失」時不會回讀狀態(正確性,中)

  • 位置:lib/features/pokemon/cubit/pokemon_cubit.dart 的 buy、buyPet
  • 同檔的 _runVoid 已處理「寫入可能已在伺服器成功、但回應沒回來」的情況(_reloadAfterLostAnswer),但購買金錢和背包的兩個方法沒有。
  • 情境:buy 遇到 20 秒 deadline 或傳輸層失敗,回傳 message == null 的失敗。此時伺服器可能已扣款,畫面卻顯示舊的金錢和背包,玩家很可能重試而重複購買。
  • 建議:在 Left 且 message == null 時,同樣呼叫 _reloadAfterLostAnswer()(buyPet 另需刷新寵物列表)。

2. 分頁載入與分類切換的競態(正確性,中)

  • 位置:loadMoreShop、loadMoreInventory(pokemon_cubit.dart)
  • 這兩個方法在請求前捕獲 current,回應後直接用 [...current.items, ...page.items] 組成新頁面,並設定 shopPage / inventoryPage,沒有檢查期間分類是否已被 setShopCategory / setInventoryCategory 改掉。
  • 情境:載入下一頁時點了另一個分類,舊分類的項目會被併進新分類清單,頁碼也被錯誤推進。_loadingMore* 旗標同時擋住新分類的載入更多,直到舊請求結束。refreshShop / refreshInventory 也有同樣的過期回應覆蓋問題。
  • 建議:請求前記下分類(或遞增 generation),回應時不一致就丟棄。

3. 讀取 formhash 的頁面請求走預設 10 秒 client(效能/可靠性,低到中)

  • 位置:HttpClient.kt 的 needsMoreTime、pokemon_repository.dart 的 _readFormHash
  • 放寬逾時的判斷依據是請求帶有 X-Pm-Formhash 標頭或 Accept: image/*,但讀取 formhash 本身的 GET(_options() 不帶標頭)拿不到放寬的逾時,仍是 10 秒。
  • 情境:在慢速裝置上,也就是這個 PR 要解決的情境,第一個請求會在 10 秒逾時,_readFormHash 回傳 null,之後的請求不帶標頭,同樣落到 10 秒 client。Dart 側設定的 20 秒 deadline 在 Android 上實際失效。
  • 建議:讓 formhash 頁面請求也走放寬的 client。做法之一是改用明確的旗標或標頭,不要靠「有沒有 formhash 標頭」這種間接訊號。

4. 測試缺口(低)

  • buy / buyPet 失敗且 message == null 時的回讀行為沒有測試。
  • 切換分類期間的載入更多(第 2 點)沒有測試。
  • Kotlin 的 postJson、pick / needsMoreTime 分流沒有任何測試。目前只靠手動驗證,isOneShot 與逾時行為尤其值得驗證。

The end-of-battle answer carries no battle id and may leave the pokemon id at 0, so heal_and_flee and the flee fallback were both sent with nothing and the battle stayed on the server. The last scene that named its battle is kept, and the end scene fills in what it is missing; without either, the party is healed and the page still remembers the battle as over.
…eporting it

The 20s deadline is shorter than the android client's 30s read timeout, so a write that was still in flight was reported as a failure although the server may well have carried it out (a second tap would buy again). Writes get 35s now, which lets the transport report its own, accurate failure.
@github-actions

Copy link
Copy Markdown

PR 審查總結

我讀了共用層的變更(Kotlin HttpClient/postJson、NetClientProvider.postJson、圖片快取、PokemonRepository)和 BattleCubit。沒有發現安全問題:formhash 只送往同一主機,日誌也沒有寫入 cookie 或 token。下面是我有把握的問題。

1. BattleCubit 的過期回應會清掉新動作的忙碌狀態(正確性)

位置:lib/features/pokemon/cubit/battle_cubit.dart 的 _runAction 與 useItem。

onAppResumed 會執行 _generation++,並把 actionInProgress 設為 false,讓玩家能繼續操作。但被 OS 暫停的舊請求 A 並沒有被取消。A 之後才回來時:

  • finally 裡的 emit(state.copyWith(actionInProgress: false)) 沒有比對 generation。
  • 場景本身有用 _emitSceneIfCurrent 擋掉,但忙碌旗標沒有。

重現步驟:

  1. 送出出招 A,A 卡在網路上。
  2. 切到背景再回來,onAppResumed 讓按鈕恢復可按。
  3. 玩家送出動作 B。
  4. A 的回應此時抵達,把 actionInProgress 清成 false。
  5. B 還在進行中,按鈕卻已恢復可按,玩家可以再送出第二個 turn/capture/switch,而這些都是非冪等的寫入。

另外,過期回應仍會執行 if (scene.isActive) _turn++(_runAction 與 useItem 都是),導致回合數被多算。

建議:在 finally 與 _turn++ 前都加上 generation == _generation 的判斷,只有當前這一代的請求才能修改 actionInProgress 與 _turn。

2. finishDefeat 在傳輸失敗時仍回報成功(邊界情況)

位置:同檔案的 finishDefeat。

healAndFlee 若因網路錯誤(不是 battleAlreadyOverError)失敗,流程會往下走。此時如果 battleId 為空(結束畫面沒有 id,而 _runningScene 也沒有),就會執行 healParty() 並回傳 success: true。

伺服器上的戰鬥其實沒有結束,但頁面會把這場戰鬥記成「已結束」。README 第 5 點寫的正是這個情況:冒險頁之後不會再用 recover 把它拉回來,玩家會卡在一場永遠無法繼續的戰鬥。

建議:只有在 healAndFlee 明確回報「戰鬥已不存在」時才視為成功,網路錯誤要回傳 success: false。

3. 測試缺口

PR 自己的 README 寫明 cubit 行覆蓋率只有 22%,view 層 0.4%。以下這些高風險路徑仍沒有測試:

  • 第 1 點的競態:onAppResumed 後舊請求晚到。
  • finishDefeat 的各個分支:healAndFlee 成功、戰鬥已結束、傳輸失敗、battleId 為空。
  • Kotlin postJson 的 singleAttempt 與 needsMoreTime 選 client 的邏輯,沒有任何原生層測試。它靠 Accept: image/* 與 X-Pm-Formhash 標頭來判斷,改動任一標頭都會悄悄退回 10s 的 client。

第 1、2 點建議在合併前修掉;這兩點的行為都可以用假 repository 寫成 cubit 單元測試。

The widened client was picked from the X-Pm-Formhash header, which the read that fetches the formhash itself does not carry - and a request made without one (a formhash that could not be read) fell back to the 10s client, exactly the case the wider timeouts were for. The plugin url is checked first now.
… call a battle ended

A resume or a newer action makes an answer stale; it no longer clears the busy state or counts a round, so the page cannot be unlocked while a non-idempotent write is still running. A defeat that could not be ended (no battle id anywhere) is not reported as done either; only 'the battle is already over' is.
…ost its answer

buy and buyPet return a failure when the transport failed, but the server may still have carried the purchase out, so the money, the bag and the party are read again before the player tries a second time.
@github-actions

Copy link
Copy Markdown

審查總結(PR #150 寵物中心)

範圍說明:我重點讀了網路層(HttpClient.kt、MainActivity.kt、net_client_provider*.dart、圖片快取)、PokemonRepository、BattleCubit 與 show_toast。UI 頁面、models 與 pokemon_cubit 只做了抽查,沒有實際執行 flutter test。

結論

沒有發現我有把握確認的正確性或安全性 bug,以下都是需要作者確認的觀察,不是阻擋合併的問題。

值得留意

  1. Kotlin 側與 Android channel 沒有測試覆蓋:postJson、needsMoreTime(依 id=pokemon、Accept: image/*、X-Pm-Formhash 選擇逾時較長的 client)、KotlinHttpClient 新增的 application/json 分支,在 test/ 和 Android 原始碼中都搜不到對應測試。這些是所有寫入操作的必經路徑,而且 singleAttempt(不重送)是防止重複購買或重複操作的關鍵,建議至少補上 Dart 端 channel 參數的測試。
  2. _isExpectedMissing 範圍偏寬(net_client_provider.dart):只要是 id=pokemon:pokemon 的 404 都只記 debug,也不會存入 NetErrorSaver。當插件被停用或端點改名時,使用者不會看到網路錯誤提示,日誌裡也只有 debug 等級的紀錄。如果只是為了 recover,可以限定 endpoint=battle&action=recover。
  3. 寫入逾時後的結果不確定:_withDeadline 逾時只是不再等待,請求本身沒有取消,所以 35 秒逾時後,伺服器仍可能已完成購買或使用道具,而 UI 會顯示失敗。程式註解已經意識到這點,戰鬥有 resync,但商店購買、使用道具等操作沒有對應的回讀,建議失敗後重新載入背包或金錢。
  4. fightAgain 沒有遞增 _generation:治療期間如果 onAppResumed 觸發,回到前景後 start 仍會照常執行,可能與回讀的場景互相覆蓋。這是邊界情況,不影響一般流程。

其他部分(formhash 的快取與重試、依帳號 uid 失效、圖片快取先寫檔再寫資料庫、decode 捕捉 TypeError)我沒有看到問題。

🤖 Generated with Claude Code

The whole plugin api was exempt, so a plugin that is disabled or renamed answered 404 without ever showing up in the log or the banner. Only the recover call, whose 404 means no battle is running, stays silent now; the image requests keep their own exemption.
onAppResumed read the battle back and painted it unconditionally; an action the player started while that read was in flight now owns the screen, and the stale read-back is dropped like any other.
loadMoreShop, loadMoreInventory, refreshShop and refreshInventory merged or replaced a list without checking that the category (or the page) was still the one the answer belongs to, so a switch made mid-request glued the old entries onto the new list and moved the page counter.
@github-actions

Copy link
Copy Markdown

Code Review 總結

已審查 Kotlin 傳輸層、PokemonRepository、AdventureCache、BattleCubit、PokemonCubit 與網路層變更。只列出有把握的問題。

1. 分頁載入後,刷新會把累積的列表換成單一頁(正確性)

lib/features/pokemon/cubit/pokemon_cubit.dart

loadMoreInventory / loadMoreShop 會把後續頁面累加到 state.inventory / state.shop,並把 inventoryPage / shopPage 推進到 N。但 refreshInventory、refreshShop、load() 都只請求 page: state.xxxPage,再用該單頁結果整個覆蓋列表。

重現:商店捲動載入到第 2 頁以上,再購買任一道具。buy 成功後呼叫 refreshShop(),列表只剩第 N 頁,第 1 頁道具消失。由於 loadMore 只會往後翻,使用者只能切換分類或重新進入頁面才能救回。背包在 useItem 後、_reloadAfterLostAnswer 之後也是同樣情況。
inventory.page != state.inventoryPage 的檢查擋不住這個問題,因為兩者剛好相等。

建議:刷新時重新載入 1..N 頁,或刷新後重設回第 1 頁。

2. load() 的商店請求忽略目前分類(正確性)

pokemon_cubit.dart 的 load()

getShop(page: state.shopPage) 沒帶 type: state.shopCategory.type。若目前選的是「石頭」等分類,重新載入會把全部道具放進 shop,但 UI 仍顯示該分類。若目前是寵物分類,也會去抓道具頁。

3. deadline 逾時不會取消底層請求,寫入可能重複執行(正確性)

pokemon_repository.dart 的 _withDeadline

Future.timeout 只是提早回傳 HttpRequestFailedException,底層 dio/Kotlin 請求仍會繼續,也沒有使用 CancelToken。寫入 deadline 是 35s,但 Android 端 connect 15s、read 30s 是分開計時,總時間可能超過 35s。

結果是:使用者看到「失敗」,伺服器卻可能稍後完成該筆寫入。buy、buyPet 在逾時後會重新讀取,但畫面若仍可點擊,重試就可能重複購買。建議:

  • 寫入逾時後保持忙碌狀態,直到重新讀取完成。
  • 或使用 CancelToken 並在逾時時取消。

4. 切換帳號時,進行中的 formhash 讀取可能污染新帳號(低風險)

pokemon_repository.dart 的 _ensureFormHash / _readFormHash

_readFormHash 完成時直接寫入靜態的 _formHash,沒有比對啟動時的 uid。帳號 A 的讀取還在進行時切換到 B,A 的 hash 會被存成 B 的快取。目前靠「formhash 失敗後重試一次」補救,所以不致命,但 B 的第一個寫入會白白失敗一次。建議在寫入前確認 _formHashUid 仍相同。

5. 測試缺口

  • 目前沒有任何測試涵蓋「loadMore* 之後再 refresh*/buy」,問題 1、2 因此沒被抓到。
  • Kotlin 端新增的 postJson、needsMoreTime 選用 client 的邏輯(id=pokemon、Accept: image/、X-Pm-Formhash)沒有單元測試。
  • 沒有測試「寫入逾時後伺服器實際成功」的情境(問題 3)。

第 1、2 點是我認為應在合併前修正的問題。

…cted category

loadMoreShop and loadMoreInventory accumulate pages, so a refresh (which happens after every purchase) that replaced the list with the page the player had reached dropped every page before it, and loadMore only goes forward. The refresh paths read the first page again and reset the counters. load() also read the shop without its category, so a stone category held the whole item list.
…re a formhash only for its account

The write deadline has to outlast connect + read (15s + 30s on android, counted separately), so it is 45s now and the transport reports its own failure. A formhash read that finishes after the account switched no longer caches the old session's hash for the new one.
@github-actions

Copy link
Copy Markdown

審查摘要

看了 repository、網路層(Dart + Kotlin)、battle_cubit、pokemon_cubit、adventure_cache。整體的併發防護(generation、formhash 共用讀取、單次嘗試寫入)設計得很仔細,只有一個我有把握的問題。

🐞 背包/商店「載入更多」永遠不會附加下一頁

lib/features/pokemon/cubit/pokemon_cubit.dart 的 loadMoreInventory 與 loadMoreShop:

final next = state.inventoryPage + 1;
final result = await _repository.getInventory(page: next, ...).run();
...
if (state.inventoryPage != next) return;   // 這裡

請求送出時 state.inventoryPage == next - 1,而在 emit 之前它不會被改成 next,所以回應到達時這個守衛恆為 true,直接 return,新頁面被丟棄。商店的一般分頁與寵物分頁(state.shopPage != next)同理。

影響:

  • 滾動到底不會載入第二頁之後的內容。
  • loadAllInventory / loadAllShop(搜尋用)在第一輪 page 沒前進就 break,搜尋只涵蓋第一頁。

守衛的原意(排除切換分類期間回來的舊答案)應改成與請求時的頁碼比較,例如 state.inventoryPage != next - 1,或在請求前記下 final base = state.inventoryPage,回來後比較 state.inventoryPage != base(並一併檢查分類仍相同,目前商店寵物分支有檢查,背包沒有比對 inventoryCategory)。

🧪 測試缺口

test_176~test_183 沒有任何一個呼叫 loadMoreInventory / loadMoreShop / loadAll*,所以上面的問題沒被抓到。建議補一個 cubit 測試:兩頁資料,loadMore 後斷言 items 長度合併且 page 前進;再加一個「請求期間切換分類,舊頁被丟棄」的案例。

其餘部分(formhash 重試、heal_and_flee 流程、逾時與 deadline 的關係、圖片快取目錄重建)我沒有發現確定的問題。

The guard added for the category race compared the jump target instead of the page the request started from, so the next page could never land and the counter never moved. The test for the list refresh caught it.
A refresh after loading more pages has to read the first page again and move the counter back with it, and only the plugin's recover 404 (and an image that is missing) may stay out of the network error banner.
@github-actions

Copy link
Copy Markdown

審查總結

這個 PR 很大(60+ 檔),我主要審查了共用層與核心邏輯:Kotlin HttpClient/MainActivity、net_client_provider、image_cache_provider、pokemon_repository、battle_cubit。沒有發現確定會出錯的阻擋性 bug,以下是我有把握的觀察,依重要性排序。

1. 寫操作逾時後請求不會被取消,可能造成重複扣款/重複回合(中)

PokemonRepository._withDeadline(pokemon_repository.dart)用 Future.timeout 在 45s 後回報 HttpRequestFailedException,但底層請求並未取消。buy、buyPet、turn、capture 等寫操作逾時時,伺服器可能已經(或稍後才)執行,UI 卻顯示「網路失敗」,使用者重試就可能重複購買或多打一回合。

  • Android 上 45s 大於 OkHttp 的 30s read timeout,風險較小。
  • 非 Android 平台由 Dio 的 20s receiveTimeout 先觸發,風險較大。
  • 另外 _ensureFormHash 的讀取(最長 20s)在寫入 deadline 之外,一次寫操作最壞可達約 65s,與註解「deadline 要蓋過傳輸層逾時」的設計不完全一致。

建議:逾時後對寫操作走 resync/重新讀取狀態,並在 UI 提示「結果未知」而非「失敗」。

2. BattleCubit.start 的失敗分支沒有檢查 generation(低~中)

成功分支使用 _emitSceneIfCurrent(..., generation),但 Left 分支直接 emit(failure/needLogin, actionInProgress: false)。如果 start 進行中發生 onAppResumed(generation 已遞增,且已重新讀回戰鬥),過期的失敗回應仍會把頁面切成 failure,並把 actionInProgress 清掉,與其他路徑「過期答案不得動畫面」的約定不一致。建議失敗分支也比對 generation。

3. 測試缺口

我用關鍵字搜尋 test_176–184,沒有找到 onAppResumed、resync、healAndFlee、postJson/singleAttempt 的引用,以下路徑似乎沒有測試覆蓋:

  • BattleCubit.onAppResumed / resync 的 generation 競態(本 PR 最複雜、也最容易出錯的部分)。
  • Dart 端 postJson 與 KotlinHttpClient 對 application/json 的 singleAttempt 放行。
  • Kotlin 端 needsMoreTime(依 URL/header 判斷用哪個 client)、isOneShot 包裝:沒有任何 Kotlin 單元測試。
  • _ErrorHandler._isExpectedMissing:缺少「非 image/非 recover 的 404 仍會報錯」的反向測試。

4. 小提醒(不阻擋)

  • needsMoreTime 用 url.contains("id=pokemon") 與 X-Pm-Formhash header 判斷,屬於字串啟發式;之後若有其他 URL 含此片段會誤用寬鬆 timeout。
  • 圖片快取改成「先寫檔、再寫 DB」是正確方向,但寫檔失敗時直接 return,usage 對應的其他 cache ref 表也一併跳過,請確認這是預期行為。

整體邏輯(formhash 快取與帳號切換、isOneShot 防重送、_decode 捕捉 Object)看起來考慮得很周全。

註:我沒有逐行審查所有 view/model/i18n 檔案,也沒有實際執行測試。

start guarded its success path with the generation but not its failure path, so an answer that a resume or a newer action had already made stale could still switch the page to the failure state and unlock it. The write deadline comment now also says that the formhash read happens before it.
@bbtu1

bbtu1 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

@claude 逐条回一下:

  1. 写超时不会取消:已知,客户端消不掉(CancelToken 在 Android 走 method channel 停不了原生请求)。思路是「传输层先报错、我们后兜底」:Android 45s 大于 read 30s;非 Android 由 Dio 的 20s receiveTimeout 先触发,也是传输层自己报的。回读已经做了:buy / buyPet / _runVoid 失败会重读金钱和背包,战斗失败会 resync。「提示结果未知」是 UX 文案,我倾向不加(玩家能看到金钱/背包已经刷新),要加我再补三语文案。你提的 formhash 读取在 deadline 之外(最坏约 65s)属实,那不是矛盾,是每个请求各有自己的上限,这点我写进注释了。
  2. start 的失败分支没比对 generation —— 属实,已修(成功分支本来就比对了)。
  3. 测试:resync 的竞态在 test_181 已有(「传输失败后重读」「被新动作超前」);「非 image/非 recover 的 404 仍会报错」上一轮加了 test_184(3 条用例)。还缺的是 onAppResumed 自己的竞态测试和 Kotlin 侧(原生层 Dart 测不到),这两个先留着。
  4. needsMoreTime 的字符串启发式是取舍:不动 method channel 协议,用请求上现成的语义信号判断;图片缓存写档失败时跳过 ref 表是预期行为(那张表指向的正是没写成功的文件)。

本地 flutter test 1826 passed、strict analyze 干净,CI 已重跑。看一下还有问题吗?

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @bbtu1's task in 16s —— View job


复核结果

我对照分支上的代码看了 battle_cubit.dart 和 pokemon_cubit.dart,没有跑测试和 analyze,下面都是静态阅读的结论。

你的四点回复,我都认可:

  1. 写超时不取消:理由成立。传输层先报错、我们后兜底,这个模型说得通。不加「结果未知」文案也可以,前提是 buy / buyPet / _runVoid 失败后确实会重读并刷新金钱和背包。
  2. start 的失败分支:battle_cubit.dart:197 已经比对了 generation,确认已修。
  3. 测试:onAppResumed 竞态测试和 Kotlin 侧测试先留着,我没有异议,建议在 PR 说明里记一笔,方便后面补。
  4. needsMoreTime 的字符串启发式和「写文件失败时跳过 ref 表」:这两个取舍都合理。

之前几轮的问题,我抽查了一下:

  • loadMoreInventory / loadMoreShop 的守卫已经改成 != next - 1(pokemon_cubit.dart:409、457、483),「载入更多永远不附加」的问题已经解决。
  • _runAction 和 useItem 里的 _turn++ 和 actionInProgress 清除都带了 generation == _generation 判断(battle_cubit.dart:366、380、419、430)。
  • forgetFinishedBattle 已在 start 成功时调用(battle_cubit.dart:209)。

还剩一处小的:

  • healAndFlee(battle_cubit.dart:232 和 242)先 emit(actionInProgress: true),finally 里无条件 emit(actionInProgress: false),没有 generation 判断。如果它进行中发生了 onAppResumed 或 start,回来时会清掉新一代请求的忙碌状态。它只在战斗结束页调用,影响很小,所以不算阻塞。想保持一致的话,跟 _runAction 一样在进入时捕获 generation,finally 里比对即可。

除此之外我没有发现新的问题。

It emitted the busy state and cleared it in its finally without checking the generation, so a resume or a newer action could have its busy state cleared by a heal that started before it. fightAgain needs no such guard: it has no finally of its own and leaves the flag to start.
@bbtu1

bbtu1 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

@claude这条也修了:

healAndFlee 进入时捕获 generation,finally 里比对,过期就不再清忙碌态(只捕获、不递增,避免顺带作废插件在途的答复)。
另外确认了 fightAgain:它没有自己的 finally,忙碌态是交给 start 清除的,而 start 的失败分支上一轮已经补了 generation 守卫,所以不用再改。后面再重新审查此PR,重点看:正确性与潜在bug、并发问题(治愈与战斗的重读路径)、Dart / Kotlin 末端postJson 的因果、图片快取的悲剧,以及安全性。

@bbtu1

bbtu1 commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

@claude 这条也修改了:

healAndFlee进入时捕获生成,终于里比对,过渡就不再清处理态(只捕获、不递增,避免顺带作插件在途的答复)。
另外确认了fightAgain:它没有自己的最后,再态是状态开始清除的,而开始的失败线索上已经补了生成守卫,所以不用改。后面重新再审查此PR,重点看:正确性与废潜在bug、野生问题(固化与战斗的重读路径)、Dart / Kotlin结束postJson的因果、图片快取的悲剧,以及安全性。

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @bbtu1's task in 2m 30s —— View job


PR #150 再次審查

  • 確認 healAndFlee 的 generation 修正與 fightAgain 的說明
  • 正確性與潛在 bug(battle_cubit / pokemon_cubit / repository)
  • 並發:治療與戰鬥的重讀路徑
  • Dart / Kotlin 兩端 postJson
  • 圖片快取改動
  • 安全性

範圍:我看了分支 HEAD d601a09 上的 battle_cubit.dart、pokemon_cubit.dart、pokemon_repository.dart,以及 git diff origin/master HEAD 裡的 HttpClient.kt、MainActivity.kt、net_client_provider*.dart、image_cache_provider.dart、show_toast.dart。這次沒有執行 flutter test 和 analyze,以下都是讀程式碼得出的結論。views、models 只抽查了和下列問題有關的部分。


上一輪的兩點:已確認

  • healAndFlee(battle_cubit.dart:233、244):進入時記下 generation 但不遞增,finally 裡比對後才清除忙碌狀態,做法正確。不遞增的理由也成立:遞增會把其他還在路上的回應一起作廢。
  • fightAgain:它沒有自己的 finally,忙碌狀態交給 start 清除,而 start 的兩個分支都已比對 generation(:197、:210)。這個說法我同意。

需要處理

1. 背包和商店的「載入更多」在切換分類後,會把舊分類的清單蓋到新分類上(正確性,中)

位置:pokemon_cubit.dart:409、:457、:483。

現在的判斷是 state.inventoryPage != next - 1。但 setInventoryCategory 和 refreshInventory 都會把頁碼重設為 1,而最常見的載入更多正好是從第 1 頁開始(next == 2),所以這個判斷擋不住:

  1. 背包在「全部」分類下,捲到底,開始讀第 2 頁(current 是「全部」的第 1 頁)。
  2. 讀取還沒回來時,玩家點了「球」,inventoryPage 回到 1,畫面載入「球」的第 1 頁。
  3. 第 2 頁的回應到達,判斷 1 != 1 為 false,於是 emit [...「全部」第 1 頁, ...「全部」第 2 頁],並把頁碼設為 2。
  4. 結果:分類 chip 顯示「球」,清單內容卻是「全部」,而且之後的載入更多會從錯誤的頁碼繼續。

商店的一般分頁同樣有這個問題(例如在 med 和 ball 之間切換),因為那裡只檢查 isPet,不看 type。另外,buy 或 useItem 之後的 refresh* 也會把頁碼重設為 1,如果這時剛好有一個從第 1 頁出發的載入更多還在路上,也會這樣覆蓋。

建議:改成比對清單本身,例如 if (!identical(state.inventory, current)) return;(商店用 state.shop 或 state.shopPets)。切換分類、重新整理、上一次載入更多都會換掉這個物件,所以能一次涵蓋所有情況。測試可以放在 test_181:先切換分類,再讓 fake repo 回傳第 2 頁。

Fix this →

2. 過期動作的傳輸失敗仍會觸發 resync,可能讓畫面退回到新動作之前的場景(並發,中低)

位置:battle_cubit.dart:415。

_runAction 的失敗分支沒有檢查 generation 就呼叫 unawaited(resync())。resync 記下的是當下的 _generation,不是觸發它的那個動作的 generation。

  1. 送出動作 A,A 被 OS 暫停。回到前景後,onAppResumed 把 generation 遞增到 g1。
  2. 玩家送出動作 B,generation 變成 g2,忙碌狀態為 true。
  3. A 這時才傳回連線錯誤,觸發 resync(),而它記下的是 g2。recover 和 B 的 turn 在不同連線上同時送到伺服器,伺服器可能先處理 recover。
  4. B 先回來,emit B 的場景並清除忙碌狀態。
  5. resync 回來時,actionInProgress == false 且 generation 仍是 g2,於是把 B 之前的場景蓋上去。這時 HP、PP 都是舊的,_turn 卻已經加過一次。

A 已經過期,onAppResumed 自己也會做一次 recover,所以這次 resync 本來就是多餘的。

建議:改成 if (_messageOf(e) == null && generation == _generation) unawaited(resync());。

Fix this →

3. fightAgain 在結束畫面的 instanceId 為 0 時,會要求伺服器治療 pokemon 0(正確性,低到中)

位置:battle_cubit.dart:286。

finishDefeat 和 _runningScene 的註解都寫到:結束畫面的 myPokemon.instanceId 可能是 0。fightAgain 卻直接呼叫 healPet(scene.myPokemon.instanceId)。

  • 這次治療會失敗,而且錯誤被忽略,受傷但沒倒下的寵物會帶著殘血和缺 PP 開下一場。start 的「拒絕後治療再重試」只處理倒下或異常狀態,接不住這種情況。
  • 建議:和 finishDefeat 一樣,先用 _runningScene?.myPokemon.instanceId 補上 id;仍然拿不到時改呼叫 healParty()。

較小的問題

  • fightAgain 和 onAppResumed 同時發生:fightAgain 會把忙碌狀態設為 true。如果在 healPet 或 start 進行中回到前景,recover 可能在新戰鬥建立之前就讀到「沒有戰鬥」,回傳 gone,而 battle_page.dart:104 會 pop()。接著 start 仍會在伺服器上開出一場新戰鬥:cubit 已經關閉,所以不會呼叫 forgetFinishedBattle,要等冒險頁的 recover 才會撿回來。實際影響是玩家多看到一次「戰鬥已結束」然後被帶回冒險頁,機率低,記錄一下就好。
  • _runVoid 在回應遺失後只重讀金錢和背包(pokemon_cubit.dart:638):release、movePokemon、setFirst、rename 改動的是寵物清單,回應遺失後清單不會更新。例如放生其實成功了,畫面上卻還看得到那隻寵物,再按一次只會得到伺服器的錯誤訊息,不會造成實際損害。可以考慮在這條路徑一併呼叫 refreshPokemons()。
  • refreshShop 非寵物分支只檢查 isPet(:384):在兩個物品分類之間快速切換時,較慢回來的舊分類回應可能覆蓋新分類。第 1 點的 identical 做法不適用這裡,因為重新整理會先清空清單。這裡建議先記下 category,回應時比對 state.shopCategory == category,寫法和 refreshInventory 一樣。

Dart / Kotlin postJson:沒有發現問題

  • 不重送:JSON 寫入走 relaxedSingleAttemptClient,它設定了 retryOnConnectionFailure(false)、不跟隨重導向,body 也標成 isOneShot。isOneShot 很關鍵:OkHttp 遇到 408、421、503 + Retry-After: 0 時,是在 RetryAndFollowUpInterceptor 裡檢查 isOneShot 才決定不重送,只關掉 retryOnConnectionFailure 擋不住這幾種情況。兩層都有,這樣是對的。
  • 通道參數:singleAttempt 從 extra[singleAttemptHttpRequestKey] 一路傳到 _postJson 再到 Kotlin,兩端的 method 名稱都是 postJson。Content-Type 以 ; 前半段分派,body as String 會成立。body 自帶的 content type 會覆蓋 header,不會衝突。
  • 逾時:needsMoreTime 以 id=pokemon 判斷,讀 formhash 的頁面(id=pokemon:game)也會命中,上一輪提到的「formhash 讀取只有 10 秒」已經解決。論壇其他請求仍用原本的 client。
  • 非 Android 平台:Dio 預設的 adapter 本來就不會重送 POST,followRedirects: false 也有設定。
  • MainActivity 裡 val headers / val body 遮蔽了外層同名變數,這和既有分支的寫法一致,只是會有 warning。

圖片快取:一處小問題

  • 「先寫檔、再寫 DB」加上重建目錄,方向正確。
  • 小風險:writeAsBytes 寫到一半失敗(例如空間不足)時,會留下截斷的檔案。如果這個 URL 之前已經有 DB 記錄(例如之前的檔案被系統清掉後重新下載),下次讀取時 existsSync() 為 true,讀到的是壞掉的圖片。建議在 catch 裡嘗試刪除這個檔案,或先寫到暫存檔再 rename。
  • updateImageCache 本身拋出例外時,仍會落到外層的 on Exception,被當成圖片載入失敗。這是原本就有的行為,不是這個 PR 造成的,而且 PR 說明只提到「寫檔失敗」,所以不算回歸。

安全性:沒有發現問題

  • formhash 只放在送往 pokemonApiBase(同一主機)的 header,而且依 uid 隔離。_readFormHash 在寫入快取前會再比對一次 uid,切換帳號時不會污染新帳號。
  • 日誌只記錄 method、URI、status 和截短的資料預覽,沒有記錄 cookie 或 formhash 的值。
  • getCacheFile 保留了防止路徑跑出快取目錄的檢查。
  • 伺服器的 error 字串只當純文字顯示。

結論

第 1、2 點建議在合併前修掉,兩者都可以用 fake repository 寫成 cubit 測試。第 3 點改動很小,也建議一起處理。其他項目不阻擋合併。

@Carinoasd

Copy link
Copy Markdown
Owner

@claude 你來處理這塊

@claude

claude Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Claude finished @Carinoasd's task in 3m 15s —— View job


處理上一輪審查的問題

  • 閱讀相關程式碼
  • 1. 載入更多在切換分類或重新整理後會覆蓋清單(pokemon_cubit.dart)
  • 2. 過期動作的傳輸失敗仍會觸發 resync(battle_cubit.dart)
  • 3. fightAgain 在 instanceId == 0 時會去治療 pokemon 0
  • 較小項:_runVoid 回讀寵物清單、refreshShop 比對分類、圖片快取刪除截斷檔
  • 在 test_181 補了 4 個測試
  • ⚠️ 執行 analyze / test:這個環境沒有執行 flutter 的權限
  • ⚠️ 推送:被 GitHub 拒絕,修改沒有進入分支,原因見下方

⚠️ 推送失敗,修改目前不在分支上

本地提交 647e47e 已經建立,但推送被 GitHub 拒絕:

! [remote rejected] HEAD -> feat/pokemon-center (refusing to allow a GitHub App to create or update workflow `.github/workflows/claude-code-review.yml` without `workflows` permission)

這次的提交沒有碰到 .github/workflows。CI 的 checkout 是 shallow clone,邊界 commit 188a309 在本地看起來像是新增了整個 .github/workflows/。我推測遠端分支和我拿到的 d601a09 已經不一致,例如作者在觸發後 force-push 過,所以 GitHub 把那些 workflow 檔案也當成這次推送的新內容。但我沒有權限執行 git fetch 或 ls-remote,所以沒辦法確認。

GitHub App 本來就不能修改 workflow 檔案,詳見 FAQ。有兩種做法:

  • 確認分支 HEAD 還是 d601a09、沒有被改寫後,再 @claude 一次,我會重新套用。
  • 或直接套用下方的 diff(基於 d601a09)。

修改內容

1. 載入更多:改成比對清單本身,不再比對頁碼(pokemon_cubit.dart)

  • loadMoreInventory、loadMoreShop 的道具和寵物分支,都改用 identical(state.inventory / shop / shopPets, current) 判斷。
  • 切換分類、重新整理、上一次載入更多,都會換掉清單物件,所以這一個判斷就能涵蓋所有情況。
  • 原本的頁碼判斷擋不住:切換分類會把頁碼重設為 1,剛好就是載入更多出發時的頁碼。

2. 過期動作不再觸發 resync(battle_cubit.dart,_runAction)

  • 條件改成 _messageOf(e) == null && generation == _generation。
  • 過期的動作如果還去重讀,會拿到較新動作的 generation,可能把新動作之前的場景蓋回畫面上。
  • 如果是 onAppResumed 讓動作過期,它自己本來就會重讀一次。

3. fightAgain 治療正確的寵物(battle_cubit.dart)

  • 結束畫面的 instanceId 為 0 時,改用 _runningScene 的 id,做法和 finishDefeat 一樣。
  • 兩者都拿不到 id 時,改為呼叫 healParty()。

較小項

  • refreshShop 的道具分支會先記下 category,回應回來時比對 state.shopCategory == category,避免兩個道具分類之間快速切換時被舊回應覆蓋。
  • _runVoid 在回應遺失時,除了金錢和背包,也會重讀寵物清單。release、movePokemon、setFirst、rename 改的都是寵物清單。
  • _saveCache 寫檔失敗時會刪掉截斷的檔案,避免之前留下的 DB 記錄指向壞圖。刪除本身失敗的話就忽略,因為前面已經記過 warning。

新增測試(test_181)。fake forum 加了 turnDelay 和 pageTwoDelay 兩個參數:

  • a load-more the player switched category under is dropped
  • a stale action that loses its answer does not read the battle back
  • fight again heals the pet the running scene named when the end scene lost its id
  • fight again heals the party when no scene named the pet

沒有驗證:這些測試和 strict analyze 我都沒辦法在這裡執行,套用後請跑一次 flutter test test/regression/test_181_pokemon_cubit_lifecycle_test.dart。「fightAgain 在 onAppResumed 期間」那個低機率情境,這次沒有處理。

完整 diff(基於 d601a09)
--- a/lib/features/pokemon/cubit/battle_cubit.dart
+++ b/lib/features/pokemon/cubit/battle_cubit.dart
@@ -283,7 +283,16 @@
     emit(state.copyWith(actionInProgress: true));
     final scene = state.scene;
     if (scene != null && _scenePetNeedsHealing(scene)) {
-      await healPet(scene.myPokemon.instanceId);
+      // The end-of-battle answer may leave the pokemon's id at 0, and healing pokemon 0 would fail silently and send the
+      // pet into the next fight hurt: take the id from the last running scene, or heal the whole party without one.
+      final instanceId = scene.myPokemon.instanceId > 0
+          ? scene.myPokemon.instanceId
+          : _runningScene?.myPokemon.instanceId ?? 0;
+      if (instanceId > 0) {
+        await healPet(instanceId);
+      } else {
+        await healParty();
+      }
       if (isClosed) return;
     }
@@ -411,8 +420,10 @@
         (e) {
           // The answer never arrived, so the scene on screen may no longer be the server's: read it again instead of
-          // leaving the player acting on a stale one.
-          if (_messageOf(e) == null) unawaited(resync());
+          // leaving the player acting on a stale one. A stale action (a resume or a newer action came in) must not: the
+          // read-back would take the newer generation and could paint a scene from before the newer action over it,
+          // and the resume reads the battle again itself.
+          if (_messageOf(e) == null && generation == _generation) unawaited(resync());
--- a/lib/features/pokemon/cubit/pokemon_cubit.dart
+++ b/lib/features/pokemon/cubit/pokemon_cubit.dart
@@ refreshShop
-    final result = await _repository.getShop(type: state.shopCategory.type).run();
+    final category = state.shopCategory;
+    final result = await _repository.getShop(type: category.type).run();
     if (isClosed) return;
     result.fold((_) => null, (shop) {
-      if (state.shopCategory.isPet) return;
+      // The player may have switched to another item category (or to the pets) while the answer was on its way.
+      if (state.shopCategory != category) return;
@@ loadMoreInventory
-          if (state.inventoryPage != next - 1) return;
+          if (!identical(state.inventory, current)) return;
@@ loadMoreShop (pets)
-            if (!state.shopCategory.isPet || state.shopPage != next - 1) return;
+            if (!identical(state.shopPets, current)) return;
@@ loadMoreShop (items)
-          if (state.shopCategory.isPet || state.shopPage != next - 1) return;
+          if (!identical(state.shop, current)) return;
@@ _runVoid
-      if (result.isLeft() && message == null) unawaited(_reloadAfterLostAnswer());
+      if (result.isLeft() && message == null) unawaited(_reloadAfterLostWrite());
       ...
-      unawaited(_reloadAfterLostAnswer());
+      unawaited(_reloadAfterLostWrite());
@@ after _reloadAfterLostAnswer
+  /// [_reloadAfterLostAnswer] plus the pokemon list, for the writes [_runVoid] runs on the party.
+  Future<void> _reloadAfterLostWrite() async {
+    await Future.wait([_reloadAfterLostAnswer(), refreshPokemons()]);
+  }
--- a/lib/shared/providers/image_cache_provider/image_cache_provider.dart
+++ b/lib/shared/providers/image_cache_provider/image_cache_provider.dart
@@ -397,6 +397,13 @@
     } on FileSystemException catch (e) {
       warning('could not save the image cache (${imageUrl.length} char url): ${e.message}');
+      // A write that failed halfway (a full disk) leaves a truncated file; a row an earlier save left for this url would
+      // then serve a broken image, so drop the file and let the next load download it again.
+      try {
+        if (cache.existsSync()) await cache.delete();
+      } on FileSystemException catch (_) {
+        // Best effort: the warning above already reports the failed save.
+      }
       return;

test_181 的 4 個新測試太長,這裡沒有全部貼上。重新觸發之後,我會連同測試一起推送。

…he right pet on fight again

- loadMore* compares the list it started from instead of the page number: a category switch or a refresh resets the
  page to 1, the very page most load-mores start from, so the old guard let the old category's pages through.
- refreshShop's item branch compares the category it asked for.
- A stale battle action that lost its answer no longer reads the battle back over a newer action's scene.
- fightAgain takes the pet id from the running scene when the end scene lost it, and heals the party without one.
- _runVoid reads the party back too after a lost answer (release, move, set first and rename change it).
- A failed image cache write drops the truncated file.

Adds four regression tests to test_181.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Repository owner deleted a comment from claude Bot Sep 30, 2026
@Carinoasd
Carinoasd merged commit 744f0f3 into Carinoasd:master Sep 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants