← Back to Blog

蛙蛙复活计划-日志

《旅行青蛙·中国之旅》游戏保存

2026.9.8得知《旅行青蛙·中国之旅》在蛙蛙的下一个生日要停服了,伤心了好一阵子。

点击查看详细内容

回想起高中时每周末上线收集明信片、装好背包;节气与节日的特殊食物与照片;以及在第一次收到北京的明信片时,发出的漂流瓶,“希望一年半之后 我也能考到这个城市”。时间过得太快太快。高考完,我如愿考到了北京,把蛙蛙寄过来的照片全部印出来,收纳在一本相册,然后不怎么再上线。继续奔波辛劳地活着……几乎不想起以前。前一天半夜睡不着,打开小程序,看到蛙蛙回来了,在庭院里闭着眼睛淋雨——玩了几年,第一次解锁这个彩蛋。独在异乡,面对着日益内卷与异化的环境,我也不再感受雨,而只是被淋湿。曾经玩的时候心想总有一天会遇到这个场景,会集齐所有彩蛋……当时只道是寻常。

其实小青蛙本会走散在2023年的春天

远行与回归……而回归的路更长。

受到小红书用户@呱命由我不由服这篇备份游戏数据的文章的启发,决定开启《旅行青蛙·中国之旅》的离线保存项目,希望可以保留原客户端、资源和自己的存档,在2026.12.8停服后,让原来的客户端可以在本地服务器上运行。

(质疑图恒宇->理解图恒宇->成为图恒宇)

该日志将持续更新该项目的进度与技术实现。由于版权原因,项目内容不公开。

自用备忘

本地服务器地址

python "D:\PKU\CODE\frog\server\server.py" --port 52352

访问模拟器

"D:\leidian\LDPlayer14\adb.exe" shell
su

2026.9.10

开启项目。电脑上安装了雷电模拟器14,可以模拟安卓环境。虽然应用商店已经将游戏下架,但是可以在官网上找到安装包apk。

1. 备份 APK 、应用私有数据、热更新资源

在真正开始写本地服务器之前,需要先尽可能把原始客户端、资源和自己的游戏数据完整留下来。

最先保存的是当前能够正常运行的 APK。《旅行青蛙·中国之旅》的 Android 客户端并不是把所有内容全部打包在 APK 里面。运行以后,它还会在应用私有目录中产生大量 SDK 数据、缓存、配置文件和后来下载的游戏资源。因此下一步是在有 root 权限的 Android 模拟器中,将整个应用私有目录打包保存。

大致流程是:

/data/data/com.aligames.lxqw.hhb

tar 打包

复制到 /sdcard

通过 adb pull 保存到电脑

最终备份出来的私有数据接近 100 MB。 里面除了常见的

shared_prefs
files
code_cache
数据库
WebView 数据
SDK 数据

之外,还有两个后来非常重要的目录:

files/games
files/jsdata

最开始我还怀疑 shared_prefs 或 SQLite 数据库中可能直接保存了完整游戏存档,例如应用中有一个体积比较大的shared_prefs/localhost.xml.但进一步检查后发现,它主要是 Egret 的 localStorage 数据,其中保存了资源版本记录、三叶草显示位置、本地公告以及一些客户端状态,并不是完整的角色存档。几个 SQLite 数据库也主要来自支付、日志、推送、下载器等 SDK,没有找到明显的核心游戏角色数据库。因而也可以看出,角色的主要状态并不完整地保存在 Android 本地,真正的存档必须继续从服务器端保存。

继续检查 files/games 后,发现这里其实保存了游戏运行时下载下来的大量热更新资源。 客户端访问过的资源 URL 被转换成了本地缓存路径。例如可以看到来自游戏热更新服务器的:

release/lingxi/android/1083/
release/lingxi/android/1085/

其中包含:

js/default.thm.js
js/main.min.js
resource/China/default.res.json
resource/China/config/gameConfig.json
各种图片、配置和 EAB 资源

files/games 中大约有几十 MB 的内容,这部分对于游戏保存非常关键。

也就是说,当前真正运行的客户端并不完全等于最初安装的 APK,而更接近:

APK
+
运行时下载的热更新 JS
+
运行时下载的配置
+
运行时下载的资源文件

如果只保存 APK,将来重新安装以后,官方热更新服务器一旦关闭,客户端很可能无法再次获得这些内容。 因此我把当前已经下载到本机的热更新目录也作为项目的重要备份保存了下来。

2. 提取客户端逻辑 main.min.js

热更新资源中最关键的文件之一是main.min.js. 这个文件有一百多万字节,实际上包含了游戏绝大部分 Egret/TypeScript 编译后的 JavaScript 客户端逻辑。

通过阅读和搜索 main.min.js,逐渐能够找到:

RoleModel
ItemModel
TravelModel
UserModel
FurnitureModel
协议列表 ProtocolList
WebSocket 管理代码

以及诸如:

clover_harvest
item_buy
item_putin_bag
item_putin_desk
client_load_role
client_load_events

这样的具体游戏协议。

因此我保存了一份完全不修改的 main.min.js,然后所有实验都在它的副本上进行。

目前大致有:

main.min.js
    原始基线

main.capture.js
    连接官方服务器
    额外输出协议日志

main.offline.js
    强制连接本地服务器

main.offline.stage5.js
    在离线版本基础上增加当前需要的本地协议扩展

3. 找到客户端自带的本地测试模式

阅读 main.min.js 时还发现了一件很有意思的事情:游戏本身其实保留了一个 TestChannel。代码中的默认配置甚至包含ws://127.0.0.1:8080以及一套不依赖正式渠道 SDK 的测试登录逻辑。正式运行时,这些配置会被线上配置覆盖,所以普通客户端最终还是连接官方服务器。但这意味着,我并不需要从零重新写一个 Android 客户端,也不必自己伪造正式渠道 SDK。只需要在自己的离线副本中,在最终配置阶段强制恢复:

ChannelType.Test
ws://127.0.0.1:8080

原来的游戏客户端就能够主动连接一个本地 WebSocket 服务器。 这一发现后来成为整个离线方案的基础。

最终结构是:

Android 模拟器中的原游戏客户端
        |
        | ws://127.0.0.1:8080
        |
     adb reverse
        |
        v
Windows 上的 Python WebSocket Server

4. 给客户端增加协议日志

接下来需要解决的问题是: 客户端代码虽然告诉我它会发送哪些协议,但是不知道官方服务器到底会返回什么。

因此我制作了一个main.capture.js. 这个版本仍然正常连接官方服务器,只是在原本的 Socket 日志代码旁边额外加入了自己的 FROG 日志标记。 因为 Android logcat 对单行日志长度有限制,所以一条较大的 JSON 协议不能直接完整打印。 最终采用的是分块方式:

FROG|消息编号|分块编号|总分块数|内容

例如一条很长的服务器响应会变成:

FROG|31|0|4|...
FROG|31|1|4|...
FROG|31|2|4|...
FROG|31|3|4|...

电脑端再通过 Python 脚本按照消息编号和分块编号重新拼接。

第一次给压缩后的 JavaScript 插入日志代码时,因为原代码处于一个逗号表达式中,我插入了不合适的分号,导致整个 main.min.js 出现 JavaScript SyntaxError,游戏直接无法运行,后来重新寻找更安全的插入位置才修好。

5. 第一次完整抓取登录后的初始化状态、数据脱敏

日志系统工作以后,下一步就是做一次最重要的抓取: 从启动游戏开始,一直到完全进入庭院,记录官方服务器发送的整个初始化过程。

最终这一次抓取成功重组出了几十条完整协议,而且没有出现丢失分块或 JSON 解析失败。

客户端登录完成后会发送:

hall.login
hall.enter_game
annual.load
client.load_all_info

随后服务器连续主动推送大量初始化状态。 这部分数据包括:

