Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
188a309
feat(pokemon): add models, translations and plugin api types
bbtu1 Sep 30, 2026
f6627f1
feat(net): support the plugin json api and make its requests diagnosable
bbtu1 Sep 30, 2026
6dd1687
feat(pokemon): add the repository, its caches and per-call deadlines
bbtu1 Sep 30, 2026
849cfcc
feat(pokemon): add the pokemon and battle cubits
bbtu1 Sep 30, 2026
637ba61
feat(pokemon): add pages, tabs and widgets
bbtu1 Sep 30, 2026
43a7b4e
feat(pokemon): wire routes and entry points
bbtu1 Sep 30, 2026
5c4c7c2
fix(cache): never fail an image load because its cache write failed
bbtu1 Sep 30, 2026
8b9bfe0
fix(ui): skip a snack bar once its messenger is gone
bbtu1 Sep 30, 2026
4e5b17f
chore: ignore the coverage output
bbtu1 Sep 30, 2026
bc975d0
test(pokemon): add the pokemon centre regression tests
bbtu1 Sep 30, 2026
e4a4007
docs(pokemon): document the module and its server quirks
bbtu1 Sep 30, 2026
aafc909
fix(pokemon): use an if-null operator instead of comparing a nullable…
bbtu1 Sep 30, 2026
664d0a2
test(pokemon): wait for the battle re-read instead of assuming a fixe…
bbtu1 Sep 30, 2026
1ccfe0c
fix(net): never replay a json write, and only silence the 404s the ap…
bbtu1 Sep 30, 2026
0d7b022
fix(pokemon): mark the plugin writes single-attempt and bind the cach…
bbtu1 Sep 30, 2026
7f100db
fix(cache): write the image file before its cache row
bbtu1 Sep 30, 2026
148defc
fix(pokemon): keep a stale battle state from surviving a newer action
bbtu1 Sep 30, 2026
7543c5a
fix(pokemon): read the profile and the bag back after a write that lo…
bbtu1 Sep 30, 2026
e93f97b
fix(ui): keep the snack bar helper comment a line comment
bbtu1 Sep 30, 2026
3aacc09
test(pokemon): cover the single-attempt writes, the per-account formh…
bbtu1 Sep 30, 2026
d704078
fix(pokemon): catch decode errors, and forget a battle the client sta…
bbtu1 Sep 30, 2026
177e5f4
fix(net): keep the wider http timeouts for the plugin api and images …
bbtu1 Sep 30, 2026
9556247
fix(pokemon): end a lost battle the end scene no longer names
bbtu1 Sep 30, 2026
515b2dd
fix(pokemon): give a write longer than the transport timeout before r…
bbtu1 Sep 30, 2026
4c07067
fix(net): give the plugin api its wider timeouts even without its header
bbtu1 Sep 30, 2026
472ec3a
fix(pokemon): keep a stale answer from unlocking the page, and do not…
bbtu1 Sep 30, 2026
1f49ca0
fix(pokemon): read the money and the bag back after a purchase that l…
bbtu1 Sep 30, 2026
cf93789
fix(net): only keep recover's 404 out of the error banner
bbtu1 Sep 30, 2026
1d7c880
fix(pokemon): drop a resume's scene if a newer action took over
bbtu1 Sep 30, 2026
dde6cd8
fix(pokemon): keep a stale list answer out of the current category
bbtu1 Sep 30, 2026
cd2e19e
fix(pokemon): reload the lists from their first page and for the sele…
bbtu1 Sep 30, 2026
380db17
fix(pokemon): keep a write alive past the transport timeouts, and sto…
bbtu1 Sep 30, 2026
be88c3e
fix(pokemon): compare the page a load-more started from
bbtu1 Sep 30, 2026
00fed85
test(pokemon): cover the list refresh reset and the 404 exemption
bbtu1 Sep 30, 2026
a9cae0b
fix(pokemon): keep a stale start failure from switching the page state
bbtu1 Sep 30, 2026
d601a09
fix(pokemon): keep healAndFlee's busy state from a stale answer too
bbtu1 Sep 30, 2026
26a0b6d
fix(pokemon): drop a load-more a category switch overtook, and heal t…
Carinoasd Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ schema_versions.dart
/web/sqlite3.debug.wasm
/web/sqlite3.wasm

# Coverage output of `flutter test --coverage`
/coverage/

# Genetated files
/lib/generated
*.g.dart
Expand Down
82 changes: 73 additions & 9 deletions android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,60 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import java.net.ProxySelector
import java.util.concurrent.TimeUnit

