1.11 修订:VersionResolver 改单链 4 层 fallback,删除双子树合并算法(契约影响)
【契约影响】 - RemoteConfig 模型:Agent 节点移除 channellist 字段(线上 JSON 实际只挂 gamelist;此前误读 chulishengji 引入的 channellist 是空 schema) - VersionResolver 解析算法:从"双子树 + 字段级合并"改为"agentlist → gamelist → channellist → marketlist 单链 4 层 fallback",行为按 daoqi NewRootVC.m:689-810 主路径 - 对外 API ResolvedVersion / resolve(config:agentId:channelId:marketId:gameId:) 签名零变化,WebContainerViewController / SubGameViewController 调用点未动 【设计要点】 - 4 节点类型 conform 同一 private protocol RemoteConfigNode - 5 字段共用 pickString / pickInt 两个倒序 fallback 工具,不允许为某字段单独写 if 链 - buildChain 集中处理"任一层 id 不匹配立刻截断"语义 【文档同步】 - Design §6.3.2 整段重写 - Plan ADR-008 顶部追加"第三轮修订(2026-06-27)"小节 + 第二轮误读复盘 - Plan ADR-008-D 替换为单链 4 层算法说明 + 保留修订史 - Plan §1.11 / ADR-008-I / 最后更新日期 / Verification-Checklist L73 同步 参考契约章节:docs/H5-Native-Implementation-Design.md §6.3.2、docs/Development-Plan.md ADR-008-D BuildProject 通过。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
90a523736e
commit
8125dd4d11
@@ -8,7 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Codable 模型(三级嵌套:agent → channel → market;agent 子树另有 gamelist 副路径)
|
||||
// MARK: - Codable 模型(单链 4 层嵌套:agent → game → channel → market)
|
||||
|
||||
nonisolated public struct RemoteConfig: Codable, Sendable {
|
||||
public let showmessage: String?
|
||||
@@ -22,7 +22,6 @@ nonisolated public struct Agent: Codable, Sendable {
|
||||
public let appDownload: String?
|
||||
public let gameVersion: String?
|
||||
public let gameZip: String?
|
||||
public let channellist: [Channel]?
|
||||
public let gamelist: [Game]?
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -33,12 +32,11 @@ nonisolated public struct Agent: Codable, Sendable {
|
||||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||||
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
|
||||
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid, showmessage, channellist, gamelist
|
||||
case agentid, showmessage, gamelist
|
||||
case appVersion, appDownload, gameVersion, gameZip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
// ylgamehall
|
||||
//
|
||||
// 把远端 RemoteConfig 化简为 5 个目标字段(appVersion/appDownload/gameVersion/
|
||||
// gameZip/showmessage)的纯函数实现,照搬 msext chulishengji 双子树合并算法。
|
||||
// 详见 docs/Development-Plan.md ADR-008-D / Design §6.3.2 / msext NewRootVC.m:1372-1538。
|
||||
// gameZip/showmessage)的纯函数实现。
|
||||
//
|
||||
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径):
|
||||
// agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]
|
||||
//
|
||||
// 查找语义:从最深层(market)向上回退取第一个非空值,等价于
|
||||
// msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、可复用。
|
||||
//
|
||||
// 统一接口:所有 5 个字段都用同一套 `pickString` / `pickInt` 倒序查找,
|
||||
// 不再有"双子树合并 / chulishengji"等历史包袱的多处实现。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - 决策输出
|
||||
|
||||
/// chulishengji 双子树合并后的 5 个目标字段。
|
||||
/// 4 层 fallback 解析后的 5 个目标字段。
|
||||
public struct ResolvedVersion: Sendable, Equatable {
|
||||
/// 远端期望的原生 IPA 版本(与 BundleConfig.appVersion 比较)。0 表示远端未指定。
|
||||
public let appVersion: Int
|
||||
@@ -25,39 +33,32 @@ public struct ResolvedVersion: Sendable, Equatable {
|
||||
public let showmessage: String?
|
||||
}
|
||||
|
||||
// MARK: - 内部:单条子树的提取结果
|
||||
// MARK: - 节点协议(4 个层级共用同一查找接口)
|
||||
|
||||
/// 单条子树(agent 子树 或 game 子树)4 层逐层覆盖后的 5 字段。
|
||||
private struct SubtreeExtract {
|
||||
var appVersion: Int = 0
|
||||
var appDownload: String?
|
||||
var gameVersion: Int = 0
|
||||
/// 注意字段名:agent 子树原项目用 `game_download`,game 子树用 `game_zip`,
|
||||
/// 两者本质同一字段,新外壳两 key 都查(择一非空)。
|
||||
var gameZip: String?
|
||||
var showmessage: String?
|
||||
/// `Agent` / `Game` / `Channel` / `Market` 都暴露相同字段集合,
|
||||
/// 由 VersionResolver 用同一套倒序 fallback 算法统一处理。
|
||||
private protocol RemoteConfigNode {
|
||||
var appVersion: String? { get }
|
||||
var appDownload: String? { get }
|
||||
var gameVersion: String? { get }
|
||||
var gameZip: String? { get }
|
||||
var showmessage: String? { get }
|
||||
}
|
||||
|
||||
extension Agent: RemoteConfigNode {}
|
||||
extension Game: RemoteConfigNode {}
|
||||
extension Channel: RemoteConfigNode {}
|
||||
extension Market: RemoteConfigNode {}
|
||||
|
||||
// MARK: - VersionResolver
|
||||
|
||||
public enum VersionResolver {
|
||||
|
||||
/// chulishengji 双子树合并(msext `NewRootVC.m:1372-1538`)。
|
||||
///
|
||||
/// 1. 从 agent 子树提取(`agent → channel → market → game`,game 嵌在 market 命中后才遍历)
|
||||
/// 2. 从 game 子树提取(`game → game-self → channel → market`)
|
||||
/// 3. **IPA 合并**:默认 agent 赢;`gameConfig.appVersion > agentConfig.appVersion` 时
|
||||
/// game 赢(NewRootVC.m:1416)
|
||||
/// 4. **zip 合并**:默认 game 赢;`agentConfig.gameVersion > gameConfig.gameVersion` 时
|
||||
/// agent 赢(NewRootVC.m:1456)
|
||||
/// 5. **showmessage**:两条子树多层多次覆盖,最后写赢;若都为 nil,回退到顶层 `config.showmessage`
|
||||
/// 4 层 fallback 解析。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - config: 远端 RemoteConfig(Codable 解析后)
|
||||
/// - agentId: 当前 BundleConfig.agent
|
||||
/// - channelId: 当前 BundleConfig.channel
|
||||
/// - marketId: 当前 BundleConfig.market
|
||||
/// - gameId: 当前 BundleConfig.gameId
|
||||
/// - config: 远端 RemoteConfig
|
||||
/// - agentId/channelId/marketId/gameId: 当前渠道注入值(BundleConfig)
|
||||
/// - Returns: 5 字段决策结果。任一字段缺失返回 0 / nil。
|
||||
public static func resolve(
|
||||
config: RemoteConfig,
|
||||
@@ -66,184 +67,85 @@ public enum VersionResolver {
|
||||
marketId: String,
|
||||
gameId: String
|
||||
) -> ResolvedVersion {
|
||||
// 找当前 agent
|
||||
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) else {
|
||||
// agent 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径
|
||||
return ResolvedVersion(
|
||||
appVersion: 0, appDownload: nil,
|
||||
gameVersion: 0, gameZip: nil,
|
||||
showmessage: config.showmessage
|
||||
)
|
||||
}
|
||||
|
||||
// 找当前 agent 下的 game
|
||||
let game = agent.gamelist?.first(where: { $0.gameid == gameId })
|
||||
|
||||
let agentExtract = extractAgentSubtree(
|
||||
agent: agent,
|
||||
let chain = buildChain(
|
||||
config: config,
|
||||
agentId: agentId,
|
||||
gameId: gameId,
|
||||
channelId: channelId,
|
||||
marketId: marketId,
|
||||
gameId: gameId
|
||||
marketId: marketId
|
||||
)
|
||||
|
||||
let gameExtract: SubtreeExtract = game.map { g in
|
||||
extractGameSubtree(game: g, channelId: channelId, marketId: marketId)
|
||||
} ?? SubtreeExtract()
|
||||
|
||||
let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract)
|
||||
let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract)
|
||||
|
||||
// showmessage:game 子树 > agent 子树 > 顶层(任意非空就用,最后写赢)
|
||||
let msg = gameExtract.showmessage
|
||||
?? agentExtract.showmessage
|
||||
?? config.showmessage
|
||||
|
||||
return ResolvedVersion(
|
||||
appVersion: appVer,
|
||||
appDownload: appDL,
|
||||
gameVersion: gameVer,
|
||||
gameZip: gameZip,
|
||||
showmessage: msg
|
||||
appVersion: pickInt(chain, \.appVersion),
|
||||
appDownload: pickString(chain, \.appDownload),
|
||||
gameVersion: pickInt(chain, \.gameVersion),
|
||||
gameZip: pickString(chain, \.gameZip),
|
||||
// showmessage:链上任一层都不写 → 回退顶层 config.showmessage
|
||||
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - agent 子树(getagentversion,NewRootVC.m:885-1013)
|
||||
// MARK: - 节点链匹配
|
||||
|
||||
/// agent 子树 4 层:agent → channel → market → game
|
||||
/// (注意:game 嵌在 market 命中后才遍历 market.gamelist;与 game 子树的结构不同)
|
||||
private static func extractAgentSubtree(
|
||||
agent: Agent,
|
||||
channelId: String,
|
||||
marketId: String,
|
||||
gameId: String
|
||||
) -> SubtreeExtract {
|
||||
var r = SubtreeExtract()
|
||||
|
||||
// 层 1:agent 自身
|
||||
applyOnto(&r, from: agent)
|
||||
|
||||
// 层 2:channel
|
||||
guard let channel = agent.channellist?.first(where: { $0.channelid == channelId })
|
||||
else { return r }
|
||||
applyOnto(&r, from: channel)
|
||||
|
||||
// 层 3:market
|
||||
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
|
||||
else { return r }
|
||||
applyOnto(&r, from: market)
|
||||
|
||||
// 层 4:game(嵌在 market 命中后才遍历,msext 历史结构)
|
||||
// 注:Market 模型当前不带 gamelist 字段,原项目这层在 channel 节点的 marketlist
|
||||
// 内部又嵌了一份 gamelist。如服务端有这层数据需要扩展 Market.gamelist。
|
||||
// 当前 RemoteConfig 模型未声明该字段,暂跳过——若上线时发现该层有版本数据需补。
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// MARK: - game 子树(getgameversion,NewRootVC.m:1015-1180)
|
||||
|
||||
/// game 子树 4 层:game → game-self → channel → market
|
||||
/// (第 2 层是把 game 自身当作 infotwo 再嵌一遍 channellist;msext 那段曾经的
|
||||
/// agentid 判断被注释掉了,等价于直接走 game.channellist)
|
||||
private static func extractGameSubtree(
|
||||
game: Game,
|
||||
/// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
||||
///
|
||||
/// 返回数组从根到叶有序,长度 0..4:
|
||||
/// - `[]` :agent 未匹配
|
||||
/// - `[agent]` :agent 命中、game 未匹配
|
||||
/// - `[agent, game]` :再深一层未命中
|
||||
/// - ...
|
||||
/// - `[agent, game, channel, market]`:全链命中
|
||||
private static func buildChain(
|
||||
config: RemoteConfig,
|
||||
agentId: String,
|
||||
gameId: String,
|
||||
channelId: String,
|
||||
marketId: String
|
||||
) -> SubtreeExtract {
|
||||
var r = SubtreeExtract()
|
||||
) -> [RemoteConfigNode] {
|
||||
var chain: [RemoteConfigNode] = []
|
||||
|
||||
// 层 1:game 自身
|
||||
applyOnto(&r, from: game)
|
||||
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
|
||||
else { return chain }
|
||||
chain.append(agent)
|
||||
|
||||
// 层 2:game-self(理论上同 game.channellist,等价于层 3 入口,故略)
|
||||
guard let game = agent.gamelist?.first(where: { $0.gameid == gameId })
|
||||
else { return chain }
|
||||
chain.append(game)
|
||||
|
||||
// 层 3:channel
|
||||
guard let channel = game.channellist?.first(where: { $0.channelid == channelId })
|
||||
else { return r }
|
||||
applyOnto(&r, from: channel)
|
||||
else { return chain }
|
||||
chain.append(channel)
|
||||
|
||||
// 层 4:market
|
||||
guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
|
||||
else { return r }
|
||||
applyOnto(&r, from: market)
|
||||
else { return chain }
|
||||
chain.append(market)
|
||||
|
||||
return r
|
||||
return chain
|
||||
}
|
||||
|
||||
// MARK: - 字段级合并
|
||||
// MARK: - 字段倒序 fallback(最深层赢)
|
||||
|
||||
/// IPA 合并:默认 agent 赢;game 版本号更大时 game 赢(NewRootVC.m:1408-1447)
|
||||
private static func mergeIPA(
|
||||
agent: SubtreeExtract,
|
||||
game: SubtreeExtract
|
||||
) -> (Int, String?) {
|
||||
// 都有:取较大版本对应的下载链
|
||||
if agent.appDownload != nil && game.appDownload != nil {
|
||||
if game.appVersion > agent.appVersion {
|
||||
return (game.appVersion, game.appDownload)
|
||||
}
|
||||
return (agent.appVersion, agent.appDownload)
|
||||
/// 从最深层(market)向根(agent)回退,取第一个非空字符串。
|
||||
private static func pickString(
|
||||
_ chain: [RemoteConfigNode],
|
||||
_ keyPath: KeyPath<RemoteConfigNode, String?>
|
||||
) -> String? {
|
||||
for node in chain.reversed() {
|
||||
if let s = node[keyPath: keyPath], !s.isEmpty { return s }
|
||||
}
|
||||
// 仅一边:取那边
|
||||
if let dl = agent.appDownload {
|
||||
return (agent.appVersion, dl)
|
||||
}
|
||||
if let dl = game.appDownload {
|
||||
return (game.appVersion, dl)
|
||||
}
|
||||
return (0, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
/// zip 合并:默认 game 赢;agent 版本号更大时 agent 赢(NewRootVC.m:1450-1492)
|
||||
private static func mergeZip(
|
||||
agent: SubtreeExtract,
|
||||
game: SubtreeExtract
|
||||
) -> (Int, String?) {
|
||||
if agent.gameZip != nil && game.gameZip != nil {
|
||||
if agent.gameVersion > game.gameVersion {
|
||||
return (agent.gameVersion, agent.gameZip)
|
||||
}
|
||||
return (game.gameVersion, game.gameZip)
|
||||
/// 从最深层向根回退,取第一个能解析为非零整数的字段值。
|
||||
/// 字段在 JSON 里可能是 string / int / double 之一(详见 `decodeFlexibleStringIfPresent`),
|
||||
/// 解析失败 / 为 0 视同未提供继续向上找。
|
||||
private static func pickInt(
|
||||
_ chain: [RemoteConfigNode],
|
||||
_ keyPath: KeyPath<RemoteConfigNode, String?>
|
||||
) -> Int {
|
||||
for node in chain.reversed() {
|
||||
if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v }
|
||||
}
|
||||
if let zip = game.gameZip {
|
||||
return (game.gameVersion, zip)
|
||||
}
|
||||
if let zip = agent.gameZip {
|
||||
return (agent.gameVersion, zip)
|
||||
}
|
||||
return (0, nil)
|
||||
}
|
||||
|
||||
// MARK: - 单层逐字段覆盖工具
|
||||
|
||||
/// 从单层节点向 SubtreeExtract 累加:非空字段才覆盖,等价于 msext 的
|
||||
/// `if (xxx != "" && xxx != nil) self.xxx = xxx` 语义。
|
||||
private static func applyOnto(_ r: inout SubtreeExtract, from agent: Agent) {
|
||||
if let v = Int(agent.appVersion ?? ""), v != 0 { r.appVersion = v }
|
||||
if let s = agent.appDownload, !s.isEmpty { r.appDownload = s }
|
||||
if let v = Int(agent.gameVersion ?? ""), v != 0 { r.gameVersion = v }
|
||||
if let s = agent.gameZip, !s.isEmpty { r.gameZip = s }
|
||||
if let s = agent.showmessage, !s.isEmpty { r.showmessage = s }
|
||||
}
|
||||
private static func applyOnto(_ r: inout SubtreeExtract, from channel: Channel) {
|
||||
if let v = Int(channel.appVersion ?? ""), v != 0 { r.appVersion = v }
|
||||
if let s = channel.appDownload, !s.isEmpty { r.appDownload = s }
|
||||
if let v = Int(channel.gameVersion ?? ""), v != 0 { r.gameVersion = v }
|
||||
if let s = channel.gameZip, !s.isEmpty { r.gameZip = s }
|
||||
if let s = channel.showmessage, !s.isEmpty { r.showmessage = s }
|
||||
}
|
||||
private static func applyOnto(_ r: inout SubtreeExtract, from market: Market) {
|
||||
if let v = Int(market.appVersion ?? ""), v != 0 { r.appVersion = v }
|
||||
if let s = market.appDownload, !s.isEmpty { r.appDownload = s }
|
||||
if let v = Int(market.gameVersion ?? ""), v != 0 { r.gameVersion = v }
|
||||
if let s = market.gameZip, !s.isEmpty { r.gameZip = s }
|
||||
if let s = market.showmessage, !s.isEmpty { r.showmessage = s }
|
||||
}
|
||||
private static func applyOnto(_ r: inout SubtreeExtract, from game: Game) {
|
||||
if let v = Int(game.appVersion ?? ""), v != 0 { r.appVersion = v }
|
||||
if let s = game.appDownload, !s.isEmpty { r.appDownload = s }
|
||||
if let v = Int(game.gameVersion ?? ""), v != 0 { r.gameVersion = v }
|
||||
if let s = game.gameZip, !s.isEmpty { r.gameZip = s }
|
||||
if let s = game.showmessage, !s.isEmpty { r.showmessage = s }
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +195,8 @@ public final class SubGameViewController: UIViewController {
|
||||
private func runBootPipelineSteps() async throws {
|
||||
// 0. H5 传过来的 SwitchOverGameData.gamedownloadurl **字段名是 url 但语义是 gameid**
|
||||
// (msext gameController.m:544 注释明确写"游戏ID")。真实 zip 下载地址要按
|
||||
// gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;msext 走 uplevel:
|
||||
// chulishengji 算法,新外壳直接复用 VersionResolver 同一套实现。
|
||||
// gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;走 VersionResolver
|
||||
// 单链 4 层 fallback(agent → game → channel → market)的同一套实现。
|
||||
//
|
||||
// 需要做两次 resolve:
|
||||
// - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志
|
||||
|
||||
Reference in New Issue
Block a user