client.load_role
clover.load_clovers
item.load_items
item.load_shop_info
mail.load
travel.load_note
client.load_events
album.load_all
guest.load
story.load
furniture.load_furniture
weather.load
calendar.load

以及大量活动和其他系统的数据,总共获得了四十多类初始化 push。

其中最重要的几个是:

client.load_role包含角色基本信息、青蛙状态、三叶草数量、设置等;

clover.load_clovers包含草地中每一株三叶草的状态;

item.load_items包含仓库、背包、餐桌;

item.load_shop_info包含商店购买记录;

client.load_events包含当前尚未处理的旅行事件;

以及album.load_allmail.loadtravel.load_note等长期存档数据。

到这里,第一次真正获得了一份服务器端存档快照。

抓协议时还有一个必须注意的问题:登录阶段不可避免会经过正式服务器的账号和认证数据。所以后来写解析工具时,我把输出明确分成两类:

LOCAL_ONLY只保存在自己的电脑上,用于最大程度保存原始数据;

SHARE_SAFE用于分析、分享和后续记录。

安全版本会自动删除或替换:

token
account
device id
IMEI
OAID
IP
正式登录数据
设备环境数据

等敏感字段。

之后分析单个动作时,也尽量只使用 SHARE_SAFE 文件,而不直接处理或者传播完整原始登录日志。

6. 本地服务器

Stage 1.能够登录

拿到初始化快照后,才开始写第一版 Python WebSocket 服务器。

Stage 1 所完成的工作:

客户端连接

本地测试账号登录

模拟 hall.login / hall.enter_game

客户端发送 client.load_all_info

把之前官方服务器的初始化 push 原样 replay

第一次看到原游戏客户端在完全不连接官方服务器的情况下正常进入庭院,是整个项目的第一个重要节点。

但这时它还只是一个“冻结的存档”。服务器每次启动都只会重新播放原来的快照,所以任何新的操作都无法永久保存。

也正是在这个基础上,后面才开始把 Stage 1 的静态 replay,一步一步改造成现在能够真实修改 state.json 的可写本地服务器。

Stage2.开启备份

将初始化抓取的数据转换为了本地 state.json,之后服务器不再每次从原始抓包重新恢复,而是从这份本地状态文件加载。客户端的一些设置修改,例如client.set_client,已经可以直接写入本地状态。保存时还会自动保留历史备份,避免调试过程中破坏唯一存档。

Stage3.收三叶草☘

在这个基础上,今天第一个真正实现的游戏操作是收三叶草。

为了确认官方服务器的真实行为,我在官方连接模式下只操作了一次普通三叶草,并单独记录这次动作的 WebSocket 协议。抓到的结果:客户端发送 clover.harvest,参数中包含 clover_id;服务器随后先发送 clover.update 更新三叶草总量,再返回包含 clover_id 的响应。

这也证明了一个很重要的思路:客户端源码可以告诉我“客户端会发什么”和“收到数据以后如何处理”,但服务器真正返回什么,仍然最好用实际协议来校准。

根据这次抓包,实现了 Stage 3。现在在本地服中收一棵普通三叶草后,服务器会修改三叶草总数,更新对应草的 last_harvest,保存到 state.json,再按照官方协议顺序向客户端发送 clover.update 和收获响应。

实际测试成功。收获前本地存档中的三叶草数量与官方快照一致,收一棵之后数量正确加一。关闭游戏和服务器后重新启动,新的数量仍然存在,说明这一操作已经不只是客户端 UI 上的临时变化,而是真正进入了本地存档。

Stage4.背包和餐桌

这部分不需要再次抓官方协议,因为客户端代码已经把协议结构暴露得非常完整:item.putin_bag
item.takeout_bag
item.putin_desk
item.takeout_desk

放入物品时发送位置和 item_id,取出时只发送位置。服务器同时维护 housebagdesk 三份状态,并通过 item.update 向客户端同步仓库中物品的绝对数量。

测试中,物品可以正常从仓库放进背包或餐桌,再取回仓库;退出、重启服务器和重新进入游戏之后,位置和数量仍然正确。

Stage5.普通商店购买

这里遇到了一个比较有意思的问题。官方客户端发送 item.buy 时,实际上只把 shop_id 发送给服务器。商品对应的 item_id 和价格都来自客户端自己的 ShopDataDB

官方服务器当然知道每个 shop_id 对应什么商品,但本地服务器并没有官方服务端的配置数据库。因此我对“本地离线版客户端”的协议做了一个很小的扩展:本地模式下,item.buy 除了原来的 shop_id,还会把客户端已经知道的 item_idprice 一并发送给本地服务器。

这样服务器就能完整执行一次购买事务:三叶草扣除商品价格,仓库对应物品数量增加,商店购买次数增加,然后整体写入 state.json,最后通过 clover.updateitem.update 同步给客户端。

实际测试同样成功。购买了 shop_id=0 的商品,价格为 10,服务器日志显示三叶草正确扣除,物品进入仓库;随后将该物品放进餐桌再取出,库存数量也始终正确。

Stage6.旅行系统(进行中)

今天最后开始研究的是旅行系统。

目前已经确认,客户端把青蛙是否在家定义为:

frog.status == 0:在家

frog.status == 1:外出

我当前保存下来的状态中,frog.status=1,游戏内确实只能看到餐桌,看不到背包。

还发现了一个很有意思的细节:青蛙外出以后,存档中的 bag 数组并不会立即清空,之前准备的物品仍然保留在那里,但 bag_completed 已经恢复为 False。客户端是否显示背包,主要由 frog.status 决定,而不是由 bag 是否为空决定。

背包点击“准备完成”时,客户端只会发送item.set_bag_completed(true),但在完整协议列表里,没有找到类似 travel.startfrog.depart 的主动“出发”请求。 结合实际游戏机制来看,这意味着青蛙什么时候离开,很可能完全由服务器根据时间、背包和其他条件决定,而不是客户端按下某个按钮之后立即触发。

旅行过程中同样主要由服务器驱动。客户端会接收:client.load_eventsnotify.new_event并维护自己的旅行事件列表;处理完某个事件后,再通过 client.confirm_event 告诉服务器已经读取。

源码中还能看到一个明确的 BackHome 旅行事件类型,因此“青蛙回家”本身很可能也是服务器生成的一条时间事件。

现在青蛙正好处于外出状态。下一步准备切回官方协议抓取模式,在餐桌上放好食物之后长时间保持游戏运行,同时持续记录 FROG WebSocket 日志。因为无法准确预测青蛙什么时候回来,所以让日志持续写入电脑文件,直到观察到青蛙已经回家。

如果顺利,这一次长时间抓取应该能够获得整个旅行系统目前最关键的一批数据:外出期间的旅行事件、notify.new_event、可能产生的明信片或邮件,以及最重要的 BackHome 事件结构。


现在已经能够在完全本地的环境中收三叶草、购买商品、管理仓库、背包和餐桌,而且所有变化都会写入独立的本地存档并在重启后恢复。

接下来真正困难的部分,是还原原服务器中那些带有时间、随机性和状态机的逻辑,尤其是旅行、明信片和回家机制。

2026.9.11

前面的工作已经解决了本地 WebSocket、登录流程、可写存档、三叶草收割、背包桌面和商店等基础功能。今天真正开始进入游戏最核心的部分——旅行,以及围绕旅行延伸出来的照片、访客、花圃、礼品盒、故事和日历系统。

Stage6.旅行系统

最开始实现旅行时,我面临的一个问题是:客户端虽然保存了大量关于目的地、食物、幸运物、工具、照片和特产的静态配置,但真正“服务器怎样抽出一次旅行结果”的算法并不完整存在于客户端。

因此目前采用的原则还是,能从客户端源码和配置确定的行为严格照原版;服务器内部看不到的概率和权重,单独作为本地策略保存。