object HttpClient {
private val client by lazy {
OkHttpClient.Builder().proxySelector(ProxySelector.getDefault()).build()
}
// The forum answers quickly, so its requests keep the OkHttp defaults (10s): a page under a weak network must fail
// as fast as it always did.
private val client by lazy { OkHttpClient.Builder().proxySelector(ProxySelector.getDefault()).build() }

// Share connections and dispatchers, but never replay a non-idempotent transaction.
private val singleAttemptClient by lazy {
// The plugin api and the image cdn answer slowly under load (the 10s defaults timed out the pokemon heal and the
// sprite downloads on slower devices), so the requests that go there get more time.
private val relaxedClient by lazy {
client.newBuilder()
.retryOnConnectionFailure(false)
.followRedirects(false)
.followSslRedirects(false)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}

// Share connections and dispatchers, but never replay a non-idempotent transaction.
private val singleAttemptClient by lazy { noReplay(client) }
private val relaxedSingleAttemptClient by lazy { noReplay(relaxedClient) }

private fun noReplay(base: OkHttpClient) = base.newBuilder()
.retryOnConnectionFailure(false)
.followRedirects(false)
.followSslRedirects(false)
.build()

private fun pick(relaxed: Boolean, singleAttempt: Boolean) = when {
relaxed && singleAttempt -> relaxedSingleAttemptClient
relaxed -> relaxedClient
singleAttempt -> singleAttemptClient
else -> client
}

// The plugin's own pages and api answer slowly, and so does the image cdn: those requests get the wider timeouts.
// The url matters most: the read that fetches the session formhash carries no plugin header yet, and a request made
// without one (a formhash that could not be read) has to keep the wider timeouts too.
private fun needsMoreTime(url: String, headers: Map<String, String>): Boolean {
if (url.contains("id=pokemon")) return true
val wantsImage = headers.entries.any { (key, value) ->
key.equals("Accept", ignoreCase = true) && value.startsWith("image/")
}
return wantsImage || headers.keys.any { it.equals("X-Pm-Formhash", ignoreCase = true) }
}

suspend fun get(url: String, headers: HashMap<String, String>): Response {
val request = Request.Builder()
.url(url)
Expand All @@ -35,7 +67,7 @@ object HttpClient {

return withContext(Dispatchers.IO) {
try {
client.newCall(request).execute()
pick(needsMoreTime(url, headers), false).newCall(request).execute()
} catch (e: Exception) {
throw e
}
Expand Down Expand Up @@ -103,6 +135,38 @@ object HttpClient {
}
}
}

suspend fun postJson(
url: String,
headers: HashMap<String, String>,
body: String,
singleAttempt: Boolean = false,
): Response {
val jsonBody = body.toRequestBody("application/json; charset=utf-8".toMediaType())

// Same reason as postForm: a write the server may already have carried out must not be sent a second time, so a
// one-shot body (OkHttp checks isOneShot before resending) goes out on the client that does not retry.
val requestBody = if (singleAttempt) object : RequestBody() {
override fun contentType() = jsonBody.contentType()
override fun contentLength() = jsonBody.contentLength()
override fun isOneShot() = true
override fun writeTo(sink: BufferedSink) = jsonBody.writeTo(sink)
} else jsonBody

val request = Request.Builder()
.url(url)
.headers(Headers.headersOf(*headers.toList().flatMap { listOf(it.first, it.second) }.toTypedArray()))
.post(requestBody)
.build()

return withContext(Dispatchers.IO) {
try {
pick(needsMoreTime(url, headers), singleAttempt).newCall(request).execute()
} catch (e: Exception) {
throw e
}
}
}
}

fun buildResponse(
Expand Down
20 changes: 20 additions & 0 deletions android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MainActivity: FlutterActivity() {
const val HTTP_GET = "get"
const val HTTP_POST_FORM = "postForm"
const val HTTP_POST_MULTIPART = "postMultipart"
const val HTTP_POST_JSON = "postJson"

/** Window size events sent to Dart, see `lib/utils/window_events.dart` (GitHub #28). */
const val WINDOW_CHANNEL = "kzs.th000.tsdm_client/windowChannel"
Expand Down Expand Up @@ -250,6 +251,25 @@ class MainActivity: FlutterActivity() {
}

}
HTTP_POST_JSON -> {
val url = call.argument<String>("url")!!
val headers = call.argument<HashMap<String, String>>("headers")!!
val body = call.argument<String>("body")!!
val singleAttempt = call.argument<Boolean>("singleAttempt") ?: false
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
try {
val resp = HttpClient.postJson(url, headers, body, singleAttempt = singleAttempt)
val statusCode = resp.code
val headers = HashMap(resp.headers.toMultimap())
val body = resp.body.bytes()
val isRedirect = resp.isRedirect
result.success(buildResponse(statusCode, headers, body, isRedirect))
} catch (e: Exception) {
Log.e("KT_HTTP_ERROR", "failed to post json: ${e.message ?: "unknown error"}")
result.error("KT_HTTP_ERROR", "failed to perform http POST json", e.message ?: "unknown error")
}
}
}
else -> {
result.notImplemented()
}
Expand Down
5 changes: 5 additions & 0 deletions lib/constants/url.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ const broadcastMessageUrl = '$baseUrl/home.php?mod=space&do=pm&filter=announcepm
/// Broadcast message detail page.
const broadcastMessageDetailUrl = '$baseUrl/home.php?mod=space&do=pm&subop=viewg&pmid=';

/// Base url of the pokemon (宠物中心) plugin JSON API.
///
/// Requests add `&endpoint=<name>&action=<action>` to this base, e.g. `&endpoint=pokemon&action=list`.
const pokemonApiBase = '$baseUrl/plugin.php?id=pokemon:pokemon';

/// The Discuz! built-in guide index page (`forum.php?mod=guide&view=index`).
///
/// Lists the four guide modules 最新热门 (hot), 最新精华 (digest), 最新回复 (new) and 最新发表 (newthread) with a few
Expand Down
13 changes: 13 additions & 0 deletions lib/exceptions/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,16 @@ final class AntitheftChallengedRequestException extends AppException with Antith
/// Url of the challenged request.
final String url;
}

/// The pokemon plugin JSON API answered an error envelope (`success: false`).
///
/// [message] carries the human-readable `error` field, [code] the optional numeric `code` field (e.g. 401 when a login
/// is required, 400 for a rejected action).
@MappableClass()
final class PokemonApiException extends AppException with PokemonApiExceptionMappable {
/// Constructor.
PokemonApiException(String message, {this.code}) : super(message: message);

/// Optional error code from the plugin API.
final int? code;
}
5 changes: 5 additions & 0 deletions lib/features/homepage/view/homepage_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,11 @@ class _HomepagePageState extends State<HomepagePage> {
if (username != null) ...[
if (showDailyActionsInBar) const CheckinButton(enableSnackBar: true),
const NoticeButton(),
IconButton(
icon: const Icon(Icons.catching_pokemon),
tooltip: context.t.pokemon.title,
onPressed: () async => context.pushNamed(ScreenPaths.pokemon),
),
IconButton(
icon: SizedBox(
width: 32,
Expand Down
1 change: 1 addition & 0 deletions lib/features/homepage/widgets/home_dashboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ class HomeToolsCard extends StatelessWidget {
(Icons.star_outline, tr.welcome.favorite, ScreenPaths.favorite),
(Icons.history_outlined, tr.welcome.history, ScreenPaths.threadVisitHistory),
(Icons.account_balance_outlined, context.t.bank.title, ScreenPaths.bank),
(Icons.catching_pokemon, context.t.pokemon.title, ScreenPaths.pokemon),
];
return Card(
margin: EdgeInsets.zero,
Expand Down
102 changes: 102 additions & 0 deletions lib/features/pokemon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 宠物中心(pokemon)

Discuz! X5 宠物插件(`plugin.php?id=pokemon:pokemon`)的原生客户端,功能包括:我的宠物 / 宠物中心 / 背包 /
商店、宠物详情与装备、冒险地图与战斗。

服务端是论坛的插件,客户端只走它的 JSON API(`endpoint=...`),不解析游戏页面。本文件记录**这一模块的结构与
必须知道的约定**,改动前先读一遍。

## 目录结构

| 目录 | 职责 |
| --- | --- |
| `models/` | 接口 DTO(`dart_mappable` + 容错 `fromMap`)与业务判定。`pokemon.dart` 放宠物自身的规则(`needsHealing`、`negativeStates`、`isCarried/isStored/isActive`);`models.dart` 是 barrel |
| `repository/` | 所有 HTTP。`pokemon_repository.dart` 是唯一的接口入口;`adventure_cache.dart` 是跨页缓存(地图、最高等级、本会话已结束的战斗);`skill_order_store.dart` 存技能顺序(本机) |
| `cubit/` | `pokemon_cubit.dart`:宠物中心页的状态与动作;`battle_cubit.dart`:战斗状态机 |
| `view/` | 页面与 tab(`pokemon_page` 外壳 + `my_pokemon_tab`/`pokemon_center_tab`/`inventory_tab`/`shop_tab`,以及 `adventure_page`、`battle_page`、`pokemon_detail_page`、`pokemon_equipment_page`、`pokemon_storage_page`)。`battle_page.dart` 是页面与状态机,展示型的私有部件在 `battle_view.dart`(`part of`)里 |
| `widgets/` | 模块内复用的小部件(与仓库其它 feature 一致,放在 **feature 级** `widgets/`,不要放回 `view/widgets/`) |
| `utils/` | `action_feedback.dart`(接口错误 → 本地化文案)、`pokemon_style.dart`(属性/地区/状态 → 颜色与文案)、`pokemon_dialogs.dart`、`pokemon_image.dart`(图片 URL)、`item_merge.dart`(背包分页合并) |

## 编码约定

- 注释与文档一律**英文 `///`**,行宽 ≤ 120,公开成员必须有文档(严格检查会拦:`dart ./scripts/check_strict_analyzing.dart`)。
- 接口错误统一返回 `Either<AppException, T>`(`AsyncEither`),不要在 repository 里抛异常。
- 模型用 `dart_mappable`;改模型或 i18n 后必须重新生成:`dart run build_runner build`。
- 所有面向用户的文案走 i18n(`context.t.pokemon.*`),三份语言文件都要加:`lib/i18n/{zh-CN,en,zh-TW}.i18n.json`。
- 提交前:`flutter analyze lib`、`dart ./scripts/check_strict_analyzing.dart`、`flutter test`。

## 服务端约定与坑(重点)

改动前务必确认下面这些,它们都是踩过的坑:

1. **`X-Pm-Formhash`**:新版插件对 `api/index.php` 的所有请求做跨站校验,请求头必须带会话 formhash。
客户端在 `_ensureFormHash` 里 GET 一次游戏页解析并缓存,遇到「formhash 校验失败」会重取一次并重试一次。
(服务端改动见插件 PR #59,已先上线、后合并。)
2. **未登录**:返回 **403 + 空体**(旧版是 401 + JSON)。`_decodeEnvelope` 把它归一化成
`PokemonApiException('需要登录', code: 401)`;有异常对象时优先判 `error.code`,只有拿不到对象时才匹配文案。
3. **`state` 状态码**(`plugin/api/pokemon.php: get_pokemon_state_text`):
`0` 濒危、`1` 正常、`2-4` 生病、`5-6` 饥饿、`7` 疲惫、`8-10` 兴奋、`11` 受伤、`12-14` 开心、`15` 惊慌、
`16-17` 自恋、`18-19` 愤怒、`20-22` 虚弱。**只有 12 个负面状态会被治疗重置**,见
`Pokemon.negativeStates`;兴奋/开心/自恋/愤怒是**故意不治**的,把它们当"需要治疗"会每次都白治一遍。
界面上**不要直接显示服务端的中文 `state_text`**,用 `pokemonStateText(context, state, stateText)`。
4. **`site`**:`1` 首发、`2` 替补、`3` 仓库。服务端自己的判定是 `site < 3`,客户端统一用
`isCarried` / `isStored` / `isActive`,不要再写 `site == 1 || site == 2`。
5. **`status: 'defeat'` 不代表战斗结束**:插件在我方倒下但仍可用替补时保留战斗,只是回 `defeat` 让前端弹换宠。
所以战斗页在 `defeat` 后要 `finishDefeat`(= `user&action=heal_and_flee`,必清战斗且顺手治疗)并记住这场战斗,
否则冒险页会用 `recover` 把它拉回来。
6. **治疗(`user&action=heal`)免费且幂等**:无条件回满 HP 与技能 PP,并把负面状态归 `1`。
因此「PP 不满」也是一次有效治疗;`healParty()` 只挑 `needsHealing` 的宠物,并且**并发**发起(一只一个往返太慢)。
**插件不会自动回血**:战斗结束既不清状态也不回 HP/PP(`clear_battle_state` 只清 npc 字段),所以网页版补满是
因为**网页自己请求了 heal**。客户端因此必须主动治,而且**每一种离开战斗页的方式**(结果框「确定」/返回键/逃跑)
都要走到同一处治疗,否则队伍就留在受伤状态。`PokemonRepository` 现在会复用同一个客户端(按账号失效),
`PokemonPage` 也会在**上层页面被 pop 回来时刷新队伍**,否则治好了界面还显示旧的。
7. **开战前不必先治疗**:`battle&action=start` 会在首发宠物不适合出战时拒绝,`BattleCubit.start` 收到该拒绝后
自己治疗一次再重试(`retryAfterHeal`)。这样「再战」常见情况只需一个往返。
8. **容量**:背包与仓库**共用** `pm_usersdata.boxnum`,把宠物放进仓库不会腾出位置;错误文案要解释这一点
(`tr.boxFullHint`)。
9. **技能**:只有 4 个槽,`pm_myskill` 没有"槽位"列,插件的 `learn_skill` 会忽略 `slot_index`,且遗忘要求该技能
**PP 已满**。客户端因此走「先遗忘、再学习」的两步,并把英文报错映射成中文文案。
10. **`recover` 的 404**(`No active battle`)是正常结果(没有进行中的战斗),不要当错误弹给用户。
冒险页会被反复进入(从战斗页返回、切 tab),而几秒之内不可能凭空多出一场战斗,所以 `AdventureCache`
把这次「没有战斗」的答案记住 10 秒(`markNoBattle`/`battleCheckFresh`),期间再进不重复问;一旦开战
(`invalidateBattleCheck`)就立刻作废,避免漏掉自己刚开的那场。
11. **图片**:大图走 CDN `https://img.tsdm39.com/Pokemon/{pm,pmb}/<id>.gif`;小图与道具图标走论坛
`source/plugin/pokemon/images/{spm,item}/...`(X3 时代的 `pokemon_system/images` 路径只有部分子目录还在,
不要用)。
12. **接口文案不稳定**:插件除登录外基本不给稳定 `code`,客户端只能按文案匹配(`pokemonMessageHint` 等)。
新增匹配时集中写在 `utils/action_feedback.dart`,并按「同一判断只写一处」的原则办。

## 测试与覆盖率

宠物相关回归测试(`test/regression/`):`test_176`(模型/图片 URL)、`test_177`(冒险/战斗模型)、
`test_178`(技能/装备/商店模型、错误文案、状态码文案与 `needsHealing`)、`test_179`(弹窗)、
`test_180`(假 Dio:客户端复用与换号重建、formhash 只读一次、`healParty` 的候选筛选与并发、从不返回的调用会超时)、
`test_181`(页面 cubit 生命周期、状态栏开关乐观更新与校正、道具动作的请求与刷新、传输失败后重读战斗)、
`test_182`(背包合并、随身/仓库计数、已结束战斗的记账与跨账号隔离)、
`test_183`(横屏自查:宠物中心 4 个 tab、冒险页、战斗页在 792×368 带挖孔的横屏下不溢出;`recover` 答案的时效)。

需要假网络时,用 `getIt.registerSingleton<NetClientProvider>(NetClientProvider.buildNoCookie(dio: …))` 装一个
带拦截器的 `Dio`(见 `test_180`/`test_181`),不要真发请求。

最近一次 `flutter test --coverage` 的宠物模块行覆盖率:

| 层 | 行覆盖率 |
| --- | --- |
| models | 97.8% |
| repository | 48.1% |
| cubit | 22.0% |
| utils | 44.9% |
| view(页面/部件) | 0.4% |
| **合计** | **22.6%**(全仓 53.5%) |

短板仍很明显:**页面层目前只覆盖到横屏布局**(`test_183` 会真的构建页面外壳、4 个 tab、冒险页与战斗页,
但只断言不溢出),详情页、装备页与各分支逻辑仍未被覆盖。补测试优先从这里下手(页面用 widget test,
纯函数直接测)。

## 尚未处理

- 首次进入宠物中心要等 5 个并行接口(网络往返 ~2.5-3s)**加首次图片下载**,图片是一次性的,接口可以再想
(背包/商店 tab 可以懒加载,`config` 可以做会话缓存 —— 都只省带宽,不省首屏时间)。
- 插件侧问题(本项目已反馈):见 `tsdm-pokemon-plugin-dev/APP_INTEGRATION_ISSUES.md`
(`get_maps` N+1、容量口径、技能槽位、`defeat` 语义、请求头未文档化等)。
- 上游相关 PR:#60(本模块提交的接口字段修复)、#59(formhash 校验)。
Loading
Loading