ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenFrontIO GameServer 可测试化重构实录:从 2400 行上帝类到九大专职模块

OpenFrontIO GameServer 可测试化重构实录:从 2400 行上帝类到九大专职模块 游戏开发后端【免费下载链接】OpenFrontIOOnline browser-based RTS game项目地址https://gitcode.com/gh_mirrors/op/OpenFrontIO点击查看免费下载本文是 OpenFrontIO 开源仓库在线浏览器 RTS 游戏中src/server/GameServer.ts可测试化与模块化重构计划的完整技术解读。该游戏的核心仿真运行在每个客户端上服务器仅负责协调与转发 Intent这使得GameServer成为承载连接管理、房间生命周期、意图授权、胜者共识、对战归档、遥测上报等十余种职责的巨型类。读完本文你将掌握一套可复制的「先补测试、后做抽取」重构方法论如何为无测试的遗留核心类建立特征化测试characterization tests与黄金帧快照golden wire transcript如何用依赖注入替换隐式依赖如何按「纯函数先行」的顺序把单一职责逐一抽取为独立模块以及如何用生命周期状态机收尾——最终把约 2400 行、40 个公开方法的上帝类收敛为 1681 行的编排层加上 9 个带直接单元测试的专职模块。重构背景与诊断为什么 2400 行无法测试GameServer.ts在重构前的状态是典型的遗留代码症状重构计划文档 将它概括为三个层面的问题。类本身三个不断膨胀的分支点整个类约 2400 行包含约 40 个公开方法、13 种职责。新功能落地的固定套路是「加一个字段、加一个方法、在joinClient/handleIntent/phase()里加一个分支」于是这三个方法持续膨胀。在游戏服务器场景中joinClient承载入场策略允许列表、IP 上限、重复会话、房主离开handleIntent承载所有玩法意图的授权与执行phase()承载生命周期判定与清理副作用——三者天然是耦合的重灾区。测试真正的问题只能绕过 API 伸手进私有成员这是文档强调的「实际问题」。由于 2400 行的类无法通过公开 API 驱动到需要的状态测试被迫使用类型断言绕过封装约 150 处(game as any).x穿墙访问横跨 29 个私有成员重灾户是gameStartInfo、archiveGame被 spy、intents、_hasStarted true、winner、handleClientDisconnect。6 个测试文件vi.mock(../../src/core/Schemas)只为 stub 掉GameStartInfoSchema.safeParse——原因是旧测试夹具里的 ID 形如p1过不了 schema 中 8 字符的ID正则。13 个文件各自定义一份makeMockWs/makeClient/mockLogger样板代码完全重复且每个副本行为细节不一致。隐式依赖逼着其余所有测试就范archive()归档上传、fetchCustomTribes()自定义部落拉取、静态方法ServerEnv.env()、裸调console.error、类内部两个setInterval。任何不 stub 这些依赖的测试都无法运行。覆盖缺口完全没有测试的分支文档明确列出零覆盖路径重复会话踢出duplicate-session kick、每 IP 3 连接上限、host_left导致的房间关闭、rejoinClient的身份/认证徽章更新与lastTurn截断、「ws 已关闭」竞态addListeners中的readyState 2分支、maxGameDuration超时、phase()中的 60 秒 ping 清理、checkDisconnectedStatus的状态翻转、findOutOfSyncClients的多数派判定逻辑。结构性坏味道structural smells七套平行集合描述同一批玩家activeClients公开数组、allClients、websockets、persistentIdToClientId、admittedPersistentIds、kickedPersistentIds、clientsDisconnectedStatus在joinClient、rejoinClient、kickClient、handleClientDisconnect、phase()里手工同步任何遗漏都会造成「人在集合里却查不到」的隐性 bug。phase()是带副作用的查询它同时关闭 socket、清理客户端、重新计票胜者而GameManager每个 tick 要调用它 23 次。生命周期由四个布尔值拼凑_hasPrestarted、_hasStarted、_hasEnded、isPaused外加hasReachedMaxPlayerCount且hasStarted()的语义是「prestarted或started」极易误用。updateGameConfig是 100 行的逐字段拷贝。end()声明为async却不 await 任何东西archive()返回的 Promise 没有被 await外层try/catch只能捕获同步抛错异步归档失败被静默吞掉。重构四原则行为不变、测试先行、单职责一 PR、移动不改行为重构计划立下了四条硬性约束这也是后续每个阶段验收的准绳客户端可见的线上行为wire behaviour不得改变。契约就是 socket 上的帧和归档记录archived record——只要这两者不变重构就是安全的。先测试后搬移。任何抽取落地之前被搬移的代码必须有特征化测试锁定其当前行为。每个 PR 只处理一个职责并且顺带删除该职责对应的全部穿墙访问。搬移时不修行为。疑似 bug 先记录成注释/问题单独开 PR 处理避免「重构夹杂行为变更」导致回归难排查。Phase 0 — 测试基础设施让 GameServer 第一次可以被公开驱动Phase 0 不做任何生产代码改动只解决「测试无法从公开 API 驱动这个类」的根本问题。产物是 tests/util/GameServerHarness.ts统一的mockLogger()一个child()返回自身的 winston 替身消除 13 份重复副本。可驱动的makeMockWs()有状态的ws.WebSocket替身。测试可以用emit(ClientMessage)像真实客户端一样发帧、用trigger(close)触发断开、用sent(ctx?)读取服务器发过的每一帧已解码。makeClient(opts)/makeGame(opts)schema 合法的客户端与游戏夹具。makeGame默认把archive与fetchTribes置为惰性 spy测试可按需注入自己的archive读取游戏产出的归档记录。startGame(game)不再靠设置_hasStarted私有权标而是真实执行prestart()start()走完完整的 lobby → 开局转换。schema 合法的夹具 IDcid(p1)生成p1000000随后删光所有vi.mock(../../src/core/Schemas)。Phase 0 还建立了整个重构的回归网黄金帧转录测试golden wire transcript test即 tests/server/GameServerWire.test.ts——在假定时器下跑一场脚本化完整对局把服务器发给每个客户端的帧解码后与最终归档记录整体快照。文件头注释写得很清楚这份快照是「可读」的重构若让快照发生变化要么是有意的线上行为变更更新快照要么是回归不允许。文档的验证标准是npm test全绿且tests/server下不再存在对Schemas的vi.mock。该阶段 2026-08-25 完成时的量化成果穿墙访问 150 → 82Schemasmock 6 → 0私有makeMockWs副本 13 → 0。剩余的 82 处集中在archiveGame10、gameStartInfo9、intents8、winner6按计划由 Phase 2 的依赖注入与startInfo()访问器消化大部分。Phase 1 — 特征化测试锁定所有未覆盖分支的当前行为Phase 1 针对诊断出的全部覆盖缺口用新 harness 写出「面向当前行为」的特征化测试。特征化测试的精髓是先记录现状、不求正确与否让后续每一步抽取都有可回滚的护栏。完成的分支清单joinClient重复会话踢掉旧客户端Prod 环境、3 IP 上限Public 非 Dev 环境、房主离开关闭房间且phase()转为Finished。rejoinClient身份更新仅在用户名变化时清除verified徽章开局后忽略重连身份更新lastTurn截断旧 socket 被关闭。addListeners损坏帧 →invalid_message踢出wsreadyState 2的「先关后挂监听」竞态按断开处理。phase()60 秒 ping 清理、maxGameDuration上限、noActive warmupOver noRecentPings的空房间退出条件。findOutOfSyncClients多数派判定、严格多数翻转strict-majority flip、单客户端场景、达成一致时设置turns[n].hash。checkDisconnectedStatus每 5 回合才检查一次、双向翻转、观众不发送mark_disconnected。验收方法很关键每个测试文件都对照它覆盖的分支做过一次手工变异如 5 回合边界、严格多数翻转、开局后身份更新、重复会话踢出变异后测试如预期失败——证明测试真的锁住了那条分支而不是空转。最终产物是tests/server/GameServerJoin.test.ts、GameServerRejoin.test.ts、GameServerPhase.test.ts、GameServerDesync.test.ts合计 35 个测试全部经由公开 API 和线上帧驱动零(game as any)。Phase 1 还顺手钉住了一个疑似 bug记为当前行为另行开 PR 修复不混入重构生产环境的重复会话踢出会调用kickClient()处理旧连接从而封禁了共享的persistentID导致存活的会话再也无法通过getClientIdForPersistentId查找、也无法重连。Phase 2 — 注入隐式依赖构造器重构成GameServerOptions GameServerDepsPhase 2 的目标是把archive、fetchTribes、env、turnIntervalMs、telemetry、buildHash这些外部依赖全部变成可注入的构造器参数。落地后的真实签名在 src/server/GameServer.tsexport interface GameServerDeps { archive: (record: PartialGameRecord) Promisevoid; fetchTribes: typeof fetchCustomTribes; env: () GameEnv; turnIntervalMs: () number; telemetry: MatchTelemetryEmitter; telemetryBuildHash: string; mintGroupToken: () string; } export function defaultGameServerDeps(): GameServerDeps { return { archive: (record) archive(finalizeGameRecord(record)), fetchTribes: fetchCustomTribes, env: () ServerEnv.env(), turnIntervalMs: () ServerEnv.turnIntervalMs(), telemetry: noopMatchTelemetryEmitter, telemetryBuildHash: DEV, mintGroupToken, }; }构造器constructor(opts: GameServerOptions, deps: PartialGameServerDeps {})以{ ...defaultGameServerDeps(), ...deps }合并注入项GameServer.ts。生产路径上唯一的调用者是 GameManager.createGame它只注入telemetry与telemetryBuildHash其余走默认实现。细节要点原来的 10 位置参数构造器被GameServerOptions对象取代env与turnIntervalMs特意设计成thunk读取时求值因为测试环境与生产环境的取值时机不同。prestart()里的裸console.error换成this.log.error。Date.now()保留不动——假定时器fake timers已经覆盖它。文档记录了一个与计划的偏差很值得学习archive接收的是“部分归档记录”默认实现内部做archive(finalizeGameRecord(record))。原因是finalizeGameRecord会读取GIT_COMMIT环境变量未设置时直接抛错——若让真实实现留在调用链上每个涉及归档的测试都得 spyServerEnv且handleWinner的 catch 会静默吞掉归档抛错。harness 的makeGame默认把archive和fetchTribes置为惰性 spy需要断言归档内容时传入deps: { archive }即可。验收结果黄金快照帧不变仅丢失三个部署时间戳字段GameServer测试中不再有对服务器模块的vi.mock不再有archiveGamespy剩余两个使用点由 Phase 3 的Consensus抽取后重写为真实加入与开局。Phase 3 — 抽取纯模块最低风险先行Phase 3 是整个重构的核心章节。策略是「移动而非重写」每个模块从GameServer中剪切出来已有测试改指新模块。抽取顺序按风险从低到高排列每个模块一个 PR验收标准是「黄金快照不变 模块有自己的单元测试 对应(game as any)全部消失」。抽取模块从哪个方法/区域搬出重新归属的既有测试ConfigPatch.tsapplyGameConfigPatch()、hostCheatsEnabledupdateGameConfigAdminBotIntent、HostedLobbyListingNameVisibility.tsseesReal、anonName、startInfoFor、lobby 花名册名称可见性辅助函数、startInfoFor、gameInfoAnonymizeNames、AnonymizeNamesTeammates、AdminClanTagsDesyncDetector.tsfindOutOfSyncClients 失同步集合handleSynchronization新建Phase 1 产物Consensus.ts胜者投票 重计票、实时数据回合 清理handleWinner、handleLiveStatsWinnerVoteRetally、LiveStatsListingState.tslisted/listedAt/label/accent/featured/autoStart分散字段HostedLobbyListingMatchTelemetryRecorder.ts序号、tick 计数、结束标志emitTelemetry*MatchTelemetryIntegrationConfigPatch两张显式 key 列表ConfigPatch.ts 是最小、最稳的抽取样本。applyGameConfigPatch只拷贝COPIED_KEYS20 个可直接复制键与NULLABLE_KEYS8 个 schema 里.nullable().optional()的键两个列表都用satisfies readonly (keyof GameConfig)[]做了编译期校验保证与GameConfig同步。两个关键语义被特别保留可空键的null → undefined转换线上协议用null表示「清除某值」存储配置里则是undefined。hostCheats的“无条件赋值”房主通过省略该字段来清除作弊开关全量配置中关闭状态就是undefined所以这里undefined的含义是「清除」而不是「保持不变」必须无条件target.hostCheats patch.hostCheats。配套的hostCheatsEnabled()检查作弊块是否真正生效——仅存在不算数客户端可能发送所有字段全关的hostCheats。抽取后updateGameConfig从 100 行缩到 12 行GameServer.ts只保留白名单 → 下架的副作用。tests/server/ConfigPatch.test.ts对每个 key 做了表驱动测试。NameVisibility展示层规则与仿真的隔离NameVisibility.ts 是 Phase 3 中最有设计含量的一步。它把「谁能看到谁的真实身份」从GameServer中完整剥离核心设计是NameVisibilityView——一组 thunkconfig、clients、teamIndex而非静态快照因为房间配置会被房主编辑、花名册会增长必须在构造每个 payload 的瞬间读取。文件头注释明确写出关键约束这些规则只是展示层只塑造每个查看者视角的线上 payloadlobby 花名册与开局消息绝不进入仿真或归档记录因此不可能造成 desync。值得展开的实现细节anonName(viewer, target)的防抱团设计匿名名由「目标在加入顺序 Map 中的槽位」「按查看者旋转的偏移」共同决定。同一查看者视角内不同目标槽位不同 → 名字互不重复不同查看者看同一玩家得到不同假名 → 防止通过名字认人抱团。sameMatchmadeTeam例外匹配赛PINNED team中队友互相可见真实身份——「无法识别队友的团队根本没法玩」但只有服务器端指派的 PINNED team 适用按 clanTag/friends 在客户端分组的团队模式不适用。startInfoFor(viewer, isAdmin, real, wire)real游戏自身、归档用永不被触碰wire客户端副本在开启匿名时替换不可见玩家的username并隐藏其cosmeticsfriends一律清空——因为它喂养确定性分队逻辑TeamAssignment.ts必须对所有客户端一致。管理员在 FFA 模式可见真实 clanTag用于现场识破抱团但只在 FFA 生效该模式不运行assignTeamsclanTag 永远进不了仿真团队模式若按查看者泄露 tag 会 desync。lobbyClients中队友揭示刻意“更窄”队友只见 username 与 teamIndex看不到 clanTag 与 friends——friends 会泄露「你的队友和某个仍匿名的对手是好友」这层第三方信息房主从未授权过。独立的friendsLookup把 publicId 好友列表映射为当局限定的 clientID 集合并排除观众观众不在仿真里成了好友会被分到永不生成的 clientID。抽取后GameServer减掉 180 行gameInfo重新变回字段列表AdminClanTags.test.ts并入NameVisibility.test.ts。DesyncDetector纯函数计票 有状态检测器DesyncDetector.ts 把「哈希多数派判定」原样搬进纯函数findOutOfSyncClients(active, turnNumber)统计每个哈希的票数 → 找出众数 → 列出不一致客户端 →当不一致客户端超过floor(active.length / 2)时触发严格多数翻转判定全员失同步多数人互相矛盾时无人可信。DesyncDetector类持有desynced与notified两个集合check每 10 回合触发一次CHECK_INTERVAL 10比较 10 回合前的哈希给足上报时间少于 2 人时返回 nullrecord返回「首次发现」的失同步客户端——每人只被告知一次无论此后继续分歧多久。失同步客户端后续的胜者票与实时数据票都被忽略。GameServer的handleSynchronization只保留解析/编码/发送/日志的顺序计票与记录全部委托给检测器。ConsensusIP 加权多数决的胜者共识与实时数据Consensus.ts 抽取的是「服务器信任谁」的核心逻辑。由于仿真在客户端运行服务器对“谁赢了”和“当前面板长什么样”只有客户端的一面之词两者都靠VoteTally.ts的按唯一 IP 加权多数决来定论。WinnerVotecast/tally/tallyAmong/ 只决定一次与LiveStatsVote每个客户端每回合投一次、二十回合滑动窗口、latest各自封装投票状态。一个值得注意的细节是statsDigest胜者票按“每玩家统计”的摘要来比较而同一份统计在不同客户端里key 的插入顺序不同全程游玩的客户端与快照恢复的客户端不同所以哈希前要在每一层排序 key、bigint 转十进制字符串归档序列化格式否则同一份正确统计会哈希出不同摘要。抽取后GameServer保留 desync/踢出护栏、reportedWinner、选举人范围、日志与archiveGame顺序不变最后两个archiveGamespy 随之消失相关测试改为真实加入客户端、开局、线上投票、从注入的archive读取记录。穿墙访问 68 → 36。ListingState公开列表状态与 GameConfig 的隔离ListingState.ts 抽取了「私有房间在公开房间浏览器的可见性」listed、listedAt、autoStartMs、label、accent、featured、queuedAt。设计要点故意不进GameConfigupdate_game_config无法设置其中任何一项只有通过订阅校验的认证列表端点和create_game能改——防止房主绕过付费门槛。重复切换不延长自动开局截止setListed里listed listed直接 return。autoStartAt()截止规则featured 房间用FEATURED_LOBBY_AUTO_START_MS普通房间用autoStartMs或默认HOSTED_LOBBY_AUTO_START_MS进入 Special 队列queue()后改由队列倒计时驱动列表截止失效。setFeatured在边界处用sanitizeLobbyLabel消毒 label保证游戏中不存在未消毒文本。GameServer保留薄薄的公开委托Worker、AdminBotRoutes、WorkerLobbyService 都调用它们与生命周期相关的maybeAutoStartListed。MatchTelemetryRecorder一场比赛的遥测视角MatchTelemetryRecorder.ts 抽出单场比赛的遥测流emit给每个事件打上 matchId、单调递增的 sequence、serverTick 与 schemaVersionintentObserved维护每个 tick 的 intent 观察/入队/丢弃计数turn_committed事件上报archive-attempted标志与「只发一次」的matchFinished。identityFor作为独立导出函数存在并刻意排除persistentID遥测身份只有 clientId 与 publicId。GameServer在旧代码依赖默认值的地方显式传入turns.length。Phase 3 收尾成绩六个模块、六个 PR、黄金快照全程不变。GameServer.ts从 2,365 行降到 1,944 行穿墙访问从约 150 降到 36且不再有对私有方法的 spy。剩余的intents、isPaused、_hasStarted、websockets、startsAt全部是生命周期与入口状态——正是 Phase 5、6 的目标。Phase 4 — Roster七套平行集合收敛为一个类Phase 4 解决诊断里最刺眼的坏味道七套手工同步的平行集合。现在的 Roster.ts 用内部六种结构表达「谁在游戏里」connected当前在线、按加入顺序、everyone所有加入过的客户端永不缩水——离开者保留记录以便仍可被踢出并出现在房主的战后对账里、sockets、reconnectablepersistentID → clientID 重连映射、admitted通过过 Turnstile 等入场授权的 persistentID跨forgetReconnect存活可跳过重复验证、kicked、disconnected。对外 API 与语义约定add/reconnect换入新 socket、关闭旧 socket、把客户端移到连接顺序末尾/markLeft/forgetReconnect释放 lobby 阶段的座位但保留 admission/kick封禁 persistentID返回是否在线/pruneStale(now, maxSilenceMs)/closeAll。byPersistentId()是裸查询——被踢检查是调用者的策略不混入查询语义。votingUniqueIPs()只统计非观众玩家文档注释解释了原因——观众跑仿真但不能投票若把观众计入多数分母五个观众围观四个玩家会让严格多数永不可达游戏将永远无法达成共识、永远无法归档计分。wasAdmitted()对被踢者返回 false强制被踢者重新过闸。Roster 只做簿记bookkeeping不做策略白名单、IP 上限、重复会话、房主离开、加入与重连时的被踢检查全部留在GameServer。activeClients不再是公开数组GameManager.activeClients()改用numClients()。完成状态Roster.test.ts覆盖簿记逻辑GameServerRejoin.test.ts与AdminBotIntent.test.ts中对websockets、allClients、kickedPersistentIds、activeClients的穿墙访问全部被真实加入 可观察结果替代。GameServer.ts降至 1,888 行穿墙访问 29。Phase 5 — 消息入口与 Intent 分发SocketIngress IntentAuthorizationSocketIngress一帧数据的完整流水线SocketIngress.ts 抽出「socket 侧」attach(client)挂载消息/关闭/错误监听并显式处理readyState 2的「监听挂上之前连接已关闭」竞态receive(client, frame)则是完整流水线——解码 → 校验 → 限流 → 观众拦截通过后才交给GameServer.handleClientMessage。关键细节解码与校验分两步先用decodeClientMessageUnvalidated做结构解码损坏字节 →invalid_message踢出且无类型可归属再用ClientMessageSchema.safeParse做语义校验结构完好但校验失败 有 bug 或作弊客户端的特征可以归属到具体 intent 计入遥测。校验失败同样踢出。限流器是构造器参数rateLimiter默认new ClientMsgRateLimiter()测试可注入替身直接驱动限流与踢出遥测路径这正是MatchTelemetryIntegration.test.ts以前需要穿墙才能覆盖的部分。观众拦截表SPECTATOR_BLOCKED_MESSAGES { intent, winner, live_stats, hash, report }——观众不在仿真里这些消息“代表一个玩家”说话hash会喂养失同步共识、report是玩家对玩家的指控。注释点明没有这个拦截「声称自己是观众」就是绕过房间人数上限混入 intent 流的漏洞。IntentAuthorization每一条护栏一张可表驱动的表IntentAuthorization.ts 把handleIntent里所有的 actor / 游戏状态护栏按原顺序抽成纯函数authorizeIntent(intent, actor, game)第一个失败的护栏即返回的 outcome全过返回null。IntentActorclientID、isLobbyCreator、isAdmin、isAdminBot与IntentOutcomeHTTP 风格 status200 成功admin-bot 路由把非 200 直接映射为响应websocket 路径记录日志并丢弃也搬到这里。值得引用的护栏示例都有注释说明反滥用动机kick_player仅房主或管理员可踢列表中的公开招募房间房主也不可踢——让房主踢陌生人是骚扰向量管理员保留执法权isListed标志故意在开局后存活公开招募的游戏保持「不可踢」。update_game_config仅房主/admin-bot 可改公开游戏不可改已开局返回 409不能把游戏改为 PublicgameType Public返回 400已列表房间不可改配置玩家是按广告的配置加入的admin-bot 除外。handleIntent自己保留需要 Roster 的部分解析踢出目标、处理按 publicId 踢离线账号——踢出即封禁 persistentID阻止重连与效果执行。IntentAuthorization.test.ts就是这张护栏表。Phase 5 完成后GameServer.ts从 1,888 行降到 1,663 行穿墙访问 26剩余全是 Phase 6 的生命周期状态。Phase 6 — 生命周期状态机从四个布尔值到stage ended pausedPhase 6 收尾把生命周期从四个布尔值换成正交拆分的状态。当前实现GameServer.tsprivate stage: lobby | prestart | started lobby; private ended false; private paused false;文档特别解释为什么不是最初规划的单一四值状态ended与 lobby → prestart → started 的推进是正交的。lobby 可以不开局就结束房主离开、比赛取消已开局的游戏结束后仍是“已开局”——end()据此归档且end()之后的 socket 关闭事件仍要经过hasStarted()在handleClientDisconnect中。现在的语义是hasStarted()即stage ! lobbyGameServer.tsisPaused()变为公开方法。同期完成的两项关键改造phase()变成纯读。60 秒 ping 清理含胜者重计票移到pruneStaleClients()GameServer.ts由GameManager.tick在phase()之前调用GameManager.ts游戏结束后即 no-op保留旧早退语义。空房间计时emptySince也在 tick 循环上累积而非按房间浏览器查询频率累积。publicLobbies()/listedLobbies()不再关闭任何人的 socket——注释点明动机「让 lobby 浏览器想查多少次 phase 就查多少次而不会关掉谁的连接」。end()依旧不 await 归档并在调用处留注释Archive.ts自己捕获记录校验与上传两类错误await 只会拖延GameManager修剪该游戏。这是对旧行为的显式确认而非疏漏。现在的phase()是清晰的纯判定GameServer.ts已结束 →Finished超过 3 小时maxGameDuration→Finished未开局且未满员 →Lobby花名册非空 →Active空房间在「30 秒预热期 20 秒无 ping」后 →Finished另有「已开局 持续空置 10 分钟」的后备收割emptyGameTimeout防止 ping 时钟被人为保温。注释还记录了一个真实修过的坑startsAt未设置时undefined 30_000是NaN导致空游戏永远不被收割、无限跑回合直到时长上限因此引入warmupFrom this.startsAt ?? this._startTime ?? this.createdAt兜底。Phase 6 完成2026-08-27最后一批穿墙访问随之消失——测试现在驱动真实生命周期startGame、prestart()、end()读公开的gameInfo()与isPaused()通过解码客户端实际收到的回合帧与开局帧来断言而不是读实例上的intents、turns、gameStartInfo。tests/server下已无任何(game as any)。GameServer.ts最终为 1,681 行。范围外事项与最终成果重构计划明确圈定了三条红线保证收敛不失控不重命名 intents/消息类型不改变归档记录形状——replay 依赖它tests/replay/ReplayGame.ts不动Worker.ts/Master.ts构造调用之外。最终成果2026-08-27九个带直接单元测试的专职模块——ConfigPatch、NameVisibility、DesyncDetector、Consensus、ListingState、MatchTelemetryRecorderPhase 3 六模块 RosterPhase 4 SocketIngress、IntentAuthorizationPhase 5——外加黄金帧快照全程不变。值得坦诚记录的一点计划的「期望结果」原本估计GameServer.ts收敛到 700900 行实际停在 1,681 行。文档自己做了复盘——剩余部分加入策略、intent 效果、开局/结束、归档记录构造本质上是在编排那九个模块它们「从来就不是抽取的候选」结构达到了预期行数估计偏乐观。这个坦率的差距本身就是方法论的一部分抽出纯逻辑容易编排逻辑不会凭空消失而它恰恰是值得留在单一视图里的部分。可复用的方法论清单把这份重构计划提炼成可迁移到任何遗留 TypeScript 服务的操作清单先修测试基础设施不碰生产代码统一 mock、schema 合法夹具、可驱动替身 socket让被测类第一次能经由公开 API 驱动到任意状态。建立黄金转录快照用假定时器跑脚本化完整流程快照全部对外帧与落库记录——这是后续每一步的安全网快照 diff 即行为变更的审计面。特征化测试锁未覆盖分支对每个目标分支做手工变异验证测试有效性疑似 bug 记录为当前行为、单独开 PR。依赖注入替代隐式依赖构造器参数对象 生产默认实现thunk 化读取时点敏感的取值注意把「真实实现会在测试链路中抛错/有副作用」的模块本例的finalizeGameRecord移出默认路径。纯函数/纯逻辑先行抽取每一抽取都是「移动而非重写」保留原有注释与顺序用satisfies在编译期锁住配置键清单把展示层规则与仿真严格隔离。簿记与策略分离集合类只做簿记本例 Roster策略留在编排层。状态机用正交维度而非枚举推进序列与终结标志正交stageended比单一大枚举更贴合「未开局即结束」这类真实语义。带副作用的查询拆成「纯读 显式副作用方法」让高频查询如 lobby 浏览器、每 tick 循环可以放心调用。在 OpenFrontIO 中这套方法的产出如今就摆在仓库里可直接阅读harness 在 tests/util/GameServerHarness.ts黄金转录在 tests/server/GameServerWire.test.ts九个模块与编排后的 src/server/GameServer.ts 共同构成了一个「面向契约、行为有快照锁定、每一职责有独立单元测试」的服务器核心。赞分享游戏开发后端【免费下载链接】OpenFrontIOOnline browser-based RTS game项目地址https://gitcode.com/gh_mirrors/op/OpenFrontIO点击查看免费下载相关推荐Scalene 重构实录从 1885 行巨型 Profiler 到职责清晰的多模块架构Scalene 重构实录从 1885 行巨型 Profiler 到职责清晰的多模块架构 本文以仓库内 refactoring_todo.md https://开发工具性能测试AI 应用Dapr ARC-001 架构决策实录面向模块化与可测试性的运行时重构Dapr ARC 001 架构决策实录面向模块化与可测试性的运行时重构 导读本文深度解读 Dapr 仓库中《ARC 001: Refactor for mo后端微服务云原生消息队列AI AgentComp AI CRM 的 NestJS 服务单一职责实践从 God Service 反模式到可测试的模块化架构Comp AI CRM 的 NestJS 服务单一职责实践从 God Service 反模式到可测试的模块化架构 导读 本文围绕 Comp AI CRM一个后端前端CRM人工智能AI Agent上一篇Salt 执行模块 cryptdev 深入解析管理 cryptsetup 加密设备与 /etc/crypttab 配置下一篇Turf 7.0生态系统深度解析第三方插件与集成工具全览创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表