旅行系统首先恢复了:

准备背包
→ 点击“准备好”
→ 等待出发
→ frog.status = 1
→ 生成目的地
→ 生成照片、特产、物品、三叶草和抽奖券
→ 等待返程
→ BackHome
→ frog.status = 0
→ 展示返家奖励

为了测试方便,一开始把旅行时间临时设成了 60 秒。

实机测试中,这套流程最终完整跑通了一次:

手动准备背包
→ 点击准备完成
→ 重新登录后青蛙出发
→ 等待 60 秒
→ 再次登录
→ 青蛙返家
→ 得到 75 个三叶草
→ 得到 2 张抽奖券
→ 得到旅行特产
→ 带回 4 张照片

随后我实际保存了其中两张照片、删除两张,再进入相册,服务器能够正确返回现有照片。这说明最核心的一条链已经真正闭环:

旅行
→ 新照片
→ 保存 / 删除
→ 总相册
→ 持久化

解决“青蛙一进游戏就不在家”的存档迁移问题

旅行系统刚加入时出现了一个很麻烦的问题:启动离线服后,青蛙直接不在家,导致根本无法继续准备行李。原因是原始正式服快照本身是在青蛙旅行途中保存的。

正式服的frog.status = 1只说明“它正在旅行”。但离线服务器并没有这趟正式服旅行对应的目的地、开始时间、返回时间、奖励计划、照片计划。于是产生了一个“悬空旅行”:客户端认为青蛙不在家,但离线服务器永远不知道它什么时候回来。最后加入了一次性迁移规则:

status != 0
+
不存在本地生成的 activity / plan
→ 判断为正式服遗留的悬空外出
→ 自动收尾并恢复 HOME

并且不是简单修改 status=0,而是根据之前真实抓到的正式服返家过程恢复物品。

之前抓到的一次正式返家中:

旅行中 bag:
[-1,-1,2007,2005]

desk:
[3,19,1001,1000,2001,2009,2010,-1]

返家后变为:

bag:
[3,1001,2010,2009]

desk:
[-1,19,-1,1000,2001,-1,-1,-1]

2005 / 2007 归还仓库。

离线迁移现在也按照这套已经验证过的正式服物品流执行。

这也是今天一个很重要的经验:不能只保存一个状态值,还必须区分“正式服遗留状态”和“离线服务器自己创建、能够继续结算的状态”。

明信片生成已经能工作,但构图还需要继续校准

旅行照片现在不是简单回放以前抓到的照片,而是根据保存下来的

Picture
resources
layers
pose

重新生成。

对于较新的照片资源,这种方法已经能正确恢复背景和青蛙图层。例如之前真实抓到的青海湖照片,可以从配置重新计算出接近正式服的青蛙坐标。

但今天实机测试时也发现了一个视觉上的问题。有些比较普通的“路过照片”,背景中的固定美术元素是固定的,而青蛙、雨伞等元素应该在一定范围内随机摆放。当前离线算法使用的随机位置范围过大,导致某张雨景照片里青蛙和伞明显偏到画面右下角,视觉上很不自然。这不影响照片存档和旅行逻辑,但后面需要按不同的Picture.typefrogPose照片模板分别设置安全随机区域,而不能所有照片都使用同一套坐标随机算法。

Stage7.特殊植物和庭院网兜三叶草

测试旅行期间顺便收了一轮庭院三叶草,碰到了一个以前没有实现的情况:收到了狗尾草。日志中这一株是:

element = 3
sprite = 2001001

之前服务器把所有 element != 0 都当成“不支持”,于是客户端触发:

clover.harvest_resend

而 resend 处理代码又错误地把一个 list 当成 dict,导致服务器直接异常。

后续通过源码和配置确认:

element = 0 → 普通三叶草
element = 1 → 四叶草
element = 2 → 幸运花
element = 3 → 特殊野生植物
element = 4 → 花盆植物

其中五种野草包括:

狗尾草
知风草

蒲公英
兔尾草

狗尾草对应:

decoration id = 1001

因此现在特殊植物收获后会进入 client.load_decorate 的花材/装饰收藏,而不是增加三叶草数量。

同时重新实现了 clover.harvest_resend,保证客户端因为网络或协议原因重发请求时:

不会再次加奖励
不会再次加入收藏
不会报错

也就是幂等处理

“网兜”属于家具系统

今天还确认了一个之前理解不准确的地方。

庭院旁边那个会慢慢积累三叶草的“网兜”,客户端真正对应的是:

furniture.load_pocket.clover

也就是说它不是:

草成熟
→ 自动增加玩家三叶草余额

而应该是:

草成熟后继续经过生长周期
→ 多出来的三叶草进入挂兜
→ 玩家点击挂兜
→ furniture.pocket_get
→ 才正式加入三叶草钱包

所以之前做的“离线期间自动加三叶草”被改成了真正的挂兜库存。

第一次启用新算法时不会追溯几个月历史数据,否则会突然产生大量三叶草;只从离线版本启用之后开始累计。

Stage8.花圃系统与花瓶系统

花圃系统

在三叶草系统之后,开始完整梳理庭院的花圃。

这一块原本以为只是简单种植物,实际牵涉的系统很多:

种子
肥料
特产
堆肥箱
访客
成长值
花盆
蔬果
观赏花
室内花瓶
旅行纪念花
嘟嘟商店

客户端静态配置意外地保存得非常完整。

目前确认有:

11 类种子
6 种肥料
每类植物 3 个成长阶段
每类种子对应多个具体品种

并且嘟嘟商店的商品也保留着官方配置:

6001–6006 → 肥料
6007–6016 / 6019 → 各类种子

肥料还能区分:

水溶肥
缓释肥

客户端文字明确描述了不同的肥力强度和持续方式,但服务器内部精确“加多少成长值、持续多久”没有静态数字,因此这些仍作为离线策略参数保存。

花圃系统现在实现了:

购买 / 获得种子
→ 花盆播种
→ 访客离开时促进生长
→ 被动成长
→ 堆肥提高成长速度
→ stage 1
→ stage 2
→ stage 3
→ 成熟
→ 收获

成熟植物分两类。

一类是蔬果,直接成为旅行特产,例如:

拇指萝卜
樱桃萝卜
樱桃番茄
草莓
迷你南瓜

另一类是观赏花,进入室内花瓶使用的 decoration 收藏。

花瓶系统

顺着花圃继续检查室内花瓶,最终发现三种来源的植物实际上汇入同一个系统:

庭院随机野草
花盆种出的观赏花
旅行带回来的纪念花

例如旅行花材包括:

蜡梅
木槿
桂花
丁香
建兰
水仙
君子兰

花盆种出来的具体品种也有对应的 decoration ID。

客户端花瓶的消耗规则也有一个比较反直觉的地方:

插入一株新花
→ 新花暂时不扣数量

下一次换花
→ 被换下来的旧花消耗 1 个
→ 新花继续保留到下一次更换

现在离线服务器按照客户端原有语义实现,而不是简单“插进去立即扣 1”。

Stage9.访客喂食

之前已经完成三位伙伴:

困困
胖胖
跳跳

的官方喜爱度矩阵。

客户端 Character.taste[] 明确保存了每位伙伴对所有特产的固定喜爱值,因此这部分完全不需要猜。

反馈档位也是客户端直接写死的:

>= 80
两眼放光,高兴地扑了上来

60–79
吃得津津有味,嘴巴吧唧个不停

20–59
一声不吭,顽强填饱了肚子

< 20
看了一眼,没有什么胃口

实机测试中:

胖胖 + item 4014
taste = 60

也成功落入正确反馈档。

今天还修正了回礼出现的时序。

正式服并不是:

喂完
→ 马上收到邮件

而是:

喂特产
→ 访客继续留在庭院
→ 退出游戏
→ 再次登录
→ 访客离开
→ 回礼或聚会邀请出现

离线服务器现在也按照这一流程工作,并且奖励/邀请在喂食时就提前写入存档,因此不能靠不断退出重进刷新结果。

Stage10.礼品盒

语义混淆

今天围绕礼品盒有一次非常值得记录的错误,主要是由于我和gpt的交流出现了一些用语上的差错,导致了理解上的错误,把伙伴礼品盒与回礼、旅途遇到用户青蛙触发的故事,以及其他用户来串门的三个系统混起来了。

最开始,我根据“其他青蛙串门”把礼品盒触发逻辑错误地接到了visit.load,并模拟了一个“匿名旅蛙”,甚至进一步关联到了省花系统。实机测试后发现客户端只弹出了“其他青蛙会串门”的教程,却没有出现预期回礼;同时“故事”页面也没有变化。 继续检查源码和原始存档后才发现,游戏里至少存在三套看起来很像、实际上完全不同的社交系统:

Guest
→ 困困 / 胖胖 / 跳跳
→ 庭院访客、喂食、回礼、聚会

Visit
→ 其他玩家青蛙在庭院串门
→ 省花收藏

Story
→ 自己的蛙旅行途中遇到其他玩家的蛙
→ 收到对方特产
→ 可以回赠
→ 长期故事记录

另外礼品盒自己还有一套:

TravelFriends
→ 壁虎
→ 刺猬
→ 萤火虫

所以一开始把:

GiftBox
Story
Visit

当成同一套系统,是错误的。

这也成为今天最重要的架构经验之一:在逆向一个长期运营游戏时,UI 语义相似不代表服务端模型相同。必须以 Model、协议和存档字段为准。

礼品盒最终改成壁虎 / 刺猬 / 萤火虫,并成功实机闭环

修正之后,礼品盒不再制造“匿名玩家蛙”。

现在流程变成:

相册照片 / 特产
→ 放入礼品盒
→ 等待
→ 壁虎 / 刺猬 / 萤火虫之一出现
→ 带回奖励
→ VisitFriend 事件
→ 原客户端回礼界面
→ client.confirm_event

今天已经实机验证成功。

日志中实际产生:

GIFTBOX FRIEND RETURN
friend_id=1

随后客户端成功弹出了回礼,并最终发送:

client.confirm_event

因此礼品盒系统现在已经完成了一次真实客户端闭环。

Stage11.故事系统

进一步检查正式服抓包时发现,story.load 保存的数据非常明显就是过去与其他玩家青蛙相遇的记录:

id
partner
name
gift
feedback

存档里已经有Story 1–25,而客户端静态 Story 配置也正好只有 25 个故事。

离线版本现在计划这样处理:

已有 25 个故事
→ 原样保存故事进度
→ 去掉真实玩家 ID / 蛙名的依赖
→ 显示统一匿名名称

未集齐故事的其他存档
→ 旅行中按官方静态参数
   friend_choice_percent = 25
→ 尝试解锁尚未获得的 Story

同时不再要求给一个实际上已经不存在的真人玩家返还特产。

这比之前简单做“匿名玩家串门”更加符合游戏自身的系统结构。

Stage12.省花(未完成)

在误接 Visit 系统的过程中还顺带确认了省花收藏和旅行地图是完全独立的。

正式服存档虽然已经去过青海,也有青海照片、字条、旅行记录,但visit.load.acquire = {}仍然为空。因此去过青海≠获得青海省花。省花来源于其他玩家青蛙的庭院串门系统,而不是自己的旅行。

旅行地图使用

encytravel.load
unlock_list
unlock_desc
show_sub

省花则使用

visit.load
acquire

当前 2026 客户端甚至把省花图鉴入口隐藏了,因此这一部分暂时降低开发优先级,以后再恢复隐藏收藏页。

Stage13.日历(进行中)

今天还实际测试了日历。当天(实则为12日凌晨)出现一个可以领取的幸运礼物,客户端请求calendar.get_luck_reward

服务器正确处理,实际获得:

item_id = 10108
count = 1

并写入存档。因此当日日历幸运礼物这一条已经闭环。

但之前已经错过日期的节气食品仍然不能领取,这说明“幸运日礼物”和“节气料理”仍是两套不同流程。

节气食品系统之前已经确认有三个任务条件:

观看广告 / 分享
累计获得 30 三叶草
抽奖中奖

离线版本计划继续保持:

广告 / 分享 → 自动完成
30 三叶草 → 必须实际完成
抽奖 → 必须实际完成

旧节气食品不能领取的问题留到下一阶段处理。

Stage14.周末帮助小伙伴 / 答谢

今晚最后又发现了一套之前没有实现的周循环玩法。

周末会出现“小伙伴的疑问”,玩家需要从候选列表中选择 5 个物品,到下一周周末,会根据其中猜对的数量获得不同档位的答谢礼物。

今天先领取了上一周小伙伴的答谢,然后尝试开始这一周的帮助,但服务器出现

lottery.confirm_reward
BLOCK 未实现写操作

说明这个玩法使用的是另一套 lottery.* 协议,和商店里item.gacha完全不是一回事。

之前正式服抓包其实已经保存过它的状态:

phase
last_phase
state
select_list
answer
right_flag
reward
extra_item

因此下一阶段会优先完整逆向这一套周循环:

本周生成问题
→ 玩家选 5 个
→ 保存答案
→ 到下一周
→ 生成正确答案
→ 计算 right_flag
→ 根据猜对数量选择 reward 档
→ 领取答谢
→ 开启下一 phase

具体答案是否纯随机、候选是否有权重、不同正确数对应什么奖励,都先从完整客户端资源中确认,不先自行设计。


截至今晚,可以把功能状态整理为:

【已实机验证闭环】

✓ 本地 WebSocket 登录
✓ 可写 state.json
✓ 普通三叶草收割
✓ 特殊野草收获
✓ clover.harvest_resend
✓ 背包 / 桌面
✓ 普通商店
✓ 博物馆已有收藏读取

✓ 普通旅行出发
✓ 旅行计时
✓ 旅行返家
✓ BackHome 奖励
✓ 特产 / 三叶草 / 抽奖券
✓ 新旅行照片
✓ 保存照片
✓ 删除照片
✓ 相册持久化

✓ 三伙伴官方喜爱值
✓ 喂食反馈
✓ 延迟回礼
✓ 聚会邀请前半段

✓ 礼品盒照片移动
✓ 壁虎 / 刺猬 / 萤火虫回礼
✓ VisitFriend
✓ client.confirm_event

✓ 当日日历幸运礼物


【已经实现,等待更多实机验证】

△ 花圃
△ 种子
△ 肥料
△ 堆肥箱
△ 花盆成长
△ 观赏花 / 蔬果
△ 挂兜
△ 花瓶
△ 嘟嘟园艺商品


【结构已经确认,但还没闭环】

△ Story 离线玩家交互
△ 聚会 PartyResult
△ 博物馆旅行奖励
△ 旅行地图 / 城市照片
△ 家具交互
△ 旅行事件历史


【下一阶段】

○ 周末“帮助小伙伴 / 答谢” lottery
○ 旧节气食品领取
○ PartyResult 聚会返程
○ 屋内地图
○ 屋内相册入口
○ 旅行事件回看
○ 照片构图校准
○ 花圃完整实机验收
○ 家具制作与旅行商人

几个技术结论

  1. 客户端资源负责告诉我们“游戏有什么”,真实协议负责告诉我们“服务器什么时候让它发生”。两者缺一不可。
  1. 状态相似并不代表系统相同。Guest、Visit、Story、TravelFriends 看起来都是“其他小伙伴”,实际上属于四套独立的数据模型。
  1. 离线复刻不能只追求“按钮能点”。一次功能真正完成的标准应该是:状态生成 → 客户端展示 → 用户操作 → 协议确认 → 存档持久化 → 重启后仍正确。
  1. 对服务器内部看不到的随机概率,不应该假装成官方值。所有推测参数都应该集中到 policy 中,未来找到证据后可以替换,而不用重构存档。
  1. 今天最重要的成果,是普通旅行从一个静态存档查看器真正变成了一个可以再次出发、再次回来、再次产生新照片和新物品的循环。

从这个节点开始,这个项目已经不再只是“保存一份关服前的数据”,而是在逐步把原来依赖远端服务器的游戏逻辑重新搬回本地。

2026.9.12

今天的主要工作:一方面继续补齐原服务器曾经负责的周循环、日历、相册和客户端状态;另一方面第一次真正碰到了“官方功能本身依赖已经失效的网页服务”的情况——旅行地图。

最后又在旅行地图的基础上,把原来的社交漂流瓶改造成了完全本地的“旅行日记”。

这一阶段的重点已经不只是“让协议不报错”,而是开始处理三个更难的问题:

  1. 如何区分真正的官方事实和离线版自己补的策略;

  2. 如何保存已经下线、依赖网页或社交服务的旧功能;

  3. 如何保证客户端看起来成功的操作,真的写进了本地存档。


Stage14. 周末“帮助小伙伴 / 答谢”(进行中)

前一天已经发现,周末“小伙伴的疑问”使用的是一套独立的 lottery.* 协议。它和商店里的普通抽奖item.gacha完全不是一回事。

正式服留下的 lottery.load 状态中可以看到:

phase
last_phase
state
select_list
answer
right_flag
reward
extra_item
egg_num

其中保存过的 phase 171 状态里,候选列表有 15 个物品,而玩家需要从里面选 5 个。

因此这套玩法本质上更像一个跨周状态机:

本周打开玩法

得到候选列表

选择 5 个物品

保存本周选择

进入下一 phase

出现 answer / right_flag

根据猜中数量得到答谢

确认奖励

进入下一轮

这里最重要的一点,是没有因为“看起来像随机竞猜”就直接自己写一套随机算法。

我们手里至少保存了两组正式服候选池:

phase 170
phase 171

所以对于已经观测过的 phase,优先复用真实数据;对于未来没有抓到的周循环,才允许进入本地 policy。

目前 offline_policy.json 里明确保留:

candidate_count = 15
selection_count = 5

同时,正式服 phase 171 还留下了两个非常有价值的事实:

打开新一轮时得到 10 个三叶草
extra_item = item 101 × 1

这两个事实可以直接复现;但未来 phase 的候选池、答案生成和奖励细节,如果没有进一步证据,就不能假装成“官方概率”。

一次很典型的存档迁移问题

实现这一套状态机时,还遇到了一个旧状态与新代码不一致的问题。

正式服最后留下的存档已经处在 phase 171,但离线服早期曾把部分状态按 phase 170 理解,导致:

phase
last_phase
state
奖励领取状态

互相不完全匹配。

最终没有选择“重置玩法”,而是加入一次性 migration,把正式服遗留状态校准到当前离线逻辑。

日志中可以明确看到:

migrate.stage6f1.lottery_phase171

之后又发现 extra_item 不应该悄悄直接进库存。为了保持原游戏“奖励通过邮件到达”的感觉,又增加了一次修复迁移:

LOTTERY EXTRA MAIL CREATED
migrate.stage6f2.lottery_extra_mail

这和后面年终总结的分享奖励一起提醒了我:

一个奖励“数值上到账”并不代表行为已经复刻正确。奖励从哪里出现、是否经过邮箱、什么时候出现红点,本身也是游戏体验的一部分。

Stage13. 日历系统收口

前一天已经跑通calendar.get_luck_reward,当天实际领取到了一个幸运礼物,并写入本地存档。

但旧节气食品仍然无法领取。这说明日历里至少存在两条不同链路:幸运日礼物≠节气料理任务。

继续梳理后,节气料理的三个任务条件可以整理成:

task1:广告 / 分享
task2:累计获得 30 三叶草
task3:完成一次抽奖

离线版对它们采用了不同处理:

广告 / 分享
→ 在线服务已经没有保存价值
→ 离线自动完成

累计获得 30 三叶草
→ 仍要求真实游戏行为

普通抽奖
→ 仍要求真实抽奖行为

这样既避免因为停服后无法播放广告导致任务永久卡死,也没有把整个任务系统改成“打开页面就自动领奖”。

旧节气奖励最终也恢复了历史领取能力。目前确认的旧奖励包括:

day 7  → item 43
day 23 → item 28

并且状态会写入 state.json,重启后不会反复领取。

Stage15.普通商店抽奖

为了让日历第三个任务能够真正完成,普通商店抽奖也一起实现。协议是item.gacha,每次消耗5 张抽奖券。

客户端静态配置中能确认奖品 rank 的权重:

White   40
Blue    25
Purple  22
Green    9
Red      3
Gold     1

这一部分和周末 lottery.* 的区别必须一直保持清楚:

item.gacha
→ 商店普通抽奖

lottery.*
→ 周末帮助小伙伴 / 下周答谢

两者 UI 都带“抽取 / 猜测 / 奖励”的感觉,但数据模型完全不同。

到这里,Stage 6G 可以真正算 CLOSED:

旧节气食品
普通 Gacha
日历任务进度
重启持久化

Stage17.聚会返程

伙伴喂食之后的聚会系统,客户端已经能确认几个关键状态:

frog.status = 3
→ 聚会中

TimerEvent.PartyGo     = 23
TimerEvent.PartyResult = 24

本地服务器也已经具备 Party 自动返程和奖励结算框架。

但是和普通旅行类似,真正的 2026 服务端概率并不都存在于客户端。当前本地 policy 中的:

duration_seconds
clover_min / clover_max
ticket_percent
page_percent
collection_percent

都明确属于离线保存策略,而不是“从客户端逆向出的官方值”。

这一点现在已经成为项目的固定规则:

客户端能证明结构,抓包能证明某一次真实行为;两者都证明不了的服务端概率,只能叫 local policy。

聚会返程虽然已有本地逻辑,但我仍把它和已经多次实机闭环的普通旅行区分开,不把“代码存在”直接等同于“完全还原官方算法”。

Stage18.相册系统(进行中)

恢复存档照片

旅行系统恢复以后,一个很现实的问题出现了:未来可以继续生成照片,但过去几年已经拍到的照片才是最值得保存的东西。

正式服抓取中实际保存了 200 条历史相册记录。最初生成 compact index 后,却只有199 条.做集合对比后发现缺的是:

album id = 1312
pic_id   = 2137

于是没有继续凭肉眼翻相册,而是直接做数据完整性检查:

official rows
local rows
unique ids
missing
extra
mismatch
duplicate

修复后结果变成:

local rows        = 200
unique ids        = 200
official rows     = 200
missing           = 0
mismatch          = 0
extra             = 0
duplicate         = 0

随后在原客户端中逐页查看,200 张历史照片的完整图层都能正常显示。

这一阶段非常值得记录,因为“有 200 个缩略图”远远不够。

照片真正需要保存的是:

pic_id
+
layers
+
每层 resource
+
坐标
+
相册 id
+
时间与其他元数据

如果只保存一个 pic_id,未来官方服务器关闭后,某些历史照片的随机人物、伙伴、前景和位置就可能永远丢失。

相册回收站与 Android 系统相册

历史照片恢复之后,又继续把原客户端的相册操作补齐:

保存旅行照片
删除旅行照片
进入回收站
恢复照片
真正删除
最近 6 张回收记录
重启持久化

还确认了一条容易遗漏的规则:旅行回来后没有选择保存的照片也应该进入和手动删除照片相同的回收站体系。

一开始点击“保存到系统相册”没有任何效果。

这并不是 Android 权限本身的问题,而是因为离线版启用了客户端自带的TestChannel.TestChannel 继承的基础实现中:save_texture_to_album实际上是空的。正式渠道使用的 Ejoy Native bridge 才真正把纹理保存到 Android 相册。

所以最终不是自己重新写一套 Android 文件导出,而是让离线 TestChannel 重新调用原客户端已经存在的 Native 能力。

修复后:

游戏内照片
→ Egret texture
→ Ejoy Native bridge
→ Android 系统相册

成功工作。

这也是一个很有代表性的经验:

离线化时不要默认“Test 模式 = 功能完整”。测试渠道经常会绕开登录 SDK,同时也可能把支付、分享、系统相册等原生能力一起空实现。

Stage19.百科、旅行百科与客户端设置持久化

Stage 6I.1(备注:工作及代码编号体系,本文为方便阅读有所修改) 主要处理了几类看起来不大、但重启后很容易露馅的状态,包括encyclopedia.set_show_sub``encytravel.set_show_sub.以前这些操作在当前会话里看起来正常,但服务器没有真正保存,所以重启后会恢复旧状态。

修复后:

客户端修改
→ 本地服务器收到协议
→ 修改对应 push
→ state.json
→ 下次 client.load_all_info replay

能够完整闭环。

同时还修正了旅行百科中的 long_id 映射,以及 Item 静态数据在:dict``list两种格式下的兼容问题。这类 bug 没有旅行和相册那么显眼,却很能检验本地服务器到底是“能演示”,还是已经开始变成一份真正可长期使用的存档。

Stage6.旅行系统修复

普通旅行已经能出发和返家之后,又发现一个体验上的缺口:青蛙确实离家了,但原版应该出现的“xxx出去旅行了”并没有弹出来。原因是客户端并不只观察frog.status,源码里真正负责这条提示的是TimerEvent.Type.GoTravel.也就是说,服务器需要同时表达两个层次:

状态:
frog.status = 1

事件:
GoTravel

前者决定“青蛙现在在不在家”,后者负责“刚刚发生了什么”。

这和之前 BackHomeVisitFriend 的逻辑是一致的。

从这之后,本地服务器对 TimerEvent 的理解也更清楚了:

GoTravel       = 1
BackHome       = 2
Picture        = 3
Gift           = 7
VisitFriend    = 16
FurniturePut   = 22
PartyGo        = 23
PartyResult    = 24

保存一个游戏,不能只保存最终 state;很多“生活感”其实来自事件流。

Stage20.家具系统(更换家具)

原游戏中,青蛙外出以后玩家可以整理房间家具。离线版一度出现青蛙已经外出, 但换家具入口仍然打不开的情况。服务端状态没有问题,真正卡住的是客户端自己的显示条件FurnitureModel.isOpen(),它还受商店 start_time 一类门槛影响。

最后只在本地 TestChannel 下放宽入口:

decorate_open
+
frog 不在家
→ 允许进入换家具

而服务端仍然保留frog 在家时拒绝玩家手动替换,避免为了修 UI 把原游戏规则一起破坏。

这个修复也体现了一个很常见的逆向误区:

按钮不出现,不一定是协议没实现;有时服务器早就准备好了,真正拦住它的是客户端本地的 feature gate。

Stage21.2023 年终总结

这一阶段还决定保留一个很有纪念意义的入口:2023 年终总结。

原来的活动有时间限制,但对于个人离线保存版本,我希望它可以永久打开,也可以作为“现在连接的是本地服”的辨识标志。处理了三个问题:入口长期开放、永久红点消除、分享奖励可领取一次。红点的原因来自AnnualReviewModel.updateRedot(),当is_share == false时会一直显示红点。

本地版关闭了这条无意义的长期提醒,并实现annual.share第一次分享后发放item 57(庆典蛋糕)且只能领取一次。

这里也留下了一个以后必须遵守的修正:这次蛋糕已经直接进入库存,作为历史结果不再回滚;但原游戏更合理的分享奖励流程应该是:

分享成功
→ 奖励邮件
→ 邮箱红点
→ mail.open
→ 进入库存

以后恢复其他历史活动时,不再跳过邮箱链路。

Stage22.旅行地图

一个回归 bug 揭开了真正的 TravelMap

开始时只是发现一个小问题:房间里点击“地图”家具→ 居然进入旅行百科。继续查源码后确认,原版正确调用链是:

MainInView.on_enterTravelMap_tap

ActivityModel.getActivity("travelmap")

TravelMapController

它根本不是 EncyTravelViewControl

继续向下追以后,才发现 TravelMap 并不是普通的 Egret 页面,而是一套外部 H5。

原页面地址被恢复为:

https://act.lingxigames.com/prism-kpf75rez

并找到了对应的:

main.a6253edc.js
main.ba4cb5db.css

原 H5 使用高德地图,并通过 Native WebView bridge 从游戏客户端读取:

getPictureList
getPictureData
bbsDetail
moment_token

这解释了为什么“地图”在原客户端里看起来和其他页面很不一样。

35 个真实目的地

从原 H5 中还提取出了全部地图点。

一共 35 个:

北京、天津、宁夏、青岛、成都、云南、拉萨、上海、苏州、杭州、
海南、桂林、广州、香港、垦丁、十分、哈尔滨、辽宁、张家界、
武汉、洛阳、安徽、西安、酒泉、重庆、贵州、福建、江西、山西、
吉林、澳门、青海、河北、内蒙古、新疆

这些点是原 TravelMap H5 自己保存的经纬度。

原高德地图配置也找到了:

zoom = 4
zooms = [4, 8]
center = [111.380283, 32.743444]

放弃额外 WebView 服务,改成原生静态地图

第一版思路是尽量忠实恢复 H5:

游戏
→ WebView
→ 本地 HTTP server
→ 原 TravelMap HTML / JS / CSS

甚至一度额外开了8081 → 52353来提供 H5。

但这个方案的问题也很明显:

需要第二个本地服务
需要 WebView 网络栈
需要 bridge
还依赖原 H5 对地图 SDK 的假设

对于一个希望多年以后仍然可以直接启动的保存版本,这反而增加了脆弱点。最后决定保存原 H5 作为历史证据,但实际离线版本使用 Egret 原生静态地图。于是 TravelMap 被重新画成游戏内部页面。

地图几何

中国行政区边界使用保存下来的 GeoJSON,在补丁阶段转换为可以直接写进客户端的线段数据。

最初几个版本先后出现:

省份填充形成大块灰色区域
地图只能横向拖
经纬度缩放不一致
城市点击热区出现黑块
文字挡住城市点

最终在 v6/v7 中改成:

1900 × 1200 的大地图画布
Mercator 投影
只画行政区 stroke,不填充 polygon
横向 + 纵向 EUI Scroller
透明点击热区
标签碰撞检测
标签主动避让所有城市点

城市照片与地图点之间的映射也没有硬编码照片编号,而是沿用游戏自己的静态数据链:

TravelModel picture

pic_id

PictureDB

place

GoalNumberDB

tag

g_beijing / g_qinghai / ...

因此以后新旅行产生照片时,只要它属于已有的 35 个目的地,重新打开地图就会自动更新:

已打卡城市数
城市点状态
城市照片数量
城市相册

不需要重新改地图代码。

从地图进入城市相册再回来

早期还有一个页面栈问题:地图 → 城市相册 → 返回会把地图一起销毁,最终回到房间。修复后使用core.RemoveViewType.Retain保留 TravelMap 页面,再叠加相册。这样返回才符合原来的交互。

顶部打卡进度

最后又根据原版截图恢复了顶部的城市打卡进度提示,包括蛙蛙已打卡多少个城市、距离 35 城成就还差多少、完成后显示称号。

到这里,原本依赖线上 H5、高德地图和社交后端的 TravelMap,已经变成完全由本地游戏资源和相册状态驱动的静态页面。

原 H5 仍然被保存下来,但运行时不再依赖它。

Stage23.从“漂流瓶”改造成个人旅行日记

TravelMap 原本还连着一个已经明显不适合离线保存的系统:漂流瓶。

原版漂流瓶本质上是社交功能。收到 Drift 邮件后,客户端会显示mail_bottle_png,并根据发件人的城市打开 TravelMap 中的 bbsDetail 页面。

原 H5 里还能看到:

收到的漂流瓶
城市留言
发送
删除
点赞 / 评论一类社交信息

在停服后的个人离线版中,我决定把这个位置重新设计成旅行日记,原因在本文开头的小作文中也有所提及,发送的第一个也是唯一一个漂流瓶虽然没有人点赞,但是对我来说意义非凡。

保留它最适合个人保存的部分:

照片
文字
城市
日期

去掉:

其他玩家昵称
IP
点赞
评论
分享
在线发送

日记数据没有新造服务器协议,而是复用 client settings

旅行日记首先要解决的问题不是 UI,而是“放在哪里”。

如果专门增加:

diary.load
diary.save
diary.delete

虽然也能做,但会让本地服务器多一整套原游戏不存在的协议。

最终选择复用客户端已经有的:

UserModel.clientSettings

增加:

{
  "travelDiary": {
    "version": 1,
    "next_id": 3,
    "entries": [
      {
        "id": 1,
        "picture_id": 168,
        "city": "g_beijing",
        "city_name": "北京",
        "text": "...",
        "created_at": 1712203200,
        "updated_at": 1712203200
      }
    ]
  }
}

这样日记跟其他客户端设置一起走现成的:

client.set_client

服务器无需增加新的业务协议。

照片也不复制一份图片,只保存:

picture_id

显示时再从 TravelModel 找到真正的相册记录。

城市信息同样不是手填:

album picture
→ pic_id
→ PictureDB.place
→ GoalNumberDB
→ city tag / city name

这让日记和旅行地图使用同一套地点语义。

第一个崩溃:有 layers 还不等于能直接拿 texture

旅行日记第一版一点击“新建”,游戏直接重启。

服务器日志没有收到新的业务请求,所以很快就可以判断:不是 server.py 崩了,不是 WebSocket 断言,而是客户端渲染路径出了问题。

继续对比原 TravelMap / 明信片代码,才发现原客户端完整的照片渲染流程是:

getPictureById

如果 layers == null
    checkPictureByIds

重新取 picture

Tabikaeru.loadPicture(picture)

Tabikaeru.getPictureTexture(picture)

第一版只做了:

checkPictureByIds
→ getPictureTexture

少了:

Tabikaeru.loadPicture

于是即使服务端已经通过:

album.load_by_id_list

返回完整图层,客户端资源仍然没有完成加载。

补上这一层之后,照片可以正常显示。

这个问题很好地说明:

“服务器已经返回数据”和“客户端已经能渲染资源”是两件事。数据层、资源加载层、纹理生成层缺一不可。

第二个坑:eui.TextInput 看得见但点不进去

第一版日记编辑器使用动态创建的eui.TextInput,输入框能画出来,但在当前客户端运行环境里点击后没有键盘,也无法输入文字。

最后改成更底层的:

var input = new egret.TextField();
input.type = egret.TextFieldType.INPUT;
input.multiline = true;
input.wordWrap = true;
input.maxChars = 140;

并单独放一个 hint Label 模拟 placeholder。

这样 Android 输入法终于能正常弹出,也可以输入中文。

这也是 Egret 老项目里一个很典型的问题:

skin 驱动的 EUI 控件在动态构造时,不一定拥有和 EXML 页面中相同的完整行为;需要输入能力时,原生 egret.TextFieldType.INPUT 反而更可靠。

日记页面完成的功能

目前旅行日记已经支持:

新建
编辑
删除
上一篇
下一篇
切换照片
140 字文字
自动显示城市
自动显示日期
重启后恢复

同时做了几处 UI 微调:

地图右上角“日记”入口
关闭按钮位置
两按钮整体居中
编辑页原生输入框
照片缺失提示

为了验证日记是否可以承载历史记录,还手工向本地存档加入了一篇指定:

日期 = 2024-04-04
照片 = 相册 #168(北京长城)
城市 = 北京

的旧日记,原客户端能够正常读取和显示。

这说明这套结构既能记录今后的旅行,也能用于整理过去已经存在的相册。

最后一个持久化 bug:UI 说“已保存”,服务器却什么都没收到

旅行日记最隐蔽的问题出现在“编辑”。

第一次新建日记可以保存,但再次进入编辑、修改文字以后当前页面看起来改好了,关闭再打开也可能暂时还在,但重启游戏以后修改消失

最开始怀疑是 state.json replay 覆盖了新内容。但服务器日志给出了最直接的证据:正常保存应该出现:

RECV cmd=client.set_client session=None
STATE SAVED reason=client.set_client

而出问题时,这两行根本没有出现。也就是说不是服务器保存错了,而是客户端根本没发保存请求。

根因:对象引用与 setClientSettings

TravelDiaryStore.get() 返回的是clientSettings.travelDiary本身。编辑时直接修改这个对象,然后再调用setClientSettings("travelDiary", t)

客户端内部有类似:

this.clientSettings[key] != value

的变化判断。但是此时this.clientSettings.travelDiaryt本来就是同一个对象。因此哪怕里面的 text 已经变了,引用比较仍然认为还是同一个值,于是没有发送 client.set_client

最终修复

最后不再依赖这一层变化检测。

保存时先深拷贝:

var copy = JSON.parse(JSON.stringify(t));

再明确更新:

userModel.clientSettings.travelDiary = copy;

然后直接走原客户端本来就使用的协议:

core.SocketManage.getInstance().send(
    "client_set_client",
    null,
    JSON.stringify(userModel.clientSettings)
);

实机日志终于出现:

RECV cmd=client.set_client session=None
STATE SAVED reason=client.set_client
已持久化 client.set_client

重新启动游戏以后,刚才修改的日记仍然存在。

这次可以确认:

UI 编辑
→ client_set_client
→ state.json
→ 重新登录 replay
→ 修改仍存在

真正闭环。

删除为什么也一起修好了

日记的删除代码并没有单独写另一套持久化逻辑。

它的流程是:

确认删除
→ 从 entries 中 splice
→ TravelDiaryStore.save()
→ 调整 currentIndex
→ refresh()

而现在 TravelDiaryStore.save() 已经统一改成强制发送 client_set_client

因此:

新建
编辑
删除

最终都会经过同一条持久化出口。

从代码路径看,删除不会再存在之前那种“当前会话看起来删了、重启又回来”的对象引用问题。

漂流瓶的处理

为了避免未来又出现在线社交漂流瓶,客户端还加入了两层拦截。

已有 Drift 邮件加载时:

revice_mails
→ 找到 Mail.EvtId.Drift
→ 不加入普通邮件展示
→ 自动 mail_open

运行过程中收到新的 Drift 时:

notify_new_mail
→ 如果是 Drift
→ 直接处理,不显示社交漂流瓶

因此在当前离线客户端中,用户看到的是个人“旅行日记”,而不是一个伪造的在线社区。

不过这里仍然留一个以后可以单独做的审计项:

目前已经在客户端消费/隐藏 Drift;后续还可以继续检查本地服务器是否存在任何主动生成 Drift 的旧逻辑,从源头完全删除。

这不影响旅行日记 CRUD 本身的闭环。


这一阶段最值得记住的技术经验

1.“能显示”不是完成,“重启还在”才是完成

这两天最明显的例子就是旅行日记。界面已经显示旅行日记已保存并不意味着服务器真的保存了。最终确认问题靠的不是 UI,而是:

客户端动作

WebSocket 日志

STATE SAVED

重启

重新 replay

以后每一个有写操作的功能,都继续用这一标准验收:

状态生成
→ 客户端显示
→ 用户操作
→ 协议确认
→ 状态落盘
→ 重启仍正确

2.区分三种“事实”

整个项目现在越来越依赖一个简单但非常重要的分类。

  • 第一类:客户端 / 静态资源明确写着的

例如:

Gacha rank 权重
35 个 TravelMap 城市坐标
TimerEvent 类型
Character.taste
Picture.place

这类可以视为高置信度原版事实。

  • 第二类:正式服抓包实际观测到的

例如:

phase 171 的 select_list
打开 phase 171 得到 10 三叶草
某一次旅行返家获得 75 三叶草、2 张券
正式相册 200 条

这类证明的是:

“官方服务器至少真实发生过这一次”

但不应该从单次样本反推出完整概率。

  • 第三类:服务器内部算法看不到,只能为了离线运行补上

例如:

未来周末候选池
聚会奖励概率
旅行部分权重
花圃成长参数

全部集中放进:

offline_policy.json

并明确写注释:

local preservation policy
not verified official server value

这样以后如果找到新的正式证据,只需要替换 policy,而不需要推翻整个存档格式。

4.原功能依赖在线服务时,不一定要机械复制架构

TravelMap 是最典型的例子。

原版架构是:

Egret
→ Native WebView
→ 线上 H5
→ AMap
→ 社交后端

如果完全照搬,就必须长期维护:

本地 HTTP server
WebView 兼容性
地图 SDK
JS bridge
H5 构建产物

但地图真正值得保存的是:

35 个地点
已去过哪些城市
每个城市有哪些照片
点击城市进入相册
原版大致视觉语义

所以最终保留:

原 H5 / JS / CSS 作为证据与归档

运行时则改成:

Egret 原生静态地图

这比“为了架构一样而继续依赖一堆已经没有意义的在线组件”更符合长期保存的目标。

5.给压缩 JS 打补丁时,必须做结构化和幂等

这两天还有一个纯工程上的教训。

最早一些 patch 脚本会要求:

必须恰好存在上一版 marker
必须恰好匹配某一整段压缩字符串

结果只要前面做过一次很小的 UI 调整,下一份 patch 就可能直接报:

expected exactly one ... found 0

文件实际上没有坏,只是补丁写得太脆弱。

后面统一改成更稳的策略:

1. 用稳定函数边界定位
2. marker 只做辅助诊断
3. 不强依赖“必须从上一小版本升级”
4. patch 重复运行应幂等
5. 修改前自动备份
6. 修改后重新读取实际文件验证关键特征

例如日记持久化最后一版,不再要求必须先是 A5,而是寻找:

TravelDiaryStore

e.save=function(...)

e.pictureMeta=function

只替换这一段。

对于长期维护一个已经被多次 patch 的百万字节压缩 JS,这种方式可靠得多。


已经完成或基本收口的部分包括:

✓ 周末 lottery 当前已知 phase 状态迁移与奖励处理
✓ 旧节气食品
✓ 普通 Gacha
✓ 日历任务进度与持久化

✓ 历史相册 200 / 200 full layers
✓ 照片保存 / 删除 / 回收站 / 恢复
✓ Android 系统相册导出

✓ 百科与旅行百科 show_sub 持久化
✓ GoTravel 出发事件
✓ 青蛙外出时家具替换
✓ 2023 年终总结长期保留
✓ annual.share 一次性庆典蛋糕

✓ TravelMap 原 H5 / bridge / 35 城市结构逆向
✓ 房间地图入口恢复
✓ 完全本地 Egret 静态旅行地图
✓ 城市照片自动统计与城市相册
✓ 双向拖动、标签避让、页面 Retain、打卡进度

✓ 本地旅行日记
✓ 新建 / 编辑 / 删除 / 翻页
✓ 照片完整加载
✓ Android 中文输入
✓ client.set_client 真正持久化
✓ 历史日期 + 指定旧照片日记

目前仍值得继续做的主要部分有:

○ 新旅行照片的构图校准
  - 过路照仍可能偏移
  - 风景照也有只剩背景的情况

○ animpicture 动态照片完整状态机

○ 花圃完整实机验收

○ Story / Visit 等社交系统最终离线化

○ 漂流瓶服务端生成源审计

○ 博物馆、手工、旅行商人

○ 更多历史限时活动常驻化

○ 聚会返程继续补更多实机证据

○ 旅行目的地 / route 的静态配置进一步还原

现在项目已经越来越不像“做一个假的服务器让旧 APK 打开”,而更像是在一点点重建一个个人游戏保存环境:

原客户端
+
原静态资源
+
自己的历史存档
+
保存下来的正式服事实
+
明确标记的本地替代策略

对我来说,这也是这个项目现在最重要的边界:不是重新设计《旅行青蛙》,而是在原服务消失之后,尽量让过去的存档、原来的规则和以后还能继续发生的新旅行共存在同一个本地世界里。

2026.9.13

看到小红书上面已经有人把离线版做出来了,感觉自己确实还是开发效率慢了点。不过第一次做游戏相关的内容,也当是积累经验了,而且也可以加入自己喜欢的功能和设置。顺便把工作区从网页版ChatGPT换成了Codex(前面两天一直在当命令行纺织工……),用luna试试看。 二编:luna实在太笨了,只是因为便宜才用。气死我了。


Stage18.相册系统

今天比较重要的一步,是把静态照片的构图方式改了一层。

原来的照片生成逻辑能够生成背景、青蛙和尾部图层,但角色位置有一部分是根据尺寸估算出来的。背景尺寸、角色尺寸或缩放比例稍有区别,最终照片里的青蛙就可能偏高、偏低,或者和官方截图中的位置对不上。

现在 server.py 会优先检查官方静态照片资源。如果背景和角色资源都存在,并且尺寸符合官方照片的 500×350 画布,就直接使用官方资源中记录的坐标生成照片图层,不再重新猜测角色应该放在哪里。

目前已经确认可以使用这套精确构图的场景共有 13 组:

哈尔滨       g_haerbin4
内蒙古       g_neimenggu1
内蒙古       g_neimenggu2
内蒙古       g_neimenggu3
新疆         g_xinjiang1
新疆         g_xinjiang2
新疆         g_xinjiang3
重庆         g_chongqing3
青岛         g_qingdao3
贵州         g_guizhou7
西安         g_xian4
郑州         g_zhengzhou1
北京         g_beijing6

照片图层现在按照下面的优先级处理:

官方完整图层模板

官方静态背景 + 官方角色资源 + 官方坐标

静态 Picture/resources 资源回退

旧的角色尺寸估算逻辑

这样处理的好处是,能直接使用官方资源的场景不会再受到本地估算误差影响;没有完整官方资源的照片仍然可以保留原来的生成能力,不会因为某一张图片缺失而影响整个旅行回程流程。

目前仍有 7 个官方场景没有在现有资源中找到完整背景:

g_xianggang3
g_xianggang5
g_changbaishan1
g_changbaishan2
g_changbaishan3
g_hainan3
g_hainan4

攻略中有对应图片,直接替换掉了。目前所有照片都有了。

Stage6.旅行系统fix——109 凤梨酥的路线映射

之前没有把 109 凤梨酥直接映射,是因为暂时找不到足够可靠的中国版攻略证据,无法证明它在本地代表数据中只对应一个确定地点。

现在先采用一个明确标注过的本地代表映射:

109 凤梨酥 → 9 垦丁 / 10 十分
概率       → 各 50%

这里的 50% 是本地离线服务器为了保留两种可能性而采用的代表性选择,不是官方路线算法,也不意味着已经还原了官方服务器内部的真实概率。

DISCUSSION

Comments

Sign in with GitHub to join the conversation.