9 Commits

Author SHA1 Message Date
jakciehan 4b31ac520d add bg.png 2026-06-07 22:08:29 +08:00
jakciehan e4140f073f fix boss tank cross brick 2026-06-07 22:08:00 +08:00
jakciehan c3a4aa8f15 feat: optimize pvp invite 2026-05-18 07:39:03 +08:00
jakciehan 7d17325be6 fix: 3v3 team match wrong problems 2026-05-16 09:59:54 +08:00
jakciehan 9359139186 feat: use wx.createUserInfoButton to get weixin's avarta 2026-05-14 22:41:32 +08:00
jakciehan c4bd390478 commit 2026-05-12 08:03:21 +08:00
jakciehan d263c7bf48 Merge feature/add_skin into master: resolve all conflicts
- GameGlobal.js: keep upstream SERVER_URL with /ws suffix
- en.js/zh.js: merge both settings.nickname and settings.profile keys
- SettingsScene.js: keep both nickname row and profile button
- server/index.js: merge express app + content security proxy with
  noServer WebSocket mode and path validation
- Add .gitignore for node_modules and .codebuddy
2026-05-12 07:05:20 +08:00
jakciehan 38294c040c chore: adjust player tank's size 2026-05-02 13:50:52 +08:00
jakciehan 0e321bcea6 add skin manager 2026-04-10 23:05:26 +08:00
111 changed files with 20348 additions and 1540 deletions
Vendored
BIN
View File
Binary file not shown.
@@ -0,0 +1,166 @@
# 需求文档 — 用户生成内容(UGC)安全审核系统
## 引言
坦克大战小游戏当前在用户自定义昵称、聊天消息、个人资料签名、个人空间描述等场景中,未具备过滤政治有害、色情、赌博违法等不当信息的机制。根据微信平台规范及中国相关法律法规要求,小游戏必须对用户产生内容(UGC)进行内容安全审核,确保上线内容合法合规。
本需求旨在接入微信公众平台内容安全 API(`msgSecCheck``imgSecCheck`),并结合客户端本地敏感词过滤、服务器端二次校验、违规记录与处罚机制,构建一套完整的内容安全审核体系,覆盖所有用户可输入文本和上传图片的场景。
### 适用场景
| 场景 | 内容类型 | 审核方式 |
|------|---------|---------|
| 用户自定义昵称 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 聊天室消息 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 个人资料签名 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 个人空间描述 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 用户头像上传 | 图片 | 服务器端 imgSecCheck |
| 用户分享卡片文案 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
---
## 需求
### 需求 1:客户端本地敏感词过滤
**用户故事:** 作为一名玩家,我希望在输入内容时能够即时收到不当内容的提示,以便我能够及时修改而不必等待服务器审核结果。
#### 验收标准
1. WHEN 用户在任意文本输入框中输入内容 AND 输入内容包含本地敏感词库中的违规词汇 THEN 系统 SHALL 立即在输入框下方显示"内容包含违规信息,请修改"的提示信息
2. WHEN 用户输入的内容通过本地敏感词过滤 AND 用户提交内容 THEN 系统 SHALL 将内容发送至服务器端进行二次审核
3. IF 本地敏感词库匹配到违规内容 THEN 系统 SHALL 阻止该内容的提交,提交按钮置灰或点击后提示违规
4. WHEN 系统初始化时 THEN 系统 SHALL 从服务器拉取最新的敏感词库并缓存到本地,缓存有效期不超过 24 小时
5. WHEN 本地敏感词库缓存过期或拉取失败 THEN 系统 SHALL 使用内置的兜底敏感词列表(硬编码的基础违规词库)继续工作
### 需求 2:服务器端文本内容安全审核(msgSecCheck
**用户故事:** 作为一名游戏运营者,我希望所有用户提交的文本内容都经过微信官方内容安全 API 审核,以便确保内容审核的准确性和合规性。
#### 验收标准
1. WHEN 服务器接收到用户提交的文本内容(昵称、聊天消息、签名、描述等) THEN 系统 SHALL 在将内容存储或转发之前,调用微信 `msgSecCheck` API 进行审核
2. IF `msgSecCheck` 返回结果为内容违规 THEN 系统 SHALL 拒绝该内容的存储/转发,并向客户端返回错误码和"内容违规,请修改"的提示
3. IF `msgSecCheck` 返回结果为内容安全 THEN 系统 SHALL 允许该内容正常存储/转发
4. IF `msgSecCheck` API 调用失败(网络超时、服务不可用等) THEN 系统 SHALL 拒绝该内容的提交,返回"审核服务暂时不可用,请稍后再试",并记录错误日志
5. WHEN 调用 `msgSecCheck` API THEN 系统 SHALL 传递用户的 `openid``scene` 值(昵称场景=1,聊天场景=2,签名场景=3,描述场景=4),以便微信进行精准审核
6. WHEN 服务器调用 `msgSecCheck` THEN 系统 SHALL 在 3 秒内完成审核并返回结果,超时则按审核失败处理
### 需求 3:服务器端图片内容安全审核(imgSecCheck
**用户故事:** 作为一名游戏运营者,我希望用户上传的图片(如头像)经过微信官方图片安全 API 审核,以便防止违规图片传播。
#### 验收标准
1. WHEN 服务器接收到用户上传的图片(头像等) THEN 系统 SHALL 在将图片存储或展示之前,调用微信 `imgSecCheck` API 进行审核
2. IF `imgSecCheck` 返回结果为图片违规 THEN 系统 SHALL 拒绝该图片的存储/展示,并向客户端返回"图片内容违规,请更换"的提示
3. IF `imgSecCheck` 返回结果为图片安全 THEN 系统 SHALL 允许该图片正常存储/展示
4. IF `imgSecCheck` API 调用失败 THEN 系统 SHALL 拒绝该图片的上传,返回"审核服务暂时不可用,请稍后再试"
5. IF 用户上传的图片大小超过 1MB THEN 系统 SHALL 在调用审核前拒绝并提示"图片大小不能超过1MB"
### 需求 4:聊天室消息实时审核
**用户故事:** 作为一名玩家,我希望聊天室中的消息都是安全的,以便我能在良好的环境中与其他玩家交流。
#### 验收标准
1. WHEN 用户在聊天室发送消息 THEN 客户端 SHALL 先进行本地敏感词过滤,通过后再发送至服务器
2. WHEN 服务器接收到聊天消息 THEN 系统 SHALL 先调用 `msgSecCheck` 审核,审核通过后再将消息广播给同房间/队伍的其他玩家
3. IF 聊天消息审核未通过 THEN 系统 SHALL 仅向发送者返回"消息发送失败:内容违规"提示,不广播该消息
4. WHEN 聊天消息正在服务器审核中 THEN 系统 SHALL 在客户端显示"发送中..."状态,审核通过后显示为已发送
5. IF 聊天消息审核耗时超过 3 秒 THEN 系统 SHALL 显示"发送超时"并允许用户重试
### 需求 5:昵称设置与修改审核
**用户故事:** 作为一名玩家,我希望设置一个合规的昵称,以便其他玩家能看到我的身份标识。
#### 验收标准
1. WHEN 用户首次设置或修改昵称 THEN 客户端 SHALL 先进行本地敏感词过滤,通过后提交至服务器
2. WHEN 服务器接收到昵称修改请求 THEN 系统 SHALL 调用 `msgSecCheck`scene=1)进行审核
3. IF 昵称审核通过 THEN 系统 SHALL 更新用户昵称并返回成功
4. IF 昵称审核未通过 THEN 系统 SHALL 返回错误,客户端提示"昵称包含违规内容,请重新输入"
5. IF 用户昵称长度超过 20 个字符 THEN 系统 SHALL 在客户端直接拒绝并提示"昵称长度不能超过20个字符"
6. IF 用户昵称长度少于 2 个字符 THEN 系统 SHALL 在客户端直接拒绝并提示"昵称长度不能少于2个字符"
### 需求 6:个人资料签名与空间描述审核
**用户故事:** 作为一名玩家,我希望在个人资料中表达自己,同时确保内容合规,以便不影响其他玩家的体验。
#### 验收标准
1. WHEN 用户设置或修改个人资料签名 THEN 系统 SHALL 经本地过滤 + `msgSecCheck`scene=3)双重审核
2. WHEN 用户设置或修改个人空间描述 THEN 系统 SHALL 经本地过滤 + `msgSecCheck`scene=4)双重审核
3. IF 签名或描述审核未通过 THEN 系统 SHALL 返回错误并提示"内容包含违规信息,请修改"
4. IF 个人资料签名长度超过 50 个字符 THEN 系统 SHALL 在客户端直接拒绝
5. IF 个人空间描述长度超过 200 个字符 THEN 系统 SHALL 在客户端直接拒绝
### 需求 7:违规记录与处罚机制
**用户故事:** 作为一名游戏运营者,我希望对多次违规的用户进行处罚,以便维护游戏环境的健康。
#### 验收标准
1. WHEN 用户的提交内容被审核判定为违规 THEN 系统 SHALL 记录违规日志,包含用户ID、违规内容、违规类型、时间戳
2. IF 用户累计违规次数达到 3 次 THEN 系统 SHALL 对该用户实施 24 小时禁言处罚
3. IF 用户累计违规次数达到 5 次 THEN 系统 SHALL 对该用户实施 7 天禁言处罚
4. IF 用户累计违规次数达到 10 次 THEN 系统 SHALL 对该用户实施永久禁言处罚
5. WHEN 用户处于禁言状态 AND 尝试发送聊天消息 THEN 系统 SHALL 提示"您已被禁言,剩余时间:X小时X分钟"
6. WHEN 用户处于禁言状态 AND 尝试修改昵称/签名/描述 THEN 系统 SHALL 提示"由于违规行为,您暂无法修改个人信息"
7. WHEN 禁言期满 THEN 系统 SHALL 自动解除禁言状态,并重置当前处罚级别的计数
### 需求 8:已有违规内容的清理与举报
**用户故事:** 作为一名玩家,我希望能够举报看到的不当内容,以便运营团队及时处理。
#### 验收标准
1. WHEN 玩家点击某条聊天消息或个人资料旁的举报按钮 THEN 系统 SHALL 弹出举报原因选择界面(政治有害/色情低俗/赌博诈骗/其他)
2. WHEN 玩家提交举报 THEN 系统 SHALL 将举报信息发送至服务器,包含被举报内容、举报原因、举报人信息
3. IF 同一条内容被 3 名以上不同用户举报 THEN 系统 SHALL 自动对该内容进行下线处理(隐藏),并标记待人工复核
4. WHEN 运营人员在后台对举报内容确认违规 THEN 系统 SHALL 对内容发布者按需求 7 的规则累计违规次数
5. WHEN 内容被自动下线或人工删除 THEN 系统 SHALL 将该用户的该字段内容重置为默认值(昵称恢复为"玩家+随机数",签名/描述清空)
### 需求 9:服务器端审核服务架构
**用户故事:** 作为一名开发者,我希望服务器端的内容审核服务有清晰的架构和可靠的接口,以便各业务模块能够方便地接入审核能力。
#### 验收标准
1. WHEN 服务器启动时 THEN 系统 SHALL 初始化微信 Access Token 管理器,自动获取和刷新 token(有效期 2 小时,提前 5 分钟刷新)
2. WHEN 业务模块需要审核文本内容 THEN 系统 SHALL 提供统一的 `checkTextContent(openid, content, scene)` 异步方法
3. WHEN 业务模块需要审核图片内容 THEN 系统 SHALL 提供统一的 `checkImageContent(imageBuffer)` 异步方法
4. IF 微信 API 调用频率超过限制(msgSecCheck: 5000次/分钟) THEN 系统 SHALL 实施请求排队机制,避免超出频率限制
5. WHEN 服务器接收到审核请求 THEN 系统 SHALL 记录审核日志(请求时间、用户ID、内容摘要、审核结果),日志保留至少 180 天
6. IF Access Token 获取失败 THEN 系统 SHALL 重试最多 3 次,每次间隔递增(2s, 4s, 8s),全部失败后标记审核服务不可用
### 需求 10:客户端 ContentSecurityManager 集成
**用户故事:** 作为一名开发者,我希望客户端有一个统一的内容安全管理器,以便各场景能方便地调用内容审核功能。
#### 验收标准
1. WHEN 游戏初始化时 THEN 系统 SHALL 创建 `ContentSecurityManager` 实例并挂载到 `GameGlobal`
2. WHEN 任意场景需要验证用户文本输入 THEN 该场景 SHALL 调用 `GameGlobal.contentSecurityManager.checkLocalText(content)` 进行本地校验
3. IF 本地校验通过 THEN 该场景 SHALL 将内容提交至服务器进行二次审核
4. IF 本地校验未通过 THEN 该场景 SHALL 显示具体的违规提示,不向服务器发送请求
5. WHEN `ContentSecurityManager` 初始化时 THEN 系统 SHALL 从服务器拉取最新敏感词库,拉取失败时使用内置兜底词库
6. WHEN 敏感词库更新接口被调用 THEN 系统 SHALL 增量更新本地缓存,不影响正在进行的审核操作
---
## 非功能性需求
### 性能要求
- 本地敏感词过滤应在 50ms 内完成
- 服务器端审核接口响应时间应 ≤ 3 秒(含微信 API 调用时间)
- 敏感词库拉取不应阻塞游戏主线程
### 安全要求
- 违规日志中的用户内容应脱敏存储(仅保留前3个和后3个字符,中间用***替代)
- Access Token 不得在日志中明文记录
- 举报人信息应对被举报者保密
### 可用性要求
- 审核服务不可用时,系统应拒绝所有UGC提交(安全优先于可用性)
- 本地敏感词库应内置至少 500 个基础违规词汇作为兜底
@@ -0,0 +1,67 @@
# 实施计划 — 用户生成内容(UGC)安全审核系统
- [ ] 1. 搭建服务器端审核服务核心架构
- 创建 `server/services/wechatTokenManager.js`:实现微信 Access Token 自动获取与刷新(2小时有效期,提前5分钟刷新),获取失败时指数退避重试(2s/4s/8s),全部失败标记服务不可用
- 创建 `server/services/contentSecurityService.js`:实现统一审核方法 `checkTextContent(openid, content, scene)``checkImageContent(imageBuffer)`,封装 `msgSecCheck``imgSecCheck` API 调用,3秒超时处理,频率限制排队(5000次/分钟)
- 创建 `server/services/auditLogger.js`:实现审核日志记录(请求时间、用户ID、内容脱敏摘要、审核结果),日志保留180天,Access Token 不得明文记录
- _需求:9.1、9.2、9.3、9.4、9.5、9.6_
- [ ] 2. 实现服务器端 HTTP API 与敏感词库接口
-`server/index.js` 中新增 HTTP 服务(Express),提供内容审核相关 API 路由:`POST /api/content/check-text``POST /api/content/check-image``GET /api/content/sensitive-words`
- 实现敏感词库拉取接口,从服务端返回最新敏感词列表供客户端缓存
- 各审核 API 内部调用 `contentSecurityService` 进行审核,审核不可用时拒绝请求并返回明确错误码
- _需求:2.1、2.4、3.1、3.4、1.4_
- [ ] 3. 实现违规记录与处罚机制
- 创建 `server/services/violationService.js`:实现违规日志记录(用户ID、违规内容、违规类型、时间戳),累计违规次数统计
- 实现禁言处罚逻辑:3次→24小时禁言,5次→7天禁言,10次→永久禁言;禁言期满自动解除并重置当前处罚级别计数
- 在审核 API 中集成违规记录:`msgSecCheck`/`imgSecCheck` 违规时自动调用 violationService 记录并触发处罚
- 添加禁言状态查询接口 `GET /api/user/mute-status`,返回是否禁言及剩余时间
- _需求:7.1、7.2、7.3、7.4、7.5、7.6、7.7_
- [ ] 4. 实现举报功能与违规内容自动下线
- 创建 `server/services/reportService.js`:实现举报提交(被举报内容、举报原因、举报人信息),举报人信息保密存储
- 实现自动下线逻辑:同一条内容被3名以上不同用户举报时自动隐藏,标记待人工复核
- 实现内容重置逻辑:内容被下线或删除时,昵称恢复为"玩家+随机数",签名/描述清空
- 添加举报 API`POST /api/content/report`,举报原因枚举(政治有害/色情低俗/赌博诈骗/其他)
- _需求:8.1、8.2、8.3、8.4、8.5_
- [ ] 5. 实现客户端 ContentSecurityManager
- 创建 `js/managers/ContentSecurityManager.js`:实现本地敏感词过滤方法 `checkLocalText(content)`50ms 内完成匹配
- 实现敏感词库远程拉取与本地缓存(24小时有效期),拉取失败使用内置兜底词库(硬编码基础违规词汇)
- 实现增量词库更新方法,不阻塞正在进行的审核操作
-`GameGlobal.js` 中初始化并挂载 `contentSecurityManager` 实例
- _需求:1.1、1.2、1.3、1.4、1.5、10.1、10.2、10.3、10.4、10.5、10.6_
- [ ] 6. 集成昵称设置与修改审核流程
- 在昵称设置/修改 UI 中添加本地敏感词实时校验,违规时输入框下方提示"内容包含违规信息,请修改",提交按钮置灰
- 添加昵称长度前端校验:2~20字符,不满足时即时提示
- 昵称提交时调用服务器 `POST /api/content/check-text`(scene=1),审核未通过提示"昵称包含违规内容,请重新输入"
- 禁言状态下尝试修改昵称时提示"由于违规行为,您暂无法修改个人信息"
- _需求:5.1、5.2、5.3、5.4、5.5、5.6、7.6_
- [ ] 7. 集成聊天室消息实时审核流程
- 在聊天发送流程中添加本地敏感词校验,违规时阻止发送并提示
- 聊天消息提交至服务器后,服务器先调用 `msgSecCheck`(scene=2)审核,通过后再广播;未通过仅向发送者返回"消息发送失败:内容违规"
- 客户端实现"发送中..."状态显示,审核通过后切换为已发送;超时3秒显示"发送超时"并允许重试
- 禁言用户发送消息时提示"您已被禁言,剩余时间:X小时X分钟"
- _需求:4.1、4.2、4.3、4.4、4.5、7.5_
- [ ] 8. 集成个人资料签名与空间描述审核流程
- 在签名/描述编辑 UI 中添加本地敏感词实时校验与长度限制(签名≤50字符,描述≤200字符)
- 签名提交调用 `msgSecCheck`scene=3),描述提交调用 `msgSecCheck`(scene=4),审核未通过提示"内容包含违规信息,请修改"
- 禁言状态下尝试修改签名/描述时提示"由于违规行为,您暂无法修改个人信息"
- _需求:6.1、6.2、6.3、6.4、6.5、7.6_
- [ ] 9. 集成头像上传图片审核与举报 UI
- 实现头像上传流程:客户端选择图片后先校验大小(≤1MB),上传至服务器调用 `imgSecCheck` 审核,违规提示"图片内容违规,请更换"
- 实现举报 UI:聊天消息和个人资料旁添加举报按钮,点击弹出举报原因选择界面(政治有害/色情低俗/赌博诈骗/其他)
- 举报提交调用 `POST /api/content/report`,成功后提示"举报已提交"
- _需求:3.1、3.2、3.3、3.4、3.5、8.1、8.2_
- [ ] 10. 端到端集成测试与日志验证
- 编写服务器端审核服务单元测试:Token管理、msgSecCheck/imgSecCheck 调用与超时、频率限制、违规记录与禁言处罚
- 编写客户端 ContentSecurityManager 单元测试:本地敏感词过滤、词库缓存与更新、兜底词库降级
- 端到端测试:昵称修改→聊天消息→签名修改→头像上传→举报→自动下线→禁言处罚全流程验证
- 验证审核日志脱敏存储(仅保留前3后3字符)和日志保留策略
- _需求:9.5、7.1、1.1、2.1、3.1、8.3_
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.codebuddy/
+87
View File
@@ -0,0 +1,87 @@
{
"plugin_protect": {
"name": "外挂防护",
"files": []
},
"watermark": {
"name": "水印保护",
"files": [
"js/data/LevelData.js",
"js/data/SkinData.js",
"js/base/GameGlobal.js",
"js/scenes/BuffSelectScene.js",
"js/scenes/ChatRoomScene.js",
"js/scenes/GameScene.js",
"js/scenes/MenuScene.js",
"js/scenes/ProfileScene.js",
"js/scenes/RankingScene.js",
"js/scenes/ResultScene.js",
"js/scenes/RoomScene.js",
"js/scenes/SettingsScene.js",
"js/scenes/ShopScene.js",
"js/scenes/SkinScene.js",
"js/scenes/BattlePassScene.js",
"js/scenes/TeamGameScene.js",
"js/scenes/TeamResultScene.js",
"js/scenes/TeamRoomScene.js",
"js/managers/PlayerProfile.js",
"js/managers/MapManager.js",
"js/managers/BuffManager.js",
"js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js",
"js/managers/SpawnManager.js",
"js/managers/StaminaManager.js",
"js/managers/StorageManager.js",
"js/managers/SkinManager.js"
]
},
"obfuscation": {
"name": "代码混淆",
"tasks": [
{
"level": 1,
"files": []
},
{
"level": 3,
"files": [
"js/data/LevelData.js",
"js/data/SkinData.js",
"js/base/GameGlobal.js",
"js/scenes/BuffSelectScene.js",
"js/scenes/ChatRoomScene.js",
"js/scenes/GameScene.js",
"js/scenes/MenuScene.js",
"js/scenes/ProfileScene.js",
"js/scenes/RankingScene.js",
"js/scenes/ResultScene.js",
"js/scenes/RoomScene.js",
"js/scenes/SettingsScene.js",
"js/scenes/ShopScene.js",
"js/scenes/SkinScene.js",
"js/scenes/BattlePassScene.js",
"js/scenes/TeamGameScene.js",
"js/scenes/TeamResultScene.js",
"js/scenes/TeamRoomScene.js",
"js/managers/PlayerProfile.js",
"js/managers/MapManager.js",
"js/managers/BuffManager.js",
"js/managers/BattlePassManager.js",
"js/managers/ResourceManager.js",
"js/managers/SpawnManager.js",
"js/managers/StaminaManager.js",
"js/managers/StorageManager.js",
"js/managers/SkinManager.js"
]
}
]
},
"anti_inject": {
"name": "代码防注入",
"files": []
},
"platform_lock": {
"name": "平台锁",
"files": []
}
}
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
FROM node:18-alpine
LABEL maintainer="tankwar-team"
LABEL description="Content Security Microservice for UGC moderation"
WORKDIR /app
# Copy package files and install dependencies
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund && \
npm cache clean --force
# Copy service code
COPY index.js ./
COPY services/ ./services/
# Expose HTTP port
EXPOSE 3000
# Environment defaults
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
# Graceful shutdown & init for PID 1
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "index.js"]
+140
View File
@@ -0,0 +1,140 @@
/**
* Content Security Microservice - Standalone Entry Point
*
* Independent content security service for UGC moderation.
* Shared by multiple mini-games via game_id tenant isolation.
*
* API Endpoints:
* POST /api/content/check-text - Text content security check
* POST /api/content/check-image - Image content security check
* GET /api/content/sensitive-words - Get sensitive word list
* GET /api/user/mute-status - Check if a user is muted
* GET /api/user/violation-summary - Get violation summary
* POST /api/content/report - Submit a content report
* GET /api/health - Health check
* GET /api/metrics - Service metrics
*/
const express = require('express');
const http = require('http');
const WechatTokenManager = require('./services/wechatTokenManager');
const AuditLogger = require('./services/auditLogger');
const { createContentSecurityRouter } = require('./services/contentSecurityRoutes');
const ViolationService = require('./services/violationService');
const ReportService = require('./services/reportService');
// ============================================================
// Configuration
// ============================================================
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
// ============================================================
// Express App
// ============================================================
const app = express();
// Parse JSON bodies
app.use(express.json({ limit: '2mb' }));
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const game_id = req.headers['x-game-id'] || req.body?.game_id || DEFAULT_GAME_ID;
console.log(`[HTTP] ${req.method} ${req.url} ${res.statusCode} ${duration}ms game_id=${game_id}`);
});
next();
});
// ============================================================
// Health Check
// ============================================================
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'content-security',
version: '1.0.0',
timestamp: Date.now(),
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
});
});
// ============================================================
// Metrics Endpoint
// ============================================================
app.get('/api/metrics', (req, res) => {
res.json({
uptime: process.uptime(),
memory: process.memoryUsage(),
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
activeViolations: violationService ? violationService._records.size : 0,
activeMutes: violationService ? violationService._mutes.size : 0,
activeReports: reportService ? reportService._reports.size : 0,
timestamp: Date.now(),
});
});
// ============================================================
// Initialize Services
// ============================================================
const auditLogger = new AuditLogger();
const tokenManager = new WechatTokenManager();
const violationService = new ViolationService({ logger: auditLogger });
const reportService = new ReportService({ logger: auditLogger, violationService });
// Mount content security routes
const contentSecurityRouter = createContentSecurityRouter({
tokenManager,
logger: auditLogger,
violationService,
reportService,
});
app.use('/api/content', contentSecurityRouter);
// ============================================================
// Start Server
// ============================================================
const server = http.createServer(app);
server.listen(PORT, HOST, async () => {
console.log(`[Content Security Service] Running on ${HOST}:${PORT}`);
console.log(`[Content Security Service] Default game_id: ${DEFAULT_GAME_ID}`);
console.log(`[Content Security Service] API URL: http://${HOST}:${PORT}`);
// Initialize token manager
const success = await tokenManager.init();
if (success) {
console.log('[Content Security Service] WeChat token manager initialized successfully');
} else {
console.warn('[Content Security Service] WeChat token manager unavailable (token fetch failed)');
console.warn('[Content Security Service] Content checks will be rejected until token is obtained');
}
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('[Content Security Service] SIGTERM received, shutting down gracefully...');
tokenManager.destroy();
auditLogger.destroy();
violationService.destroy();
server.close(() => {
console.log('[Content Security Service] Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('[Content Security Service] SIGINT received, shutting down gracefully...');
tokenManager.destroy();
auditLogger.destroy();
violationService.destroy();
server.close(() => {
console.log('[Content Security Service] Server closed');
process.exit(0);
});
});
module.exports = { app, server };
+971
View File
@@ -0,0 +1,971 @@
{
"name": "content-security-service",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "content-security-service",
"version": "1.0.0",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://mirrors.tencent.com/npm/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://mirrors.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://mirrors.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://mirrors.tencent.com/npm/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://mirrors.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://mirrors.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://mirrors.tencent.com/npm/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://mirrors.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://mirrors.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://mirrors.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://mirrors.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://mirrors.tencent.com/npm/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "content-security-service",
"version": "1.0.0",
"description": "Content security microservice for UGC moderation (WeChat msgSecCheck/imgSecCheck)",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"test": "node --test test/"
},
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1"
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# ============================================================
# Content Security Service K8s Deploy Script
# Syncs source -> Master node -> builds Docker image ->
# distributes image to Worker nodes via ctr -> applies K8s resources
# -> rolls out the content-security-service deployment.
# ============================================================
set -e
LOG="/tmp/content-security-k8s-deploy.log"
> "$LOG"
exec > >(tee -a "$LOG") 2>&1
SERVICE_DIR="/Users/hanchengxi/workspace/tankwar_proj/content-security-service"
DEPLOY_DIR="/Users/hanchengxi/workspace/tankwar_proj/deploy/content-security"
MASTER="root@host_172.16.16.16"
WORKERS_IP=("10.1.0.6" "172.16.32.10" "172.16.32.16")
REMOTE_BUILD_DIR="/tmp/content-security-build"
IMAGE_NAME="content-security-service:latest"
ts() { echo "[$(date '+%H:%M:%S')]"; }
# ------------------------------------------------------------
# Step 0: Sync service source to master node
# ------------------------------------------------------------
echo "$(ts) ===== Syncing content-security-service source to master node ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "mkdir -p $REMOTE_BUILD_DIR"
rsync -az --delete --exclude='.git' --exclude='node_modules' \
-e "ssh -o StrictHostKeyChecking=no" \
"$SERVICE_DIR/" "${MASTER}:${REMOTE_BUILD_DIR}/"
echo "$(ts) ✓ Source synced"
# ------------------------------------------------------------
# Step 1: Ensure docker is available on master
# ------------------------------------------------------------
echo "$(ts) ===== Checking docker on master ====="
if ! ssh -o StrictHostKeyChecking=no "$MASTER" "which docker >/dev/null 2>&1"; then
echo "$(ts) Docker not found on master. Installing..."
ssh -o StrictHostKeyChecking=no "$MASTER" "curl -fsSL https://get.docker.com | sh"
fi
ssh -o StrictHostKeyChecking=no "$MASTER" "docker version --format '{{.Server.Version}}' 2>/dev/null || systemctl start docker"
echo "$(ts) ✓ Docker ready on master"
# ------------------------------------------------------------
# Step 2: Build image on master
# ------------------------------------------------------------
echo "$(ts) ===== Building $IMAGE_NAME on master ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"cd $REMOTE_BUILD_DIR && docker build -t $IMAGE_NAME -f Dockerfile ."
echo "$(ts) ✓ Image built"
# ------------------------------------------------------------
# Step 3: Distribute image to workers (containerd / ctr)
# ------------------------------------------------------------
echo "$(ts) ===== Distributing $IMAGE_NAME to workers ====="
# Master itself may also be a worker; import locally first
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ctr -n k8s.io images import -"
for w in "${WORKERS_IP[@]}"; do
echo "$(ts) -> $w"
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ssh -o StrictHostKeyChecking=no root@$w 'ctr -n k8s.io images import -'"
done
echo "$(ts) ✓ Image distributed"
# ------------------------------------------------------------
# Step 4: Apply K8s manifests
# ------------------------------------------------------------
echo "$(ts) ===== Applying K8s manifests ====="
# Apply in order: namespace first, then configmap/secret, then deployment/service
for manifest in namespace.yaml configmap.yaml secret.yaml deployment.yaml service.yaml networkpolicy.yaml; do
if [ -f "$DEPLOY_DIR/$manifest" ]; then
echo "$(ts) Applying $manifest"
cat "$DEPLOY_DIR/$manifest" | \
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl apply -f -"
fi
done
echo "$(ts) ✓ Manifests applied"
# ------------------------------------------------------------
# Step 5: Restart deployment to pick up the new image
# ------------------------------------------------------------
echo "$(ts) ===== Restarting content-security-service deployment ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n content-security rollout restart deployment/content-security-service" || true
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n content-security rollout status deployment/content-security-service --timeout=120s" || true
# ------------------------------------------------------------
# Step 6: Show final status
# ------------------------------------------------------------
echo "$(ts) ===== Final Status ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get pods -o wide"
echo ""
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get svc"
echo ""
echo "$(ts) ===== ALL DONE ====="
echo "$(ts) Internal endpoint: content-security-service.content-security.svc.cluster.local:3000"
echo "$(ts) Game services should call: http://content-security-service.content-security.svc.cluster.local:3000/api/content/..."
# Cleanup
ssh -o StrictHostKeyChecking=no "$MASTER" "rm -rf $REMOTE_BUILD_DIR" 2>/dev/null || true
@@ -0,0 +1,199 @@
/**
* Audit Logger
* Logs content security audit events with content desensitization.
* Logs are retained for at least 180 days.
*
* Features:
* - Content desensitization: only first 3 and last 3 chars kept, middle replaced with ***
* - Access Token never logged in plaintext
* - Structured JSON log format
* - Automatic log rotation
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// Configuration
// ============================================================
const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit');
const LOG_RETENTION_DAYS = 180;
const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily
class AuditLogger {
constructor() {
this._ensureLogDir();
this._currentLogFile = this._getLogFilePath(new Date());
this._rotationTimer = null;
this._startRotation();
}
/**
* Log an audit event.
* @param {object} entry
* @param {string} entry.userId - User identifier (openid)
* @param {string} entry.contentType - 'text' or 'image'
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @param {string} entry.result - 'pass', 'reject', 'error', 'rejected'
* @param {string} entry.reason - Reason for the result
* @param {number} [entry.duration] - Duration of the check in ms
*/
logAudit(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
userId: entry.userId || 'unknown',
contentType: entry.contentType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
scene: entry.scene || 0,
result: entry.result || 'unknown',
reason: entry.reason || '',
duration: entry.duration || 0,
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write log:', err.message);
}
}
/**
* Log a violation event.
* @param {object} entry
* @param {string} entry.userId - User identifier
* @param {string} entry.violationType - Type of violation
* @param {string} entry.contentSummary - Desensitized content
* @param {string} entry.action - Action taken (e.g., 'mute_24h')
*/
logViolation(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'violation',
userId: entry.userId || 'unknown',
violationType: entry.violationType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
action: entry.action || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write violation log:', err.message);
}
}
/**
* Log a report event.
* @param {object} entry
* @param {string} entry.reporterId - Reporter's user ID (kept confidential)
* @param {string} entry.targetUserId - Reported user's ID
* @param {string} entry.contentSummary - Desensitized reported content
* @param {string} entry.reason - Report reason
*/
logReport(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'report',
reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential
targetUserId: entry.targetUserId || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
reason: entry.reason || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write report log:', err.message);
}
}
/**
* Ensure the log directory exists.
*/
_ensureLogDir() {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
} catch (err) {
console.error('[AuditLogger] Failed to create log directory:', err.message);
}
}
/**
* Get the log file path for a given date.
* @param {Date} date
* @returns {string}
*/
_getLogFilePath(date) {
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
return path.join(LOG_DIR, `audit-${dateStr}.log`);
}
/**
* Start daily log rotation.
*/
_startRotation() {
this._rotationTimer = setInterval(() => {
const newLogFile = this._getLogFilePath(new Date());
if (newLogFile !== this._currentLogFile) {
this._currentLogFile = newLogFile;
console.log('[AuditLogger] Rotated to new log file:', newLogFile);
}
// Clean up old logs
this._cleanOldLogs();
}, LOG_ROTATION_INTERVAL_MS);
}
/**
* Remove log files older than the retention period.
*/
_cleanOldLogs() {
try {
const files = fs.readdirSync(LOG_DIR);
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(LOG_DIR, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log('[AuditLogger] Deleted old log file:', file);
}
}
} catch (err) {
console.error('[AuditLogger] Failed to clean old logs:', err.message);
}
}
/**
* Sanitize content: keep only first 3 and last 3 chars, replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
/**
* Clean up resources.
*/
destroy() {
if (this._rotationTimer) {
clearInterval(this._rotationTimer);
this._rotationTimer = null;
}
console.log('[AuditLogger] Destroyed');
}
}
module.exports = AuditLogger;
@@ -0,0 +1,302 @@
/**
* Content Security API Routes
* Express routes for content security checking, sensitive words, and reporting.
*/
const express = require('express');
const multer = require('multer');
const ContentSecurityService = require('./contentSecurityService');
const ViolationService = require('./violationService');
const ReportService = require('./reportService');
const sensitiveWords = require('./sensitiveWords');
// Configure multer for image uploads (memory storage, max 1MB)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 }, // 1MB max
});
/**
* Create and return the content security router.
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @returns {express.Router}
*/
function createContentSecurityRouter(options = {}) {
const router = express.Router();
const contentSecurity = new ContentSecurityService({
tokenManager: options.tokenManager,
logger: options.logger,
});
const violationService = options.violationService || new ViolationService({
logger: options.logger,
});
const reportService = options.reportService || new ReportService({
logger: options.logger,
violationService,
});
// ============================================================
// Middleware: Extract game_id from request
// Priority: X-Game-Id header > game_id body/query param > DEFAULT_GAME_ID
// ============================================================
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
function extractGameId(req) {
return req.headers['x-game-id'] || req.body?.game_id || req.query?.game_id || DEFAULT_GAME_ID;
}
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
const gameId = extractGameId(req);
// Validate required fields
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40001,
errmsg: 'openid is required',
});
}
if (!content || typeof content !== 'string') {
return res.status(400).json({
errcode: 40002,
errmsg: 'content is required',
});
}
if (typeof scene !== 'number' || ![1, 2, 3, 4].includes(scene)) {
return res.status(400).json({
errcode: 40003,
errmsg: 'scene must be 1(nickname), 2(chat), 3(signature), or 4(description)',
});
}
try {
// First, do server-side local sensitive word check
const localCheck = sensitiveWords.checkText(content);
if (localCheck.hasViolation) {
// Auto-record violation
violationService.recordViolation({
userId: openid,
gameId,
violationType: 'local_sensitive_word',
contentSummary: content.substring(0, 20),
scene,
});
return res.json({
pass: false,
errcode: 0,
errmsg: '内容违规,请修改',
suggest: 'risky',
label: 20000,
localCheck: true,
categories: localCheck.categories,
game_id: gameId,
});
}
// Then, call WeChat msgSecCheck API
const result = await contentSecurity.checkTextContent(openid, content, scene);
// Auto-record violation if msgSecCheck rejects
if (!result.pass && result.label > 0) {
violationService.recordViolation({
userId: openid,
gameId,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json({ ...result, game_id: gameId });
} catch (err) {
console.error('[API] check-text error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// POST /api/content/check-image - Image content security check
// ============================================================
router.post('/check-image', upload.single('image'), async (req, res) => {
const gameId = extractGameId(req);
if (!req.file) {
return res.status(400).json({
errcode: 40004,
errmsg: 'image file is required',
});
}
// Check file size (multer already enforces this, but double-check)
if (req.file.size > 1024 * 1024) {
return res.status(400).json({
errcode: 40005,
errmsg: '图片大小不能超过1MB',
});
}
try {
const result = await contentSecurity.checkImageContent(req.file.buffer);
// Auto-record violation if image is rejected
if (!result.pass && req.body && req.body.openid) {
violationService.recordViolation({
userId: req.body.openid,
gameId,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json({ ...result, game_id: gameId });
} catch (err) {
console.error('[API] check-image error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// GET /api/content/sensitive-words - Get sensitive word list for client caching
// ============================================================
router.get('/sensitive-words', (req, res) => {
const version = req.query.version;
// If client sends current version and it matches, return 304
if (version && version === sensitiveWords.getVersion()) {
return res.status(304).json({
version: sensitiveWords.getVersion(),
updated: false,
});
}
return res.json({
version: sensitiveWords.getVersion(),
updated: true,
words: sensitiveWords.getAllWords(),
categories: sensitiveWords.getWordsByCategory(),
});
});
// ============================================================
// GET /api/user/mute-status - Check if a user is muted
// ============================================================
router.get('/user/mute-status', (req, res) => {
const openid = req.query.openid;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid, gameId);
return res.json({ ...status, game_id: gameId });
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid, gameId);
return res.json(summary);
});
// ============================================================
// POST /api/content/report - Submit a content report
// ============================================================
router.post('/report', express.json(), (req, res) => {
const { contentId, targetUserId, contentType, contentSummary, reporterId, reason } = req.body;
const gameId = extractGameId(req);
// Validate required fields
if (!contentId || !targetUserId || !contentType || !reporterId || !reason) {
return res.status(400).json({
errcode: 40007,
errmsg: 'Missing required fields: contentId, targetUserId, contentType, reporterId, reason',
});
}
// Validate reason
const validReasons = Object.values(ReportService.REPORT_REASONS);
if (!validReasons.includes(reason)) {
return res.status(400).json({
errcode: 40008,
errmsg: `Invalid reason. Must be one of: ${validReasons.join(', ')}`,
});
}
const result = reportService.submitReport({
contentId,
targetUserId,
contentType,
contentSummary: contentSummary || '',
reporterId,
reason,
gameId,
});
if (!result.success) {
if (result.reportCount === 0) {
return res.status(400).json({
errcode: 40009,
errmsg: '举报提交失败',
});
}
// Already reported
return res.json({
success: false,
message: '您已举报过该内容',
reportCount: result.reportCount,
game_id: gameId,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
game_id: gameId,
});
});
// ============================================================
// POST /api/content/check-text - Auto-record violation on reject
// (Extended: records violation when content is rejected)
// ============================================================
// This is already handled in the check-text route above,
// but we also add violation recording here for server-side content.
// The check-text endpoint above will auto-record violations
// when msgSecCheck returns a rejection.
return router;
}
module.exports = { createContentSecurityRouter };
@@ -0,0 +1,475 @@
/**
* Content Security Service
* Provides unified methods for text and image content security checking
* via WeChat's msgSecCheck and imgSecCheck APIs.
*
* Features:
* - checkTextContent(openid, content, scene): Text content audit
* - checkImageContent(imageBuffer): Image content audit
* - 3-second timeout for each API call
* - Rate limiting queue (5000 requests/minute for msgSecCheck)
*/
const https = require('https');
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check';
const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check';
const API_TIMEOUT_MS = 3000; // 3 seconds timeout
const RATE_LIMIT_PER_MINUTE = 5000;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
// Scene mapping for msgSecCheck
const SCENE_MAP = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
class ContentSecurityService {
/**
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {object} [options.logger] - Optional custom logger
*/
constructor(options = {}) {
this.tokenManager = options.tokenManager;
this.logger = options.logger || new AuditLogger();
// Rate limiting
this._requestTimestamps = [];
this._requestQueue = [];
this._isProcessingQueue = false;
}
/**
* Check text content for security violations using msgSecCheck.
* @param {string} openid - User's openid
* @param {string} content - Text content to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
const startTime = Date.now();
// Validate scene value
if (!Object.values(SCENE_MAP).includes(scene)) {
const result = {
pass: false,
errcode: -1,
errmsg: 'Invalid scene value',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'invalid_scene',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`;
const postData = JSON.stringify({
openid,
scene,
version: 2,
content,
});
const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass';
const result = {
pass,
errcode: apiResult.errcode || 0,
errmsg: apiResult.errmsg || '',
suggest: apiResult.result ? apiResult.result.suggest : 'risky',
label: apiResult.result ? apiResult.result.label : 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: pass ? 'pass' : 'reject',
reason: pass ? 'content_safe' : `label_${result.label}`,
duration,
});
return result;
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] msgSecCheck error:', err.message);
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'error',
reason: err.message,
duration,
});
// On error, reject the content (fail-closed: safety over availability)
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content for security violations using imgSecCheck.
* @param {Buffer} imageBuffer - Image data buffer
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(imageBuffer) {
const startTime = Date.now();
// Check image size (max 1MB)
if (imageBuffer.length > 1024 * 1024) {
const result = {
pass: false,
errcode: -1,
errmsg: '图片大小不能超过1MB',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'image_too_large',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`;
// Build multipart form data for imgSecCheck
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`;
const suffix = `\r\n--${boundary}--\r\n`;
const buffer = Buffer.concat([
Buffer.from(prefix),
imageBuffer,
Buffer.from(suffix),
]);
const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0;
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: pass ? 'pass' : 'reject',
reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`,
duration,
});
return {
pass,
errcode: apiResult.errcode || 0,
errmsg: pass ? 'ok' : '图片内容违规,请更换',
};
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] imgSecCheck error:', err.message);
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'error',
reason: err.message,
duration,
});
// Fail-closed
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Call WeChat API with JSON POST data.
* @param {string} url
* @param {string} postData
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPI(url, postData, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(postData);
req.end();
});
}
/**
* Call WeChat API with multipart form data (for image upload).
* @param {string} url
* @param {Buffer} buffer
* @param {string} boundary
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPIWithBuffer(url, buffer, boundary, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': buffer.length,
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(buffer);
req.end();
});
}
/**
* Enforce rate limiting: queue requests if exceeding 5000/min.
* @returns {Promise<void>}
*/
async _enforceRateLimit() {
const now = Date.now();
// Clean old timestamps outside the current window
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) {
this._requestTimestamps.push(now);
return;
}
// Rate limited - wait until a slot opens
return new Promise((resolve) => {
this._requestQueue.push(resolve);
this._processQueue();
});
}
/**
* Process queued requests as rate limit slots become available.
*/
_processQueue() {
if (this._isProcessingQueue || this._requestQueue.length === 0) return;
this._isProcessingQueue = true;
const processNext = () => {
const now = Date.now();
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) {
this._requestTimestamps.push(now);
const nextResolve = this._requestQueue.shift();
nextResolve();
processNext();
} else if (this._requestQueue.length > 0) {
// Wait for the oldest timestamp to expire
const oldestTs = this._requestTimestamps[0];
const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100;
setTimeout(processNext, waitTime);
} else {
this._isProcessingQueue = false;
}
};
processNext();
}
/**
* Sanitize content for logging: keep only first 3 and last 3 chars,
* replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
}
// Export scene mapping for external use
ContentSecurityService.SCENE_MAP = SCENE_MAP;
module.exports = ContentSecurityService;
@@ -0,0 +1,322 @@
/**
* Report Service
* Handles user reports of inappropriate content and automatic content takedown.
*
* Features:
* - Report submission with confidential reporter identity
* - Auto-takedown when 3+ different users report the same content
* - Content reset on takedown (nickname → "玩家+随机数", signature/description cleared)
* - Admin review confirmation
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const AUTO_TAKEOWN_THRESHOLD = 3; // Number of reports to trigger auto-takedown
// Valid report reasons
const REPORT_REASONS = {
POLITICS: 'politics',
PORNOGRAPHY: 'pornography',
GAMBLING: 'gambling',
OTHER: 'other',
};
class ReportService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @param {import('./violationService')} [options.violationService] - Violation service instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
this.violationService = options.violationService || null;
/**
* In-memory report records.
* Key: contentId, Value: { targetUserId, contentType, contentSummary, reports: [{reporterId, reason, timestamp}], status, createdAt }
* @type {Map<string, object>}
*/
this._reports = new Map();
/**
* User content storage (for content reset on takedown).
* Key: userId, Value: { nickname, signature, description, avatarUrl }
* @type {Map<string, object>}
*/
this._userContent = new Map();
}
/**
* Submit a report for inappropriate content.
* @param {object} entry
* @param {string} entry.contentId - Unique identifier for the reported content
* @param {string} entry.targetUserId - User ID of the content author
* @param {string} entry.contentType - Type of content ('chat', 'nickname', 'signature', 'description', 'avatar')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {string} entry.reporterId - User ID of the reporter
* @param {string} entry.reason - Report reason (politics/pornography/gambling/other)
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }}
*/
submitReport(entry) {
const {
contentId,
targetUserId,
contentType,
contentSummary,
reporterId,
reason,
} = entry;
const gameId = entry.gameId || 'default';
// Namespace contentId with gameId to avoid cross-game collisions
const namespacedContentId = `${gameId}:${contentId}`;
// Validate reason
if (!Object.values(REPORT_REASONS).includes(reason)) {
return { success: false, reportCount: 0, autoTakenDown: false };
}
// Get or create report record (using namespaced key)
let record = this._reports.get(namespacedContentId);
if (!record) {
record = {
contentId,
gameId,
namespacedContentId,
targetUserId,
contentType,
contentSummary,
reports: [],
status: 'active', // active | taken_down | reviewed
createdAt: Date.now(),
};
this._reports.set(namespacedContentId, record);
}
// Check if already reported by same user
const alreadyReported = record.reports.some(
(r) => r.reporterId === reporterId
);
if (alreadyReported) {
return {
success: false,
reportCount: record.reports.length,
autoTakenDown: false,
};
}
// Add the report
record.reports.push({
reporterId,
reason,
timestamp: Date.now(),
});
// Log the report (reporter identity kept confidential)
this.logger.logReport({
reporterId,
targetUserId,
contentSummary,
reason,
gameId,
});
console.log(
`[ReportService] Report submitted for content ${contentId} (game: ${gameId}) by user *** (${record.reports.length}/${AUTO_TAKEOWN_THRESHOLD})`
);
// Check for auto-takedown
let autoTakenDown = false;
if (
record.reports.length >= AUTO_TAKEOWN_THRESHOLD &&
record.status === 'active'
) {
autoTakenDown = this._autoTakedown(namespacedContentId, record);
}
return {
success: true,
reportCount: record.reports.length,
autoTakenDown,
};
}
/**
* Auto-takedown content when threshold is reached.
* @param {string} contentId
* @param {object} record
* @returns {boolean}
*/
_autoTakedown(contentId, record) {
record.status = 'taken_down';
record.takenDownAt = Date.now();
console.log(
`[ReportService] Auto-takedown triggered for content ${contentId} (${record.reports.length} reports)`
);
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
gameId: record.gameId,
violationType: 'report_takedown',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
return true;
}
/**
* Confirm a report as violation (admin action).
* @param {string} contentId - Original contentId (will be namespaced internally)
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {{ success: boolean }}
*/
confirmViolation(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
if (!record) {
return { success: false };
}
record.status = 'reviewed';
record.reviewedAt = Date.now();
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
gameId: record.gameId,
violationType: 'admin_confirmed',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
console.log(
`[ReportService] Admin confirmed violation for content ${contentId} (game: ${gid})`
);
return { success: true };
}
/**
* Reset user content after takedown or deletion.
* Nickname → "玩家" + random 4-digit number
* Signature/description → cleared
* @param {string} userId
* @param {string} contentType
*/
_resetUserContent(userId, contentType) {
let userContent = this._userContent.get(userId);
if (!userContent) {
userContent = { nickname: '', signature: '', description: '', avatarUrl: '' };
this._userContent.set(userId, userContent);
}
switch (contentType) {
case 'nickname':
userContent.nickname = `玩家${Math.floor(1000 + Math.random() * 9000)}`;
break;
case 'signature':
userContent.signature = '';
break;
case 'description':
userContent.description = '';
break;
case 'avatar':
userContent.avatarUrl = '';
break;
case 'chat':
// Chat messages are just hidden, no user content reset needed
break;
}
console.log(
`[ReportService] Content reset for user ${userId}, type: ${contentType}`
);
}
/**
* Convert content type to scene number.
* @param {string} contentType
* @returns {number}
*/
_contentTypeToScene(contentType) {
const map = {
nickname: 1,
chat: 2,
signature: 3,
description: 4,
avatar: 5,
};
return map[contentType] || 0;
}
/**
* Get report status for a content item.
* @param {string} contentId - Original contentId
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {object|null}
*/
getReportStatus(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
if (!record) return null;
return {
contentId: record.contentId,
gameId: record.gameId,
contentType: record.contentType,
reportCount: record.reports.length,
status: record.status,
// Do NOT expose reporter identities
};
}
/**
* Get all pending reports for admin review.
* @param {string} [gameId] - Filter by game identifier (optional, returns all if not specified)
* @returns {Array}
*/
getPendingReports(gameId) {
const pending = [];
for (const record of this._reports.values()) {
// Filter by gameId if specified
if (gameId && record.gameId !== gameId) continue;
if (record.status === 'taken_down') {
pending.push({
contentId: record.contentId,
gameId: record.gameId,
targetUserId: record.targetUserId,
contentType: record.contentType,
contentSummary: record.contentSummary,
reportCount: record.reports.length,
reasons: [...new Set(record.reports.map((r) => r.reason))],
takenDownAt: record.takenDownAt,
});
}
}
return pending;
}
}
// Export report reasons for external use
ReportService.REPORT_REASONS = REPORT_REASONS;
module.exports = ReportService;
@@ -0,0 +1,148 @@
/**
* Sensitive Words Dictionary
* Provides sensitive word lists for content security filtering.
* This is the server-side master word list that gets served to clients.
*
* Categories:
* - politics: Politically harmful content
* - pornography: Pornographic and obscene content
* - gambling: Gambling and illegal betting content
* - violence: Violent and threatening content
* - abuse: Abusive and insulting language
* - fraud: Fraud and scam content
* - other: Other regulated content
*/
const SENSITIVE_WORDS = {
politics: [
// Politically sensitive terms (representative samples)
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'反动', '暴乱', '煽动颠覆', '分裂势力', '恐怖袭击',
'邪教组织', '法轮', '法轮功', '台独', '藏独', '疆独',
// Political figure names and common variants (homophone / split-char evasion)
'习近平', '刁近平', '习大大', '习主席', '习总',
'XiJinping', 'xijinping', '习近', '近平',
'李强', '王岐山', '栗战书', '汪洋', '韩正',
'李克强', '胡锦涛', '江泽民', '温家宝', '朱镕基',
'邓小平', '毛泽东', '周恩来', '刘少奇', '彭德怀',
'薄熙来', '周永康', '徐才厚', '郭伯雄', '令计划',
'孙政才', '赵乐际', '王沪宁', '丁薛祥', '蔡奇',
],
pornography: [
// Pornographic and obscene terms (representative samples)
'色情', '淫秽', '裸体', '性交', '卖淫',
'嫖娼', '成人电影', '情色', '黄色视频', '一夜情',
'援交', '约炮', '色诱', '露点', '性服务',
],
gambling: [
// Gambling related terms (representative samples)
'赌博', '赌场', '下注', '赌资', '博彩',
'六合彩', '时时彩', '赌球', '网络赌', '百家乐',
'老虎机', '扑克赌', '赌狗', '开盘下注', '庄家赔率',
],
violence: [
// Violence related terms (representative samples)
'杀人', '砍人', '捅死', '爆炸装置', '自制炸弹',
'灭门', '血腥屠杀', '残忍杀害', '暴力袭击', '砍杀',
],
abuse: [
// Abusive language (representative samples)
'傻逼', '操你', '妈的', '去死', '废物',
'滚蛋', '贱人', '狗日的', '草泥马', '脑残',
'白痴', '弱智', '猪头', '王八蛋', '混蛋',
],
fraud: [
// Fraud and scam terms (representative samples)
'代开发票', '虚假投资', '传销', '诈骗', '骗钱',
'刷单', '套现', '洗钱', '假币', '传销组织',
],
other: [
// Other regulated terms
'代孕', '买卖器官', '毒品', '吸毒', '走私',
'枪支', '管制刀具', '假药', '违禁品',
],
};
/**
* Get all sensitive words as a flat array.
* @returns {string[]}
*/
function getAllWords() {
return Object.values(SENSITIVE_WORDS).flat();
}
/**
* Get words grouped by category.
* @returns {object}
*/
function getWordsByCategory() {
return { ...SENSITIVE_WORDS };
}
/**
* Get the total count of words.
* @returns {number}
*/
function getWordCount() {
return getAllWords().length;
}
/**
* Check if a text contains any sensitive words.
* @param {string} text - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[], categories: string[] }}
*/
function checkText(text) {
if (!text || typeof text !== 'string') {
return { hasViolation: false, matchedWords: [], categories: [] };
}
const matchedWords = [];
const categories = new Set();
const lowerText = text.toLowerCase();
// Strip common evasion characters for split-char detection
const strippedText = text.replace(/[\s\u3000.,;:!?·…—\-_\|\\/~`@#$%^&*+=<>()\[\]{}""''「」『』【】()〈〕\u200b\u200c\u200d\ufeff]/g, '').toLowerCase();
for (const [category, words] of Object.entries(SENSITIVE_WORDS)) {
for (const word of words) {
const lowerWord = word.toLowerCase();
if (lowerText.includes(lowerWord)) {
matchedWords.push(word);
categories.add(category);
} else if (word.length >= 2 && strippedText.includes(lowerWord)) {
// Split-char evasion detected
matchedWords.push(word);
categories.add(category);
}
}
}
return {
hasViolation: matchedWords.length > 0,
matchedWords: [...new Set(matchedWords)],
categories: [...categories],
};
}
/**
* Get the version/timestamp of the word list for cache validation.
* @returns {string}
*/
function getVersion() {
return '2026-05-12-v2';
}
module.exports = {
SENSITIVE_WORDS,
getAllWords,
getWordsByCategory,
getWordCount,
checkText,
getVersion,
};
@@ -0,0 +1,292 @@
/**
* Violation Service
* Manages user violation records and mute penalties.
*
* Penalty tiers:
* - 3 violations → 24-hour mute
* - 5 violations → 7-day mute
* - 10 violations → permanent mute
*
* When a mute period expires, the count for the current penalty tier is reset.
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Penalty Tiers
// ============================================================
const PENALTY_TIERS = [
{ threshold: 3, durationMs: 24 * 60 * 60 * 1000, label: '24小时禁言' },
{ threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000, label: '7天禁言' },
{ threshold: 10, durationMs: Infinity, label: '永久禁言' },
];
// Scene labels for logging
const SCENE_LABELS = {
1: 'nickname',
2: 'chat',
3: 'signature',
4: 'description',
5: 'image',
};
class ViolationService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
/**
* In-memory violation records.
* Key: "game_id:userId" (composite key for tenant isolation)
* Value: { count, violations[], currentTierStart, gameId, userId }
* In production, this should be backed by a database.
* @type {Map<string, { count: number, violations: Array, currentTierStart: number, gameId: string, userId: string }>}
*/
this._records = new Map();
/**
* In-memory mute records.
* Key: "game_id:userId" (composite key for tenant isolation)
* Value: { expiresAt: number|null, tier: number }
* @type {Map<string, { expiresAt: number|null, tier: number }>}
*/
this._mutes = new Map();
// Start periodic cleanup of expired mutes
this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000);
}
/**
* Build composite key for tenant isolation.
* @param {string} gameId
* @param {string} userId
* @returns {string}
*/
_compositeKey(gameId, userId) {
return `${gameId}:${userId}`;
}
/**
* Record a violation for a user.
* @param {object} entry
* @param {string} entry.userId - User's openid
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
* @param {string} entry.violationType - Type of violation (e.g., 'text_violation', 'image_violation')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
recordViolation(entry) {
const { userId, violationType, contentSummary, scene } = entry;
const gameId = entry.gameId || 'default';
const key = this._compositeKey(gameId, userId);
// Get or create record
let record = this._records.get(key);
if (!record) {
record = { count: 0, violations: [], currentTierStart: 0, gameId, userId };
this._records.set(key, record);
}
// Increment violation count
record.count++;
record.violations.push({
type: violationType,
contentSummary,
scene,
timestamp: Date.now(),
});
// Log the violation
this.logger.logViolation({
userId,
gameId,
violationType,
contentSummary,
action: `violation_count_${record.count}`,
});
// Check if a penalty should be applied
const penalty = this._checkPenalty(key, record.count);
return penalty;
}
/**
* Check if a user is currently muted.
* @param {string} userId - User's openid
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
*/
getMuteStatus(userId, gameId) {
const gid = gameId || 'default';
const key = this._compositeKey(gid, userId);
const mute = this._mutes.get(key);
if (!mute) {
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
// Permanent mute
if (mute.expiresAt === null) {
return { isMuted: true, remainingMs: Infinity, remainingText: '永久', tier: mute.tier };
}
const remaining = mute.expiresAt - Date.now();
if (remaining <= 0) {
// Mute expired, clean up
this._removeMute(key);
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
return {
isMuted: true,
remainingMs: remaining,
remainingText: this._formatDuration(remaining),
tier: mute.tier,
};
}
/**
* Remove a mute (used when mute expires or is manually lifted).
* Resets the violation count for the current penalty tier.
* @param {string} key - Composite key "gameId:userId"
*/
_removeMute(key) {
const record = this._records.get(key);
if (record) {
// Find which tier they were at and reset count to just below that tier
const mute = this._mutes.get(key);
if (mute && mute.tier > 0) {
const tierIndex = PENALTY_TIERS.findIndex(t => t.threshold === mute.tier);
if (tierIndex > 0) {
record.count = PENALTY_TIERS[tierIndex - 1].threshold;
} else {
record.count = 0;
}
record.currentTierStart = record.count;
}
}
this._mutes.delete(key);
console.log(`[ViolationService] Mute removed for key ${key}`);
}
/**
* Check if a penalty should be applied based on violation count.
* @param {string} key - Composite key "gameId:userId"
* @param {number} count
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
_checkPenalty(key, count) {
// Check penalty tiers in reverse order (highest first)
for (let i = PENALTY_TIERS.length - 1; i >= 0; i--) {
const tier = PENALTY_TIERS[i];
if (count >= tier.threshold) {
// Only apply if not already muted at this or higher tier
const currentMute = this._mutes.get(key);
if (currentMute && currentMute.tier >= tier.threshold) {
return { penalty: null, isMuted: true, muteDuration: null };
}
// Apply mute
const expiresAt = tier.durationMs === Infinity ? null : Date.now() + tier.durationMs;
this._mutes.set(key, {
expiresAt,
tier: tier.threshold,
});
console.log(`[ViolationService] User ${key} muted: ${tier.label} (violations: ${count})`);
// Extract userId and gameId from key for logging
const colonIdx = key.indexOf(':');
const gameId = key.substring(0, colonIdx);
const userId = key.substring(colonIdx + 1);
this.logger.logViolation({
userId,
gameId,
violationType: 'penalty_applied',
contentSummary: '',
action: `mute_${tier.label}`,
});
return {
penalty: tier.label,
isMuted: true,
muteDuration: tier.durationMs === Infinity ? '永久' : this._formatDuration(tier.durationMs),
};
}
}
return { penalty: null, isMuted: false, muteDuration: null };
}
/**
* Clean up expired mutes periodically.
*/
_cleanupExpiredMutes() {
const now = Date.now();
for (const [userId, mute] of this._mutes) {
if (mute.expiresAt !== null && mute.expiresAt <= now) {
this._removeMute(userId);
}
}
}
/**
* Format a duration in milliseconds to a human-readable string.
* @param {number} ms
* @returns {string}
*/
_formatDuration(ms) {
if (ms === Infinity) return '永久';
const totalMinutes = Math.floor(ms / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (days > 0) {
return `${days}${remainingHours}小时`;
}
return `${hours}小时${minutes}分钟`;
}
/**
* Get violation summary for a user.
* @param {string} userId
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ count: number, isMuted: boolean, recentViolations: Array, gameId: string }}
*/
getViolationSummary(userId, gameId) {
const gid = gameId || 'default';
const key = this._compositeKey(gid, userId);
const record = this._records.get(key);
const muteStatus = this.getMuteStatus(userId, gid);
return {
count: record ? record.count : 0,
isMuted: muteStatus.isMuted,
muteRemainingText: muteStatus.remainingText,
recentViolations: record ? record.violations.slice(-5) : [],
gameId: gid,
};
}
/**
* Clean up resources.
*/
destroy() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
}
console.log('[ViolationService] Destroyed');
}
}
module.exports = ViolationService;
@@ -0,0 +1,223 @@
/**
* WeChat Access Token Manager
* Manages the lifecycle of WeChat access tokens for content security APIs.
* - Auto-refreshes tokens before expiry (2-hour validity, refresh 5 minutes early)
* - Retry with exponential backoff on failure (2s, 4s, 8s)
* - Marks service as unavailable after all retries exhausted
*/
const https = require('https');
// ============================================================
// Configuration
// ============================================================
const TOKEN_VALIDITY_MS = 2 * 60 * 60 * 1000; // 2 hours
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry
const RETRY_DELAYS = [2000, 4000, 8000]; // Exponential backoff delays in ms
const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token';
class WechatTokenManager {
/**
* @param {object} options
* @param {string} options.appId - WeChat Mini Program App ID
* @param {string} options.appSecret - WeChat Mini Program App Secret
*/
constructor(options = {}) {
this.appId = options.appId || process.env.WX_APPID || '';
this.appSecret = options.appSecret || process.env.WX_APPSECRET || '';
this._accessToken = null;
this._expiresAt = 0;
this._refreshTimer = null;
this._isAvailable = false;
this._isRefreshing = false;
this._retryCount = 0;
}
/**
* Initialize the token manager and fetch the first token.
* @returns {Promise<boolean>} true if token obtained successfully
*/
async init() {
console.log('[TokenManager] Initializing...');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
return success;
}
/**
* Get the current valid access token.
* If the token is expired or about to expire, refresh it first.
* @returns {Promise<string|null>} The access token, or null if unavailable
*/
async getAccessToken() {
// If token is still valid (with margin), return it directly
if (this._accessToken && Date.now() < this._expiresAt - TOKEN_REFRESH_MARGIN_MS) {
return this._accessToken;
}
// Token expired or about to expire, try to refresh
if (!this._isRefreshing) {
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}
return this._isAvailable ? this._accessToken : null;
}
/**
* Check if the token manager is currently available.
* @returns {boolean}
*/
isAvailable() {
return this._isAvailable;
}
/**
* Refresh the access token by calling WeChat API.
* Implements retry with exponential backoff.
* @returns {Promise<boolean>}
*/
async _refreshToken() {
if (this._isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this._isRefreshing) {
clearInterval(checkInterval);
resolve(this._isAvailable);
}
}, 100);
});
}
this._isRefreshing = true;
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
try {
const token = await this._fetchTokenFromWechat();
if (token) {
this._accessToken = token;
this._expiresAt = Date.now() + TOKEN_VALIDITY_MS;
this._isAvailable = true;
this._retryCount = 0;
this._isRefreshing = false;
console.log('[TokenManager] Token refreshed successfully, expires at:',
new Date(this._expiresAt).toISOString());
return true;
}
} catch (err) {
console.error(`[TokenManager] Fetch token failed (attempt ${attempt + 1}):`, err.message);
}
// Wait before retry (if there are more retries)
if (attempt < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[attempt];
console.log(`[TokenManager] Retrying in ${delay}ms...`);
await this._sleep(delay);
}
}
// All retries exhausted
this._isAvailable = false;
this._isRefreshing = false;
this._accessToken = null;
console.error('[TokenManager] All retry attempts exhausted. Service marked as unavailable.');
// Schedule a recovery attempt after 10 minutes
setTimeout(() => {
console.log('[TokenManager] Attempting recovery refresh...');
this._refreshToken().then((success) => {
if (success) {
this._scheduleRefresh();
}
});
}, 10 * 60 * 1000);
return false;
}
/**
* Fetch access token from WeChat API.
* @returns {Promise<string|null>}
*/
_fetchTokenFromWechat() {
return new Promise((resolve, reject) => {
if (!this.appId || !this.appSecret) {
reject(new Error('AppId or AppSecret not configured'));
return;
}
const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`;
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.access_token) {
resolve(result.access_token);
} else {
reject(new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`));
}
} catch (e) {
reject(new Error(`Invalid JSON response: ${data.substring(0, 100)}`));
}
});
}).on('error', (err) => {
reject(err);
});
});
}
/**
* Schedule the next token refresh before expiry.
*/
_scheduleRefresh() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
}
const refreshAt = this._expiresAt - TOKEN_REFRESH_MARGIN_MS;
const delay = Math.max(refreshAt - Date.now(), 60000); // At least 1 minute
console.log(`[TokenManager] Next refresh scheduled in ${Math.round(delay / 1000)}s`);
this._refreshTimer = setTimeout(async () => {
console.log('[TokenManager] Scheduled refresh triggered');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}, delay);
}
/**
* Utility: sleep for a given number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clean up resources.
*/
destroy() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
this._accessToken = null;
this._isAvailable = false;
console.log('[TokenManager] Destroyed');
}
}
module.exports = WechatTokenManager;
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
# ============================================================
# ConfigMap: content-security-config
# Centralized configuration for content security service.
# WX_APPID and WX_APPSECRET should be overridden via SealedSecret
# or external secret management in production.
# ============================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: content-security-config
namespace: content-security
labels:
app: content-security-service
data:
NODE_ENV: "production"
HOST: "0.0.0.0"
PORT: "3000"
# Default game_id for requests without explicit game_id
DEFAULT_GAME_ID: "tankwar"
# Audit log retention in days
AUDIT_LOG_RETENTION_DAYS: "180"
# Rate limit: max requests per minute to WeChat API
RATE_LIMIT_PER_MINUTE: "5000"
# API timeout in milliseconds
API_TIMEOUT_MS: "3000"
+75
View File
@@ -0,0 +1,75 @@
# ============================================================
# Deployment: content-security-service
# Content security microservice for UGC moderation.
# Shared by multiple mini-games via game_id tenant isolation.
# ============================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: content-security-service
namespace: content-security
labels:
app: content-security-service
app.kubernetes.io/part-of: content-security
spec:
replicas: 2
selector:
matchLabels:
app: content-security-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: content-security-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: content-security-service
image: content-security-service:latest
imagePullPolicy: Never
ports:
- name: http
containerPort: 3000
protocol: TCP
envFrom:
- configMapRef:
name: content-security-config
- secretRef:
name: content-security-secrets
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: audit-logs
mountPath: /app/logs/audit
volumes:
- name: audit-logs
emptyDir: {}
terminationGracePeriodSeconds: 15
@@ -0,0 +1,8 @@
# ============================================================
# Namespace label patch for tankwar
# This adds the kubernetes.io/metadata.name label to the tankwar
# namespace so that NetworkPolicy can reference it.
# Apply: kubectl label namespace tankwar kubernetes.io/metadata.name=tankwar --overwrite
# ============================================================
# Note: Kubernetes 1.21+ automatically adds this label to namespaces.
# Verify with: kubectl get namespace tankwar --show-labels
+12
View File
@@ -0,0 +1,12 @@
# ============================================================
# Namespace: content-security
# Independent namespace for content security service,
# shared by multiple mini-games.
# ============================================================
apiVersion: v1
kind: Namespace
metadata:
name: content-security
labels:
app.kubernetes.io/part-of: content-security
app.kubernetes.io/managed-by: kubectl
@@ -0,0 +1,40 @@
# ============================================================
# NetworkPolicy: content-security-policy
# Restrict access to content security service:
# - Only allow ingress from game namespaces (tankwar, etc.)
# - Allow egress to WeChat APIs and DNS
# ============================================================
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: content-security-ingress-policy
namespace: content-security
spec:
podSelector:
matchLabels:
app: content-security-service
policyTypes:
- Ingress
ingress:
# Allow from tankwar namespace
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: tankwar
ports:
- protocol: TCP
port: 3000
# Allow from any namespace with the game-client label
- from:
- podSelector:
matchLabels:
content-security-client: "true"
ports:
- protocol: TCP
port: 3000
# Allow health checks from within same namespace
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 3000
+19
View File
@@ -0,0 +1,19 @@
# ============================================================
# Secret: content-security-secrets
# WeChat Mini Program credentials for content security APIs.
# IMPORTANT: In production, use SealedSecret or external secret
# management (e.g., HashiCorp Vault) instead of plain Secrets.
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: content-security-secrets
namespace: content-security
labels:
app: content-security-service
type: Opaque
stringData:
# WeChat Mini Program App ID (replace with actual value)
WX_APPID: "wx3527fe2fd49db523"
# WeChat Mini Program App Secret (replace with actual value)
WX_APPSECRET: "a8e92749ccf2f4bc2667833812a7bf4e"
+43
View File
@@ -0,0 +1,43 @@
# ============================================================
# Service: content-security-service
# ClusterIP service for internal access from game namespaces.
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: content-security-service
namespace: content-security
labels:
app: content-security-service
spec:
type: ClusterIP
ports:
- name: http
port: 3000
protocol: TCP
targetPort: 3000
selector:
app: content-security-service
---
# ============================================================
# Service: content-security-nodeport
# NodePort service for external access (development/debug only).
# Should be removed or restricted in production.
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: content-security-nodeport
namespace: content-security
labels:
app: content-security-service
spec:
type: NodePort
ports:
- name: http
port: 3000
protocol: TCP
targetPort: 3000
nodePort: 30082
selector:
app: content-security-service
+112
View File
@@ -0,0 +1,112 @@
#
# Edge reverse proxy: 42.194.185.163
# Forwards public 80/443 traffic (L4 passthrough) to the K8s worker nodes
# that run the warmcheck/nginx DaemonSet (hostNetwork hostPort 80/443).
#
# Worker public IPs (cross-VPC, so we must use public addresses):
# - vm-0-6-opencloudos / 10.1.0.6
# - vm-32-10-tencentos / 172.16.32.10
# - vm-32-16-tencentos / 172.16.32.16
#
# Master (43.139.80.61 / 172.16.16.16) is excluded — DaemonSet nodeAffinity
# skips control-plane nodes, so it does NOT listen on :80/:443.
#
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
# Load dynamic modules (stream module ships as a dynamic module on
# OpenCloudOS 9 / RHEL 9 and lives in /usr/share/nginx/modules/).
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 8192;
use epoll;
multi_accept on;
}
# ============================================================
# L4 stream passthrough (HTTP 80 + HTTPS 443 + WSS)
# ============================================================
stream {
log_format basic '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time "$upstream_addr" '
'"$upstream_bytes_sent" "$upstream_bytes_received" '
'"$upstream_connect_time"';
access_log /var/log/nginx/stream-access.log basic buffer=32k flush=5s;
# ---- HTTP upstream (80) ----
upstream k8s_http {
# 3 workers; passive health check via max_fails/fail_timeout.
server 10.1.0.6:80 max_fails=3 fail_timeout=30s;
server 172.16.32.10:80 max_fails=3 fail_timeout=30s;
server 172.16.32.16:80 max_fails=3 fail_timeout=30s;
}
# ---- HTTPS upstream (443, SNI passthrough) ----
upstream k8s_https {
server 10.1.0.6:443 max_fails=3 fail_timeout=30s;
server 172.16.32.10:443 max_fails=3 fail_timeout=30s;
server 172.16.32.16:443 max_fails=3 fail_timeout=30s;
}
# ---- Listeners ----
server {
listen 80;
listen [::]:80;
proxy_pass k8s_http;
proxy_connect_timeout 5s;
proxy_timeout 300s; # long enough for WS keep-alive
proxy_socket_keepalive on;
}
server {
listen 443;
listen [::]:443;
proxy_pass k8s_https;
proxy_connect_timeout 5s;
proxy_timeout 300s; # long enough for WSS keep-alive
proxy_socket_keepalive on;
}
}
# ============================================================
# Local-only HTTP block for status / health probing on :8080
# (bound to 127.0.0.1 so it doesn't clash with stream :80)
# ============================================================
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 4096;
server {
listen 127.0.0.1:8080;
server_name _;
location = /edge-health {
return 200 "edge-ok\n";
add_header Content-Type text/plain;
}
location / {
return 404;
}
}
}
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: tankwar-server
namespace: tankwar
labels:
app: tankwar-server
spec:
# Single replica for Plan A (in-memory room state).
# Kubernetes will auto-reschedule if the pod or its node fails.
replicas: 1
strategy:
# Recreate instead of RollingUpdate so the port & state are never
# duplicated across two pods with in-memory room registry.
type: Recreate
selector:
matchLabels:
app: tankwar-server
template:
metadata:
labels:
app: tankwar-server
spec:
terminationGracePeriodSeconds: 15
containers:
- name: tankwar-server
image: tankwar-server:latest
imagePullPolicy: Never
ports:
- name: ws
containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "3000"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "1000m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: tankwar
labels:
name: tankwar
app: tankwar
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# =============================================================================
# Tank War Server — one-shot K8s deploy
#
# Mirrors the style of WarmCheck's run-deploy.sh:
# 1) sync source to master
# 2) build docker image on master
# 3) distribute image tarball to all worker nodes and `ctr` import
# 4) apply K8s manifests and restart the deployment
# =============================================================================
set -euo pipefail
# ---------- Configurable ------------------------------------------------------
# Host that the LOCAL developer machine can reach (uses your ssh_config alias).
MASTER_HOST="${MASTER_HOST:-host_172.16.16.16}"
# Intranet IPs that the MASTER uses to reach workers (no alias on the CVMs).
WORKER_INTRANET_IPS=(
"10.1.0.6"
"172.16.32.10"
"172.16.32.16"
)
NAMESPACE="tankwar"
IMAGE_NAME="tankwar-server"
IMAGE_TAG="${IMAGE_TAG:-latest}"
REMOTE_WORKDIR="/root/tankwar"
SSH_USER="root"
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
# ---------- Paths -------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
SERVER_DIR="${PROJECT_ROOT}/server"
K8S_DIR="${PROJECT_ROOT}/deploy/k8s"
# ---------- Helpers -----------------------------------------------------------
log() { printf "\033[1;36m[deploy]\033[0m %s\n" "$*"; }
ok() { printf "\033[1;32m[ ok ]\033[0m %s\n" "$*"; }
warn() { printf "\033[1;33m[warn]\033[0m %s\n" "$*"; }
die() { printf "\033[1;31m[fail]\033[0m %s\n" "$*" >&2; exit 1; }
ssh_master() { ssh ${SSH_OPTS} "${SSH_USER}@${MASTER_HOST}" "$@"; }
ssh_host() { local h="$1"; shift; ssh ${SSH_OPTS} "${SSH_USER}@${h}" "$@"; }
# =============================================================================
# Step 1 — sync server source code & k8s manifests to master
# =============================================================================
step_sync() {
log "1/5 Syncing source to master (${MASTER_HOST}) ..."
ssh_master "mkdir -p ${REMOTE_WORKDIR}/server ${REMOTE_WORKDIR}/deploy/k8s"
rsync -az --delete \
--exclude '.git' \
--exclude '.DS_Store' \
-e "ssh ${SSH_OPTS}" \
"${SERVER_DIR}/" \
"${SSH_USER}@${MASTER_HOST}:${REMOTE_WORKDIR}/server/"
rsync -az --delete \
-e "ssh ${SSH_OPTS}" \
"${K8S_DIR}/" \
"${SSH_USER}@${MASTER_HOST}:${REMOTE_WORKDIR}/deploy/k8s/"
ok "source synced"
}
# =============================================================================
# Step 2 — build docker image on master
# =============================================================================
step_build() {
log "2/5 Building image ${IMAGE_NAME}:${IMAGE_TAG} on master ..."
ssh_master "cd ${REMOTE_WORKDIR}/server && \
docker build --pull -t ${IMAGE_NAME}:${IMAGE_TAG} -t ${IMAGE_NAME}:latest ."
ok "image built"
}
# =============================================================================
# Step 3 — distribute image to every worker via containerd (ctr import)
#
# The cluster uses containerd directly (not docker-shim). Each node must
# have the image in the "k8s.io" namespace to be usable by kubelet.
# =============================================================================
step_distribute() {
log "3/5 Distributing image to workers ..."
# Export once on master
local remote_tar="/tmp/${IMAGE_NAME}-${IMAGE_TAG}.tar"
ssh_master "docker save ${IMAGE_NAME}:${IMAGE_TAG} -o ${remote_tar} && ls -lh ${remote_tar}"
# Import on master's containerd (k8s.io ns) so the scheduler can use it locally too
ssh_master "ctr -n k8s.io images import ${remote_tar} && \
ctr -n k8s.io images tag --force docker.io/library/${IMAGE_NAME}:${IMAGE_TAG} \
docker.io/library/${IMAGE_NAME}:latest"
ok "master imported"
# Fan-out to workers — executed FROM the master using intranet IPs.
for ip in "${WORKER_INTRANET_IPS[@]}"; do
log " -> ${ip}"
ssh_master "scp ${SSH_OPTS} ${remote_tar} ${SSH_USER}@${ip}:${remote_tar} && \
ssh ${SSH_OPTS} ${SSH_USER}@${ip} 'ctr -n k8s.io images import ${remote_tar} && \
ctr -n k8s.io images tag --force docker.io/library/${IMAGE_NAME}:${IMAGE_TAG} \
docker.io/library/${IMAGE_NAME}:latest && \
rm -f ${remote_tar}'"
ok " ${ip} imported"
done
ssh_master "rm -f ${remote_tar}"
ok "distribution done"
}
# =============================================================================
# Step 4 — apply manifests & roll the deployment
# =============================================================================
step_apply() {
log "4/5 Applying K8s manifests ..."
ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/namespace.yaml"
ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/service.yaml"
ssh_master "kubectl apply -f ${REMOTE_WORKDIR}/deploy/k8s/deployment.yaml"
# Force a new rollout so we pick up the newly-imported image even
# when the tag stays :latest.
ssh_master "kubectl -n ${NAMESPACE} set image deploy/tankwar-server \
tankwar-server=${IMAGE_NAME}:${IMAGE_TAG} --record=false || true"
log " waiting for rollout ..."
ssh_master "kubectl -n ${NAMESPACE} rollout status deploy/tankwar-server --timeout=180s"
ok "deployment is live"
}
# =============================================================================
# Step 5 — sanity check
# =============================================================================
step_verify() {
log "5/5 Verifying ..."
ssh_master "kubectl -n ${NAMESPACE} get pods,svc -o wide"
echo
ssh_master "kubectl -n ${NAMESPACE} logs deploy/tankwar-server --tail=20 || true"
echo
ok "all done. NodePort: 30081"
cat <<EOF
------------------------------------------------------------
Public endpoints (after DNS on www.igeek.site takes effect):
wss://www.igeek.site:30081/ (via NodePort)
Direct CVM access for smoke test:
wscat -c ws://43.139.80.61:30081/
Remember to allow TCP/30081 in the CVM security group.
------------------------------------------------------------
EOF
}
main() {
log "Tank War Server deploy — tag=${IMAGE_TAG}"
step_sync
step_build
step_distribute
step_apply
step_verify
}
main "$@"
+48
View File
@@ -0,0 +1,48 @@
---
# ClusterIP service — consumed by Nginx reverse-proxy inside the cluster.
apiVersion: v1
kind: Service
metadata:
name: tankwar-server
namespace: tankwar
labels:
app: tankwar-server
spec:
type: ClusterIP
selector:
app: tankwar-server
ports:
- name: ws
port: 3000
targetPort: 3000
protocol: TCP
# Sticky sessions keep a given client pinned to the same pod while
# the (future) multi-replica version is considered.
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
---
# NodePort service — exposes the WebSocket server directly on every node
# at port 30081, so the public domain can be pointed here once DNS resolves.
apiVersion: v1
kind: Service
metadata:
name: tankwar-server-nodeport
namespace: tankwar
labels:
app: tankwar-server
spec:
type: NodePort
selector:
app: tankwar-server
ports:
- name: ws
port: 3000
targetPort: 3000
nodePort: 30081
protocol: TCP
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Convenience Nginx snippet for the cluster-internal nginx (warmcheck ns).
# Drop this into warmcheck's nginx configmap under `location /games/wx/tankwar`
# once the DNS for www.igeek.site points to the 3 CVMs.
#
# Paste this whole block inside the existing `server { ... }` block that
# serves www.igeek.site with TLS.
# server { ... listen 443 ssl; server_name www.igeek.site; ... }
# Health endpoint (handy for uptime checks; optional)
location = /games/wx/tankwar/health {
proxy_pass http://tankwar-server.tankwar.svc.cluster.local:3000/health;
proxy_http_version 1.1;
}
# WebSocket endpoint that the game actually uses
location /games/wx/tankwar/ws {
proxy_pass http://tankwar-server.tankwar.svc.cluster.local:3000;
proxy_http_version 1.1;
# WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived WS — well past server HEARTBEAT_INTERVAL (10s)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
proxy_buffering off;
}
+408
View File
@@ -0,0 +1,408 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/json application/javascript;
client_max_body_size 10m;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
# Upstream definitions - using K8s service DNS names
# keepalive enables persistent connections to reduce latency
upstream user_service {
server user-service.warmcheck.svc.cluster.local:8081;
keepalive 16;
}
upstream interaction_service {
server interaction-service.warmcheck.svc.cluster.local:8082;
keepalive 16;
}
upstream social_service {
server social-service.warmcheck.svc.cluster.local:8083;
keepalive 8;
}
upstream push_service {
server push-service.warmcheck.svc.cluster.local:8084;
keepalive 8;
}
upstream ws_gateway {
server gateway.warmcheck.svc.cluster.local:8085;
}
upstream admin_service {
server admin-service.warmcheck.svc.cluster.local:8086;
keepalive 4;
}
# Tank War WebSocket server (separate namespace)
upstream tankwar_server {
server tankwar-server.tankwar.svc.cluster.local:3000;
keepalive 16;
}
# HTTPS server - for external access via HK CVM nginx
server {
listen 443 ssl;
server_name api.warmcheck.app;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx","ssl":true}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server - for internal health checks and backward compatibility
server {
listen 80;
server_name api.warmcheck.app;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx"}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User service routes (feedback uses exact path without trailing slash)
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# =========================================================
# www.igeek.site — Tank War game WebSocket entry
# Reuses the existing nginx-tls-secret (CN=igeek.site,
# SAN includes www.igeek.site).
# =========================================================
server {
listen 443 ssl;
server_name www.igeek.site igeek.site;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Gateway self health
location = /health {
return 200 '{"status":"ok","gateway":"nginx","site":"igeek"}';
add_header Content-Type application/json;
}
# Tank War health passthrough
location = /games/wx/tankwar/health {
proxy_pass http://tankwar_server/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
}
# Tank War WebSocket endpoint
location /games/wx/tankwar/ws {
proxy_pass http://tankwar_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long-lived WS — well past server HEARTBEAT_INTERVAL (10s)
proxy_connect_timeout 10s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
# HTTP for www.igeek.site — redirect to HTTPS
server {
listen 80;
server_name www.igeek.site igeek.site;
location = /health {
return 200 '{"status":"ok","gateway":"nginx","site":"igeek","tls":false}';
add_header Content-Type application/json;
}
location / {
return 301 https://$host$request_uri;
}
}
}
@@ -0,0 +1,331 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/json application/javascript;
client_max_body_size 10m;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
# Upstream definitions - using K8s service DNS names
# keepalive enables persistent connections to reduce latency
upstream user_service {
server user-service.warmcheck.svc.cluster.local:8081;
keepalive 16;
}
upstream interaction_service {
server interaction-service.warmcheck.svc.cluster.local:8082;
keepalive 16;
}
upstream social_service {
server social-service.warmcheck.svc.cluster.local:8083;
keepalive 8;
}
upstream push_service {
server push-service.warmcheck.svc.cluster.local:8084;
keepalive 8;
}
upstream ws_gateway {
server gateway.warmcheck.svc.cluster.local:8085;
}
upstream admin_service {
server admin-service.warmcheck.svc.cluster.local:8086;
keepalive 4;
}
# HTTPS server - for external access via HK CVM nginx
server {
listen 443 ssl;
server_name api.warmcheck.app;
ssl_certificate /etc/nginx/ssl/tls.crt;
ssl_certificate_key /etc/nginx/ssl/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx","ssl":true}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
proxy_send_timeout 10s;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server - for internal health checks and backward compatibility
server {
listen 80;
server_name api.warmcheck.app;
# Health check
location /health {
return 200 '{"status":"ok","gateway":"nginx"}';
add_header Content-Type application/json;
}
# Auth routes (stricter rate limit)
location /auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# User service routes (feedback uses exact path without trailing slash)
location ~ ^/api/v1/(user|sign|growth|museum|feedback)(/|$) {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# User service routes (emergency contact)
location ~ ^/api/v1/users/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://user_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Interaction service routes
location ~ ^/api/v1/(story-card|care-card|urgent-care)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://interaction_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Social service routes
location ~ ^/api/v1/(magnet|chat)/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://social_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Push service routes
location ~ ^/api/v1/notification/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://push_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (report & feedback submission from app)
location ~ ^/api/v1/(report|feedback)$ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket gateway
location /ws {
proxy_pass http://ws_gateway;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# AI service routes
location /ai/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://ai-service.warmcheck.svc.cluster.local:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Analytics service routes
location /analytics/ {
limit_req zone=api burst=10 nodelay;
proxy_pass http://analytics-service.warmcheck.svc.cluster.local:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin service routes (UI + API)
location /admin {
proxy_pass http://admin_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+218 -18
View File
@@ -4,6 +4,19 @@
* Initializes canvas, sets up the game loop, and manages scene lifecycle. * Initializes canvas, sets up the game loop, and manages scene lifecycle.
*/ */
// Ensure timer globals exist (some WeChat base library versions may not
// inject them early enough, causing WAGame.js internal error-reporting to
// crash with "Can't find variable: setTimeout").
if (typeof setTimeout === 'undefined') {
GameGlobal.setTimeout = GameGlobal.setTimeout || function (fn, ms) {
// Fallback: execute synchronously when real timer is unavailable
fn();
};
GameGlobal.setInterval = GameGlobal.setInterval || function () {};
GameGlobal.clearTimeout = GameGlobal.clearTimeout || function () {};
GameGlobal.clearInterval = GameGlobal.clearInterval || function () {};
}
const SceneManager = require('./js/managers/SceneManager'); const SceneManager = require('./js/managers/SceneManager');
const ResourceManager = require('./js/managers/ResourceManager'); const ResourceManager = require('./js/managers/ResourceManager');
const StorageManager = require('./js/managers/StorageManager'); const StorageManager = require('./js/managers/StorageManager');
@@ -14,7 +27,11 @@ const ShareManager = require('./js/managers/ShareManager');
const CurrencyManager = require('./js/managers/CurrencyManager'); const CurrencyManager = require('./js/managers/CurrencyManager');
const PaymentManager = require('./js/managers/PaymentManager'); const PaymentManager = require('./js/managers/PaymentManager');
const ComplianceManager = require('./js/managers/ComplianceManager'); const ComplianceManager = require('./js/managers/ComplianceManager');
const ContentSecurityManager = require('./js/managers/ContentSecurityManager');
const BuffManager = require('./js/managers/BuffManager'); const BuffManager = require('./js/managers/BuffManager');
const SkinManager = require('./js/managers/SkinManager');
const PlayerProfile = require('./js/managers/PlayerProfile');
const PrivacyPopup = require('./js/ui/PrivacyPopup');
const EventBus = require('./js/base/EventBus'); const EventBus = require('./js/base/EventBus');
const { const {
SCREEN_WIDTH, SCREEN_WIDTH,
@@ -61,13 +78,21 @@ const shareManager = new ShareManager();
const currencyManager = new CurrencyManager(); const currencyManager = new CurrencyManager();
const paymentManager = new PaymentManager(); const paymentManager = new PaymentManager();
const complianceManager = new ComplianceManager(); const complianceManager = new ComplianceManager();
const contentSecurityManager = new ContentSecurityManager();
const buffManager = new BuffManager(); const buffManager = new BuffManager();
const skinManager = new SkinManager();
const playerProfile = new PlayerProfile();
const privacyPopup = new PrivacyPopup();
GameGlobal.adManager = adManager; GameGlobal.adManager = adManager;
GameGlobal.shareManager = shareManager; GameGlobal.shareManager = shareManager;
GameGlobal.currencyManager = currencyManager; GameGlobal.currencyManager = currencyManager;
GameGlobal.paymentManager = paymentManager; GameGlobal.paymentManager = paymentManager;
GameGlobal.complianceManager = complianceManager; GameGlobal.complianceManager = complianceManager;
GameGlobal.contentSecurityManager = contentSecurityManager;
GameGlobal.buffManager = buffManager; GameGlobal.buffManager = buffManager;
GameGlobal.skinManager = skinManager;
GameGlobal.playerProfile = playerProfile;
GameGlobal.privacyPopup = privacyPopup;
// ============================================================ // ============================================================
// Game State // Game State
@@ -79,15 +104,60 @@ let lastTimestamp = 0;
// Touch Event Forwarding // Touch Event Forwarding
// ============================================================ // ============================================================
wx.onTouchStart((e) => { wx.onTouchStart((e) => {
// Privacy popup consumes all touches while active
if (privacyPopup.active) {
privacyPopup.handleTouch('touchstart', e);
return;
}
sceneManager.handleTouch('touchstart', e); sceneManager.handleTouch('touchstart', e);
}); });
wx.onTouchMove((e) => { wx.onTouchMove((e) => {
if (privacyPopup.active) return;
sceneManager.handleTouch('touchmove', e); sceneManager.handleTouch('touchmove', e);
}); });
wx.onTouchEnd((e) => { wx.onTouchEnd((e) => {
if (privacyPopup.active) {
privacyPopup.handleTouch('touchend', e);
return;
}
sceneManager.handleTouch('touchend', e); sceneManager.handleTouch('touchend', e);
}); });
// ============================================================
// WeChat Privacy Authorization (required since base library 2.32.3)
// ============================================================
if (typeof wx !== 'undefined' && typeof wx.onNeedPrivacyAuthorization === 'function') {
// Once the user has explicitly agreed to privacy, we auto-resolve any
// subsequent onNeedPrivacyAuthorization callbacks without showing the
// popup again. This prevents showing duplicate popups.
let _privacyAgreed = false;
wx.onNeedPrivacyAuthorization((resolve, eventInfo) => {
console.log('[game.js] onNeedPrivacyAuthorization triggered, eventInfo:', eventInfo, ', alreadyAgreed:', _privacyAgreed);
// If the user already agreed, auto-resolve without showing the popup.
if (_privacyAgreed) {
console.log('[game.js] Auto-resolving privacy (already agreed)');
resolve({ event: 'agree' });
return;
}
// Show the privacy popup for the first-time authorization.
// When the user taps "Agree" or "Decline", the popup calls resolve().
const wrappedResolve = (result) => {
console.log('[game.js] Privacy resolved with:', JSON.stringify(result));
resolve(result);
// Remember that the user agreed — future triggers auto-resolve.
if (result && result.event === 'agree') {
_privacyAgreed = true;
}
};
privacyPopup.show(wrappedResolve, eventInfo);
});
}
// ============================================================ // ============================================================
// Lifecycle: pause / resume on background switch // Lifecycle: pause / resume on background switch
// ============================================================ // ============================================================
@@ -105,18 +175,27 @@ wx.onShow((res) => {
console.log(`[game.js] onShow fired: scene=${res && res.scene}, query=${JSON.stringify(res && res.query)}, shareTicket=${res && res.shareTicket}`); console.log(`[game.js] onShow fired: scene=${res && res.scene}, query=${JSON.stringify(res && res.query)}, shareTicket=${res && res.shareTicket}`);
// Check for teamId from invite card (3v3 mode) // Check for teamId from invite card (3v3 mode) or roomId from invite card (1v1 mode)
const teamId = _extractTeamId(res && res.query); const teamId = _extractTeamId(res && res.query);
const teamMode = _extractTeamMode(res && res.query);
const roomId = _extractRoomId(res && res.query);
if (teamId) { if (teamId) {
_handleInviteTeamId(teamId); _handleInviteTeamId(teamId, teamMode);
} else if (roomId) {
_handleInviteRoomId(roomId);
} else { } else {
// Fallback: also check launch options in case onShow query is empty on cold start // Fallback: also check launch options in case onShow query is empty on cold start
try { try {
const launchOptions = wx.getLaunchOptionsSync(); const launchOptions = wx.getLaunchOptionsSync();
const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query); const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query);
const fallbackTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
const fallbackRoomId = _extractRoomId(launchOptions && launchOptions.query);
if (fallbackTeamId) { if (fallbackTeamId) {
console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`); console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`);
_handleInviteTeamId(fallbackTeamId); _handleInviteTeamId(fallbackTeamId, fallbackTeamMode);
} else if (fallbackRoomId) {
console.log(`[game.js] onShow query empty, but found roomId in launchOptions: ${fallbackRoomId}`);
_handleInviteRoomId(fallbackRoomId);
} }
} catch (e) {} } catch (e) {}
} }
@@ -166,11 +245,107 @@ function _extractTeamId(query) {
} }
/** /**
* Handle teamId from invite card (shared between onShow and cold launch). * Extract mode parameter from query (e.g. mode=2v2 or mode=3v3).
* Navigates to TeamRoomScene if possible, otherwise stores as pending. * Used to route team invites to the correct room scene.
* @param {string} teamId * @param {object|string|undefined} query
* @returns {string|null}
*/ */
function _handleInviteTeamId(teamId) { function _extractTeamMode(query) {
if (!query) return null;
if (typeof query === 'object' && query.mode) {
return query.mode;
}
if (typeof query === 'string') {
const match = query.match(/mode=([^&]+)/);
if (match) return match[1];
}
if (typeof query === 'object') {
const keys = Object.keys(query);
for (const key of keys) {
const combined = key + (query[key] ? '=' + query[key] : '');
const match = combined.match(/mode=([^&]+)/);
if (match) return match[1];
}
}
return null;
}
/**
* Extract roomId from query parameter (1v1 invite card).
* Similar to _extractTeamId, but looks for roomId key.
* @param {object|string|undefined} query
* @returns {string|null}
*/
function _extractRoomId(query) {
if (!query) return null;
// Case 1: query is already an object with roomId property
if (typeof query === 'object' && query.roomId) {
return query.roomId;
}
// Case 2: query is a string like 'roomId=R12345' or 'roomId=R12345&foo=bar'
if (typeof query === 'string') {
const match = query.match(/roomId=([^&]+)/);
if (match) return match[1];
}
// Case 3: query is an object but roomId might be nested in a raw string field
if (typeof query === 'object') {
const keys = Object.keys(query);
for (const key of keys) {
const combined = key + (query[key] ? '=' + query[key] : '');
const match = combined.match(/roomId=([^&]+)/);
if (match) return match[1];
}
}
return null;
}
/**
* Handle roomId from 1v1 invite card.
* Navigates to RoomScene (auto-join) if possible, otherwise stores as pending.
* @param {string} roomId
*/
function _handleInviteRoomId(roomId) {
if (!roomId) return;
// Avoid duplicate processing if already pending the same roomId
if (GameGlobal._pendingRoomId === roomId) {
console.log(`[game.js] roomId ${roomId} already pending, skipping duplicate`);
return;
}
console.log(`[game.js] Received roomId from 1v1 invite: ${roomId}, currentScene: ${sceneManager._currentName}`);
// If already past loading, navigate directly to PVP room scene
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
console.log(`[game.js] Navigating directly to RoomScene with roomId: ${roomId}`);
if (!sceneManager._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./js/scenes/RoomScene');
sceneManager.register(SCENE.PVP_ROOM, RoomScene);
}
sceneManager.switchTo(SCENE.PVP_ROOM, { roomId });
GameGlobal._pendingRoomId = null;
} else {
// Still loading — store pending roomId for auto-navigation after load
console.log(`[game.js] Still loading, storing pendingRoomId: ${roomId}`);
GameGlobal._pendingRoomId = roomId;
}
}
/**
* Handle teamId from invite card (shared between onShow and cold launch).
* Routes to Team2v2RoomScene or TeamRoomScene based on mode parameter.
* @param {string} teamId
* @param {string|null} mode - '2v2' or '3v3' (default: '3v3')
*/
function _handleInviteTeamId(teamId, mode) {
if (!teamId) return; if (!teamId) return;
// Avoid duplicate processing if already pending the same teamId // Avoid duplicate processing if already pending the same teamId
@@ -179,33 +354,45 @@ function _handleInviteTeamId(teamId) {
return; return;
} }
console.log(`[game.js] Received teamId from invite: ${teamId}, currentScene: ${sceneManager._currentName}`); const is2v2 = mode === '2v2';
const targetScene = is2v2 ? SCENE.TEAM_2V2_ROOM : SCENE.TEAM_ROOM;
const sceneName = is2v2 ? 'Team2v2RoomScene' : 'TeamRoomScene';
// If already past loading, navigate directly to team room console.log(`[game.js] Received teamId from invite: ${teamId}, mode: ${mode || '3v3'}, targetScene: ${targetScene}, currentScene: ${sceneManager._currentName}`);
// If already past loading, navigate directly to the team room
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) { if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
console.log(`[game.js] Navigating directly to TeamRoomScene with teamId: ${teamId}`); console.log(`[game.js] Navigating directly to ${sceneName} with teamId: ${teamId}`);
if (!sceneManager._scenes.has(SCENE.TEAM_ROOM)) { if (!sceneManager._scenes.has(targetScene)) {
const TeamRoomScene = require('./js/scenes/TeamRoomScene'); const SceneModule = is2v2
sceneManager.register(SCENE.TEAM_ROOM, TeamRoomScene); ? require('./js/scenes/Team2v2RoomScene')
: require('./js/scenes/TeamRoomScene');
sceneManager.register(targetScene, SceneModule);
} }
sceneManager.switchTo(SCENE.TEAM_ROOM, { teamId }); sceneManager.switchTo(targetScene, { teamId });
GameGlobal._pendingTeamId = null; GameGlobal._pendingTeamId = null;
GameGlobal._pendingTeamMode = null;
} else { } else {
// Still loading — store pending teamId for auto-navigation after load // Still loading — store pending teamId for auto-navigation after load
console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}`); console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}, mode: ${mode || '3v3'}`);
GameGlobal._pendingTeamId = teamId; GameGlobal._pendingTeamId = teamId;
GameGlobal._pendingTeamMode = mode || null;
} }
} }
// Check for teamId from cold launch (user opened game via invite card) // Check for teamId / roomId from cold launch (user opened game via invite card)
try { try {
const launchOptions = wx.getLaunchOptionsSync(); const launchOptions = wx.getLaunchOptionsSync();
console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`); console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`);
const launchTeamId = _extractTeamId(launchOptions && launchOptions.query); const launchTeamId = _extractTeamId(launchOptions && launchOptions.query);
const launchTeamMode = _extractTeamMode(launchOptions && launchOptions.query);
const launchRoomId = _extractRoomId(launchOptions && launchOptions.query);
if (launchTeamId) { if (launchTeamId) {
_handleInviteTeamId(launchTeamId); _handleInviteTeamId(launchTeamId, launchTeamMode);
} else if (launchRoomId) {
_handleInviteRoomId(launchRoomId);
} else { } else {
console.log('[game.js] No teamId found in cold launch options'); console.log('[game.js] No teamId/roomId found in cold launch options');
} }
} catch (e) { } catch (e) {
console.error('[game.js] getLaunchOptionsSync failed:', e); console.error('[game.js] getLaunchOptionsSync failed:', e);
@@ -233,6 +420,11 @@ function gameLoop(timestamp) {
// Update & render current scene // Update & render current scene
sceneManager.update(dt); sceneManager.update(dt);
sceneManager.render(ctx); sceneManager.render(ctx);
// Privacy popup renders on top of everything
if (privacyPopup.active) {
privacyPopup.render(ctx);
}
} }
// ============================================================ // ============================================================
@@ -252,6 +444,14 @@ const LoadingScene = {
// Initialize audio system (programmatic synthesis, no files needed) // Initialize audio system (programmatic synthesis, no files needed)
audioManager.init(); audioManager.init();
// Initialize content security manager (local word list + remote sync)
// Derive HTTP base URL from WebSocket server URL for content security API calls
const wsUrl = networkManager._serverUrl || '';
const httpServerUrl = wsUrl.replace(/^wss?/, 'https');
contentSecurityManager.init({
serverUrl: httpServerUrl,
});
// Define all image assets to preload // Define all image assets to preload
// For now we use procedural drawing, so asset list is empty. // For now we use procedural drawing, so asset list is empty.
// Assets can be added later as the game grows. // Assets can be added later as the game grows.
+1
View File
@@ -1,6 +1,7 @@
{ {
"deviceOrientation": "landscape", "deviceOrientation": "landscape",
"showStatusBar": false, "showStatusBar": false,
"__usePrivacyCheck__": true,
"networkTimeout": { "networkTimeout": {
"request": 10000, "request": 10000,
"connectSocket": 10000, "connectSocket": 10000,
BIN
View File
Binary file not shown.
+15 -1
View File
@@ -102,6 +102,7 @@ const TANK_CONFIG = {
hp: 6, hp: 6,
color: '#8B0000', // dark red color: '#8B0000', // dark red
size: TILE_SIZE * 1.2, size: TILE_SIZE * 1.2,
colliderSize: TILE_SIZE * 0.85,
score: 500, score: 500,
}, },
}; };
@@ -164,6 +165,7 @@ const SCENE = {
RANKING: 'ranking', RANKING: 'ranking',
SETTINGS: 'settings', SETTINGS: 'settings',
SHOP: 'shop', SHOP: 'shop',
SKIN: 'skin',
BUFF_SELECT: 'buff_select', BUFF_SELECT: 'buff_select',
PVP_ROOM: 'pvp_room', PVP_ROOM: 'pvp_room',
PVP_GAME: 'pvp_game', PVP_GAME: 'pvp_game',
@@ -171,6 +173,8 @@ const SCENE = {
TEAM_ROOM: 'team_room', TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game', TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result', TEAM_RESULT: 'team_result',
TEAM_2V2_ROOM: 'team_2v2_room',
CHAT_ROOM: 'chat_room',
}; };
// ============================================================ // ============================================================
@@ -180,6 +184,7 @@ const GAME_MODE = {
CLASSIC: 'classic', CLASSIC: 'classic',
ENDLESS: 'endless', ENDLESS: 'endless',
PVP: 'pvp', PVP: 'pvp',
TEAM_2V2: 'team_2v2',
TEAM_3V3: 'team_3v3', TEAM_3V3: 'team_3v3',
}; };
@@ -196,7 +201,7 @@ const PVP_BASE_HP = 5; // base hit points for 1v1 PVP mode
// Server Configuration // Server Configuration
// ============================================================ // ============================================================
// const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production // const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production
const SERVER_URL = 'wss://www.igeek.site/games/wx/tankwar'; const SERVER_URL = 'wss://game.igeek.site/tankwar/ws';
// ============================================================ // ============================================================
@@ -219,6 +224,13 @@ const BATTLE_CONFIG = {
fillWithBots: false, fillWithBots: false,
mapPool: 'pvp', mapPool: 'pvp',
}, },
'2v2': {
teamSize: 2,
baseHp: 8,
respawnDelay: TEAM_RESPAWN_DELAY,
fillWithBots: true,
mapPool: 'team',
},
'3v3': { '3v3': {
teamSize: 3, teamSize: 3,
baseHp: TEAM_BASE_HP, baseHp: TEAM_BASE_HP,
@@ -270,6 +282,8 @@ const NET_MSG = {
PLAYER_RESPAWN: 'player_respawn', PLAYER_RESPAWN: 'player_respawn',
TEAM_GAME_START: 'team_game_start', TEAM_GAME_START: 'team_game_start',
TEAM_GAME_OVER: 'team_game_over', TEAM_GAME_OVER: 'team_game_over',
TERRAIN_CHANGE: 'terrain_change',
BOT_STATE: 'bot_state',
RECONNECT: 'reconnect', RECONNECT: 'reconnect',
RECONNECT_OK: 'reconnect_ok', RECONNECT_OK: 'reconnect_ok',
PLAYER_DISCONNECT: 'player_disconnect', PLAYER_DISCONNECT: 'player_disconnect',
+4 -3
View File
@@ -45,6 +45,7 @@ class BotTank extends Tank {
hp: cfg.hp, hp: cfg.hp,
color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'), color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'),
size: cfg.size, size: cfg.size,
colliderSize: cfg.colliderSize || cfg.size,
direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT, direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT,
}); });
@@ -215,10 +216,10 @@ class BotTank extends Tank {
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE; const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize; const left = testX - this.colliderHalfSize;
const top = testY - this.halfSize; const top = testY - this.colliderHalfSize;
if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
this.direction = dir; this.direction = dir;
return; return;
} }
+13 -1
View File
@@ -77,12 +77,24 @@ class Bullet {
/** /**
* Render the bullet. * Render the bullet.
* In team battle mode, bullet color is determined by the _isAlly flag
* set by the scene: ally = bright yellow, enemy = red.
* In single-player / PVE mode, owner-based colors are used.
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
*/ */
render(ctx) { render(ctx) {
if (!this.alive) return; if (!this.alive) return;
ctx.fillStyle = this.owner === 'player' ? '#FFFF00' : '#FF6600'; let fillColor;
if (this._isAlly !== undefined) {
// Team mode: use team-aware coloring
fillColor = this._isAlly ? '#FFFF00' : '#FF4444';
} else {
// Single-player / PVE fallback
fillColor = this.owner === 'player' ? '#FFFF00' : '#FF6600';
}
ctx.fillStyle = fillColor;
ctx.fillRect( ctx.fillRect(
this.x - this.halfSize, this.x - this.halfSize,
this.y - this.halfSize, this.y - this.halfSize,
+4 -3
View File
@@ -45,6 +45,7 @@ class EnemyTank extends Tank {
hp: cfg.hp, hp: cfg.hp,
color: cfg.color, color: cfg.color,
size: cfg.size, size: cfg.size,
colliderSize: cfg.colliderSize || cfg.size,
direction: DIRECTION.DOWN, direction: DIRECTION.DOWN,
}); });
@@ -176,10 +177,10 @@ class EnemyTank extends Tank {
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE; const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize; const left = testX - this.colliderHalfSize;
const top = testY - this.halfSize; const top = testY - this.colliderHalfSize;
if (!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (!mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
this.direction = dir; this.direction = dir;
return; return;
} }
+1
View File
@@ -45,6 +45,7 @@ class PlayerTank extends Tank {
// Skin colors (reserved for future use) // Skin colors (reserved for future use)
this._skinColors = null; this._skinColors = null;
this._skinId = null;
// Fire level system // Fire level system
this.fireLevel = FIRE_LEVEL.LV1; this.fireLevel = FIRE_LEVEL.LV1;
+75 -31
View File
@@ -13,6 +13,7 @@ const {
DIRECTION, DIRECTION,
DIR_VECTORS, DIR_VECTORS,
} = require('../base/GameGlobal'); } = require('../base/GameGlobal');
const { drawTankSkin, DESIGN_HALF_SIZE } = require('./TankSkinRenderer');
class Tank { class Tank {
/** /**
@@ -37,8 +38,11 @@ class Tank {
this.alive = true; this.alive = true;
this.visible = true; this.visible = true;
// Collision size (can differ from visual size for large tanks like Boss)
this.colliderSize = config.colliderSize || this.size;
// Half-size for collision calculations // Half-size for collision calculations
this.halfSize = this.size / 2; this.halfSize = this.size / 2;
this.colliderHalfSize = this.colliderSize / 2;
} }
/** /**
@@ -69,10 +73,10 @@ class Tank {
// Clamp to map boundaries instead of rejecting movement entirely. // Clamp to map boundaries instead of rejecting movement entirely.
// This allows the tank to slide along the edge smoothly. // This allows the tank to slide along the edge smoothly.
const minX = MAP_OFFSET_X + this.halfSize; const minX = MAP_OFFSET_X + this.colliderHalfSize;
const minY = MAP_OFFSET_Y + this.halfSize; const minY = MAP_OFFSET_Y + this.colliderHalfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
newX = Math.max(minX, Math.min(newX, maxX)); newX = Math.max(minX, Math.min(newX, maxX));
newY = Math.max(minY, Math.min(newY, maxY)); newY = Math.max(minY, Math.min(newY, maxY));
@@ -83,11 +87,11 @@ class Tank {
} }
// Calculate bounding box at clamped position // Calculate bounding box at clamped position
const left = newX - this.halfSize; const left = newX - this.colliderHalfSize;
const top = newY - this.halfSize; const top = newY - this.colliderHalfSize;
// Terrain collision check // Terrain collision check
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
// Try to align to grid for smoother movement along walls // Try to align to grid for smoother movement along walls
return this._tryAlignedMove(dir, dt, mapManager); return this._tryAlignedMove(dir, dt, mapManager);
} }
@@ -106,10 +110,10 @@ class Tank {
*/ */
_snapToGrid(oldDir) { _snapToGrid(oldDir) {
const halfTile = TILE_SIZE / 2; const halfTile = TILE_SIZE / 2;
const minX = MAP_OFFSET_X + this.halfSize; const minX = MAP_OFFSET_X + this.colliderHalfSize;
const minY = MAP_OFFSET_Y + this.halfSize; const minY = MAP_OFFSET_Y + this.colliderHalfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize; const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize; const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) { if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
// Was moving vertically → snap Y to nearest grid-cell center // Was moving vertically → snap Y to nearest grid-cell center
@@ -145,6 +149,8 @@ class Tank {
const moveAmount = this.speed * dt * 60; const moveAmount = this.speed * dt * 60;
const vec = DIR_VECTORS[dir]; const vec = DIR_VECTORS[dir];
const halfTile = TILE_SIZE / 2; const halfTile = TILE_SIZE / 2;
const colliderHS = this.colliderHalfSize;
const colliderS = this.colliderSize;
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) { if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
// Moving vertically but blocked — try to slide horizontally into a gap // Moving vertically but blocked — try to slide horizontally into a gap
@@ -163,14 +169,14 @@ class Tank {
// Check whether moving in the desired direction would be clear at this aligned X // Check whether moving in the desired direction would be clear at this aligned X
const testX = alignedX; const testX = alignedX;
const testY = this.y + vec.dy * moveAmount; const testY = this.y + vec.dy * moveAmount;
const left = testX - this.halfSize; const left = testX - colliderHS;
const top = testY - this.halfSize; const top = testY - colliderHS;
if ( if (
left >= MAP_OFFSET_X && left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y && top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH && left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
) { ) {
candidates.push({ alignedX, diffX: Math.abs(diffX) }); candidates.push({ alignedX, diffX: Math.abs(diffX) });
} }
@@ -185,7 +191,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffX), moveAmount); const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount; this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize)); this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
return false; return false;
} }
@@ -197,7 +203,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffX), moveAmount); const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount; this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize)); this.x = Math.max(MAP_OFFSET_X + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
} }
} else { } else {
// Moving horizontally but blocked — try to slide vertically into a gap // Moving horizontally but blocked — try to slide vertically into a gap
@@ -213,14 +219,14 @@ class Tank {
if (Math.abs(diffY) < TILE_SIZE * 0.55) { if (Math.abs(diffY) < TILE_SIZE * 0.55) {
const testX = this.x + vec.dx * moveAmount; const testX = this.x + vec.dx * moveAmount;
const testY = alignedY; const testY = alignedY;
const left = testX - this.halfSize; const left = testX - colliderHS;
const top = testY - this.halfSize; const top = testY - colliderHS;
if ( if (
left >= MAP_OFFSET_X && left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y && top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH && left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT && top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size) !mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
) { ) {
candidates.push({ alignedY, diffY: Math.abs(diffY) }); candidates.push({ alignedY, diffY: Math.abs(diffY) });
} }
@@ -234,7 +240,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffY), moveAmount); const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount; this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize)); this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
return false; return false;
} }
@@ -246,7 +252,7 @@ class Tank {
const slideAmount = Math.min(Math.abs(diffY), moveAmount); const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount; this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding // Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize)); this.y = Math.max(MAP_OFFSET_Y + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
} }
} }
@@ -288,9 +294,38 @@ class Tank {
}; };
ctx.rotate(angles[this.direction]); ctx.rotate(angles[this.direction]);
const hs = this.halfSize; // ★ Unified skin path — any tank with a skin id uses the SAME drawing
// code as the SkinScene preview. Scale to match the actual tank size.
// Clip laterally to the collision box so wide tracks / decorations
// don't make the tank look wider than non-skinned tanks. Leave the
// top/bottom un-clipped so the barrel can extend naturally (same as
// legacy rendering).
if (this._skinId) {
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
const k = this.halfSize / DESIGN_HALF_SIZE;
// Clip: lateral bounds = collision box; vertical = generous to allow barrel
const barrelExtra = this.size * 0.55; // same as legacy barrelH
ctx.beginPath();
ctx.rect(-this.halfSize, -this.halfSize - barrelExtra, this.size, this.size + barrelExtra * 2);
ctx.clip();
ctx.save();
ctx.scale(k, k);
drawTankSkin(ctx, this._skinId, this._skinColors, t);
ctx.restore();
// Gold border for local player identification
if (this._isLocal) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.strokeRect(-this.halfSize, -this.halfSize, this.size, this.size);
// Also outline the barrel area
ctx.strokeRect(-this.size * 0.15 / 2, -this.halfSize - barrelExtra, this.size * 0.15, barrelExtra);
}
ctx.restore();
return;
}
// Determine colors: use skin colors if this is a player tank with a skin // ── Legacy fallback for tanks without a skin id (enemy AI, etc.) ──
const hs = this.halfSize;
let bodyColor = this.color; let bodyColor = this.color;
let turretColor = this._darkenColor(this.color, 0.3); let turretColor = this._darkenColor(this.color, 0.3);
let trackColor = this._darkenColor(this.color, 0.4); let trackColor = this._darkenColor(this.color, 0.4);
@@ -322,6 +357,15 @@ class Tank {
ctx.fillRect(-hs, -hs, trackW, this.size); ctx.fillRect(-hs, -hs, trackW, this.size);
ctx.fillRect(hs - trackW, -hs, trackW, this.size); ctx.fillRect(hs - trackW, -hs, trackW, this.size);
// Gold border for local player identification
if (this._isLocal) {
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.strokeRect(-hs, -hs, this.size, this.size);
// Barrel outline
ctx.strokeRect(-barrelW / 2, -hs - barrelH, barrelW, barrelH);
}
ctx.restore(); ctx.restore();
} }
@@ -331,10 +375,10 @@ class Tank {
*/ */
getBounds() { getBounds() {
return { return {
x: this.x - this.halfSize, x: this.x - this.colliderHalfSize,
y: this.y - this.halfSize, y: this.y - this.colliderHalfSize,
w: this.size, w: this.colliderSize,
h: this.size, h: this.colliderSize,
}; };
} }
File diff suppressed because it is too large Load Diff
+107 -11
View File
@@ -8,6 +8,7 @@ module.exports = {
// Common // Common
// ============================================================ // ============================================================
'common.back': '← Back', 'common.back': '← Back',
'common.cancel': 'Cancel',
'common.joinBtn': 'Join', 'common.joinBtn': 'Join',
'common.cannotConnect': 'Cannot connect to server', 'common.cannotConnect': 'Cannot connect to server',
'common.connectFailed': 'Connection failed', 'common.connectFailed': 'Connection failed',
@@ -20,31 +21,68 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': 'Tank Adventure', 'menu.title': 'Tank Adventure',
'menu.subtitle': 'TANK WAR', 'menu.subtitle': 'TANK WAR · Battle with Friends',
'menu.classic': 'Classic', 'menu.classic': 'Classic Mode',
'menu.endless': 'Endless', 'menu.classic.sub': 'Classic tank battle',
'menu.pvp': 'PVP', 'menu.endless': 'Endless Mode',
'menu.team3v3': '3v3 Battle', 'menu.endless.sub': 'Push your limits',
'menu.pvp': '1v1 Duel',
'menu.pvp.sub': 'Winner Takes All.',
'menu.team2v2': '2v2 Brawl',
'menu.team2v2.sub': 'Co-op strategy wins',
'menu.team3v3': '3v3 Team Battle',
'menu.team3v3.sub': 'Teamwork dominates the battlefield',
'menu.shop': 'Shop', 'menu.shop': 'Shop',
'menu.skin': 'Skins',
'menu.skin.sub': 'Many skins to choose from',
'menu.ranking': 'Ranking', 'menu.ranking': 'Ranking',
'menu.ranking.sub': 'Climb the leaderboard',
'menu.settings': 'Settings', 'menu.settings': 'Settings',
'menu.settings.sub': 'Customize your experience',
'menu.chat': 'Chat',
'menu.tapToAuth': 'Tap to authorize',
// ============================================================ // ============================================================
// Room Scene (PVP) // Room Scene (PVP)
// ============================================================ // ============================================================
'room.title': 'PVP Battle', 'room.title': 'PVP Battle',
'room.idleHint': 'Create a room or join with a code',
'room.create': 'Create Room',
'room.join': 'Join Room',
'room.connecting': 'Connecting{dots}', 'room.connecting': 'Connecting{dots}',
'room.roomCode': 'Room Code:', 'room.roomCode': 'Room Code:',
'room.waiting': 'Waiting for opponent{dots}', 'room.waiting': 'Waiting for opponent{dots}',
'room.shareHint': 'Share the room code with your friend', 'room.inviteFriend': '📨 Invite Friend',
'room.inputCode': 'Enter Room Code:', 'room.shareTitle': 'Come play 1v1 Tank Battle with me!',
'room.shareHint': 'Or share the room code with your friend',
'room.opponentFound': 'Opponent found!', 'room.opponentFound': 'Opponent found!',
'room.starting': 'Game starting...', 'room.starting': 'Game starting...',
'room.tapBack': 'Tap anywhere to go back', 'room.tapBack': 'Tap anywhere to go back',
// ============================================================
// Team 2v2 Room Scene
// ============================================================
'team2v2Room.title': '2v2 Brawl',
'team2v2Room.chooseMode': 'Choose how to play',
'team2v2Room.createTeam': '🎮 Create Team',
'team2v2Room.soloMatch': '⚡ Quick Match',
'team2v2Room.teamId': 'Team: {id}',
'team2v2Room.leader': 'Leader',
'team2v2Room.ready': '✓ Ready',
'team2v2Room.notReady': 'Not Ready',
'team2v2Room.emptySlot': 'Empty',
'team2v2Room.invite': '📨 Invite',
'team2v2Room.startMatch': '🔍 Start Match',
'team2v2Room.disband': 'Disband',
'team2v2Room.readyBtn': '✓ Ready',
'team2v2Room.cancelReady': 'Cancel Ready',
'team2v2Room.leaveTeam': 'Leave Team',
'team2v2Room.matching': 'Matching{dots}',
'team2v2Room.waitTime': 'Waited {seconds}s',
'team2v2Room.cancelMatch': 'Cancel Match',
'team2v2Room.matchFound': 'Match found!',
'team2v2Room.enterBattle': 'Entering battle...',
'team2v2Room.tapBack': 'Tap anywhere to go back',
'team2v2Room.shareTitle': 'Tank 2v2, join the brawl!',
'team2v2Room.joining': 'Joining room',
// ============================================================ // ============================================================
// Team Room Scene (3v3) // Team Room Scene (3v3)
// ============================================================ // ============================================================
@@ -223,13 +261,40 @@ module.exports = {
// ============================================================
// Chat Room Scene
// ============================================================
'chat.title': 'Chat Room',
'chat.inputPlaceholder': 'Type a message...',
'chat.send': 'Send',
'chat.reportTitle': 'Report',
'chat.reportPolitics': 'Harmful politics',
'chat.reportPornography': 'Pornography',
'chat.reportGambling': 'Gambling & fraud',
'chat.reportOther': 'Other',
'chat.reportCancel': 'Cancel',
// ============================================================
// Content Security
// ============================================================
'contentSecurity.violation': 'Content contains prohibited information',
'contentSecurity.nicknameViolation': 'Nickname contains prohibited content',
'contentSecurity.chatViolation': 'Message failed: content violation',
'contentSecurity.muted': 'You cannot modify profile due to violation',
'contentSecurity.mutedChat': 'You are muted, remaining: {time}',
'contentSecurity.imageViolation': 'Image contains prohibited content',
'contentSecurity.checking': 'Checking...',
'contentSecurity.timeout': 'Check timeout, please retry',
'contentSecurity.reportSuccess': 'Report submitted',
'contentSecurity.reportFail': 'Report failed',
// ============================================================ // ============================================================
// Ad System // Ad System
// ============================================================ // ============================================================
'ad.reviveTitle': 'Revive Chance', 'ad.reviveTitle': 'Revive Chance',
'ad.reviveDesc': 'Choose how to revive and continue', 'ad.reviveDesc': 'Choose how to revive and continue',
'ad.watchAd': '📺 Watch Ad (Free)', 'ad.watchAd': '📺 Watch Ad (Free)',
'ad.goldRevive': '🪙 Gold Revive (200)', 'ad.goldRevive': '🪙 Gold Revive',
'ad.giveUp': 'Give Up', 'ad.giveUp': 'Give Up',
'ad.doubleReward': '🎬 Watch Ad for 2x Reward', 'ad.doubleReward': '🎬 Watch Ad for 2x Reward',
'ad.unavailable': 'Ad temporarily unavailable', 'ad.unavailable': 'Ad temporarily unavailable',
@@ -266,7 +331,38 @@ module.exports = {
// Daily Gold // Daily Gold
// ============================================================ // ============================================================
'dailyGold.btn': '🪙 Get Gold', 'dailyGold.btn': '🪙 Get Gold',
'dailyGold.desc': 'Daily reward',
'dailyGold.remaining': '{remaining}/3', 'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': 'Come back tomorrow', 'dailyGold.exhausted': 'Come back tomorrow',
'dailyGold.reward': '+100 Gold!', 'dailyGold.reward': '+100 Gold!',
// ============================================================
// Skin System
// ============================================================
'skin.title': 'Tank Skins',
'skin.default': 'Classic',
'skin.arctic': 'Arctic',
'skin.inferno': 'Inferno',
'skin.phantom': 'Phantom',
'skin.jungle': 'Jungle',
'skin.neon': 'Neon',
'skin.nebula': 'Nebula',
'skin.royal': 'Royal',
'skin.sakura': 'Sakura',
'skin.thunder': 'Thunder',
'skin.diamond': 'Diamond',
'skin.equipped': '✓ Equipped',
'skin.owned': 'Owned',
'skin.equipSuccess': '✓ Skin equipped!',
'skin.purchaseSuccess': '✓ Skin unlocked!',
// ============================================================
// Privacy Authorization
// ============================================================
'privacy.title': 'Privacy Notice',
'privacy.body': 'To display your nickname and avatar in the game, we need access to your WeChat basic info.\n\nYou can review the Privacy Policy to understand how your data is used.\n\nIf you decline, you will play anonymously.',
'privacy.policyLink': '👉 View Privacy Policy',
'privacy.agree': 'Agree',
'privacy.decline': 'Decline',
'privacy.footer': 'Your info is only used for in-game display and never shared with third parties',
}; };
+106 -10
View File
@@ -8,6 +8,7 @@ module.exports = {
// Common // Common
// ============================================================ // ============================================================
'common.back': '← 返回', 'common.back': '← 返回',
'common.cancel': '取消',
'common.joinBtn': '加入', 'common.joinBtn': '加入',
'common.cannotConnect': '无法连接服务器', 'common.cannotConnect': '无法连接服务器',
'common.connectFailed': '连接失败', 'common.connectFailed': '连接失败',
@@ -20,31 +21,68 @@ module.exports = {
// Menu Scene // Menu Scene
// ============================================================ // ============================================================
'menu.title': '坦克探险', 'menu.title': '坦克探险',
'menu.subtitle': '经典坦克对战', 'menu.subtitle': '经典坦克对战 · 兄弟集结开黑',
'menu.classic': '经典模式', 'menu.classic': '经典模式',
'menu.classic.sub': '经典坦克对战',
'menu.endless': '无尽模式', 'menu.endless': '无尽模式',
'menu.pvp': '双人对战', 'menu.endless.sub': '挑战极限,突破自我',
'menu.team3v3': '3v3 对战', 'menu.pvp': '1v1 决斗',
'menu.pvp.sub': '单挑对决,谁与争锋',
'menu.team2v2': '2v2 激斗',
'menu.team2v2.sub': '双人协作,策略制胜',
'menu.team3v3': '3v3 团战',
'menu.team3v3.sub': '团队协作,称霸战场',
'menu.shop': '商店', 'menu.shop': '商店',
'menu.skin': '皮肤',
'menu.skin.sub': '多款皮肤任你选',
'menu.ranking': '排行榜', 'menu.ranking': '排行榜',
'menu.ranking.sub': '冲击榜单,赢取荣誉',
'menu.settings': '设置', 'menu.settings': '设置',
'menu.settings.sub': '个性设置,畅快体验',
'menu.chat': '聊天室',
'menu.tapToAuth': '点击授权头像',
// ============================================================ // ============================================================
// Room Scene (PVP) // Room Scene (PVP)
// ============================================================ // ============================================================
'room.title': '双人对战', 'room.title': '1v1决斗',
'room.idleHint': '创建房间或输入房间号加入',
'room.create': '创建房间',
'room.join': '加入房间',
'room.connecting': '连接中{dots}', 'room.connecting': '连接中{dots}',
'room.roomCode': '房间号:', 'room.roomCode': '房间号:',
'room.waiting': '等待对手加入{dots}', 'room.waiting': '等待对手加入{dots}',
'room.shareHint': '将房间号分享给好友', 'room.inviteFriend': '📨 邀请好友',
'room.inputCode': '输入房间号:', 'room.shareTitle': '来和我1v1坦克大战吧!',
'room.shareHint': '或者将房间号分享给好友',
'room.opponentFound': '对手已找到!', 'room.opponentFound': '对手已找到!',
'room.starting': '即将开始...', 'room.starting': '即将开始...',
'room.tapBack': '点击任意位置返回', 'room.tapBack': '点击任意位置返回',
// ============================================================
// Team 2v2 Room Scene
// ============================================================
'team2v2Room.title': '2v2 激斗',
'team2v2Room.chooseMode': '选择游戏方式',
'team2v2Room.createTeam': '🎮 组队开黑',
'team2v2Room.soloMatch': '⚡ 快速匹配',
'team2v2Room.teamId': '队伍:{id}',
'team2v2Room.leader': '队长',
'team2v2Room.ready': '✓ 已准备',
'team2v2Room.notReady': '未准备',
'team2v2Room.emptySlot': '空位',
'team2v2Room.invite': '📨 邀请好友',
'team2v2Room.startMatch': '🔍 开始匹配',
'team2v2Room.disband': '解散队伍',
'team2v2Room.readyBtn': '✓ 准备',
'team2v2Room.cancelReady': '取消准备',
'team2v2Room.leaveTeam': '退出队伍',
'team2v2Room.matching': '匹配中{dots}',
'team2v2Room.waitTime': '已等待 {seconds} 秒',
'team2v2Room.cancelMatch': '取消匹配',
'team2v2Room.matchFound': '对手已找到!',
'team2v2Room.enterBattle': '即将进入战斗...',
'team2v2Room.tapBack': '点击任意位置返回',
'team2v2Room.shareTitle': '坦克2v2,速来开黑!',
'team2v2Room.joining': '正在加入房间',
// ============================================================ // ============================================================
// Team Room Scene (3v3) // Team Room Scene (3v3)
// ============================================================ // ============================================================
@@ -223,13 +261,40 @@ module.exports = {
// ============================================================
// Chat Room Scene
// ============================================================
'chat.title': '聊天室',
'chat.inputPlaceholder': '输入消息...',
'chat.send': '发送',
'chat.reportTitle': '举报',
'chat.reportPolitics': '政治有害',
'chat.reportPornography': '色情低俗',
'chat.reportGambling': '赌博诈骗',
'chat.reportOther': '其他',
'chat.reportCancel': '取消',
// ============================================================
// Content Security
// ============================================================
'contentSecurity.violation': '内容包含违规信息,请修改',
'contentSecurity.nicknameViolation': '昵称包含违规内容,请重新输入',
'contentSecurity.chatViolation': '消息发送失败:内容违规',
'contentSecurity.muted': '由于违规行为,您暂无法修改个人信息',
'contentSecurity.mutedChat': '您已被禁言,剩余时间:{time}',
'contentSecurity.imageViolation': '图片内容违规,请更换',
'contentSecurity.checking': '审核中...',
'contentSecurity.timeout': '审核超时,请重试',
'contentSecurity.reportSuccess': '举报已提交',
'contentSecurity.reportFail': '举报失败',
// ============================================================ // ============================================================
// Ad System // Ad System
// ============================================================ // ============================================================
'ad.reviveTitle': '复活机会', 'ad.reviveTitle': '复活机会',
'ad.reviveDesc': '选择复活方式继续游戏', 'ad.reviveDesc': '选择复活方式继续游戏',
'ad.watchAd': '📺 观看广告(免费)', 'ad.watchAd': '📺 观看广告(免费)',
'ad.goldRevive': '🪙 金币复活200', 'ad.goldRevive': '🪙 金币复活',
'ad.giveUp': '放弃', 'ad.giveUp': '放弃',
'ad.doubleReward': '🎬 看广告双倍奖励', 'ad.doubleReward': '🎬 看广告双倍奖励',
'ad.unavailable': '广告暂时不可用', 'ad.unavailable': '广告暂时不可用',
@@ -266,7 +331,38 @@ module.exports = {
// Daily Gold // Daily Gold
// ============================================================ // ============================================================
'dailyGold.btn': '🪙 领金币', 'dailyGold.btn': '🪙 领金币',
'dailyGold.desc': '每日领取奖励',
'dailyGold.remaining': '{remaining}/3', 'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': '明日再来', 'dailyGold.exhausted': '明日再来',
'dailyGold.reward': '+100 金币!', 'dailyGold.reward': '+100 金币!',
// ============================================================
// Skin System
// ============================================================
'skin.title': '坦克皮肤',
'skin.default': '经典',
'skin.arctic': '极地',
'skin.inferno': '烈焰',
'skin.phantom': '幻影',
'skin.jungle': '丛林',
'skin.neon': '霓虹',
'skin.nebula': '星云',
'skin.royal': '皇家',
'skin.sakura': '樱花',
'skin.thunder': '雷电',
'skin.diamond': '钻石',
'skin.equipped': '✓ 使用中',
'skin.owned': '已拥有',
'skin.equipSuccess': '✓ 已装备!',
'skin.purchaseSuccess': '✓ 已解锁!',
// ============================================================
// Privacy Authorization
// ============================================================
'privacy.title': '用户隐私保护提示',
'privacy.body': '为了在游戏中展示你的昵称和头像,我们需要使用你的微信基本信息。\n\n你可以在《隐私政策》中了解具体的信息使用方式和范围。\n\n如果你不同意,将以匿名身份参与游戏。',
'privacy.policyLink': '👉 查看《隐私政策》',
'privacy.agree': '同意',
'privacy.decline': '不同意',
'privacy.footer': '同意后仅用于游戏内展示,不会向第三方提供',
}; };
+2 -2
View File
@@ -262,7 +262,7 @@ class CollisionManager {
* @private * @private
*/ */
_isPositionValid(tank, x, y) { _isPositionValid(tank, x, y) {
const hs = tank.halfSize; const hs = tank.colliderHalfSize;
const left = x - hs; const left = x - hs;
const top = y - hs; const top = y - hs;
const right = x + hs; const right = x + hs;
@@ -279,7 +279,7 @@ class CollisionManager {
} }
// Terrain collision check // Terrain collision check
if (this._map.rectCollidesWithTerrain(left, top, tank.size, tank.size)) { if (this._map.rectCollidesWithTerrain(left, top, tank.colliderSize, tank.colliderSize)) {
return false; return false;
} }
+522
View File
@@ -0,0 +1,522 @@
/**
* ContentSecurityManager
* Client-side content security filtering for WeChat mini game.
*
* Features:
* - Local sensitive word filtering (50ms target)
* - Remote word list fetching with 24-hour local cache
* - Built-in fallback word list when remote fetch fails
* - Incremental word list updates without blocking ongoing checks
* - Integration with server-side msgSecCheck/imgSecCheck APIs
*/
// ============================================================
// Configuration
// ============================================================
const CACHE_KEY = 'content_security_word_cache';
const CACHE_VERSION_KEY = 'content_security_word_version';
const CACHE_TIMESTAMP_KEY = 'content_security_word_timestamp';
const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 hours
const SERVER_BASE_URL = ''; // Will use relative URL or configured server URL
const DEFAULT_GAME_ID = 'tankwar'; // Default game identifier for multi-tenant isolation
// Scene mapping (must match server-side)
const SCENE = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
// Report reasons
const REPORT_REASONS = {
POLITICS: 'politics',
PORNOGRAPHY: 'pornography',
GAMBLING: 'gambling',
OTHER: 'other',
};
// Report reason display labels
const REPORT_REASON_LABELS = {
politics: '政治有害',
pornography: '色情低俗',
gambling: '赌博诈骗',
other: '其他',
};
// ============================================================
// Built-in Fallback Sensitive Word List
// This is used when the remote word list is unavailable.
// ============================================================
const FALLBACK_WORDS = [
// Politics
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'法轮', '法轮功', '台独', '藏独', '疆独',
'习近平', '刁近平', '习大大', '习主席', '习总',
'XiJinping', 'xijinping', '习近', '近平',
'李强', '王岐山', '栗战书', '汪洋', '韩正',
'李克强', '胡锦涛', '江泽民', '温家宝', '朱镕基',
'邓小平', '毛泽东', '周恩来', '刘少奇', '彭德怀',
'薄熙来', '周永康', '徐才厚', '郭伯雄', '令计划',
'孙政才', '赵乐际', '王沪宁', '丁薛祥', '蔡奇',
// Pornography
'色情', '淫秽', '裸体', '卖淫', '嫖娼',
'约炮', '援交', '一夜情', '黄色视频',
// Gambling
'赌博', '赌场', '下注', '博彩', '六合彩',
'时时彩', '赌球', '百家乐', '老虎机',
// Violence
'杀人', '砍人', '捅死', '自制炸弹', '血腥屠杀',
// Abuse
'傻逼', '操你', '妈的', '草泥马', '脑残',
'贱人', '狗日的', '废物', '滚蛋', '王八蛋',
// Fraud
'代开发票', '传销', '诈骗', '洗钱',
// Other
'代孕', '毒品', '吸毒', '走私', '枪支',
];
class ContentSecurityManager {
constructor() {
/** @type {string[]} Active sensitive word list */
this._words = [];
/** @type {string} Current word list version */
this._version = '';
/** @type {boolean} Whether the manager is initialized */
this._initialized = false;
/** @type {boolean} Whether an update is in progress */
this._updating = false;
/** @type {string} Server base URL for API calls */
this._serverUrl = '';
/** @type {string} Game identifier for multi-tenant isolation */
this._gameId = DEFAULT_GAME_ID;
}
/**
* Initialize the manager: load cached words, then fetch latest.
* @param {object} [options]
* @param {string} [options.serverUrl] - Server base URL
* @returns {Promise<void>}
*/
async init(options = {}) {
this._serverUrl = options.serverUrl || '';
this._gameId = options.gameId || DEFAULT_GAME_ID;
// Try to load from cache first
this._loadFromCache();
// If no cached words, use fallback
if (this._words.length === 0) {
this._words = [...FALLBACK_WORDS];
console.log('[ContentSecurity] Using fallback word list (' + this._words.length + ' words)');
}
this._initialized = true;
// Fetch latest words from server (non-blocking)
this.fetchWordList();
console.log('[ContentSecurity] Initialized with ' + this._words.length + ' words');
}
/**
* Check text content against local sensitive words.
* Must complete within 50ms.
* @param {string} content - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[] }}
*/
checkLocalText(content) {
if (!content || typeof content !== 'string') {
return { hasViolation: false, matchedWords: [] };
}
const startTime = Date.now();
const lowerContent = content.toLowerCase();
// Strip common evasion characters (punctuation, spaces, zero-width chars) for split-char detection
const strippedContent = content.replace(/[\s\u3000.,;:!?·…—\-_\|\\/~`@#$%^&*+=<>()\[\]{}""''「」『』【】()〈〕\u200b\u200c\u200d\ufeff]/g, '').toLowerCase();
const matchedWords = [];
for (let i = 0; i < this._words.length; i++) {
const word = this._words[i];
if (!word) continue;
const lowerWord = word.toLowerCase();
// Direct match
if (lowerContent.includes(lowerWord)) {
matchedWords.push(word);
if (matchedWords.length >= 3) break;
continue;
}
// Split-char evasion match: check if word chars appear in order with evasion chars between
// Only for multi-char words (length >= 2)
if (word.length >= 2 && strippedContent.includes(lowerWord)) {
matchedWords.push(word);
if (matchedWords.length >= 3) break;
}
}
const duration = Date.now() - startTime;
if (duration > 50) {
console.warn('[ContentSecurity] Local check took ' + duration + 'ms (target: 50ms)');
}
return {
hasViolation: matchedWords.length > 0,
matchedWords,
};
}
/**
* Check text content via server-side API (msgSecCheck).
* @param {string} openid - User's openid
* @param {string} content - Text to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
if (!this._serverUrl) {
return {
pass: true,
errcode: 0,
errmsg: 'ok',
suggest: 'pass',
label: 100,
};
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/check-text`,
method: 'POST',
data: { openid, content, scene },
});
return res;
} catch (err) {
console.error('[ContentSecurity] checkTextContent error:', err && (err.message || err.errMsg) || 'unknown');
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content via server-side API (imgSecCheck).
* @param {string} filePath - Local temporary file path of the image
* @param {string} openid - User's openid
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(filePath, openid) {
if (!this._serverUrl) {
return { pass: true, errcode: 0, errmsg: 'ok' };
}
try {
const res = await this._uploadImage(filePath, openid);
return res;
} catch (err) {
console.error('[ContentSecurity] checkImageContent error:', err && (err.message || err.errMsg) || 'unknown');
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Fetch sensitive word list from server with cache validation.
* Non-blocking: does not interfere with ongoing checks.
* @returns {Promise<boolean>}
*/
async fetchWordList() {
if (this._updating) return false;
if (!this._serverUrl) return false;
this._updating = true;
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/sensitive-words`,
method: 'GET',
data: { version: this._version },
});
if (res.updated && res.words) {
// Incremental update: replace word list atomically
this._words = res.words;
this._version = res.version;
// Cache locally
this._saveToCache();
console.log('[ContentSecurity] Word list updated: ' + this._words.length + ' words, version: ' + this._version);
}
this._updating = false;
return true;
} catch (err) {
console.error('[ContentSecurity] fetchWordList error:', err && (err.message || err.errMsg) || 'unknown');
this._updating = false;
return false;
}
}
/**
* Check if user is muted.
* @param {string} openid
* @returns {Promise<{isMuted: boolean, remainingMs: number, remainingText: string}>}
*/
async getMuteStatus(openid) {
if (!this._serverUrl) {
return { isMuted: false, remainingMs: 0, remainingText: '' };
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/user/mute-status`,
method: 'GET',
data: { openid },
});
return res;
} catch (err) {
console.error('[ContentSecurity] getMuteStatus error:', err && (err.message || err.errMsg) || 'unknown');
return { isMuted: false, remainingMs: 0, remainingText: '' };
}
}
/**
* Submit a content report.
* @param {object} entry
* @param {string} entry.contentId - Unique content identifier
* @param {string} entry.targetUserId - Content author's user ID
* @param {string} entry.contentType - Content type ('chat', 'nickname', 'signature', 'description', 'avatar')
* @param {string} entry.contentSummary - Content summary
* @param {string} entry.reporterId - Reporter's user ID
* @param {string} entry.reason - Report reason
* @returns {Promise<{success: boolean, message: string}>}
*/
async submitReport(entry) {
if (!this._serverUrl) {
return { success: false, message: '举报功能暂不可用' };
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/report`,
method: 'POST',
data: entry,
});
return res;
} catch (err) {
console.error('[ContentSecurity] submitReport error:', err && (err.message || err.errMsg) || 'unknown');
return { success: false, message: '举报提交失败,请稍后再试' };
}
}
/**
* Full text check: local check first, then server-side if local passes.
* @param {string} openid
* @param {string} content
* @param {number} scene
* @returns {Promise<{pass: boolean, localViolation: boolean, serverResult: object|null, errorMessage: string}>}
*/
async fullTextCheck(openid, content, scene) {
// Step 1: Local sensitive word check (fast)
const localResult = this.checkLocalText(content);
if (localResult.hasViolation) {
return {
pass: false,
localViolation: true,
serverResult: null,
errorMessage: this._getErrorMessage({ suggest: 'risky' }, scene),
};
}
// Step 2: Check mute status (for nickname/signature/description scenes)
if (scene === SCENE.NICKNAME || scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
const muteStatus = await this.getMuteStatus(openid);
if (muteStatus.isMuted) {
return {
pass: false,
localViolation: false,
serverResult: null,
errorMessage: '由于违规行为,您暂无法修改个人信息',
};
}
}
// Step 3: Server-side msgSecCheck
const serverResult = await this.checkTextContent(openid, content, scene);
if (!serverResult.pass) {
return {
pass: false,
localViolation: false,
serverResult,
errorMessage: this._getErrorMessage(serverResult, scene),
};
}
return {
pass: true,
localViolation: false,
serverResult,
errorMessage: '',
};
}
/**
* Get appropriate error message based on scene and result.
* @param {object} result
* @param {number} scene
* @returns {string}
*/
_getErrorMessage(result, scene) {
if (scene === SCENE.NICKNAME) {
return '昵称包含违规内容,请重新输入';
}
if (scene === SCENE.CHAT) {
return '消息发送失败:内容违规';
}
if (scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
return '内容包含违规信息,请修改';
}
return '内容违规,请修改';
}
/**
* Make an HTTP request using wx.request.
* @param {object} options
* @returns {Promise<object>}
*/
_request(options) {
return new Promise((resolve, reject) => {
const header = Object.assign({}, options.header || { 'content-type': 'application/json' });
// Add X-Game-Id header for multi-tenant isolation
if (this._gameId) {
header['X-Game-Id'] = this._gameId;
}
wx.request({
url: options.url,
method: options.method || 'GET',
data: options.data || {},
header,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data);
} else {
reject(new Error(`HTTP ${res.statusCode}`));
}
},
fail: (err) => {
reject(err);
},
});
});
}
/**
* Upload image for security check using wx.uploadFile.
* @param {string} filePath - Local file path
* @param {string} openid - User openid
* @returns {Promise<object>}
*/
_uploadImage(filePath, openid) {
return new Promise((resolve, reject) => {
const header = {};
if (this._gameId) {
header['X-Game-Id'] = this._gameId;
}
wx.uploadFile({
url: `${this._serverUrl}/api/content/check-image`,
filePath,
name: 'image',
formData: { openid },
header,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const data = JSON.parse(res.data);
resolve(data);
} catch (e) {
reject(new Error('Invalid response'));
}
} else {
reject(new Error(`HTTP ${res.statusCode}`));
}
},
fail: (err) => {
reject(err);
},
});
});
}
/**
* Load word list from local cache.
*/
_loadFromCache() {
try {
const timestamp = wx.getStorageSync(CACHE_TIMESTAMP_KEY);
const version = wx.getStorageSync(CACHE_VERSION_KEY);
const words = wx.getStorageSync(CACHE_KEY);
if (timestamp && version && words && Array.isArray(words)) {
// Check if cache is still valid
if (Date.now() - timestamp < CACHE_VALIDITY_MS) {
this._words = words;
this._version = version;
console.log('[ContentSecurity] Loaded ' + words.length + ' words from cache (version: ' + version + ')');
}
}
} catch (err) {
console.error('[ContentSecurity] Failed to load cache:', err.message);
}
}
/**
* Save word list to local cache.
*/
_saveToCache() {
try {
wx.setStorageSync(CACHE_KEY, this._words);
wx.setStorageSync(CACHE_VERSION_KEY, this._version);
wx.setStorageSync(CACHE_TIMESTAMP_KEY, Date.now());
} catch (err) {
console.error('[ContentSecurity] Failed to save cache:', err.message);
}
}
/**
* Get current word count.
* @returns {number}
*/
getWordCount() {
return this._words.length;
}
/**
* Get current version.
* @returns {string}
*/
getVersion() {
return this._version;
}
/**
* Check if initialized.
* @returns {boolean}
*/
isInitialized() {
return this._initialized;
}
}
// Export scene and report reason constants
ContentSecurityManager.SCENE = SCENE;
ContentSecurityManager.REPORT_REASONS = REPORT_REASONS;
ContentSecurityManager.REPORT_REASON_LABELS = REPORT_REASON_LABELS;
module.exports = ContentSecurityManager;
+106 -15
View File
@@ -46,17 +46,29 @@ class NetworkManager {
// Generate a unique player ID // Generate a unique player ID
this._playerId = this._generatePlayerId(); this._playerId = this._generatePlayerId();
// Connection mutex: queue of pending connect() callers
/** @type {Array<{resolve: Function, timeoutMs: number}>} */
this._connectQueue = [];
} }
/** /**
* Connect to the WebSocket server. * Connect to the WebSocket server.
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws'). * @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
* @param {number} [timeoutMs=10000] - Connect timeout in milliseconds.
* @returns {Promise<boolean>} Whether connection succeeded. * @returns {Promise<boolean>} Whether connection succeeded.
*/ */
connect(serverUrl) { connect(serverUrl, timeoutMs = 10000) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this._connected || this._connecting) { // If already connected, resolve immediately
resolve(this._connected); if (this._connected) {
resolve(true);
return;
}
// If another connect() is in progress, queue this one and wait
if (this._connecting) {
this._connectQueue.push({ resolve, timeoutMs });
return; return;
} }
@@ -64,20 +76,59 @@ class NetworkManager {
this._connecting = true; this._connecting = true;
this._shouldReconnect = true; this._shouldReconnect = true;
// Guard: make sure resolve is called exactly once.
let settled = false;
const finish = (ok, reason) => {
if (settled) return;
settled = true;
if (connectTimer) {
clearTimeout(connectTimer);
connectTimer = null;
}
if (!ok) {
// Tear down broken socket so next connect() starts clean.
this._connecting = false;
this._shouldReconnect = false; // a first-time failure should NOT auto-reconnect
if (this._ws) {
try { this._ws.close({}); } catch (e) { /* ignore */ }
this._ws = null;
}
console.warn('[NetworkManager] connect() failed:', reason || 'unknown');
}
resolve(ok);
// Resolve all queued connect() calls with the same result
const queue = this._connectQueue;
this._connectQueue = [];
for (const pending of queue) {
pending.resolve(ok);
}
};
// Connection timeout guard (e.g. DNS/TLS hang on cellular).
let connectTimer = setTimeout(() => {
finish(false, `connect timeout after ${timeoutMs}ms, url=${serverUrl}`);
}, timeoutMs);
try { try {
this._ws = wx.connectSocket({ this._ws = wx.connectSocket({
url: serverUrl, url: serverUrl,
header: { 'content-type': 'application/json' }, header: { 'content-type': 'application/json' },
success: (res) => { console.log('[NetworkManager] wx.connectSocket invoked:', res && res.errMsg); },
fail: (err) => {
console.error('[NetworkManager] wx.connectSocket API failed:', JSON.stringify(err));
finish(false, `wx.connectSocket fail: ${err && err.errMsg}`);
},
}); });
this._ws.onOpen(() => { this._ws.onOpen(() => {
console.log('[NetworkManager] Connected to server'); console.log('[NetworkManager] Connected to server:', serverUrl);
this._connected = true; this._connected = true;
this._connecting = false; this._connecting = false;
this._reconnectAttempts = 0; this._reconnectAttempts = 0;
this._startHeartbeat(); this._startHeartbeat();
this._emit('connected'); this._emit('connected');
resolve(true); finish(true);
}); });
this._ws.onMessage((res) => { this._ws.onMessage((res) => {
@@ -85,28 +136,40 @@ class NetworkManager {
}); });
this._ws.onError((err) => { this._ws.onError((err) => {
console.error('[NetworkManager] WebSocket error:', err); console.error('[NetworkManager] WebSocket error:',
this._connecting = false; (err && (err.errMsg || err.message)) || err,
'url=', serverUrl);
this._emit('error', err); this._emit('error', err);
resolve(false); if (!this._connected) {
finish(false, `onError before open: ${err && (err.errMsg || err.message)}`);
} else {
this._connecting = false;
}
}); });
this._ws.onClose((res) => { this._ws.onClose((res) => {
console.log('[NetworkManager] Connection closed:', res.code, res.reason); const code = res && res.code;
const reason = res && res.reason;
console.log('[NetworkManager] Connection closed:', code, reason, 'url=', serverUrl);
const wasConnected = this._connected;
this._connected = false; this._connected = false;
this._connecting = false; this._connecting = false;
this._stopHeartbeat(); this._stopHeartbeat();
this._emit('disconnected', { code: res.code, reason: res.reason }); this._emit('disconnected', { code, reason });
if (!wasConnected) {
finish(false, `onClose before open: code=${code} reason=${reason}`);
return;
}
// Auto-reconnect if needed
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) { if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
this._attemptReconnect(); this._attemptReconnect();
} }
}); });
} catch (e) { } catch (e) {
console.error('[NetworkManager] Failed to create WebSocket:', e); console.error('[NetworkManager] Failed to create WebSocket:', e);
this._connecting = false; finish(false, `exception: ${e && e.message}`);
resolve(false);
} }
}); });
} }
@@ -132,6 +195,13 @@ class NetworkManager {
this._connecting = false; this._connecting = false;
this._roomId = null; this._roomId = null;
this._playerSlot = 0; this._playerSlot = 0;
// Clear connect queue
const queue = this._connectQueue;
this._connectQueue = [];
for (const pending of queue) {
pending.resolve(false);
}
} }
/** /**
@@ -145,10 +215,21 @@ class NetworkManager {
return; return;
} }
// Always include the player's current nickname and avatarUrl so the server
// can propagate them to other clients. Falls back silently when the
// profile is not yet available.
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
const nickname = (profile && profile.nickname) ? profile.nickname : '';
const avatarUrl = (profile && profile.avatarUrl) ? profile.avatarUrl : '';
const skinId = (GameGlobal && GameGlobal.skinManager) ? (GameGlobal.skinManager.getEquippedSkinId() || '') : '';
const message = JSON.stringify({ const message = JSON.stringify({
type, type,
data, data,
playerId: this._playerId, playerId: this._playerId,
nickname,
avatarUrl,
skinId,
roomId: this._roomId, roomId: this._roomId,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@@ -210,10 +291,12 @@ class NetworkManager {
/** /**
* Create a new team for 3v3 mode. * Create a new team for 3v3 mode.
* @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3').
*/ */
createTeam() { createTeam(battleMode = '3v3') {
this.send(NET_MSG.CREATE_TEAM, { this.send(NET_MSG.CREATE_TEAM, {
playerId: this._playerId, playerId: this._playerId,
battleMode,
}); });
} }
@@ -288,10 +371,12 @@ class NetworkManager {
/** /**
* Start solo matchmaking for 3v3. * Start solo matchmaking for 3v3.
* @param {string} [battleMode='3v3'] - Battle mode ('1v1', '2v2', '3v3').
*/ */
soloMatch() { soloMatch(battleMode = '3v3') {
this.send(NET_MSG.SOLO_MATCH, { this.send(NET_MSG.SOLO_MATCH, {
playerId: this._playerId, playerId: this._playerId,
battleMode,
}); });
} }
@@ -514,6 +599,12 @@ class NetworkManager {
return this._playerId; return this._playerId;
} }
/** Player display nickname (may be empty until profile is fetched). */
get nickname() {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
return (profile && profile.nickname) ? profile.nickname : '';
}
/** Current latency in ms. */ /** Current latency in ms. */
get latency() { get latency() {
return this._latency; return this._latency;
+504
View File
@@ -0,0 +1,504 @@
/**
* PlayerProfile.js
* Manages the player's public profile (nickname + avatar url) for the game.
*
* Acquisition strategy (WeChat mini-game, compliant, 2024+):
* 1. Try to read nickname from local storage cache (`playerProfile`).
* 2. If absent, try `wx.getUserInfo({ withCredentials: false })` — this does NOT
* require an authorization popup since 2022 and returns an anonymous
* "微信用户" placeholder + default avatar. That is acceptable as the
* silent fallback.
* 3. The caller (MenuScene) can additionally create a `UserInfoButton`
* and register a click handler via `bindUserInfoButton()` below to
* upgrade the placeholder into the real nickname when the user taps
* the overlay button.
*
* Display helpers:
* - `getDisplayName()` — returns a "safe" display string (nickname if set,
* otherwise "坦克手_XXXX" derived from playerId).
* - `truncate(name, n)` — truncate at n Chinese-equivalent characters.
*/
const STORAGE_KEY = 'playerProfile';
class PlayerProfile {
constructor() {
/** @type {string} Real WeChat nickname, '' if not yet granted. */
this._nickname = '';
/** @type {string} Avatar URL, '' if not yet granted. */
this._avatarUrl = '';
/** @type {boolean} Whether we have attempted a silent fetch at least once. */
this._silentFetched = false;
/** @type {boolean} Whether the real (non-anonymous) nickname has been granted. */
this._granted = false;
this._loadFromCache();
}
// ============================================================
// Public API
// ============================================================
/** @returns {string} the cached nickname, or '' if never set. */
get nickname() {
return this._nickname;
}
/** @returns {string} avatar URL or '' */
get avatarUrl() {
return this._avatarUrl;
}
/** @returns {boolean} whether the user has granted the real nickname. */
get granted() {
return this._granted;
}
/**
* Build a safe display name for UI rendering.
* Order of preference: real nickname > anonymous from last silent fetch
* > deterministic "Tanker_XXXX" fallback based on playerId.
* @param {string} [playerIdFallback] - optional player id for deterministic fallback.
* @returns {string}
*/
getDisplayName(playerIdFallback) {
if (this._nickname) return this._nickname;
if (playerIdFallback && typeof playerIdFallback === 'string') {
// Use the last 4 chars of playerId for a stable anonymous tag.
const tail = playerIdFallback.slice(-4).toUpperCase();
return `Tanker_${tail}`;
}
return 'Tanker';
}
/**
* Truncate a display name to at most `maxChineseChars` Chinese-equivalent chars.
* A Chinese char counts as 1; a latin char counts as 0.5.
* @param {string} name
* @param {number} [maxChineseChars=4]
* @returns {string}
*/
truncate(name, maxChineseChars = 4) {
if (!name) return '';
let widthBudget = maxChineseChars * 2; // in half-width units
let out = '';
for (let i = 0; i < name.length; i++) {
const ch = name.charAt(i);
const code = name.charCodeAt(i);
// Treat CJK + Full-width chars as 2 half-width units, others as 1.
const w = code > 0x7f ? 2 : 1;
if (widthBudget - w < 0) {
return out + '..';
}
widthBudget -= w;
out += ch;
}
return out;
}
/**
* Fetch the user's nickname + avatar following the official WeChat flow:
*
* 1. `wx.getSetting` → check `scope.userInfo`
* 2. Already authorized → `wx.getUserInfo` to get profile directly
* 3. Not authorized → `wx.createUserInfoButton` overlay, wait for tap
*
* @param {object} [layout] - Position/size for the UserInfoButton overlay.
* { x, y, width, height } in CSS pixels (logical pixels).
* If omitted, a default area in the top-left corner is used.
* @returns {Promise<boolean>} true if a real (non-placeholder) nickname was set.
*/
fetchSilent(layout) {
return new Promise((resolve) => {
if (this._granted) {
resolve(true);
return;
}
try {
if (typeof wx === 'undefined') {
resolve(false);
return;
}
// ── Step 1: wx.getSetting — check if scope.userInfo is already granted ──
if (typeof wx.getSetting === 'function') {
console.log('[PlayerProfile] Checking auth setting via wx.getSetting...');
wx.getSetting({
success: (settingRes) => {
const authorized = settingRes && settingRes.authSetting && settingRes.authSetting['scope.userInfo'];
console.log('[PlayerProfile] getSetting result: scope.userInfo =', authorized);
if (authorized) {
// ── Step 2: Already authorized → getUserInfo directly ──
this._fetchViaGetUserInfo(resolve);
} else {
// ── Step 3: Not authorized → create UserInfoButton ──
this._createUserInfoButton(layout, resolve);
}
},
fail: (err) => {
console.warn('[PlayerProfile] wx.getSetting fail:', err && err.errMsg, '— falling back to UserInfoButton');
// Can't determine auth status — create the button as a safe default
this._createUserInfoButton(layout, resolve);
},
});
return;
}
// ── Fallback: no wx.getSetting → try UserInfoButton, then getUserInfo ──
if (typeof wx.createUserInfoButton === 'function') {
this._createUserInfoButton(layout, resolve);
return;
}
if (typeof wx.getUserInfo === 'function') {
this._fetchViaGetUserInfo(resolve);
return;
}
resolve(false);
} catch (e) {
console.warn('[PlayerProfile] fetchSilent error:', e && e.message);
resolve(false);
}
});
}
/**
* Create a transparent UserInfoButton overlay and wait for user tap.
* This is the ONLY way to acquire real profile data from a user who
* has not yet granted scope.userInfo.
* @param {object} [layout] - { x, y, width, height }
* @param {Function} resolve - Promise resolver
* @private
*/
_createUserInfoButton(layout, resolve) {
if (typeof wx.createUserInfoButton !== 'function') {
resolve(false);
return;
}
console.log('[PlayerProfile] Creating UserInfoButton (scope not authorized)...');
const btnLayout = layout || { x: 10, y: 10, width: 120, height: 32 };
const button = wx.createUserInfoButton({
type: 'text',
text: '',
style: {
left: btnLayout.x,
top: btnLayout.y,
width: btnLayout.width,
height: btnLayout.height,
backgroundColor: 'transparent',
borderColor: 'transparent',
color: 'transparent',
fontSize: 1,
borderRadius: 0,
textAlign: 'center',
lineHeight: btnLayout.height,
},
});
button.onTap((res) => {
console.log('[PlayerProfile] UserInfoButton onTap:',
res && res.userInfo ? { nickName: res.userInfo.nickName, hasAvatar: !!res.userInfo.avatarUrl }
: (res && res.errMsg ? { errMsg: res.errMsg } : 'null'));
const u = res && res.userInfo;
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) {
console.log('[PlayerProfile] UserInfoButton returned placeholder or no data — keeping button for retry');
if (u && u.avatarUrl && !this._avatarUrl) {
this._avatarUrl = u.avatarUrl;
this._saveToCache();
this._emitProfileUpdate();
}
// Don't destroy the button — user may tap again later.
if (!this._granted) resolve(false);
return;
}
// Success — destroy the button, we don't need it anymore
try { button.destroy(); } catch (e) { /* ignore */ }
this._userInfoButton = null;
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._granted = true;
this._silentFetched = true;
this._saveToCache();
this._emitProfileUpdate();
console.log('[PlayerProfile] Profile granted via UserInfoButton:', this._nickname);
resolve(true);
});
button.show();
// Store button ref so MenuScene can destroy it on exit
this._userInfoButton = button;
// Timeout: if user never taps, unblock the Promise but keep the button visible
setTimeout(() => {
if (!this._granted && this._userInfoButton === button) {
console.log('[PlayerProfile] UserInfoButton not tapped after 30s — resolving false, button stays visible');
resolve(false);
}
}, 30000);
}
/**
* Deprecated fallback: call wx.getUserInfo.
* On modern WeChat (2022-10+) this returns "scope unauthorized" and
* should NOT be used as the primary acquisition path.
* @param {Function} resolve
* @private
*/
_fetchViaGetUserInfo(resolve) {
if (this._silentFetched) {
resolve(!!this._nickname);
return;
}
this._silentFetched = true;
let resolved = false;
console.log('[PlayerProfile] Calling wx.getUserInfo (deprecated fallback)...');
wx.getUserInfo({
withCredentials: false,
success: (res) => {
if (resolved) return;
resolved = true;
const u = res && res.userInfo;
console.log('[PlayerProfile] wx.getUserInfo success, userInfo:', u ? { nickName: u.nickName, hasAvatar: !!u.avatarUrl } : 'null');
if (u) {
let changed = false;
if (u.nickName && u.nickName !== this._nickname && !this.isPlaceholderName(u.nickName)) {
this._nickname = u.nickName;
this._granted = true;
changed = true;
}
if (u.avatarUrl && u.avatarUrl !== this._avatarUrl) {
this._avatarUrl = u.avatarUrl;
changed = true;
}
if (changed) {
console.log('[PlayerProfile] Profile updated from getUserInfo');
this._saveToCache();
this._emitProfileUpdate();
}
resolve(!!this._nickname);
return;
}
resolve(false);
},
fail: (err) => {
if (resolved) return;
resolved = true;
console.warn('[PlayerProfile] wx.getUserInfo fail:', err && err.errMsg);
resolve(false);
},
});
// Timeout guard
setTimeout(() => {
if (!resolved) {
resolved = true;
console.warn('[PlayerProfile] wx.getUserInfo timed out after 5s');
resolve(false);
}
}, 5000);
}
/**
* Destroy the UserInfoButton overlay if one exists.
* Call this when leaving the MenuScene or when the profile is granted.
*/
destroyUserInfoButton() {
if (this._userInfoButton) {
try {
this._userInfoButton.destroy();
} catch (e) { /* ignore */ }
this._userInfoButton = null;
}
}
/**
* Detect the well-known WeChat anonymous placeholder. Since 2022-10,
* `wx.getUserInfo` / `UserInfoButton` return this string for any user who
* has not explicitly granted profile access — it must NOT be promoted to
* the "granted" state.
* @param {string} name
* @returns {boolean}
*/
isPlaceholderName(name) {
if (!name) return true;
return name === '微信用户' || name === 'WeChat User' || name === 'Weixin User';
}
/**
* Handle the result of a `UserInfoButton` tap. Should be wired in by
* whichever scene created the button (usually MenuScene).
* @param {object} res - the `res` payload passed into the button's onTap callback.
* @returns {boolean} true if a REAL (non-placeholder) nickname was stored.
*/
applyUserInfoResult(res) {
const u = res && res.userInfo;
if (!u || !u.nickName) return false;
// Reject WeChat's anonymous placeholder — it's what `getUserInfo` now
// returns for non-granted users and we must not mark that as "granted".
if (this.isPlaceholderName(u.nickName)) {
console.log('[PlayerProfile] Ignored placeholder nickname from UserInfoButton.');
return false;
}
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._granted = true;
this._saveToCache();
this._emitProfileUpdate();
console.log('[PlayerProfile] Nickname granted:', this._nickname);
return true;
}
/**
* Active authorization flow for WeChat mini-GAMES.
*
* Since 2022-10 `wx.createUserInfoButton` silently returns the "微信用户"
* placeholder, so the ONLY API that still pops a real authorization UI and
* returns the user's actual WeChat nickname on small-game runtimes is
* `wx.getUserProfile`. This call MUST be triggered directly by a user tap
* (a touchend / click handler) — not by any async continuation — or WeChat
* will reject it with `fail api scope is not declared in the privacy agreement`.
*
* @returns {Promise<boolean>} true iff a real nickname was granted.
*/
requestUserProfile() {
return new Promise((resolve) => {
try {
if (typeof wx === 'undefined' || typeof wx.getUserProfile !== 'function') {
resolve(false);
return;
}
const doProfile = () => {
wx.getUserProfile({
desc: '用于在对战中展示你的昵称',
lang: 'zh_CN',
success: (res) => {
const u = res && res.userInfo;
if (!u || !u.nickName || this.isPlaceholderName(u.nickName)) {
console.log('[PlayerProfile] getUserProfile returned placeholder.');
resolve(false);
return;
}
this._nickname = u.nickName;
this._avatarUrl = u.avatarUrl || '';
this._granted = true;
this._saveToCache();
this._emitProfileUpdate();
console.log('[PlayerProfile] Nickname granted via getUserProfile:', this._nickname);
resolve(true);
},
fail: (err) => {
console.log('[PlayerProfile] getUserProfile fail:', err && err.errMsg);
resolve(false);
},
});
};
// Call wx.getUserProfile directly. If the user hasn't authorized
// privacy yet, WeChat will trigger wx.onNeedPrivacyAuthorization
// (registered in game.js) which shows our PrivacyPopup, then
// automatically retries the pending API call after the user agrees.
//
// We do NOT call wx.requirePrivacyAuthorize because it is unreliable
// in WeChat mini-games — see fetchSilent() comments for details.
doProfile();
} catch (e) {
console.warn('[PlayerProfile] requestUserProfile error:', e && e.message);
resolve(false);
}
});
}
/**
* Manual nickname fallback — used when `getUserProfile` is unavailable or
* denied (e.g. user refused, API deprecated). Stores the user-typed name
* and marks the profile as "granted" so the button stops prompting.
* @param {string} name
* @returns {boolean}
*/
setManualNickname(name) {
if (!name || typeof name !== 'string') return false;
const trimmed = name.trim();
if (!trimmed || this.isPlaceholderName(trimmed)) return false;
this._nickname = trimmed.slice(0, 16); // hard cap to 16 chars
this._granted = true;
this._saveToCache();
console.log('[PlayerProfile] Nickname set manually:', this._nickname);
return true;
}
/**
* Clear the cached profile (e.g. user wants to re-auth).
*/
reset() {
this._nickname = '';
this._avatarUrl = '';
this._granted = false;
try { wx.removeStorageSync(STORAGE_KEY); } catch (e) { /* ignore */ }
}
// ============================================================
// Private
// ============================================================
_loadFromCache() {
try {
const raw = wx.getStorageSync(STORAGE_KEY);
if (raw && typeof raw === 'object') {
this._nickname = raw.nickname || '';
this._avatarUrl = raw.avatarUrl || '';
this._granted = !!raw.granted;
}
} catch (e) {
// Ignore storage errors.
}
}
_saveToCache() {
try {
wx.setStorageSync(STORAGE_KEY, {
nickname: this._nickname,
avatarUrl: this._avatarUrl,
granted: this._granted,
});
} catch (e) {
// Ignore storage errors.
}
}
/**
* Emit a `profile:updated` event via the global EventBus so that
* other systems (e.g. NetworkManager, TeamRoomScene) can react to
* nickname / avatarUrl changes — typically by re-sending the latest
* profile data to the server so peers see the updated avatar.
* @private
*/
_emitProfileUpdate() {
try {
const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null;
if (bus && typeof bus.emit === 'function') {
bus.emit('profile:updated', {
nickname: this._nickname,
avatarUrl: this._avatarUrl,
granted: this._granted,
});
}
} catch (e) {
// Non-critical — event emission should never break the game.
}
}
}
module.exports = PlayerProfile;
+143 -3
View File
@@ -11,6 +11,8 @@ class ShareManager {
imageUrl: '', imageUrl: '',
query: '', query: '',
}; };
// Cached temp file path from last canvas capture
this._cachedImageUrl = '';
// Register share menu and callback ONCE at startup. // Register share menu and callback ONCE at startup.
// The callback reads this._shareContent dynamically so it always // The callback reads this._shareContent dynamically so it always
@@ -20,7 +22,7 @@ class ShareManager {
if (wx.showShareMenu) { if (wx.showShareMenu) {
wx.showShareMenu({ wx.showShareMenu({
withShareTicket: true, withShareTicket: true,
menus: ['shareAppMessage'], menus: ['shareAppMessage', 'shareTimeline'],
}); });
} }
if (wx.onShareAppMessage) { if (wx.onShareAppMessage) {
@@ -28,7 +30,7 @@ class ShareManager {
console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query); console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query);
return { return {
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!', title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
imageUrl: this._shareContent.imageUrl || '', imageUrl: this._shareContent.imageUrl || this._cachedImageUrl || '',
query: this._shareContent.query || '', query: this._shareContent.query || '',
}; };
}); });
@@ -39,6 +41,137 @@ class ShareManager {
} }
} }
/**
* Generate a share image from the current game canvas.
* Outputs a 5:4 portrait-ratio image so WeChat's share card shows
* the full screen without cropping.
*
* Strategy:
* 1. Try offscreen canvas redraw (ideal — contain-fit into 5:4)
* 2. Fallback: direct capture with portrait dest dimensions
*
* @param {function} [callback] - Called with (tempFilePath) on success.
*/
generateShareImage(callback) {
var self = this;
try {
const srcCanvas = GameGlobal && GameGlobal.canvas;
if (!srcCanvas || typeof wx === 'undefined' || !wx.canvasToTempFilePath) {
if (callback) callback();
return;
}
const DPR = GameGlobal.DEVICE_PIXEL_RATIO || 1;
const SHARE_W = 500;
const SHARE_H = 625; // 5:4 portrait for WeChat share card
const srcW = srcCanvas.width / DPR;
const srcH = srcCanvas.height / DPR;
// --- Try offscreen canvas approach first ---
var offCanvas = null;
try {
offCanvas = wx.createCanvas && wx.createCanvas();
} catch (e2) {
offCanvas = null;
}
if (offCanvas) {
try {
offCanvas.width = SHARE_W;
offCanvas.height = SHARE_H;
const offCtx = offCanvas.getContext('2d');
if (offCtx) {
// Dark background
offCtx.fillStyle = '#0a0e1a';
offCtx.fillRect(0, 0, SHARE_W, SHARE_H);
// Contain-fit source into portrait frame
const srcRatio = srcW / srcH;
const dstRatio = SHARE_W / SHARE_H;
var drawW, drawH, dx, dy;
if (srcRatio > dstRatio) {
drawW = SHARE_W;
drawH = SHARE_W / srcRatio;
dx = 0;
dy = (SHARE_H - drawH) / 2;
} else {
drawH = SHARE_H;
drawW = SHARE_H * srcRatio;
dx = (SHARE_W - drawW) / 2;
dy = 0;
}
offCtx.drawImage(srcCanvas, dx, dy, drawW, drawH);
// Export offscreen canvas
wx.canvasToTempFilePath({
canvas: offCanvas,
width: SHARE_W,
height: SHARE_H,
destWidth: SHARE_W,
destHeight: SHARE_H,
fileType: 'png',
quality: 0.92,
success: function(res) {
if (res.tempFilePath) {
console.log('[ShareManager] Share image (offscreen):', res.tempFilePath);
self._cachedImageUrl = res.tempFilePath;
if (callback) callback(res.tempFilePath);
} else {
self._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
}
},
fail: function() {
self._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
},
});
return; // done via offscreen path
}
} catch (e3) {
console.warn('[ShareManager] Offscreen canvas draw failed:', e3);
}
}
// --- Fallback: direct canvas capture with portrait output ---
this._fallbackDirectCapture(srcCanvas, srcW, srcH, SHARE_W, SHARE_H, callback);
} catch (e) {
console.warn('[ShareManager] generateShareImage error:', e);
if (callback) callback();
}
}
/**
* Fallback: export main canvas directly with portrait dest dimensions.
*/
_fallbackDirectCapture(canvas, srcW, srcH, outW, outH, callback) {
try {
wx.canvasToTempFilePath({
canvas: canvas,
width: srcW,
height: srcH,
destWidth: outW,
destHeight: outH,
fileType: 'png',
quality: 0.92,
success: function(res) {
if (res.tempFilePath) {
console.log('[ShareManager] Share image (direct):', res.tempFilePath);
this._cachedImageUrl = res.tempFilePath;
}
if (callback) callback(res && res.tempFilePath ? res.tempFilePath : undefined);
}.bind(this),
fail: function(err) {
console.warn('[ShareManager] Direct canvasToTempFilePath failed:', err);
if (callback) callback();
},
});
} catch (e) {
console.warn('[ShareManager] _fallbackDirectCapture error:', e);
if (callback) callback();
}
}
/** /**
* Update open data for friend ranking. * Update open data for friend ranking.
* @param {number} score * @param {number} score
@@ -90,6 +223,10 @@ class ShareManager {
setShareContent(opts) { setShareContent(opts) {
this._shareContent = opts || {}; this._shareContent = opts || {};
console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent)); console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent));
// Auto-generate canvas screenshot as share image if none provided
if (!this._shareContent.imageUrl && !this._cachedImageUrl) {
this.generateShareImage();
}
// Re-register callback to ensure WeChat picks up the new content // Re-register callback to ensure WeChat picks up the new content
this._refreshShareCallback(); this._refreshShareCallback();
} }
@@ -106,6 +243,9 @@ class ShareManager {
// Update passive share callback (right-corner ··· menu fallback) // Update passive share callback (right-corner ··· menu fallback)
this.setShareContent(data); this.setShareContent(data);
// Ensure we have a share image — generate synchronously-style via cache
const imageUrl = data.imageUrl || this._cachedImageUrl || '';
// Directly invoke wx.shareAppMessage() to open the friend-picker panel. // Directly invoke wx.shareAppMessage() to open the friend-picker panel.
// This is permitted because triggerShare is called from a touchstart handler. // This is permitted because triggerShare is called from a touchstart handler.
try { try {
@@ -113,7 +253,7 @@ class ShareManager {
console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query); console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query);
wx.shareAppMessage({ wx.shareAppMessage({
title: data.title || '', title: data.title || '',
imageUrl: data.imageUrl || '', imageUrl: imageUrl,
query: data.query || '', query: data.query || '',
}); });
} }
+50 -9
View File
@@ -4,6 +4,12 @@
* Skins are cosmetic-only color schemes purchased with gold. * Skins are cosmetic-only color schemes purchased with gold.
*/ */
/**
* DEV MODE: Set to true to unlock all skins for testing.
* ⚠️ MUST be set to false before publishing!
*/
const DEV_UNLOCK_ALL = false;
/** Skin definitions with id, name, cost, and color scheme. */ /** Skin definitions with id, name, cost, and color scheme. */
const SKINS = { const SKINS = {
default: { default: {
@@ -45,15 +51,15 @@ const SKINS = {
id: 'neon', id: 'neon',
nameKey: 'skin.neon', nameKey: 'skin.neon',
cost: 2000, cost: 2000,
colors: { body: '#00FF7F', turret: '#00CED1', track: '#008B8B' }, colors: { body: '#FF1493', turret: '#FF6EC7', track: '#C71585' },
preview: '#00FF7F', preview: '#FF1493',
}, },
shadow: { nebula: {
id: 'shadow', id: 'nebula',
nameKey: 'skin.shadow', nameKey: 'skin.nebula',
cost: 3000, cost: 3000,
colors: { body: '#2C2C2C', turret: '#1A1A1A', track: '#0D0D0D' }, colors: { body: '#6A0DAD', turret: '#FF00FF', track: '#3D0066' },
preview: '#2C2C2C', preview: '#6A0DAD',
}, },
royal: { royal: {
id: 'royal', id: 'royal',
@@ -62,10 +68,31 @@ const SKINS = {
colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' }, colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' },
preview: '#FFD700', preview: '#FFD700',
}, },
sakura: {
id: 'sakura',
nameKey: 'skin.sakura',
cost: 3500,
colors: { body: '#FFB7C5', turret: '#FF69B4', track: '#C44D78' },
preview: '#FFB7C5',
},
thunder: {
id: 'thunder',
nameKey: 'skin.thunder',
cost: 4000,
colors: { body: '#1E90FF', turret: '#00BFFF', track: '#0A5E9C' },
preview: '#1E90FF',
},
diamond: {
id: 'diamond',
nameKey: 'skin.diamond',
cost: 8000,
colors: { body: '#E0F7FF', turret: '#7DF9FF', track: '#5B8FA8' },
preview: '#7DF9FF',
},
}; };
/** Ordered list of skin IDs for display. */ /** Ordered list of skin IDs for display. */
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'shadow', 'royal']; const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'nebula', 'royal', 'sakura', 'thunder', 'diamond'];
class SkinManager { class SkinManager {
constructor() { constructor() {
@@ -131,6 +158,7 @@ class SkinManager {
* @returns {boolean} * @returns {boolean}
*/ */
isUnlocked(skinId) { isUnlocked(skinId) {
if (DEV_UNLOCK_ALL) return true;
return this._unlocked.has(skinId); return this._unlocked.has(skinId);
} }
@@ -180,6 +208,19 @@ class SkinManager {
return { success: false, error: 'Already unlocked' }; return { success: false, error: 'Already unlocked' };
} }
// Dev mode: unlock for free without spending gold
if (DEV_UNLOCK_ALL) {
this._unlocked.add(skinId);
this._save();
console.log(`[SkinManager][DEV] Free unlock skin: ${skinId}`);
try {
if (GameGlobal.eventBus) {
GameGlobal.eventBus.emit('skin:purchased', { id: skinId, cost: 0 });
}
} catch (e) {}
return { success: true };
}
const cm = GameGlobal.currencyManager; const cm = GameGlobal.currencyManager;
if (!cm || !cm.hasGold(skin.cost)) { if (!cm || !cm.hasGold(skin.cost)) {
return { success: false, error: 'Insufficient gold' }; return { success: false, error: 'Insufficient gold' };
@@ -215,7 +256,7 @@ class SkinManager {
return { success: false, error: 'Invalid skin' }; return { success: false, error: 'Invalid skin' };
} }
if (!this._unlocked.has(skinId)) { if (!DEV_UNLOCK_ALL && !this._unlocked.has(skinId)) {
return { success: false, error: 'Not unlocked' }; return { success: false, error: 'Not unlocked' };
} }
+4 -1
View File
@@ -200,7 +200,10 @@ const BuffSelectScene = {
const GameScene = require('./GameScene'); const GameScene = require('./GameScene');
sm.register(SCENE.GAME, GameScene); sm.register(SCENE.GAME, GameScene);
} }
sm.switchTo(SCENE.GAME, this._gameParams); // ★ DEBUG: Force level 20 (Boss Battle) for quick verification of Boss tank gap-fix
// const params = Object.assign({}, this._gameParams, { level: 20 });
const params = Object.assign({}, this._gameParams);
sm.switchTo(SCENE.GAME, params);
}, },
handleTouch(eventType, e) { handleTouch(eventType, e) {
+548
View File
@@ -0,0 +1,548 @@
/**
* ChatRoomScene.js
* Chat room scene for in-game communication.
* All messages are checked against content security before sending.
* Muted users cannot send messages.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ContentSecurityManager = require('../managers/ContentSecurityManager');
// ============================================================
// Layout Constants
// ============================================================
const CENTER_X = SCREEN_WIDTH / 2;
const INPUT_HEIGHT = 36;
const SEND_BTN_WIDTH = 60;
const MAX_VISIBLE_MESSAGES = 50;
const MESSAGE_AREA_TOP = 50;
const MESSAGE_AREA_BOTTOM = SCREEN_HEIGHT - 60;
// ============================================================
// Chat Room Scene
// ============================================================
const ChatRoomScene = {
// Chat state
_messages: [],
_inputText: '',
_errorMessage: '',
_isSending: false,
_localViolation: false,
_isMuted: false,
_muteRemainingText: '',
_scrollOffset: 0,
// User info
_openid: '',
_nickname: '',
// Touch rects
_backBtnRect: null,
_inputRect: null,
_sendBtnRect: null,
_reportTargetMsgIdx: -1,
// Report overlay state
_showReportOverlay: false,
_reportContentId: '',
_reportTargetUserId: '',
_reportContentSummary: '',
enter() {
this._messages = [];
this._inputText = '';
this._errorMessage = '';
this._isSending = false;
this._localViolation = false;
this._isMuted = false;
this._muteRemainingText = '';
this._scrollOffset = 0;
this._showReportOverlay = false;
// Load user info
try {
this._openid = wx.getStorageSync('player_openid') || '';
const profile = wx.getStorageSync('player_profile');
if (profile) {
const parsed = JSON.parse(profile);
this._nickname = parsed.nickname || '玩家';
}
} catch (e) {
this._nickname = '玩家';
}
// Calculate layouts
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
this._inputRect = {
x: 10,
y: SCREEN_HEIGHT - INPUT_HEIGHT - 10,
w: SCREEN_WIDTH - SEND_BTN_WIDTH - 30,
h: INPUT_HEIGHT,
};
this._sendBtnRect = {
x: SCREEN_WIDTH - SEND_BTN_WIDTH - 10,
y: SCREEN_HEIGHT - INPUT_HEIGHT - 10,
w: SEND_BTN_WIDTH,
h: INPUT_HEIGHT,
};
// Check mute status
this._checkMuteStatus();
},
exit() {
wx.hideKeyboard();
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title bar
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, SCREEN_WIDTH, 45);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.title'), CENTER_X, 22);
// Back button
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillText('← ' + t('common.back'), 15, 22);
// Messages area
this._renderMessages(ctx);
// Input area
this._renderInputArea(ctx);
// Error message
if (this._errorMessage) {
ctx.fillStyle = '#FF4444';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMessage, CENTER_X, SCREEN_HEIGHT - INPUT_HEIGHT - 25);
}
// Report overlay
if (this._showReportOverlay) {
this._renderReportOverlay(ctx);
}
},
handleTouch(type, e) {
if (type !== 'touchstart') return;
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
// Report overlay handling
if (this._showReportOverlay) {
this._handleReportOverlayTouch(x, y);
return;
}
// Back button
if (this._hitTest(x, y, this._backBtnRect)) {
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
// Input field - show keyboard
if (this._hitTest(x, y, this._inputRect)) {
if (this._isMuted) {
this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`;
return;
}
this._showKeyboard();
return;
}
// Send button
if (this._hitTest(x, y, this._sendBtnRect)) {
this._handleSend();
return;
}
// Long press on message for reporting
// (Simplified: double-tap to report for now)
},
// ============================================================
// Message Sending
// ============================================================
_showKeyboard() {
wx.showKeyboard({
defaultValue: this._inputText,
maxLength: 200,
multiple: false,
confirmHold: false,
confirmType: 'send',
});
this._onKeyboardInput = (res) => {
this._inputText = res.value;
// Real-time local sensitive word check
if (this._inputText) {
const csm = GameGlobal.contentSecurityManager;
if (csm && csm.isInitialized()) {
const localCheck = csm.checkLocalText(this._inputText);
if (localCheck.hasViolation) {
this._localViolation = true;
this._errorMessage = '内容包含违规信息,请修改';
} else {
this._localViolation = false;
this._errorMessage = '';
}
}
}
};
this._onKeyboardConfirm = () => {
this._handleSend();
};
wx.onKeyboardInput(this._onKeyboardInput);
wx.onKeyboardConfirm(this._onKeyboardConfirm);
},
_hideKeyboard() {
if (this._onKeyboardInput) {
wx.offKeyboardInput(this._onKeyboardInput);
this._onKeyboardInput = null;
}
if (this._onKeyboardConfirm) {
wx.offKeyboardConfirm(this._onKeyboardConfirm);
this._onKeyboardConfirm = null;
}
wx.hideKeyboard();
},
async _handleSend() {
if (this._isSending || this._localViolation) return;
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
const content = this._inputText.trim();
if (!content) return;
// Check mute status first
if (this._isMuted) {
this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`;
return;
}
// Local check
const localCheck = csm.checkLocalText(content);
if (localCheck.hasViolation) {
this._errorMessage = '内容包含违规信息,请修改';
return;
}
this._isSending = true;
this._errorMessage = '';
// Server-side check
const result = await csm.fullTextCheck(this._openid, content, ContentSecurityManager.SCENE.CHAT);
this._isSending = false;
if (result.pass) {
// Add message to list
this._messages.push({
id: `msg_${Date.now()}`,
userId: this._openid,
nickname: this._nickname,
content,
timestamp: Date.now(),
isLocal: true,
});
// Keep only last MAX_VISIBLE_MESSAGES
if (this._messages.length > MAX_VISIBLE_MESSAGES) {
this._messages = this._messages.slice(-MAX_VISIBLE_MESSAGES);
}
this._inputText = '';
this._errorMessage = '';
this._hideKeyboard();
// Scroll to bottom
this._scrollOffset = 0;
} else {
this._errorMessage = result.errorMessage;
}
},
// ============================================================
// Mute Status Check
// ============================================================
async _checkMuteStatus() {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized() || !this._openid) return;
try {
const status = await csm.getMuteStatus(this._openid);
this._isMuted = status.isMuted;
this._muteRemainingText = status.remainingText || '';
} catch (e) {
// Assume not muted on error
this._isMuted = false;
}
},
// ============================================================
// Reporting
// ============================================================
_showReportForMessage(msgIdx) {
if (msgIdx < 0 || msgIdx >= this._messages.length) return;
const msg = this._messages[msgIdx];
if (msg.isLocal) return; // Can't report own messages
this._reportContentId = msg.id;
this._reportTargetUserId = msg.userId;
this._reportContentSummary = msg.content.substring(0, 20);
this._showReportOverlay = true;
},
async _submitReport(reason) {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
const result = await csm.submitReport({
contentId: this._reportContentId,
targetUserId: this._reportTargetUserId,
contentType: 'chat',
contentSummary: this._reportContentSummary,
reporterId: this._openid,
reason,
});
this._showReportOverlay = false;
if (result.success) {
wx.showToast({ title: '举报已提交', icon: 'success' });
} else {
wx.showToast({ title: result.message || '举报失败', icon: 'none' });
}
},
// ============================================================
// Render Helpers
// ============================================================
_renderMessages(ctx) {
const areaTop = MESSAGE_AREA_TOP;
const areaBottom = MESSAGE_AREA_BOTTOM;
const padding = 10;
const msgHeight = 40;
const startY = areaBottom - msgHeight;
// Clip to message area
ctx.save();
ctx.beginPath();
ctx.rect(0, areaTop, SCREEN_WIDTH, areaBottom - areaTop);
ctx.clip();
// Render messages from bottom to top
let y = startY - this._scrollOffset;
for (let i = this._messages.length - 1; i >= 0; i--) {
const msg = this._messages[i];
if (y < areaTop - msgHeight || y > areaBottom) {
y -= msgHeight;
continue;
}
const isLocal = msg.isLocal;
const msgX = isLocal ? SCREEN_WIDTH - padding - 200 : padding;
// Message bubble
ctx.fillStyle = isLocal ? 'rgba(74, 144, 226, 0.3)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(msgX, y, 200, msgHeight - 4);
// Border
ctx.strokeStyle = isLocal ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(msgX, y, 200, msgHeight - 4);
// Nickname
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(msg.nickname, msgX + 6, y + 3);
// Content
ctx.fillStyle = '#FFFFFF';
ctx.font = '11px Arial';
const contentPreview = msg.content.length > 25 ? msg.content.substring(0, 25) + '...' : msg.content;
ctx.fillText(contentPreview, msgX + 6, y + 18);
// Report button for non-local messages
if (!isLocal) {
ctx.fillStyle = '#FF6347';
ctx.font = '9px Arial';
ctx.textAlign = 'right';
ctx.fillText('⚠举报', msgX + 196, y + 3);
}
y -= msgHeight;
}
ctx.restore();
// Scroll hint if there are more messages
if (this._messages.length > 0 && this._scrollOffset > 0) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('↑ 向上滚动查看更多', CENTER_X, areaTop + 10);
}
},
_renderInputArea(ctx) {
// Input box background
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h);
ctx.strokeStyle = this._localViolation ? '#FF4444' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h);
// Input text or placeholder
ctx.fillStyle = this._inputText ? '#FFFFFF' : '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = this._inputText || (this._isMuted ? '您已被禁言' : t('chat.inputPlaceholder'));
const truncated = displayText.length > 25 ? displayText.substring(0, 25) + '...' : displayText;
ctx.fillText(truncated, this._inputRect.x + 8, this._inputRect.y + this._inputRect.h / 2);
// Send button
const canSend = this._inputText.trim() && !this._localViolation && !this._isSending && !this._isMuted;
ctx.fillStyle = canSend ? '#4a90d9' : '#555555';
ctx.fillRect(this._sendBtnRect.x, this._sendBtnRect.y, this._sendBtnRect.w, this._sendBtnRect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this._isSending ? '...' : t('chat.send'), this._sendBtnRect.x + this._sendBtnRect.w / 2, this._sendBtnRect.y + this._sendBtnRect.h / 2);
},
_renderReportOverlay(ctx) {
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Report dialog
const dialogW = Math.min(SCREEN_WIDTH * 0.8, 300);
const dialogH = 220;
const dialogX = CENTER_X - dialogW / 2;
const dialogY = SCREEN_HEIGHT / 2 - dialogH / 2;
ctx.fillStyle = '#2a2a3e';
ctx.fillRect(dialogX, dialogY, dialogW, dialogH);
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 2;
ctx.strokeRect(dialogX, dialogY, dialogW, dialogH);
// Title
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.reportTitle'), CENTER_X, dialogY + 25);
// Report reason buttons
const reasons = [
{ key: 'politics', label: t('chat.reportPolitics') },
{ key: 'pornography', label: t('chat.reportPornography') },
{ key: 'gambling', label: t('chat.reportGambling') },
{ key: 'other', label: t('chat.reportOther') },
];
const btnW = dialogW - 40;
const btnH = 30;
let btnY = dialogY + 55;
this._reportBtnRects = [];
for (const reason of reasons) {
const rect = { x: dialogX + 20, y: btnY, w: btnW, h: btnH };
this._reportBtnRects.push({ rect, key: reason.key });
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(reason.label, rect.x + rect.w / 2, rect.y + rect.h / 2);
btnY += btnH + 8;
}
// Cancel button
const cancelRect = { x: CENTER_X - 50, y: dialogY + dialogH - 35, w: 100, h: 25 };
this._reportCancelRect = cancelRect;
ctx.fillStyle = '#666666';
ctx.fillRect(cancelRect.x, cancelRect.y, cancelRect.w, cancelRect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.reportCancel'), cancelRect.x + cancelRect.w / 2, cancelRect.y + cancelRect.h / 2);
},
_handleReportOverlayTouch(x, y) {
// Check report reason buttons
if (this._reportBtnRects) {
for (const btn of this._reportBtnRects) {
if (this._hitTest(x, y, btn.rect)) {
this._submitReport(btn.key);
return;
}
}
}
// Cancel button
if (this._reportCancelRect && this._hitTest(x, y, this._reportCancelRect)) {
this._showReportOverlay = false;
return;
}
},
// ============================================================
// Utility
// ============================================================
_hitTest(x, y, rect) {
return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
},
};
module.exports = ChatRoomScene;
+34 -14
View File
@@ -79,6 +79,7 @@ const GameScene = {
// Revive ad state // Revive ad state
_reviveAdUsed: false, _reviveAdUsed: false,
_reviveCount: 0, // Track revive count for escalating cost
_showingReviveDialog: false, _showingReviveDialog: false,
_reviveDialogButtons: null, _reviveDialogButtons: null,
@@ -97,6 +98,7 @@ const GameScene = {
this._gameOverDelay = 0; this._gameOverDelay = 0;
this._cachedBasePos = null; this._cachedBasePos = null;
this._reviveAdUsed = false; this._reviveAdUsed = false;
this._reviveCount = 0;
this._showingReviveDialog = false; this._showingReviveDialog = false;
this._reviveDialogButtons = null; this._reviveDialogButtons = null;
@@ -138,6 +140,15 @@ const GameScene = {
}); });
this._playerTank.activateShield(3000); // spawn protection this._playerTank.activateShield(3000); // spawn protection
// Apply equipped skin — only non-default skins override tank color
if (GameGlobal.skinManager) {
const skinId = GameGlobal.skinManager.getEquippedSkinId();
if (skinId && skinId !== 'default') {
this._playerTank._skinColors = GameGlobal.skinManager.getCurrentSkinColors();
this._playerTank._skinId = skinId;
}
}
// Safety: ensure player spawn area is clear of blocking terrain // Safety: ensure player spawn area is clear of blocking terrain
this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row); this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row);
@@ -471,14 +482,15 @@ const GameScene = {
ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2); ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2);
} }
// Gold Revive button (orange) // Gold Revive button (orange) - show escalating cost
if (btns.goldRevive) { if (btns.goldRevive) {
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(200); const reviveCost = this._getReviveCost();
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(reviveCost);
ctx.fillStyle = hasGold ? '#FF9800' : '#555555'; ctx.fillStyle = hasGold ? '#FF9800' : '#555555';
ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h); ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h);
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial'; ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.goldRevive') || '🪙 Gold Revive (200)', btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2); ctx.fillText(`🪙 ${t('ad.goldRevive') || 'Gold Revive'} (${reviveCost})`, btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
} }
// Give Up button (gray) // Give Up button (gray)
@@ -680,13 +692,8 @@ const GameScene = {
const hasLives = this._playerTank.die(); const hasLives = this._playerTank.die();
if (!hasLives) { if (!hasLives) {
// Check if revive ad is available and not yet used this level // Always show revive dialog (escalating cost each time)
if (!this._reviveAdUsed) {
// Always show revive dialog (with ad and/or gold options)
this._showReviveAdDialog(); this._showReviveAdDialog();
} else {
this._triggerGameOver();
}
} }
}, },
@@ -701,6 +708,18 @@ const GameScene = {
return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE); return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE);
}, },
/**
* Get the current revive gold cost based on revive count (escalating).
* 1st revive: 200, 2nd: 400, 3rd+: 800
* @returns {number}
* @private
*/
_getReviveCost() {
if (this._reviveCount === 0) return 200;
if (this._reviveCount === 1) return 400;
return 800;
},
/** /**
* Show the revive dialog overlay with dual options. * Show the revive dialog overlay with dual options.
* Pauses the game and presents watch-ad / gold-revive / give-up options. * Pauses the game and presents watch-ad / gold-revive / give-up options.
@@ -736,17 +755,18 @@ const GameScene = {
}, },
/** /**
* Handle the player choosing to revive with gold (200 gold). * Handle the player choosing to revive with gold (escalating cost).
* @private * @private
*/ */
_onGoldRevive() { _onGoldRevive() {
const cost = this._getReviveCost();
const cm = GameGlobal.currencyManager; const cm = GameGlobal.currencyManager;
if (cm && cm.spendGold(200)) { if (cm && cm.spendGold(cost)) {
this._showingReviveDialog = false; this._showingReviveDialog = false;
this._reviveDialogButtons = null; this._reviveDialogButtons = null;
this._reviveAdUsed = true; this._reviveCount++;
this._revivePlayer(); this._revivePlayer();
console.log('[GameScene] Player revived via gold (200)'); console.log(`[GameScene] Player revived via gold (${cost}), revive #${this._reviveCount}`);
} }
}, },
@@ -762,7 +782,7 @@ const GameScene = {
this._showingReviveDialog = false; this._showingReviveDialog = false;
this._reviveDialogButtons = null; this._reviveDialogButtons = null;
if (completed) { if (completed) {
this._reviveAdUsed = true; this._reviveCount++;
this._revivePlayer(); this._revivePlayer();
} else { } else {
this._triggerGameOver(); this._triggerGameOver();
+1174 -132
View File
File diff suppressed because it is too large Load Diff
+755
View File
@@ -0,0 +1,755 @@
/**
* ProfileScene.js
* Player profile editing scene.
* Supports nickname, signature, description editing and avatar upload.
* All user-generated content is checked against content security before saving.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ContentSecurityManager = require('../managers/ContentSecurityManager');
// ============================================================
// Layout Constants
// ============================================================
const CENTER_X = SCREEN_WIDTH / 2;
const INPUT_WIDTH = Math.min(SCREEN_WIDTH * 0.7, 280);
const INPUT_HEIGHT = 36;
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 200);
const BTN_HEIGHT = 36;
// Field constraints
const NICKNAME_MIN = 2;
const NICKNAME_MAX = 20;
const SIGNATURE_MAX = 50;
const DESCRIPTION_MAX = 200;
// Profile field IDs
const FIELD = {
NICKNAME: 'nickname',
SIGNATURE: 'signature',
DESCRIPTION: 'description',
AVATAR: 'avatar',
};
// ============================================================
// Profile Scene
// ============================================================
const ProfileScene = {
// Current editing state
_editingField: null, // Which field is being edited
_inputText: '', // Current input text
_originalText: '', // Original text before editing (for change detection)
_errorMessage: '', // Error message to display
_isSubmitting: false, // Whether a submission is in progress
_localViolation: false, // Whether local check found violation
// User profile data
_profile: {
nickname: '',
signature: '',
description: '',
avatarUrl: '',
},
// Open ID for API calls
_openid: '',
// Button rects for touch handling
_backBtnRect: null,
_nicknameRect: null,
_signatureRect: null,
_descriptionRect: null,
_avatarRect: null,
_saveBtnRect: null,
_cancelBtnRect: null,
enter() {
this._editingField = null;
this._inputText = '';
this._errorMessage = '';
this._isSubmitting = false;
this._localViolation = false;
// Load profile from storage
this._loadProfile();
// Get openid
try {
this._openid = wx.getStorageSync('player_openid') || '';
} catch (e) {
this._openid = '';
}
// Calculate button positions
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
this._avatarRect = {
x: CENTER_X - 40,
y: 70,
w: 80,
h: 80,
};
this._nicknameRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 175,
w: INPUT_WIDTH,
h: INPUT_HEIGHT,
};
this._signatureRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 240,
w: INPUT_WIDTH,
h: INPUT_HEIGHT,
};
this._descriptionRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 305,
w: INPUT_WIDTH,
h: 80,
};
this._saveBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT - 100,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
exit() {
// Save profile to storage
this._saveProfile();
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = CENTER_X;
// Back button
this._renderBackButton(ctx);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('profile.title'), cx, 50);
// Avatar
this._renderAvatar(ctx);
// Nickname field
this._renderField(ctx, this._nicknameRect, t('profile.nickname'), this._profile.nickname, FIELD.NICKNAME);
// Signature field
this._renderField(ctx, this._signatureRect, t('profile.signature'), this._profile.signature, FIELD.SIGNATURE);
// Description field
this._renderDescriptionField(ctx);
// Error message (only show when NOT editing to avoid overlap)
if (this._errorMessage && !this._editingField) {
ctx.fillStyle = '#FF4444';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMessage, cx, SCREEN_HEIGHT - 140);
}
// Editing overlay (includes save/cancel buttons)
if (this._editingField) {
this._renderEditOverlay(ctx);
}
},
handleTouch(type, e) {
if (type !== 'touchstart') return;
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
// If editing, handle save/cancel in overlay
if (this._editingField) {
this._handleEditOverlayTouch(x, y);
return;
}
// Back button
if (this._hitTest(x, y, this._backBtnRect)) {
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
// Avatar click
if (this._hitTest(x, y, this._avatarRect)) {
this._handleChangeAvatar();
return;
}
// Nickname click
if (this._hitTest(x, y, this._nicknameRect)) {
this._startEditing(FIELD.NICKNAME, this._profile.nickname);
return;
}
// Signature click
if (this._hitTest(x, y, this._signatureRect)) {
this._startEditing(FIELD.SIGNATURE, this._profile.signature);
return;
}
// Description click
if (this._hitTest(x, y, this._descriptionRect)) {
this._startEditing(FIELD.DESCRIPTION, this._profile.description);
return;
}
},
_startEditing(field, currentValue) {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
this._editingField = field;
this._inputText = currentValue || '';
this._originalText = currentValue || '';
this._errorMessage = '';
this._localViolation = false;
// Determine input constraints
let maxLength = 200;
if (field === FIELD.NICKNAME) maxLength = NICKNAME_MAX;
if (field === FIELD.SIGNATURE) maxLength = SIGNATURE_MAX;
if (field === FIELD.DESCRIPTION) maxLength = DESCRIPTION_MAX;
// Show keyboard with empty defaultValue, so the native "Done" button highlights
// as soon as user types any character. The original value is preserved in
// _originalText and shown in the overlay preview for reference.
wx.showKeyboard({
defaultValue: '',
maxLength,
multiple: field === FIELD.DESCRIPTION,
confirmHold: false,
confirmType: 'done',
});
// Reset _inputText so we know if user actually typed something
this._inputText = '';
// Listen for keyboard input
this._onKeyboardInput = (res) => {
this._inputText = res.value || '';
this._validateInput(field);
};
this._onKeyboardConfirm = (res) => {
// Ensure we have the final value on confirm
if (res && res.value !== undefined) {
this._inputText = res.value;
}
this._validateInput(field);
// Only auto-submit if no violations
if (!this._localViolation) {
this._handleSubmit();
}
};
this._onKeyboardComplete = (res) => {
// Keyboard closed - get final value
if (res && res.value !== undefined) {
this._inputText = res.value;
this._validateInput(field);
}
};
wx.onKeyboardInput(this._onKeyboardInput);
wx.onKeyboardConfirm(this._onKeyboardConfirm);
wx.onKeyboardComplete(this._onKeyboardComplete);
// Initial validation to set correct button state
this._validateInput(field);
},
_stopEditing() {
if (this._onKeyboardInput) {
wx.offKeyboardInput(this._onKeyboardInput);
this._onKeyboardInput = null;
}
if (this._onKeyboardConfirm) {
wx.offKeyboardConfirm(this._onKeyboardConfirm);
this._onKeyboardConfirm = null;
}
if (this._onKeyboardComplete) {
wx.offKeyboardComplete(this._onKeyboardComplete);
this._onKeyboardComplete = null;
}
wx.hideKeyboard();
this._editingField = null;
},
_validateInput(field) {
const csm = GameGlobal.contentSecurityManager;
const text = this._inputText || '';
// Reset violation state first
this._localViolation = false;
this._errorMessage = '';
// Empty check
if (!text || text.trim().length === 0) {
this._localViolation = true;
this._errorMessage = '内容不能为空';
return;
}
// Sensitive word check
if (csm && csm.isInitialized()) {
const localCheck = csm.checkLocalText(text);
if (localCheck.hasViolation) {
this._localViolation = true;
this._errorMessage = '内容包含违规信息,请修改';
return;
}
}
// Length validation
if (field === FIELD.NICKNAME) {
if (text.length < NICKNAME_MIN) {
this._localViolation = true;
this._errorMessage = `昵称至少需要${NICKNAME_MIN}个字符`;
} else if (text.length > NICKNAME_MAX) {
this._localViolation = true;
this._errorMessage = `昵称不能超过${NICKNAME_MAX}个字符`;
}
} else if (field === FIELD.SIGNATURE) {
if (text.length > SIGNATURE_MAX) {
this._localViolation = true;
this._errorMessage = `签名不能超过${SIGNATURE_MAX}个字符`;
}
} else if (field === FIELD.DESCRIPTION) {
if (text.length > DESCRIPTION_MAX) {
this._localViolation = true;
this._errorMessage = `描述不能超过${DESCRIPTION_MAX}个字符`;
}
}
},
async _handleSubmit() {
if (this._isSubmitting || this._localViolation) return;
const csm = GameGlobal.contentSecurityManager;
if (!csm || !this._editingField) return;
const content = this._inputText.trim();
if (!content) {
this._errorMessage = '内容不能为空';
return;
}
this._isSubmitting = true;
this._errorMessage = '审核中...';
// Determine scene
const sceneMap = {
[FIELD.NICKNAME]: ContentSecurityManager.SCENE.NICKNAME,
[FIELD.SIGNATURE]: ContentSecurityManager.SCENE.SIGNATURE,
[FIELD.DESCRIPTION]: ContentSecurityManager.SCENE.DESCRIPTION,
};
const scene = sceneMap[this._editingField];
// Full text check (local + server)
const result = await csm.fullTextCheck(this._openid, content, scene);
this._isSubmitting = false;
if (result.pass) {
// Save the content
this._profile[this._editingField] = content;
this._saveProfile();
this._errorMessage = '';
this._stopEditing();
} else {
this._errorMessage = result.errorMessage;
}
},
// ============================================================
// Avatar Upload
// ============================================================
_handleChangeAvatar() {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths[0];
// Check file size
const fileInfo = wx.getFileInfo({ filePath });
if (fileInfo && fileInfo.size > 1024 * 1024) {
this._errorMessage = '图片大小不能超过1MB';
return;
}
this._isSubmitting = true;
this._errorMessage = '审核中...';
// Check image content
const result = await csm.checkImageContent(filePath, this._openid);
this._isSubmitting = false;
if (result.pass) {
this._profile.avatarUrl = filePath;
this._saveProfile();
this._errorMessage = '';
} else {
this._errorMessage = result.errmsg || '图片内容违规,请更换';
}
},
fail: () => {
// User cancelled or error
},
});
},
// ============================================================
// Render Helpers
// ============================================================
_renderBackButton(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('← ' + t('common.back'), 15, 25);
},
_renderAvatar(ctx) {
const rect = this._avatarRect;
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
const r = rect.w / 2;
// Circle background
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// Camera icon
ctx.fillStyle = '#FFFFFF';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('📷', cx, cy);
// Hint text
ctx.fillStyle = '#888888';
ctx.font = '10px Arial';
ctx.fillText(t('profile.changeAvatar'), cx, rect.y + rect.h + 14);
},
_renderField(ctx, rect, label, value, field) {
const isEditing = this._editingField === field;
// Label
ctx.fillStyle = '#CCCCCC';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(label, rect.x, rect.y - 4);
// Input box
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
// Border
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
// Value
ctx.fillStyle = value ? '#FFFFFF' : '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = value || t('profile.tapToEdit');
const truncated = displayText.length > 20 ? displayText.substring(0, 20) + '...' : displayText;
ctx.fillText(truncated, rect.x + 8, rect.y + rect.h / 2);
},
_renderDescriptionField(ctx) {
const rect = this._descriptionRect;
const isEditing = this._editingField === FIELD.DESCRIPTION;
// Label
ctx.fillStyle = '#CCCCCC';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(t('profile.description'), rect.x, rect.y - 4);
// Input box (taller for description)
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
// Value (multi-line)
ctx.fillStyle = this._profile.description ? '#FFFFFF' : '#666666';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const displayText = this._profile.description || t('profile.tapToEdit');
const lines = this._wrapText(ctx, displayText, rect.w - 16, 4);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], rect.x + 8, rect.y + 6 + i * 16);
}
},
_renderEditOverlay(ctx) {
// Place overlay at TOP of screen, so save/cancel buttons are NOT hidden by keyboard.
// The keyboard occupies the bottom portion of the screen when shown.
const overlayY = 0;
const overlayH = Math.min(SCREEN_HEIGHT * 0.4, 220);
ctx.fillStyle = 'rgba(0, 0, 0, 0.92)';
ctx.fillRect(0, overlayY, SCREEN_WIDTH, overlayH);
// Field label
const fieldLabels = {
[FIELD.NICKNAME]: t('profile.nickname'),
[FIELD.SIGNATURE]: t('profile.signature'),
[FIELD.DESCRIPTION]: t('profile.description'),
};
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('编辑 ' + (fieldLabels[this._editingField] || ''), CENTER_X, overlayY + 14);
// Input text preview area
const previewBoxY = overlayY + 30;
const previewBoxH = 50;
ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
ctx.fillRect(20, previewBoxY, SCREEN_WIDTH - 40, previewBoxH);
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 1;
ctx.strokeRect(20, previewBoxY, SCREEN_WIDTH - 40, previewBoxH);
// Current input text
ctx.fillStyle = '#FFFFFF';
ctx.font = '15px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const previewText = this._inputText || '';
if (previewText) {
const lines = this._wrapText(ctx, previewText, SCREEN_WIDTH - 60, 2);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], CENTER_X, previewBoxY + 16 + i * 18);
}
} else {
// Placeholder
ctx.fillStyle = '#888888';
ctx.font = '13px Arial';
ctx.fillText(
this._originalText ? `当前: ${this._originalText}(在下方键盘输入新内容)` : '请在下方键盘输入',
CENTER_X,
previewBoxY + previewBoxH / 2
);
}
// Character count
let maxLen = 200;
if (this._editingField === FIELD.NICKNAME) maxLen = NICKNAME_MAX;
if (this._editingField === FIELD.SIGNATURE) maxLen = SIGNATURE_MAX;
ctx.fillStyle = '#888888';
ctx.font = '11px Arial';
ctx.textAlign = 'right';
ctx.fillText(`${this._inputText.length}/${maxLen}`, SCREEN_WIDTH - 24, previewBoxY + previewBoxH + 12);
// Violation warning
if (this._localViolation && this._errorMessage) {
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'center';
ctx.fillText('⚠ ' + this._errorMessage, CENTER_X, previewBoxY + previewBoxH + 12);
}
// Buttons row at bottom of overlay
const btnRowY = overlayY + overlayH - BTN_HEIGHT - 12;
const btnGap = 12;
const btnW = (SCREEN_WIDTH - 40 - btnGap) / 2;
// Cancel button (left)
this._cancelBtnRect = {
x: 20,
y: btnRowY,
w: btnW,
h: BTN_HEIGHT,
};
// Save button (right)
this._saveBtnRect = {
x: 20 + btnW + btnGap,
y: btnRowY,
w: btnW,
h: BTN_HEIGHT,
};
const btnR = 6;
// Cancel button render
ctx.fillStyle = '#3a3a3a';
this._drawRoundRect(ctx, this._cancelBtnRect.x, this._cancelBtnRect.y, this._cancelBtnRect.w, this._cancelBtnRect.h, btnR);
ctx.fill();
ctx.strokeStyle = '#666666';
ctx.lineWidth = 1;
this._drawRoundRect(ctx, this._cancelBtnRect.x, this._cancelBtnRect.y, this._cancelBtnRect.w, this._cancelBtnRect.h, btnR);
ctx.stroke();
ctx.fillStyle = '#CCCCCC';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.cancel') || '取消', this._cancelBtnRect.x + this._cancelBtnRect.w / 2, this._cancelBtnRect.y + this._cancelBtnRect.h / 2);
// Save button render: always highlighted unless submitting
const isActive = !this._isSubmitting;
if (isActive) {
ctx.shadowColor = '#00CC66';
ctx.shadowBlur = 16;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = '#00CC66';
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.fillStyle = '#3a3a3a';
}
this._drawRoundRect(ctx, this._saveBtnRect.x, this._saveBtnRect.y, this._saveBtnRect.w, this._saveBtnRect.h, btnR);
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
if (isActive) {
ctx.strokeStyle = '#33FF99';
ctx.lineWidth = 2;
} else {
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
}
this._drawRoundRect(ctx, this._saveBtnRect.x, this._saveBtnRect.y, this._saveBtnRect.w, this._saveBtnRect.h, btnR);
ctx.stroke();
ctx.fillStyle = isActive ? '#FFFFFF' : '#666666';
ctx.font = 'bold 15px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this._isSubmitting ? '审核中...' : t('profile.save'), this._saveBtnRect.x + this._saveBtnRect.w / 2, this._saveBtnRect.y + this._saveBtnRect.h / 2);
},
_handleEditOverlayTouch(x, y) {
// Save button - always allow click, _handleSubmit will validate
if (this._hitTest(x, y, this._saveBtnRect)) {
if (!this._isSubmitting) {
// Re-validate before submit (in case keyboard callbacks did not fire)
this._validateInput(this._editingField);
if (!this._localViolation) {
this._handleSubmit();
}
// If validation fails, error message is now shown via _localViolation/_errorMessage
}
return;
}
// Cancel button
if (this._hitTest(x, y, this._cancelBtnRect)) {
this._stopEditing();
return;
}
// Ignore taps elsewhere inside the overlay (do NOT auto-cancel)
},
// ============================================================
// Utility
// ============================================================
_hitTest(x, y, rect) {
return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_wrapText(ctx, text, maxWidth, maxLines) {
const words = text.split('');
const lines = [];
let currentLine = '';
for (const char of words) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
if (lines.length >= maxLines) {
lines[lines.length - 1] += '...';
break;
}
} else {
currentLine = testLine;
}
}
if (currentLine && lines.length < maxLines) {
lines.push(currentLine);
}
return lines;
},
_loadProfile() {
try {
const saved = wx.getStorageSync('player_profile');
if (saved) {
this._profile = { ...this._profile, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[ProfileScene] Failed to load profile:', e);
}
},
_saveProfile() {
try {
wx.setStorageSync('player_profile', JSON.stringify(this._profile));
} catch (e) {
console.warn('[ProfileScene] Failed to save profile:', e);
}
},
};
module.exports = ProfileScene;
+12 -12
View File
@@ -82,27 +82,27 @@ const ResultScene = {
*/ */
_calculateGoldReward() { _calculateGoldReward() {
const stats = this._stats; const stats = this._stats;
let gold = 50; // Base reward per requirements let gold = 30; // Base reward (reduced from 50)
// Bonus per kill type // Bonus per kill type (reduced)
gold += (stats.kills.normal || 0) * 5; gold += (stats.kills.normal || 0) * 3;
gold += (stats.kills.fast || 0) * 10; gold += (stats.kills.fast || 0) * 5;
gold += (stats.kills.armor || 0) * 15; gold += (stats.kills.armor || 0) * 8;
gold += (stats.kills.boss || 0) * 25; gold += (stats.kills.boss || 0) * 15;
// Victory bonus // Victory bonus (reduced from 50)
if (this._victory) { if (this._victory) {
gold += 50; gold += 30;
} }
// Time bonus (faster = more gold, max 30 gold for under 60s) // Time bonus (faster = more gold, max 20 gold for under 60s, reduced from 30)
if (this._victory && stats.timeElapsed < 300) { if (this._victory && stats.timeElapsed < 300) {
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 10)); gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 15));
} }
// Base alive bonus // Base alive bonus (reduced from 20)
if (stats.baseAlive) { if (stats.baseAlive) {
gold += 20; gold += 10;
} }
return gold; return gold;
+194 -167
View File
@@ -1,7 +1,8 @@
/** /**
* RoomScene.js * RoomScene.js
* Room creation/joining UI for PVP online multiplayer mode. * PVP 1v1 online multiplayer scene.
* Allows players to create a room or join an existing one by room code. * Entering this scene automatically creates a room and triggers invite share.
* From an invite card, auto-joins the specified room.
*/ */
const { const {
@@ -26,11 +27,9 @@ const CENTER_X = SCREEN_WIDTH / 2;
// Room Scene States // Room Scene States
// ============================================================ // ============================================================
const ROOM_STATE = { const ROOM_STATE = {
IDLE: 'idle', // Initial state: show create/join buttons CREATING: 'creating', // Connecting and creating room (also initial state)
CREATING: 'creating', // Connecting and creating room
WAITING: 'waiting', // Room created, waiting for opponent WAITING: 'waiting', // Room created, waiting for opponent
JOINING: 'joining', // Joining a room JOINING: 'joining', // Joining a room
INPUT_CODE: 'input', // Entering room code
COUNTDOWN: 'countdown', // Both players ready, counting down COUNTDOWN: 'countdown', // Both players ready, counting down
ERROR: 'error', // Error state ERROR: 'error', // Error state
}; };
@@ -39,9 +38,8 @@ const ROOM_STATE = {
// Room Scene // Room Scene
// ============================================================ // ============================================================
const RoomScene = { const RoomScene = {
_state: ROOM_STATE.IDLE, _state: ROOM_STATE.CREATING,
_roomCode: '', _roomCode: '',
_inputCode: '',
_errorMsg: '', _errorMsg: '',
_countdown: 3, _countdown: 3,
_countdownTimer: 0, _countdownTimer: 0,
@@ -53,17 +51,10 @@ const RoomScene = {
_serverUrl: SERVER_URL, _serverUrl: SERVER_URL,
// Button rects (calculated in enter) // Button rects (calculated in enter)
_createBtnRect: null,
_joinBtnRect: null,
_backBtnRect: null, _backBtnRect: null,
_confirmBtnRect: null, _inviteBtnRect: null,
_numpadRects: [],
_deleteBtnRect: null,
enter() { enter(params) {
this._state = ROOM_STATE.IDLE;
this._roomCode = '';
this._inputCode = '';
this._errorMsg = ''; this._errorMsg = '';
this._countdown = 3; this._countdown = 3;
this._countdownTimer = 0; this._countdownTimer = 0;
@@ -71,20 +62,6 @@ const RoomScene = {
this._pendingStartData = null; this._pendingStartData = null;
this._networkManager = GameGlobal.networkManager; this._networkManager = GameGlobal.networkManager;
// Calculate button positions
const btnY = SCREEN_HEIGHT * 0.4;
this._createBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._joinBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = { this._backBtnRect = {
x: 10, x: 10,
y: 10, y: 10,
@@ -92,47 +69,37 @@ const RoomScene = {
h: 30, h: 30,
}; };
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm) // Invite friend button (shown in WAITING state)
this._buildNumpad(); const inviteBtnW = Math.min(SCREEN_WIDTH * 0.5, 240);
const inviteBtnH = Math.min(40, SCREEN_HEIGHT * 0.08);
// Confirm button for code input this._inviteBtnRect = {
this._confirmBtnRect = { x: CENTER_X - inviteBtnW / 2,
x: CENTER_X - BTN_WIDTH / 2, y: SCREEN_HEIGHT * 0.73,
y: SCREEN_HEIGHT * 0.75, w: inviteBtnW,
w: BTN_WIDTH, h: inviteBtnH,
h: BTN_HEIGHT,
}; };
// Setup network event listeners // Setup network event listeners
this._setupNetworkEvents(); this._setupNetworkEvents();
// Decide initial flow based on entry params
if (params && params.roomId) {
// From invite card — auto-join the room
this._autoJoinRoom(params.roomId);
} else {
// Direct entry from menu — auto-create room + share (one-step invite)
this._state = ROOM_STATE.CREATING;
this._roomCode = '';
this._handleInviteAndCreate();
}
}, },
exit() { exit() {
this._cleanupNetworkEvents(); this._cleanupNetworkEvents();
}, // Reset share content when leaving room
const shareManager = GameGlobal.shareManager;
_buildNumpad() { if (shareManager) {
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200); shareManager.resetShareContent();
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
const startX = CENTER_X - padWidth / 2;
const startY = SCREEN_HEIGHT * 0.42;
const cellW = padWidth / 3;
const cellH = padHeight / 4;
this._numpadRects = [];
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
for (let i = 0; i < 12; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
if (nums[i] !== null) {
this._numpadRects.push({
x: startX + col * cellW,
y: startY + row * cellH,
w: cellW - 4,
h: cellH - 4,
value: nums[i],
});
}
} }
}, },
@@ -146,6 +113,8 @@ const RoomScene = {
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => { unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
this._roomCode = data.roomId || data.roomCode || ''; this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING; this._state = ROOM_STATE.WAITING;
// Update share content so right-corner menu also carries the roomId
this._updateShareContent();
})); }));
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => { unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
@@ -183,7 +152,7 @@ const RoomScene = {
})); }));
unsubs.push(nm.on('disconnected', () => { unsubs.push(nm.on('disconnected', () => {
if (this._state !== ROOM_STATE.IDLE) { if (this._state !== ROOM_STATE.CREATING) {
this._errorMsg = t('common.disconnected'); this._errorMsg = t('common.disconnected');
this._state = ROOM_STATE.ERROR; this._state = ROOM_STATE.ERROR;
} }
@@ -218,6 +187,134 @@ const RoomScene = {
} }
}, },
/**
* Update share content so the right-corner ··· menu always carries
* the current roomId for 1v1 invite.
* @private
*/
_updateShareContent() {
if (!this._roomCode) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`,
});
}
},
/**
* Handle "Invite Friend" button tap trigger WeChat share.
* MUST be called within a touch event for WeChat policy compliance.
* @private
*/
_handleInvite() {
if (!this._roomCode) return;
const shareData = {
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${this._roomCode}`,
};
console.log(`[RoomScene] Sharing 1v1 invite with query: roomId=${this._roomCode}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e) {
console.log('[RoomScene] Share not available, roomId:', this._roomCode);
}
}
},
/**
* One-step invite: auto-create room then immediately share the invite card.
* This is the primary action automatically creates a room and triggers invite share
* and we handle everything in one go.
* MUST be called within a touch event for WeChat policy compliance.
* @private
*/
async _handleInviteAndCreate() {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
// If already connected and in a room (WAITING state), just re-share
if (this._state === ROOM_STATE.WAITING && this._roomCode) {
this._handleInvite();
return;
}
// Connect if needed
if (!nm.connected) {
this._state = ROOM_STATE.CREATING;
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
// Create room — the ROOM_CREATED event handler will set state to WAITING
// and store the roomCode. We need to share AFTER the room is created,
// so we listen for the event once.
const shareOnCreated = (data) => {
const roomId = data.roomId || data.roomCode || '';
if (!roomId) return;
const shareData = {
title: t('room.shareTitle'),
imageUrl: 'js/ui/images/1v1.png',
query: `roomId=${roomId}`,
};
console.log(`[RoomScene] Auto-sharing 1v1 invite after room created: roomId=${roomId}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e) {
console.log('[RoomScene] Share not available after room created');
}
}
};
// Subscribe one-time for auto-share after room creation
const unsub = nm.on(NET_MSG.ROOM_CREATED, (data) => {
// Unsubscribe immediately (one-time listener)
unsub();
shareOnCreated(data);
});
// Now create the room
nm.createRoom();
},
_startGame(data) { _startGame(data) {
const sm = GameGlobal.sceneManager; const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) { if (!sm._scenes.has(SCENE.TEAM_GAME)) {
@@ -281,9 +378,6 @@ const RoomScene = {
// Render based on state // Render based on state
switch (this._state) { switch (this._state) {
case ROOM_STATE.IDLE:
this._renderIdle(ctx);
break;
case ROOM_STATE.CREATING: case ROOM_STATE.CREATING:
case ROOM_STATE.JOINING: case ROOM_STATE.JOINING:
this._renderConnecting(ctx); this._renderConnecting(ctx);
@@ -291,9 +385,6 @@ const RoomScene = {
case ROOM_STATE.WAITING: case ROOM_STATE.WAITING:
this._renderWaiting(ctx); this._renderWaiting(ctx);
break; break;
case ROOM_STATE.INPUT_CODE:
this._renderInputCode(ctx);
break;
case ROOM_STATE.COUNTDOWN: case ROOM_STATE.COUNTDOWN:
this._renderCountdown(ctx); this._renderCountdown(ctx);
break; break;
@@ -303,16 +394,6 @@ const RoomScene = {
} }
}, },
_renderIdle(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createBtnRect, t('room.create'));
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
},
_renderConnecting(ctx) { _renderConnecting(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4); const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
@@ -336,48 +417,15 @@ const RoomScene = {
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4); const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
ctx.fillStyle = '#AAAAAA'; ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial'; ctx.font = '16px Arial';
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55); ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.52);
// Hint // Invite friend button (primary action)
this._drawButton(ctx, this._inviteBtnRect, t('room.inviteFriend'), false, 16, '#e94560');
// Hint text below the button
ctx.fillStyle = '#666666'; ctx.fillStyle = '#666666';
ctx.font = '12px Arial'; ctx.font = '12px Arial';
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65); ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.84);
},
_renderInputCode(ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
// Code display box
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
const boxH = 40;
const boxX = CENTER_X - boxW / 2;
const boxY = SCREEN_HEIGHT * 0.30;
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = COLORS.MENU_TITLE;
ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
// Input text
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
// Numpad
for (const btn of this._numpadRects) {
const label = btn.value === 'del' ? '⌫' : String(btn.value);
this._drawButton(ctx, btn, label, false, 16);
}
// Confirm button
if (this._inputCode.length >= 4) {
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
}
}, },
_renderCountdown(ctx) { _renderCountdown(ctx) {
@@ -406,11 +454,11 @@ const RoomScene = {
ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55); ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
}, },
_drawButton(ctx, rect, label, pressed, fontSize) { _drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return; if (!rect) return;
const fs = fontSize || 16; const fs = fontSize || 16;
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN; ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER; ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2; ctx.lineWidth = 2;
@@ -456,64 +504,37 @@ const RoomScene = {
} }
switch (this._state) { switch (this._state) {
case ROOM_STATE.IDLE:
if (this._hitTest(tx, ty, this._createBtnRect)) {
this._handleCreateRoom();
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
this._state = ROOM_STATE.INPUT_CODE;
this._inputCode = '';
}
break;
case ROOM_STATE.INPUT_CODE:
// Check numpad
for (const btn of this._numpadRects) {
if (this._hitTest(tx, ty, btn)) {
if (btn.value === 'del') {
this._inputCode = this._inputCode.slice(0, -1);
} else if (this._inputCode.length < 6) {
this._inputCode += String(btn.value);
}
return;
}
}
// Check confirm
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
this._handleJoinRoom();
}
break;
case ROOM_STATE.ERROR: case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE; this._state = ROOM_STATE.CREATING;
this._errorMsg = ''; this._errorMsg = '';
break; break;
case ROOM_STATE.WAITING: case ROOM_STATE.WAITING:
// Invite friend button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
}
// Allow going back while waiting // Allow going back while waiting
break; break;
} }
}, },
async _handleCreateRoom() { /**
this._state = ROOM_STATE.CREATING; * Auto-join a 1v1 room when entering from an invite card.
const nm = this._networkManager; * @param {string} roomId - Room ID from the invite card query parameter.
*/
if (!nm.connected) { async _autoJoinRoom(roomId) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.createRoom();
},
async _handleJoinRoom() {
this._state = ROOM_STATE.JOINING; this._state = ROOM_STATE.JOINING;
this._errorMsg = '';
const nm = this._networkManager; const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
try {
if (!nm.connected) { if (!nm.connected) {
const ok = await nm.connect(this._serverUrl); const ok = await nm.connect(this._serverUrl);
if (!ok) { if (!ok) {
@@ -523,7 +544,13 @@ const RoomScene = {
} }
} }
nm.joinRoom(this._inputCode); console.log(`[RoomScene] Auto-joining 1v1 room ${roomId}`);
nm.joinRoom(roomId);
} catch (e) {
console.error('[RoomScene] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
}
}, },
_goBack() { _goBack() {
+26 -13
View File
@@ -49,32 +49,44 @@ const SettingsScene = {
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2; const cx = SCREEN_WIDTH / 2;
let y = 60;
// Reset button map each frame so layout changes don't keep stale rects.
this._buttons = {};
// Title // Title
const titleY = Math.max(48, SCREEN_HEIGHT * 0.08);
ctx.fillStyle = COLORS.MENU_TITLE; ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial'; ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(t('settings.title'), cx, y); ctx.fillText(t('settings.title'), cx, titleY);
y += 70; // Back button (reserved at bottom so we can layout rows above it).
const backH = 42;
const backMarginBottom = 28;
const backCenterY = SCREEN_HEIGHT - backMarginBottom - backH / 2;
// Toggle items // Rows: nickname + 3 toggles. Distribute evenly between title and back btn.
const toggles = [ const rows = [
{ key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' }, { type: 'toggle', key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
{ key: 'musicEnabled', label: t('settings.music'), icon: '🎵' }, { type: 'toggle', key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
{ key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' }, { type: 'toggle', key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
]; ];
const rowH = 50;
const topPad = titleY + 36;
const bottomPad = backCenterY - backH / 2 - 20;
const availH = Math.max(rowH * rows.length, bottomPad - topPad);
const step = Math.max(rowH + 8, availH / rows.length);
const firstCenterY = topPad + step / 2;
for (const toggle of toggles) { for (let i = 0; i < rows.length; i++) {
this._renderToggle(ctx, cx, y, toggle); const row = rows[i];
y += 70; const cy = firstCenterY + i * step;
this._renderToggle(ctx, cx, cy, row);
} }
// Back button // Back button
y = SCREEN_HEIGHT - 80; this._renderBackButton(ctx, cx, backCenterY);
this._renderBackButton(ctx, cx, y);
}, },
_renderToggle(ctx, cx, y, toggle) { _renderToggle(ctx, cx, y, toggle) {
@@ -162,6 +174,7 @@ const SettingsScene = {
} }
} }
}, },
}; };
module.exports = SettingsScene; module.exports = SettingsScene;
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
/**
* Team2v2RoomScene.js
* 2v2 Brawl team room delegates to the shared TeamRoomSceneFactory.
*/
const { createTeamRoomScene } = require('./TeamRoomSceneFactory');
const Team2v2RoomScene = createTeamRoomScene({
teamSize: 2,
battleMode: '2v2',
i18nPrefix: 'team2v2Room',
logTag: 'Team2v2Room',
shareImageUrl: 'js/ui/images/2v2.png',
});
module.exports = Team2v2RoomScene;
+251 -31
View File
@@ -158,6 +158,10 @@ const TeamGameScene = {
const teamAMembers = (params && params.teamA) || []; const teamAMembers = (params && params.teamA) || [];
const teamBMembers = (params && params.teamB) || []; const teamBMembers = (params && params.teamB) || [];
console.log(`[TeamGameScene] teamA: ${JSON.stringify(teamAMembers.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
console.log(`[TeamGameScene] teamB: ${JSON.stringify(teamBMembers.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
console.log(`[TeamGameScene] myPlayerId: ${this._myPlayerId}`);
this._myTeam = teamAMembers.find(m => m.playerId === this._myPlayerId) ? 'A' : 'B'; this._myTeam = teamAMembers.find(m => m.playerId === this._myPlayerId) ? 'A' : 'B';
// Create all player tanks // Create all player tanks
@@ -165,9 +169,25 @@ const TeamGameScene = {
this._createTeamPlayers(teamAMembers, 'A', this._mapData.teamASpawns); this._createTeamPlayers(teamAMembers, 'A', this._mapData.teamASpawns);
this._createTeamPlayers(teamBMembers, 'B', this._mapData.teamBSpawns); this._createTeamPlayers(teamBMembers, 'B', this._mapData.teamBSpawns);
// Log _players for duplicate check
const playerIds = this._players.map(p => p.playerId);
const duplicates = playerIds.filter((id, idx) => playerIds.indexOf(id) !== idx);
if (duplicates.length > 0) {
console.error(`[TeamGameScene] DUPLICATE playerIds: ${JSON.stringify(duplicates)}`);
}
console.log(`[TeamGameScene] All players: ${JSON.stringify(this._players.map(p => ({ playerId: p.playerId, team: p.team, isLocal: p.isLocal, isBot: p.isBot })))}`);
// Find local player // Find local player
this._localPlayer = this._players.find(p => p.isLocal); this._localPlayer = this._players.find(p => p.isLocal);
// Check if enemy team has no human players (all bots) —
// if so, this client must run enemy bot AI locally since no remote
// client exists to act as authority
const enemyTeam = this._myTeam === 'A' ? 'B' : 'A';
this._enemyTeamAllBots = !this._players.some(
p => p.team === enemyTeam && !p.isBot && !p.isLocal
);
// Initialize stats // Initialize stats
for (const p of this._players) { for (const p of this._players) {
this._stats[p.playerId] = { kills: 0, deaths: 0, assists: 0, baseDamage: 0 }; this._stats[p.playerId] = { kills: 0, deaths: 0, assists: 0, baseDamage: 0 };
@@ -203,14 +223,9 @@ const TeamGameScene = {
tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR;
} }
} else { } else {
// 3v3: local=gold, ally=blue, enemy=red // 3v3: Team A = blue, Team B = red
if (isLocal) { // Local player uses team color too (gold border drawn separately for identification)
tankColor = LOCAL_PLAYER_COLOR; tankColor = team === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR;
} else if (isMyTeam) {
tankColor = TEAM_A_COLOR;
} else {
tankColor = TEAM_B_COLOR;
}
} }
let tank; let tank;
@@ -240,10 +255,28 @@ const TeamGameScene = {
tank.color = tankColor; tank.color = tankColor;
// Unlimited lives for 3v3 // Unlimited lives for 3v3
tank.lives = 999; tank.lives = 999;
// Apply equipped skin — only non-default skins override team color
if (GameGlobal.skinManager) {
const skinId = isLocal
? GameGlobal.skinManager.getEquippedSkinId()
: (member.skinId || '');
if (skinId && skinId !== 'default') {
const skinDef = GameGlobal.skinManager.getSkin(skinId);
if (skinDef && skinDef.colors) {
tank._skinColors = skinDef.colors;
tank._skinId = skinId;
}
}
}
} }
tank.activateShield(3000); tank.activateShield(3000);
// Mark local player's tank for gold-border rendering
if (isLocal) {
tank._isLocal = true;
}
// Set initial direction based on team // Set initial direction based on team
if (team === 'A') { if (team === 'A') {
tank.direction = DIRECTION.RIGHT; tank.direction = DIRECTION.RIGHT;
@@ -256,6 +289,7 @@ const TeamGameScene = {
const playerData = { const playerData = {
playerId: member.playerId, playerId: member.playerId,
nickname: member.nickname || '',
tank, tank,
isBot, isBot,
team, team,
@@ -315,9 +349,13 @@ const TeamGameScene = {
// Receive bullet fire from other players // Receive bullet fire from other players
unsubs.push(nm.on(NET_MSG.BULLET_FIRE, (data) => { unsubs.push(nm.on(NET_MSG.BULLET_FIRE, (data) => {
if (data.playerId && data.playerId !== this._myPlayerId) { if (!data.playerId) return;
// Ignore our own bullets (local player or our team's bots) — we already
// created them locally. Only spawn remote bullets for enemy players/bots.
const shooter = this._players.find(p => p.playerId === data.playerId);
if (shooter && shooter.team === this._myTeam) return;
if (data.playerId === this._myPlayerId) return;
this._spawnRemoteBullet(data); this._spawnRemoteBullet(data);
}
})); }));
// Receive player killed notification // Receive player killed notification
@@ -441,6 +479,48 @@ const TeamGameScene = {
} }
})); }));
// Receive live team roster updates — keeps every tank's overhead label in
// sync with the real WeChat nickname, which may be granted AFTER the match
// has already started (via MenuScene's UserInfoButton).
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
if (!data) return;
const rosterA = Array.isArray(data.teamA) ? data.teamA : [];
const rosterB = Array.isArray(data.teamB) ? data.teamB : [];
const byId = Object.create(null);
for (const m of rosterA) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
for (const m of rosterB) if (m && m.playerId) byId[m.playerId] = m.nickname || '';
let changed = false;
for (const p of this._players) {
const nn = byId[p.playerId];
if (nn && p.nickname !== nn) {
p.nickname = nn;
changed = true;
}
}
if (changed) {
console.log('[TeamGameScene] Roster nicknames refreshed.');
}
}));
// Receive terrain changes from remote client (brick/steel/base_wall destruction)
unsubs.push(nm.on(NET_MSG.TERRAIN_CHANGE, (data) => {
if (data.row !== undefined && data.col !== undefined && data.terrain !== undefined) {
const currentTerrain = this._mapManager.getTerrain(data.row, data.col);
// Only apply if the terrain still matches the original type (avoid double-apply)
// Accept EMPTY→EMPTY as no-op, but apply any real change
if (currentTerrain !== data.terrain) {
this._mapManager.setTerrain(data.row, data.col, data.terrain);
}
}
}));
// Receive bot state from remote client (enemy team bots)
unsubs.push(nm.on(NET_MSG.BOT_STATE, (data) => {
if (data.playerId) {
this._updateRemotePlayerState(data);
}
}));
this._unsubscribers = unsubs; this._unsubscribers = unsubs;
}, },
@@ -529,9 +609,16 @@ const TeamGameScene = {
} else if (!player.isBot) { } else if (!player.isBot) {
// Remote player interpolation // Remote player interpolation
this._interpolateRemoteTank(player, dt); this._interpolateRemoteTank(player, dt);
} else { } else if (player.team === this._myTeam) {
// Bot AI using BotTank.updateAI // Our team's bot — we are the authority, run AI locally
this._updateBotAI(player, dt); this._updateBotAI(player, dt);
} else if (this._enemyTeamAllBots) {
// Enemy team's bot but enemy has no human players —
// run AI locally since no remote client exists to drive them
this._updateBotAI(player, dt);
} else {
// Enemy team's bot — interpolated from remote state (authority is on their side)
this._interpolateRemoteTank(player, dt);
} }
player.tank.update(dt); player.tank.update(dt);
@@ -644,6 +731,9 @@ const TeamGameScene = {
hp: tank.hp, hp: tank.hp,
alive: tank.alive, alive: tank.alive,
}); });
// Also sync our team's bot states
this._sendBotStates();
}, },
_sendInputIfChanged() { _sendInputIfChanged() {
@@ -667,6 +757,32 @@ const TeamGameScene = {
} }
}, },
_sendTerrainChange(row, col, newTerrain) {
if (!this._networkManager) return;
this._networkManager.send(NET_MSG.TERRAIN_CHANGE, {
row,
col,
terrain: newTerrain,
});
},
_sendBotStates() {
if (!this._networkManager) return;
// Only sync bots on OUR team (we are the authority for our team's bots)
const myBots = this._players.filter(p => p.isBot && p.team === this._myTeam);
for (const bot of myBots) {
const tank = bot.tank;
this._networkManager.send(NET_MSG.BOT_STATE, {
playerId: bot.playerId,
col: (tank.x - MAP_OFFSET_X) / TILE_SIZE,
row: (tank.y - MAP_OFFSET_Y) / TILE_SIZE,
direction: tank.direction,
alive: tank.alive,
hp: tank.hp,
});
}
},
// ============================================================ // ============================================================
// Collision Detection // Collision Detection
// ============================================================ // ============================================================
@@ -709,15 +825,21 @@ const TeamGameScene = {
} }
const terrain = this._mapManager.getTerrain(row, col); const terrain = this._mapManager.getTerrain(row, col);
const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId);
// Only our team's bullets can modify terrain locally; enemy bullets are
// visual-only and their terrain changes come via TERRAIN_CHANGE messages.
const isAuthority = bulletOwner && bulletOwner.team === this._myTeam;
if (terrain === TERRAIN.BRICK) { if (terrain === TERRAIN.BRICK) {
if (isAuthority) {
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
this._sendTerrainChange(row, col, TERRAIN.EMPTY);
}
bullet.destroy(); bullet.destroy();
this._spawnExplosion(bullet.x, bullet.y, false); this._spawnExplosion(bullet.x, bullet.y, false);
} else if (terrain === TERRAIN.BASE_WALL) { } else if (terrain === TERRAIN.BASE_WALL) {
// Determine which team this base wall belongs to // Determine which team this base wall belongs to
const wallTeam = this._getBaseWallTeam(row, col); const wallTeam = this._getBaseWallTeam(row, col);
const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId);
// Friendly-fire immunity: own team's bullets don't damage own base walls // Friendly-fire immunity: own team's bullets don't damage own base walls
if (bulletOwner && wallTeam && bulletOwner.team === wallTeam) { if (bulletOwner && wallTeam && bulletOwner.team === wallTeam) {
@@ -725,13 +847,21 @@ const TeamGameScene = {
return; return;
} }
if (isAuthority) {
// Base wall has HP — use bulletHitTerrain for proper HP tracking // Base wall has HP — use bulletHitTerrain for proper HP tracking
const prevTerrain = this._mapManager.getTerrain(row, col);
this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel); this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel);
const newTerrain = this._mapManager.getTerrain(row, col);
if (prevTerrain !== newTerrain) {
this._sendTerrainChange(row, col, newTerrain);
}
}
bullet.destroy(); bullet.destroy();
this._spawnExplosion(bullet.x, bullet.y, false); this._spawnExplosion(bullet.x, bullet.y, false);
} else if (terrain === TERRAIN.STEEL) { } else if (terrain === TERRAIN.STEEL) {
if (bullet.canBreakSteel) { if (bullet.canBreakSteel && isAuthority) {
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
this._sendTerrainChange(row, col, TERRAIN.EMPTY);
} }
bullet.destroy(); bullet.destroy();
this._spawnExplosion(bullet.x, bullet.y, false); this._spawnExplosion(bullet.x, bullet.y, false);
@@ -807,8 +937,11 @@ const TeamGameScene = {
return; // ignore friendly base hit return; // ignore friendly base hit
} }
// Local player or local bot reports base hits to server // Local player or our team's bot reports base hits to server.
if ((bulletOwner.isLocal || bulletOwner.isBot) && this._networkManager) { // Also report when enemy team is all bots (we are the authority for them).
const isLocalAuthority = bulletOwner.isLocal || (bulletOwner.isBot && bulletOwner.team === this._myTeam);
const isEnemyBotAuthority = this._enemyTeamAllBots && bulletOwner.isBot && bulletOwner.team !== this._myTeam;
if ((isLocalAuthority || isEnemyBotAuthority) && this._networkManager) {
this._networkManager.send(NET_MSG.BASE_HIT, { this._networkManager.send(NET_MSG.BASE_HIT, {
targetTeam, targetTeam,
damage: 1, damage: 1,
@@ -827,6 +960,13 @@ const TeamGameScene = {
const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId);
if (!bulletOwner) return; if (!bulletOwner) return;
// Only perform tank hit detection for bullets from our team.
// Enemy bullets are visual-only on our side; the enemy client is the
// authority for those hits and will send PLAYER_KILLED.
// Exception: when the enemy team has no human players (all bots),
// we run enemy bot AI locally and must also resolve their bullet hits.
if (bulletOwner.team !== this._myTeam && !this._enemyTeamAllBots) return;
for (const player of this._players) { for (const player of this._players) {
if (!player.tank.alive || player.isRespawning) continue; if (!player.tank.alive || player.isRespawning) continue;
@@ -899,15 +1039,15 @@ const TeamGameScene = {
// Validate pushed positions against terrain; revert if stuck in wall // Validate pushed positions against terrain; revert if stuck in wall
if (this._mapManager) { if (this._mapManager) {
const leftA = tankA.x - tankA.halfSize; const leftA = tankA.x - tankA.colliderHalfSize;
const topA = tankA.y - tankA.halfSize; const topA = tankA.y - tankA.colliderHalfSize;
if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.size, tankA.size)) { if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.colliderSize, tankA.colliderSize)) {
tankA.x = origAX; tankA.x = origAX;
tankA.y = origAY; tankA.y = origAY;
} }
const leftB = tankB.x - tankB.halfSize; const leftB = tankB.x - tankB.colliderHalfSize;
const topB = tankB.y - tankB.halfSize; const topB = tankB.y - tankB.colliderHalfSize;
if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.size, tankB.size)) { if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.colliderSize, tankB.colliderSize)) {
tankB.x = origBX; tankB.x = origBX;
tankB.y = origBY; tankB.y = origBY;
} }
@@ -943,6 +1083,7 @@ const TeamGameScene = {
ownerTank: tank, ownerTank: tank,
}); });
bullet.ownerPlayerId = this._myPlayerId; bullet.ownerPlayerId = this._myPlayerId;
bullet._isAlly = true; // local player's bullets are always ally
tank.activeBullets++; tank.activeBullets++;
this._bullets.push(bullet); this._bullets.push(bullet);
GameGlobal.audioManager.playSFX('shoot'); GameGlobal.audioManager.playSFX('shoot');
@@ -974,8 +1115,20 @@ const TeamGameScene = {
ownerTank: tank, ownerTank: tank,
}); });
bullet.ownerPlayerId = player.playerId; bullet.ownerPlayerId = player.playerId;
bullet._isAlly = player.team === this._myTeam; // bot on my team = ally
tank.activeBullets++; tank.activeBullets++;
this._bullets.push(bullet); this._bullets.push(bullet);
// Sync our team's bot bullets to the remote client
if (player.team === this._myTeam && this._networkManager) {
this._networkManager.send(NET_MSG.BULLET_FIRE, {
playerId: player.playerId,
col: (bullet.x - MAP_OFFSET_X) / TILE_SIZE,
row: (bullet.y - MAP_OFFSET_Y) / TILE_SIZE,
direction: bullet.direction,
canBreakSteel: false,
});
}
}, },
_spawnRemoteBullet(data) { _spawnRemoteBullet(data) {
@@ -1002,6 +1155,7 @@ const TeamGameScene = {
ownerTank: player.tank, ownerTank: player.tank,
}); });
bullet.ownerPlayerId = data.playerId; bullet.ownerPlayerId = data.playerId;
bullet._isAlly = player.team === this._myTeam; // remote on my team = ally
player.tank.activeBullets++; player.tank.activeBullets++;
this._bullets.push(bullet); this._bullets.push(bullet);
}, },
@@ -1041,8 +1195,11 @@ const TeamGameScene = {
// Start respawn timer // Start respawn timer
this._startRespawn(victim); this._startRespawn(victim);
// If local player or local bot killed someone, notify server // If local player or our team's bot killed someone, notify server.
if ((killer.isLocal || killer.isBot) && this._networkManager) { // Also notify when enemy team is all bots (we are the authority for them).
const isLocalAuthority = killer.isLocal || (killer.isBot && killer.team === this._myTeam);
const isEnemyBotAuthority = this._enemyTeamAllBots && killer.isBot && killer.team !== this._myTeam;
if ((isLocalAuthority || isEnemyBotAuthority) && this._networkManager) {
this._networkManager.send(NET_MSG.PLAYER_KILLED, { this._networkManager.send(NET_MSG.PLAYER_KILLED, {
killerId: killer.playerId, killerId: killer.playerId,
victimId: victim.playerId, victimId: victim.playerId,
@@ -1126,6 +1283,7 @@ const TeamGameScene = {
stats: this._stats, stats: this._stats,
players: this._players.map(p => ({ players: this._players.map(p => ({
playerId: p.playerId, playerId: p.playerId,
nickname: p.nickname || '',
team: p.team, team: p.team,
isBot: p.isBot, isBot: p.isBot,
isLocal: p.isLocal, isLocal: p.isLocal,
@@ -1154,16 +1312,40 @@ const TeamGameScene = {
if (player.tank.alive && !player.isRespawning) { if (player.tank.alive && !player.isRespawning) {
player.tank.render(ctx); player.tank.render(ctx);
// Draw team indicator above tank // Name & team indicator above the tank
if (!player.isLocal) {
const tx = player.tank.x; const tx = player.tank.x;
const ty = player.tank.y - player.tank.halfSize - 8; const labelY = player.tank.y - player.tank.halfSize - 4;
ctx.fillStyle = player.team === this._myTeam ? TEAM_A_COLOR : TEAM_B_COLOR; const nameY = labelY - 10;
ctx.font = 'bold 8px Arial';
// Per-tank team color:
// - local player → gold
// - Team A (ally or enemy) → blue
// - Team B (ally or enemy) → red
let labelColor;
if (player.isLocal) labelColor = LOCAL_PLAYER_COLOR;
else labelColor = player.team === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR;
ctx.fillStyle = labelColor;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const label = player.isBot ? '🤖' : (player.team === this._myTeam ? '▲' : '▼');
ctx.fillText(label, tx, ty); // Arrow / bot tag
ctx.font = 'bold 8px Arial';
let marker;
if (player.isLocal) marker = '★';
else if (player.isBot) marker = '🤖';
else marker = (player.team === this._myTeam) ? '▲' : '▼';
ctx.fillText(marker, tx, labelY);
// Nickname (truncated to 4 Chinese-equivalent chars)
const name = this._getTankLabel(player);
if (name) {
ctx.font = 'bold 9px Arial';
// Outline for readability on busy backgrounds
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(0,0,0,0.7)';
ctx.strokeText(name, tx, nameY);
ctx.fillText(name, tx, nameY);
} }
} }
} }
@@ -1220,6 +1402,44 @@ const TeamGameScene = {
return { kills, deaths }; return { kills, deaths };
}, },
/**
* Compute a short label ( 4 Chinese-equivalent chars) to draw above a tank.
* Uses real WeChat nickname if available, otherwise a stable fallback.
* @private
*/
_getTankLabel(player) {
if (!player) return '';
const profile = GameGlobal.playerProfile;
let raw = '';
if (player.isLocal) {
// For local player prefer the freshest profile nickname if granted.
if (profile && profile.nickname) raw = profile.nickname;
else raw = player.nickname || '';
} else {
// For remote players, use the server-provided nickname.
// Do NOT fall back to profile.getDisplayName() because that returns
// the LOCAL player's nickname when set, which would show the wrong
// name for every remote player.
raw = player.nickname || '';
}
if (!raw) {
if (player.isBot) {
raw = ''; // bot — we already draw the 🤖 marker, skip name
} else if (player.playerId && typeof player.playerId === 'string') {
// Derive a stable anonymous tag from the remote player's own ID.
const tail = player.playerId.slice(-4).toUpperCase();
raw = `Tanker_${tail}`;
} else {
raw = '';
}
}
if (!raw) return '';
if (profile && typeof profile.truncate === 'function') {
return profile.truncate(raw, 4);
}
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
},
_renderHUD(ctx) { _renderHUD(ctx) {
const hudY = 4; const hudY = 4;
+34 -8
View File
@@ -151,19 +151,19 @@ const TeamResultScene = {
* @private * @private
*/ */
_calculateAndAwardGold() { _calculateAndAwardGold() {
let gold = 50; // Base reward per requirements let gold = 30; // Base reward (reduced from 50)
// Find local player stats // Find local player stats
const localPlayer = this._players.find(p => p.isLocal); const localPlayer = this._players.find(p => p.isLocal);
if (localPlayer) { if (localPlayer) {
const stats = this._stats[localPlayer.playerId] || {}; const stats = this._stats[localPlayer.playerId] || {};
gold += (stats.kills || 0) * 10; gold += (stats.kills || 0) * 5;
gold += (stats.assists || 0) * 5; gold += (stats.assists || 0) * 3;
} }
// Victory bonus // Victory bonus (reduced from 50)
if (this._didWin) { if (this._didWin) {
gold += 50; gold += 30;
} }
this._goldReward = gold; this._goldReward = gold;
@@ -358,7 +358,7 @@ const TeamResultScene = {
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC'; ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial'; ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId); const name = player.isBot ? t('teamResult.bot') : this._getDisplayName(player);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2); ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats // Stats
@@ -406,7 +406,7 @@ const TeamResultScene = {
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC'; ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial'; ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId); const name = player.isBot ? t('teamResult.bot') : this._getDisplayName(player);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2); ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats // Stats
@@ -451,7 +451,7 @@ const TeamResultScene = {
ctx.fillStyle = '#FFD700'; ctx.fillStyle = '#FFD700';
ctx.font = 'bold 11px Arial'; ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : mvp.playerId; const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : this._getDisplayName(mvp);
ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y); ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y);
} }
@@ -583,6 +583,32 @@ const TeamResultScene = {
return; return;
} }
}, },
/**
* Compute a display name for the results table ( 4 CJK chars).
* @private
*/
_getDisplayName(player) {
if (!player) return '';
const profile = GameGlobal.playerProfile;
let raw = '';
if (player.isLocal && profile && profile.nickname) {
raw = profile.nickname;
} else {
raw = player.nickname || '';
}
if (!raw) {
if (profile && typeof profile.getDisplayName === 'function') {
raw = profile.getDisplayName(player.playerId);
} else {
raw = player.playerId || '';
}
}
if (profile && typeof profile.truncate === 'function') {
return profile.truncate(raw, 4);
}
return raw.length > 10 ? raw.substring(0, 10) + '..' : raw;
},
}; };
module.exports = TeamResultScene; module.exports = TeamResultScene;
+8 -824
View File
@@ -1,832 +1,16 @@
/** /**
* TeamRoomScene.js * TeamRoomScene.js
* 3v3 Team room UI scene. * 3v3 Team room delegates to the shared TeamRoomSceneFactory.
* Supports team creation, joining, ready state, leader controls,
* matchmaking, and WeChat friend invitation.
*/ */
const { const { createTeamRoomScene } = require('./TeamRoomSceneFactory');
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
TEAM_SIZE,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================ const TeamRoomScene = createTeamRoomScene({
// Layout Constants teamSize: 3,
// ============================================================ battleMode: '3v3',
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180); i18nPrefix: 'teamRoom',
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07); logTag: 'TeamRoom',
const BTN_GAP = 10; shareImageUrl: 'js/ui/images/3v3.png',
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select', // Choose: create team or solo match
JOINING: 'joining', // Auto-joining a team from invite
FORMING: 'forming', // Team room, waiting for members
MATCHING: 'matching', // In matchmaking queue
COUNTDOWN: 'countdown', // Match found, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Team Room Scene
// ============================================================
const TeamRoomScene = {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null, // { teamId, state, leaderId, teamA, teamB }
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0, // Seconds elapsed in matching
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._buildLayout();
// Setup network events BEFORE auto-join so listeners are ready
this._setupNetworkEvents();
// If entering with a teamId (from invite card), auto-join
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
/**
* Update share content so that any share from the top-right menu
* always carries the current teamId.
* @private
*/
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${this._teamData.teamId}`,
}); });
}
},
exit() {
this._cleanupNetworkEvents();
// Reset share content when leaving team room
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Team member slots (5 slots in a row)
const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < TEAM_SIZE; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({
x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT,
});
// Kick button (small X at top-right of slot)
this._kickBtnRects.push({
x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16,
});
}
// Action buttons (below slots)
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
// Keep share content up-to-date with current teamId
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
// Only switch to error state if not already in game transition
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId: this._myPlayerId,
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
// Team ID display
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
// Render team member slots
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
// Slot background
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
// Avatar placeholder (circle)
const avatarR = Math.min(rect.w, rect.h) * 0.22;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.3;
ctx.fillStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.beginPath();
ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2);
ctx.fill();
// Player icon
ctx.fillStyle = '#FFFFFF';
ctx.font = `${avatarR}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🎖', avatarCX, avatarCY);
// Leader badge
if (member.isLeader) {
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 9px Arial';
ctx.fillText(t('teamRoom.leader'), avatarCX, avatarCY + avatarR + 10);
}
// Player name (truncated)
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
// Ready state
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
// Kick button (only for leader, not on self)
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
// Action buttons based on role
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite'));
// Match button: only enabled if all ready
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000');
} else {
// Member: ready/unready button
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
// Render team slots (smaller, at top)
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = '#0f3460';
ctx.lineWidth = 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
// Matching animation
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
// Cancel button (leader only)
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
// Invite button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
// Match button
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
// Disband button
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
// Kick buttons
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
// Ready button
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
// Leave button
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.send(NET_MSG.CREATE_TEAM, {});
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
// State will be updated by TEAM_STATE event from server
nm.send(NET_MSG.SOLO_MATCH, {});
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
// Show a joining indicator
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error('[TeamRoom] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${teamId}`,
};
console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`);
// WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden.
// Must use passive sharing via onShareAppMessage callback.
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log('[TeamRoom] Share not available, teamId:', teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
// Check all members are ready before sending
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
// Leave team if in one
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
// Cancel match first, then disband
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = TeamRoomScene; module.exports = TeamRoomScene;
+947
View File
@@ -0,0 +1,947 @@
/**
* TeamRoomSceneFactory.js
* Factory function that creates a team room scene object.
* Shared by 2v2 and 3v3 team rooms eliminates code duplication.
*
* Usage:
* const Team2v2RoomScene = createTeamRoomScene({
* teamSize: 2,
* battleMode: '2v2',
* i18nPrefix: 'team2v2Room',
* logTag: 'Team2v2Room',
* });
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants (shared across all team room variants)
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 10;
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States (shared)
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select',
JOINING: 'joining',
FORMING: 'forming',
MATCHING: 'matching',
COUNTDOWN: 'countdown',
ERROR: 'error',
};
// ============================================================
// i18n helper
// ============================================================
function tp(prefix, key, params) {
const fullKey = `${prefix}.${key}`;
return params ? t(fullKey, params) : t(fullKey);
}
// ============================================================
// Factory
// ============================================================
function createTeamRoomScene(config) {
const {
teamSize,
battleMode,
i18nPrefix,
logTag,
shareImageUrl = '',
} = config;
return {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null,
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0,
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
_avatarImages: {},
// ---- Lifecycle ----
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._avatarImages = {};
this._buildLayout();
this._setupNetworkEvents();
this._setupProfileListener();
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
exit() {
this._cleanupNetworkEvents();
this._cleanupProfileListener();
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
// ---- Layout ----
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
const totalSlotsWidth = teamSize * SLOT_WIDTH + (teamSize - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < teamSize; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({ x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT });
this._kickBtnRects.push({ x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16 });
}
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
// ---- Share ----
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: tp(i18nPrefix, 'shareTitle'),
imageUrl: shareImageUrl,
query: `teamId=${this._teamData.teamId}&mode=${battleMode}`,
});
}
},
// ---- Network Events ----
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
if (data.teamA) {
for (const m of data.teamA) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
if (data.teamB) {
for (const m of data.teamB) {
if (m.avatarUrl && this._avatarImages[m.playerId] === null) {
delete this._avatarImages[m.playerId];
}
}
}
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
// ---- Profile Listener ----
_setupProfileListener() {
this._cleanupProfileListener();
const bus = (typeof GameGlobal !== 'undefined') ? GameGlobal.eventBus : null;
if (!bus || typeof bus.on !== 'function') return;
this._profileUnsub = bus.on('profile:updated', () => {
if (this._teamData && this._networkManager && this._networkManager.connected) {
this._networkManager.send(NET_MSG.PING);
}
if (this._myPlayerId && this._avatarImages[this._myPlayerId] !== undefined) {
delete this._avatarImages[this._myPlayerId];
}
});
},
_cleanupProfileListener() {
if (this._profileUnsub) {
this._profileUnsub();
this._profileUnsub = null;
}
},
// ---- Game Start ----
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
battleMode: data.battleMode || battleMode,
roomId: data.roomId || '',
myPlayerId: this._myPlayerId,
});
},
// ---- Render ----
render(ctx) {
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tp(i18nPrefix, 'title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tp(i18nPrefix, 'joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, tp(i18nPrefix, 'createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, tp(i18nPrefix, 'soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
const members = this._teamData.teamA || [];
for (let i = 0; i < teamSize; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
member.ready ? tp(i18nPrefix, 'ready') : tp(i18nPrefix, 'notReady'),
avatarCX, rect.y + rect.h * 0.88,
);
}
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(tp(i18nPrefix, 'emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, tp(i18nPrefix, 'invite'));
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, tp(i18nPrefix, 'startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, tp(i18nPrefix, 'disband'), false, 12, '#8B0000');
} else {
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? tp(i18nPrefix, 'cancelReady') : tp(i18nPrefix, 'readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, tp(i18nPrefix, 'leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
const members = this._teamData.teamA || [];
for (let i = 0; i < teamSize; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
const avatarR = Math.min(rect.w, rect.h) * 0.32;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.42;
this._drawAvatar(ctx, member, avatarCX, avatarCY, avatarR);
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
member.ready ? tp(i18nPrefix, 'ready') : tp(i18nPrefix, 'notReady'),
avatarCX, rect.y + rect.h * 0.88,
);
}
} else {
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, tp(i18nPrefix, 'cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(tp(i18nPrefix, 'matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(tp(i18nPrefix, 'tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
// ---- Drawing Helpers ----
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_drawAvatar(ctx, member, cx, cy, r) {
const img = this._avatarImages[member.playerId];
if (img) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.strokeStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
} else {
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl && !this._avatarImages[member.playerId]) {
this._loadAvatar(member);
}
}
const bgColor = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.fillStyle = bgColor;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.beginPath();
ctx.arc(cx, cy - r * 0.2, r * 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx, cy + r * 0.55, r * 0.55, r * 0.35, 0, Math.PI, 0);
ctx.fill();
ctx.strokeStyle = bgColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);
ctx.stroke();
this._loadAvatar(member);
}
},
_loadAvatar(member) {
let avatarUrl = member.avatarUrl;
if (member.playerId === this._myPlayerId) {
const profile = (typeof GameGlobal !== 'undefined') ? GameGlobal.playerProfile : null;
if (profile && profile.avatarUrl) {
avatarUrl = profile.avatarUrl;
}
}
if (!avatarUrl || this._avatarImages[member.playerId] !== undefined) return;
this._avatarImages[member.playerId] = null;
try {
const img = wx.createImage();
img.onload = () => {
this._avatarImages[member.playerId] = img;
};
img.onerror = () => {
// Keep null so we don't retry endlessly
};
img.src = avatarUrl;
} catch (e) {
// wx.createImage not available
}
},
_getDisplayName(member) {
if (!member) return '';
const profile = GameGlobal.playerProfile;
let raw = member.nickname || '';
if (!raw) {
if (profile && typeof profile.getDisplayName === 'function') {
raw = profile.getDisplayName(member.playerId);
} else {
raw = member.playerId || '';
}
}
if (profile && typeof profile.truncate === 'function') {
return profile.truncate(raw, 4);
}
return raw.length > 8 ? raw.substring(0, 8) + '..' : raw;
},
// ---- Touch Handling ----
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
// ---- Action Handlers ----
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.createTeam(battleMode);
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
nm.soloMatch(battleMode);
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[${logTag}] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error(`[${logTag}] Auto-join failed:`, e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: tp(i18nPrefix, 'shareTitle'),
imageUrl: shareImageUrl,
query: `teamId=${teamId}&mode=${battleMode}`,
};
console.log(`[${logTag}] Sharing invite with query: teamId=${teamId}&mode=${battleMode}`);
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log(`[${logTag}] Share not available, teamId:`, teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
}
module.exports = { createTeamRoomScene, TEAM_STATE };
+389
View File
@@ -0,0 +1,389 @@
/**
* PrivacyPopup.js
* Privacy authorization popup for WeChat mini-game.
*
* When a privacy-sensitive API (e.g. wx.getUserInfo) is called, WeChat
* triggers `wx.onNeedPrivacyAuthorization`. This popup shows a compliant
* dialog that explains what data we collect and lets the user agree or
* decline before we call `resolve({ buttonAction: 'agree' })`.
*
* Compliance notes (WeChat 2024+):
* - Must clearly state what data is collected and why.
* - Must provide a way to decline (user can still play with placeholder).
* - Must link to the full privacy policy.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Colors
const OVERLAY_BG = 'rgba(0, 0, 0, 0.75)';
const DIALOG_BG = '#1e1e32';
const DIALOG_BORDER = '#4a90d9';
const TITLE_COLOR = '#FFD700';
const TEXT_COLOR = '#CCCCCC';
const LINK_COLOR = '#4a90d9';
const AGREE_BG = '#4a90d9';
const AGREE_TEXT = '#FFFFFF';
const DECLINE_BG = 'rgba(255, 255, 255, 0.08)';
const DECLINE_BORDER = '#666666';
const DECLINE_TEXT = '#AAAAAA';
class PrivacyPopup {
constructor() {
/** @type {boolean} Whether the popup is currently visible. */
this._active = false;
/** @type {Function|null} The resolve callback from onNeedPrivacyAuthorization. */
this._resolveCallback = null;
/** @type {string} Which API triggered the privacy request. */
this._referrer = '';
/** @type {object|null} Button hit-test rects (computed during render). */
this._agreeBtn = null;
this._declineBtn = null;
this._policyLink = null;
}
// ============================================================
// Public API
// ============================================================
get active() {
return this._active;
}
/**
* Show the privacy popup.
* @param {Function} resolve - The resolve callback from onNeedPrivacyAuthorization.
* @param {object} eventInfo - The eventInfo from onNeedPrivacyAuthorization.
*/
show(resolve, eventInfo) {
// Guard: if popup is already active, chain the new resolve callback
// so that when the current popup resolves, both callbacks get called.
// This prevents showing two popups at once.
if (this._active && this._resolveCallback) {
console.warn('[PrivacyPopup] Already active, chaining resolve callback. referrer:', this._referrer, ', new referrer:', (eventInfo && eventInfo.referrer) || '');
const prevResolve = this._resolveCallback;
this._resolveCallback = (result) => {
try { prevResolve(result); } catch (e) { /* ignore */ }
try { resolve(result); } catch (e) { /* ignore */ }
};
return;
}
this._active = true;
this._resolveCallback = resolve;
this._referrer = (eventInfo && eventInfo.referrer) || '';
console.log('[PrivacyPopup] Showing popup, referrer:', this._referrer);
}
/**
* Hide the popup (without resolving used internally after resolve is called).
*/
hide() {
this._active = false;
this._resolveCallback = null;
this._agreeBtn = null;
this._declineBtn = null;
this._policyLink = null;
}
/**
* Handle touch event. Returns true if the touch was consumed.
* @param {string} eventType - 'touchstart' | 'touchend'
* @param {object} e - The touch event object.
* @returns {boolean}
*/
handleTouch(eventType, e) {
if (!this._active) return false;
if (eventType !== 'touchend') return true; // Consume touchstart to prevent bleed-through
const touch = e.changedTouches && e.changedTouches[0];
if (!touch) return true;
const tx = touch.clientX;
const ty = touch.clientY;
// Agree button
if (this._agreeBtn && this._hitTest(tx, ty, this._agreeBtn)) {
console.log('[PrivacyPopup] User tapped AGREE');
this._resolve('agree');
return true;
}
// Decline button
if (this._declineBtn && this._hitTest(tx, ty, this._declineBtn)) {
console.log('[PrivacyPopup] User tapped DECLINE');
this._resolve('disagree');
return true;
}
// Privacy policy link
if (this._policyLink && this._hitTest(tx, ty, this._policyLink)) {
console.log('[PrivacyPopup] User tapped privacy policy link');
this._openPrivacyPolicy();
return true;
}
return true; // Consume all touches while popup is active
}
// ============================================================
// Render
// ============================================================
render(ctx) {
if (!this._active) return;
const cw = SCREEN_WIDTH;
const ch = SCREEN_HEIGHT;
const cx = cw / 2;
const cy = ch / 2;
// --- Semi-transparent overlay ---
ctx.fillStyle = OVERLAY_BG;
ctx.fillRect(0, 0, cw, ch);
// --- Dialog box ---
const dialogW = Math.min(cw * 0.88, 420);
const dialogH = Math.min(ch * 0.78, 320);
const dialogX = cx - dialogW / 2;
const dialogY = cy - dialogH / 2;
// Shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
this._roundRect(ctx, dialogX + 4, dialogY + 4, dialogW, dialogH, 12);
ctx.fill();
// Background
ctx.fillStyle = DIALOG_BG;
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
ctx.fill();
// Border
ctx.strokeStyle = DIALOG_BORDER;
ctx.lineWidth = 1.5;
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
ctx.stroke();
// --- Shield icon (Canvas path) ---
const iconY = dialogY + 32;
const sw = 22; // shield width
const sh = 26; // shield height
const sx = cx - sw / 2;
const sy = iconY - sh / 2;
// Shield body
ctx.fillStyle = DIALOG_BORDER;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + sw, sy);
ctx.lineTo(sx + sw, sy + sh * 0.55);
ctx.quadraticCurveTo(sx + sw, sy + sh * 0.85, cx, sy + sh);
ctx.quadraticCurveTo(sx, sy + sh * 0.85, sx, sy + sh * 0.55);
ctx.closePath();
ctx.fill();
// Checkmark inside shield
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(cx - 6, iconY - 1);
ctx.lineTo(cx - 2, iconY + 4);
ctx.lineTo(cx + 7, iconY - 5);
ctx.stroke();
// --- Title ---
const titleY = iconY + 30;
ctx.fillStyle = TITLE_COLOR;
ctx.font = 'bold 17px Arial';
ctx.fillText(t('privacy.title'), cx, titleY);
// --- Body text ---
const bodyY = titleY + 28;
const bodyW = dialogW - 50;
ctx.fillStyle = TEXT_COLOR;
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const bodyLines = this._wrapText(ctx, t('privacy.body'), bodyW);
for (let i = 0; i < bodyLines.length; i++) {
ctx.fillText(bodyLines[i], dialogX + 25, bodyY + i * 20);
}
// --- Privacy policy link ---
const linkY = bodyY + bodyLines.length * 20 + 10;
const linkText = t('privacy.policyLink');
ctx.fillStyle = LINK_COLOR;
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(linkText, cx, linkY);
// Store link hit area
const linkMetrics = ctx.measureText(linkText);
this._policyLink = {
x: cx - linkMetrics.width / 2 - 6,
y: linkY - 10,
w: linkMetrics.width + 12,
h: 20,
};
// Underline the link
ctx.strokeStyle = LINK_COLOR;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - linkMetrics.width / 2, linkY + 7);
ctx.lineTo(cx + linkMetrics.width / 2, linkY + 7);
ctx.stroke();
// --- Buttons ---
const btnAreaY = linkY + 30;
const btnW = Math.min((dialogW - 60) / 2, 150);
const btnH = 38;
const btnGap = 16;
const agreeX = cx - btnW - btnGap / 2;
const declineX = cx + btnGap / 2;
// Agree button
ctx.fillStyle = AGREE_BG;
this._roundRect(ctx, agreeX, btnAreaY, btnW, btnH, 8);
ctx.fill();
ctx.fillStyle = AGREE_TEXT;
ctx.font = 'bold 15px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('privacy.agree'), agreeX + btnW / 2, btnAreaY + btnH / 2);
this._agreeBtn = { x: agreeX, y: btnAreaY, w: btnW, h: btnH };
// Decline button
ctx.fillStyle = DECLINE_BG;
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
ctx.fill();
ctx.strokeStyle = DECLINE_BORDER;
ctx.lineWidth = 1;
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
ctx.stroke();
ctx.fillStyle = DECLINE_TEXT;
ctx.font = '14px Arial';
ctx.fillText(t('privacy.decline'), declineX + btnW / 2, btnAreaY + btnH / 2);
this._declineBtn = { x: declineX, y: btnAreaY, w: btnW, h: btnH };
// --- Footer hint ---
const footerY = btnAreaY + btnH + 16;
ctx.fillStyle = '#666666';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('privacy.footer'), cx, footerY);
}
// ============================================================
// Private
// ============================================================
/**
* Resolve the privacy authorization request.
* @param {'agree'|'disagree'} action
*/
_resolve(action) {
if (!this._resolveCallback) {
console.warn('[PrivacyPopup] No resolve callback to call');
this.hide();
return;
}
try {
if (action === 'agree') {
console.log('[PrivacyPopup] Resolving with agree');
this._resolveCallback({ event: 'agree' });
} else {
console.log('[PrivacyPopup] Resolving with disagree');
// Disagree still needs to resolve, but the API will fail gracefully
this._resolveCallback({ event: 'disagree' });
}
} catch (e) {
console.error('[PrivacyPopup] resolve() threw:', e);
}
this.hide();
}
/**
* Open the privacy policy document.
* Uses wx.openPrivacyContract if available.
*/
_openPrivacyPolicy() {
try {
if (typeof wx !== 'undefined' && typeof wx.openPrivacyContract === 'function') {
wx.openPrivacyContract({
success: () => console.log('[PrivacyPopup] Privacy contract opened'),
fail: (err) => console.warn('[PrivacyPopup] Failed to open privacy contract:', err),
});
} else {
console.warn('[PrivacyPopup] wx.openPrivacyContract is not available');
}
} catch (e) {
console.warn('[PrivacyPopup] Error opening privacy contract:', e);
}
}
/**
* Simple hit-test for rectangular area.
*/
_hitTest(tx, ty, rect) {
return tx >= rect.x && tx <= rect.x + rect.w &&
ty >= rect.y && ty <= rect.y + rect.h;
}
/**
* Wrap text into lines that fit within maxWidth.
*/
_wrapText(ctx, text, maxWidth) {
const lines = [];
const paragraphs = text.split('\n');
for (const para of paragraphs) {
let line = '';
for (let i = 0; i < para.length; i++) {
const testLine = line + para[i];
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line.length > 0) {
lines.push(line);
line = para[i];
} else {
line = testLine;
}
}
if (line) lines.push(line);
}
return lines;
}
/**
* Draw a rounded rectangle path.
*/
_roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
}
module.exports = PrivacyPopup;
Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
"disableSWC": true "disableSWC": true
}, },
"compileType": "game", "compileType": "game",
"libVersion": "2.25.0", "libVersion": "3.15.1",
"appid": "wx3527fe2fd49db523", "appid": "wx3527fe2fd49db523",
"projectname": "tankwar", "projectname": "tankwar",
"condition": {}, "condition": {},
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"libVersion": "3.15.1", "libVersion": "3.15.1",
"projectname": "tankwar", "projectname": "tankwar_proj",
"condition": { "condition": {
"game": { "game": {
"list": [ "list": [
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
npm-debug.log
.DS_Store
.git
.gitignore
Dockerfile
.dockerignore
*.md
+257
View File
@@ -0,0 +1,257 @@
# Tank War Server - 完整部署指南
## 概述
本指南详细说明如何将Tank War Server部署到由3台CVM组成的Kubernetes集群中。
## 集群信息
目标K8s集群由以下4台CVM组成:
- 43.139.80.61 (host_172.16.16.16) — Master
- 10.1.0.6 (vm-0-6-opencloudos) — Worker
- 172.16.32.10 (vm-32-10-tencentos) — Worker
- 172.16.32.16 (vm-32-16-tencentos) — Worker
SSH连接:`ssh root@host_172.16.16.16`
## 部署前准备
### 1. 环境检查
```bash
# 在server目录下运行测试脚本
cd /Users/hanchengxi/workspace/tankwar_proj/server
./test-deployment.sh
```
### 2. 配置Kubernetes访问
确保kubectl已配置连接到目标集群:
```bash
# 检查集群连接
kubectl cluster-info
# 查看当前上下文
kubectl config current-context
# 如果未配置,需要获取集群的kubeconfig文件
# 通常从集群管理员处获取或通过云平台控制台下载
```
## 部署步骤
### 步骤1:构建Docker镜像
```bash
# 在server目录下构建镜像
docker build -t tankwar-server:latest .
# 验证镜像构建成功
docker images | grep tankwar-server
```
### 步骤2:部署到Kubernetes
```bash
# 方法1:使用部署脚本(推荐)
chmod +x deploy.sh
./deploy.sh
# 方法2:手动部署
kubectl create namespace tankwar --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s-deployment.yaml -n tankwar
```
### 步骤3:验证部署
```bash
# 运行验证脚本
chmod +x verify-deployment.sh
./verify-deployment.sh
# 或手动验证
kubectl get all -n tankwar
kubectl logs -l app=tankwar-server -n tankwar
```
## 配置文件说明
### Dockerfile
- 基于Node.js 18 Alpine镜像
- 暴露端口3000
- 生产环境配置
### k8s-deployment.yaml
包含以下Kubernetes资源:
1. **ConfigMap**: 环境变量配置
2. **Deployment**:
- 3个副本
- 资源限制:内存512MiCPU 500m
- 健康检查探针
3. **Service**:
- LoadBalancer类型
- 端口3000
## 网络配置
### 服务暴露
服务使用LoadBalancer类型,将通过云平台的负载均衡器暴露:
```bash
# 获取外部IP
kubectl get svc tankwar-server-service -n tankwar
# WebSocket连接地址
ws://<external-ip>:3000
```
### 端口映射
- 容器端口:3000
- 服务端口:3000
- 外部访问端口:3000
## 健康检查
服务器提供HTTP健康检查端点:
```bash
# 健康检查URL
http://<external-ip>:3000/health
# 返回JSON格式的健康状态
{
"status": "healthy",
"timestamp": "2024-01-01T00:00:00.000Z",
"activeConnections": 0,
"activeRooms": 0,
"activeTeamRooms": 0
}
```
## 监控和维护
### 查看状态
```bash
# 查看Pod状态
kubectl get pods -n tankwar -w
# 查看服务状态
kubectl get svc -n tankwar
# 查看日志
kubectl logs -l app=tankwar-server -n tankwar --tail=50
# 查看资源使用
kubectl top pods -n tankwar
```
### 扩展和伸缩
```bash
# 扩展副本数量
kubectl scale deployment/tankwar-server --replicas=5 -n tankwar
# 自动伸缩(如果配置了HPA
kubectl autoscale deployment/tankwar-server --min=3 --max=10 --cpu-percent=80 -n tankwar
```
### 更新部署
```bash
# 重新构建镜像
docker build -t tankwar-server:latest .
# 滚动更新
kubectl rollout restart deployment/tankwar-server -n tankwar
# 查看更新状态
kubectl rollout status deployment/tankwar-server -n tankwar
```
## 故障排除
### 常见问题
1. **镜像构建失败**
```bash
# 检查Docker守护进程
docker info
# 检查Dockerfile语法
docker build --no-cache -t tankwar-server:latest .
```
2. **Pod无法启动**
```bash
# 查看Pod详情
kubectl describe pod <pod-name> -n tankwar
# 查看事件
kubectl get events -n tankwar
```
3. **服务无法访问**
```bash
# 检查服务端点
kubectl get endpoints tankwar-server-service -n tankwar
# 检查网络策略
kubectl get networkpolicies -n tankwar
```
4. **健康检查失败**
```bash
# 检查Pod内部
kubectl exec -it <pod-name> -n tankwar -- wget -qO- http://localhost:3000/health
```
### 调试命令
```bash
# 进入Pod调试
kubectl exec -it <pod-name> -n tankwar -- /bin/sh
# 端口转发本地调试
kubectl port-forward svc/tankwar-server-service 3000:3000 -n tankwar
# 然后访问:http://localhost:3000/health
```
## 清理部署
```bash
# 删除部署
kubectl delete -f k8s-deployment.yaml -n tankwar
# 删除命名空间
kubectl delete namespace tankwar
# 清理镜像
docker rmi tankwar-server:latest
```
## 安全考虑
1. **网络策略**: 配置适当的网络策略限制访问
2. **资源限制**: 设置合理的资源限制防止资源耗尽
3. **镜像安全**: 定期更新基础镜像修复安全漏洞
4. **访问控制**: 配置RBAC权限控制
## 性能优化建议
1. **副本数量**: 根据负载调整副本数量
2. **资源分配**: 根据实际使用情况调整资源限制
3. **连接池**: 考虑使用连接池优化WebSocket连接
4. **监控告警**: 配置监控和告警系统
## 支持信息
如有问题,请检查:
- 服务器日志:`kubectl logs -l app=tankwar-server -n tankwar`
- 部署状态:`kubectl get all -n tankwar`
- 集群状态:`kubectl cluster-info`
+19
View File
@@ -0,0 +1,19 @@
FROM node:18-alpine
LABEL maintainer="tankwar" \
description="Tank War PVP WebSocket Server"
WORKDIR /app
# Copy all source including node_modules (pre-installed locally)
# This avoids npm install issues due to network restrictions on build nodes
COPY . .
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
EXPOSE 3000
# Use node directly (tini unavailable due to network restrictions on nodes)
CMD ["node", "index.js"]
+183
View File
@@ -0,0 +1,183 @@
# Tank War Server - Kubernetes部署指南
## 概述
Tank War Server是一个基于WebSocket的多人坦克对战游戏服务器,支持1v1和3v3对战模式。本文档说明如何将服务器部署到Kubernetes集群中。
## 前置要求
- Docker
- Kubernetes集群访问权限
- kubectl命令行工具
- 对目标K8s集群的访问配置
## 部署步骤
### 1. 准备环境
确保您已配置好对目标Kubernetes集群的访问:
```bash
# 检查集群连接
kubectl cluster-info
# 查看当前上下文
kubectl config current-context
```
### 2. 构建Docker镜像
```bash
# 在server目录下构建镜像
cd server
docker build -t tankwar-server:latest .
```
### 3. 部署到Kubernetes
使用提供的部署脚本:
```bash
# 给脚本执行权限
chmod +x deploy.sh
# 执行部署
./deploy.sh
```
或者手动部署:
```bash
# 创建命名空间
kubectl create namespace tankwar
# 部署应用
kubectl apply -f k8s-deployment.yaml -n tankwar
# 等待部署完成
kubectl rollout status deployment/tankwar-server -n tankwar
```
### 4. 验证部署
```bash
# 查看Pod状态
kubectl get pods -n tankwar
# 查看服务信息
kubectl get svc -n tankwar
# 查看日志
kubectl logs -l app=tankwar-server -n tankwar
```
## 配置说明
### 环境变量
- `PORT`: 服务器端口(默认:3000
- `HOST`: 绑定地址(默认:0.0.0.0
- `NODE_ENV`: 运行环境(默认:production
### 资源限制
- 内存请求:256Mi,限制:512Mi
- CPU请求:250m,限制:500m
### 健康检查
服务器提供健康检查端点:
- URL: `/health`
- 返回JSON格式的健康状态信息
## 网络配置
服务使用LoadBalancer类型暴露,可以通过外部IP访问。WebSocket连接地址格式:
```
ws://<external-ip>:3000
```
## 监控和日志
### 查看日志
```bash
# 查看所有Pod日志
kubectl logs -l app=tankwar-server -n tankwar
# 查看特定Pod日志
kubectl logs <pod-name> -n tankwar
```
### 扩展和伸缩
```bash
# 扩展副本数量
kubectl scale deployment/tankwar-server --replicas=5 -n tankwar
# 查看资源使用情况
kubectl top pods -n tankwar
```
## 故障排除
### 常见问题
1. **镜像构建失败**
- 检查Docker守护进程是否运行
- 确认Dockerfile语法正确
2. **部署失败**
- 检查kubectl集群连接
- 验证k8s-deployment.yaml文件语法
3. **Pod无法启动**
- 查看Pod事件:`kubectl describe pod <pod-name> -n tankwar`
- 检查资源配额是否足够
4. **连接问题**
- 确认服务已分配外部IP
- 检查防火墙规则
### 调试命令
```bash
# 查看Pod详细信息
kubectl describe pod -l app=tankwar-server -n tankwar
# 进入Pod调试
kubectl exec -it <pod-name> -n tankwar -- /bin/sh
# 查看服务端点
kubectl get endpoints tankwar-server-service -n tankwar
```
## 维护操作
### 更新部署
```bash
# 重新构建镜像
docker build -t tankwar-server:latest .
# 更新部署
kubectl rollout restart deployment/tankwar-server -n tankwar
```
### 清理部署
```bash
# 删除部署
kubectl delete -f k8s-deployment.yaml -n tankwar
# 删除命名空间
kubectl delete namespace tankwar
```
## 安全考虑
- 在生产环境中考虑使用Ingress控制器
- 配置适当的网络策略
- 定期更新镜像以修复安全漏洞
- 监控资源使用情况防止资源耗尽
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Tank War Server K8s部署脚本
set -e
# 配置变量
IMAGE_NAME="tankwar-server"
K8S_NAMESPACE="tankwar"
K8S_CONFIG="k8s-deployment.yaml"
# 检查Docker是否运行
if ! docker info > /dev/null 2>&1; then
echo "错误: Docker守护进程未运行"
exit 1
fi
# 构建Docker镜像
echo "构建Docker镜像..."
docker build -t $IMAGE_NAME:latest .
# 登录到目标K8s集群(假设已配置kubectl)
echo "检查Kubernetes集群连接..."
kubectl cluster-info
# 创建命名空间(如果不存在)
echo "创建命名空间..."
kubectl create namespace $K8S_NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
# 部署应用到K8s
echo "部署应用到Kubernetes..."
kubectl apply -f $K8S_CONFIG -n $K8S_NAMESPACE
# 等待部署完成
echo "等待部署完成..."
kubectl rollout status deployment/tankwar-server -n $K8S_NAMESPACE --timeout=300s
# 获取服务信息
echo "获取服务信息..."
kubectl get svc tankwar-server-service -n $K8S_NAMESPACE
echo "部署完成!"
echo "使用以下命令查看Pod状态:"
echo "kubectl get pods -n $K8S_NAMESPACE"
echo ""
echo "使用以下命令查看日志:"
echo "kubectl logs -l app=tankwar-server -n $K8S_NAMESPACE"
+264 -36
View File
@@ -2,18 +2,111 @@
* Tank War PVP Server * Tank War PVP Server
* WebSocket server for online 1v1 multiplayer. * WebSocket server for online 1v1 multiplayer.
* Handles room management, message relay, and basic game state authority. * Handles room management, message relay, and basic game state authority.
*
* Deployment note:
* - /health HTTP health check (used by K8s livenessProbe/readinessProbe)
* - /tankwar/ws WebSocket upgrade path (exposed publicly via Nginx)
* Both share the same HTTP server on PORT.
*/ */
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const express = require('express');
const http = require('http');
// ============================================================
// Content Security Proxy Configuration// The content security service is now deployed as an independent
// microservice in the "content-security" K8s namespace.
// This proxy forwards /api/content/* requests to that service.
// ============================================================
const CONTENT_SECURITY_SERVICE_URL = process.env.CONTENT_SECURITY_SERVICE_URL
|| 'http://content-security-service.content-security.svc.cluster.local:3000';
const GAME_ID = process.env.GAME_ID || 'tankwar';
// ============================================================ // ============================================================
// Configuration // Configuration
// ============================================================ // ============================================================
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
const WS_PATH = process.env.WS_PATH || '/tankwar/ws';
const HEARTBEAT_INTERVAL = 10000; // ms const HEARTBEAT_INTERVAL = 10000; // ms
const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout
// ============================================================
// Express HTTP Server + Content Security API
// ============================================================
const app = express();
const server = http.createServer(app);
// Parse JSON bodies
app.use(express.json({ limit: '2mb' }));
// Health check endpoints (for K8s livenessProbe/readinessProbe)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/tankwar/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
// ============================================================
// Content Security Proxy
// Forward /api/content/* requests to the independent
// content-security-service in the content-security namespace.
// This allows the tankwar-server to delegate all content
// moderation to the shared microservice.
// ============================================================
app.use('/api/content', (req, res, next) => {
// Parse target host and port
const urlObj = new URL(CONTENT_SECURITY_SERVICE_URL);
const targetPath = `/api/content${req.path}`;
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const finalPath = qs ? `${targetPath}${qs}` : targetPath;
const proxyReq = http.request({
hostname: urlObj.hostname,
port: urlObj.port || 80,
path: finalPath,
method: req.method,
headers: {
...req.headers,
host: `${urlObj.hostname}:${urlObj.port || 80}`,
'X-Game-Id': GAME_ID,
'X-Forwarded-For': req.ip || req.socket.remoteAddress,
},
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
proxyReq.on('error', (err) => {
console.error('[TankWar Proxy] Failed to forward to content-security-service:', err.message);
if (!res.headersSent) {
res.status(502).json({
errcode: -1,
errmsg: '内容安全服务暂时不可用,请稍后再试',
});
}
});
// Pipe request body to proxy
req.pipe(proxyReq, { end: true });
});
// ============================================================ // ============================================================
// Message Types (must match client NET_MSG) // Message Types (must match client NET_MSG)
// ============================================================ // ============================================================
@@ -53,6 +146,8 @@ const NET_MSG = {
PLAYER_RESPAWN: 'player_respawn', PLAYER_RESPAWN: 'player_respawn',
TEAM_GAME_START: 'team_game_start', TEAM_GAME_START: 'team_game_start',
TEAM_GAME_OVER: 'team_game_over', TEAM_GAME_OVER: 'team_game_over',
TERRAIN_CHANGE: 'terrain_change',
BOT_STATE: 'bot_state',
RECONNECT: 'reconnect', RECONNECT: 'reconnect',
RECONNECT_OK: 'reconnect_ok', RECONNECT_OK: 'reconnect_ok',
PLAYER_DISCONNECT: 'player_disconnect', PLAYER_DISCONNECT: 'player_disconnect',
@@ -74,6 +169,7 @@ const TEAM_RECONNECT_TIMEOUT = 60000; // 60s to reconnect
// ============================================================ // ============================================================
const BATTLE_CONFIG = { const BATTLE_CONFIG = {
'1v1': { teamSize: 1, baseHp: 5, fillWithBots: false }, '1v1': { teamSize: 1, baseHp: 5, fillWithBots: false },
'2v2': { teamSize: 2, baseHp: 8, fillWithBots: true },
'3v3': { teamSize: 3, baseHp: 10, fillWithBots: true }, '3v3': { teamSize: 3, baseHp: 10, fillWithBots: true },
}; };
@@ -135,6 +231,9 @@ class PlayerInfo {
constructor(ws, playerId) { constructor(ws, playerId) {
this.ws = ws; this.ws = ws;
this.playerId = playerId; this.playerId = playerId;
this.nickname = '';
this.avatarUrl = '';
this.skinId = ''; // equipped tank skin id
this.roomId = null; this.roomId = null;
this.teamId = null; this.teamId = null;
this.isAlive = true; this.isAlive = true;
@@ -152,8 +251,9 @@ class TeamRoom {
* @param {WebSocket} leaderWs - WebSocket of the team leader * @param {WebSocket} leaderWs - WebSocket of the team leader
* @param {string} leaderId - Player id of the leader * @param {string} leaderId - Player id of the leader
* @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3')
* @param {string} [leaderNickname=''] - Display nickname of the leader
*/ */
constructor(id, leaderWs, leaderId, battleMode = '3v3') { constructor(id, leaderWs, leaderId, battleMode = '3v3', leaderNickname = '', leaderAvatarUrl = '', leaderSkinId = '') {
this.id = id; this.id = id;
this.state = 'forming'; // forming | matching | playing | finished this.state = 'forming'; // forming | matching | playing | finished
this.createdAt = Date.now(); this.createdAt = Date.now();
@@ -164,8 +264,8 @@ class TeamRoom {
this.teamSize = config.teamSize; this.teamSize = config.teamSize;
this.fillWithBotsEnabled = config.fillWithBots; this.fillWithBotsEnabled = config.fillWithBots;
// Team A members: { ws, playerId, ready, isBot, disconnectedAt } // Team A members: { ws, playerId, nickname, avatarUrl, skinId, ready, isBot, disconnectedAt }
this.teamA = [{ ws: leaderWs, playerId: leaderId, ready: true, isBot: false, disconnectedAt: null }]; this.teamA = [{ ws: leaderWs, playerId: leaderId, nickname: leaderNickname || '', avatarUrl: leaderAvatarUrl || '', skinId: leaderSkinId || '', ready: true, isBot: false, disconnectedAt: null }];
// Team B members // Team B members
this.teamB = []; this.teamB = [];
this.leaderId = leaderId; this.leaderId = leaderId;
@@ -222,6 +322,11 @@ class TeamRoom {
return this.teamA.length >= this.teamSize; return this.teamA.length >= this.teamSize;
} }
/** Check if team B is full */
isTeamBFull() {
return this.teamB.length >= this.teamSize;
}
/** Check if both teams are full */ /** Check if both teams are full */
isFull() { isFull() {
return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize; return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize;
@@ -233,19 +338,21 @@ class TeamRoom {
} }
/** Add a player to team A */ /** Add a player to team A */
addToTeamA(ws, playerId) { addToTeamA(ws, playerId, nickname = '', avatarUrl = '', skinId = '') {
if (this.isTeamAFull()) return false; if (this.teamA.length >= this.teamSize) return false;
this.teamA.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); // Prevent duplicate playerId across both teams
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamA.push({ ws, playerId, nickname, avatarUrl, skinId, ready: false, isBot: false, disconnectedAt: null });
return true; return true;
} }
/** Add a player to team B */ addToTeamB(ws, playerId, nickname = '', avatarUrl = '', skinId = '') {
addToTeamB(ws, playerId) {
if (this.teamB.length >= this.teamSize) return false; if (this.teamB.length >= this.teamSize) return false;
this.teamB.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); // Prevent duplicate playerId across both teams
if (this.teamA.some(m => m.playerId === playerId) || this.teamB.some(m => m.playerId === playerId)) return false;
this.teamB.push({ ws, playerId, nickname, avatarUrl, skinId, ready: false, isBot: false, disconnectedAt: null });
return true; return true;
} }
/** Remove a player from the team room */ /** Remove a player from the team room */
removePlayer(playerId) { removePlayer(playerId) {
this.teamA = this.teamA.filter(m => m.playerId !== playerId); this.teamA = this.teamA.filter(m => m.playerId !== playerId);
@@ -260,6 +367,8 @@ class TeamRoom {
this.teamA.push({ this.teamA.push({
ws: null, ws: null,
playerId: `bot_a_${botCounter}_${this.id}`, playerId: `bot_a_${botCounter}_${this.id}`,
nickname: '',
avatarUrl: '',
ready: true, ready: true,
isBot: true, isBot: true,
disconnectedAt: null, disconnectedAt: null,
@@ -270,6 +379,8 @@ class TeamRoom {
this.teamB.push({ this.teamB.push({
ws: null, ws: null,
playerId: `bot_b_${botCounter}_${this.id}`, playerId: `bot_b_${botCounter}_${this.id}`,
nickname: '',
avatarUrl: '',
ready: true, ready: true,
isBot: true, isBot: true,
disconnectedAt: null, disconnectedAt: null,
@@ -335,6 +446,9 @@ class TeamRoom {
teamSize: this.teamSize, teamSize: this.teamSize,
teamA: this.teamA.map(m => ({ teamA: this.teamA.map(m => ({
playerId: m.playerId, playerId: m.playerId,
nickname: m.nickname || '',
avatarUrl: m.avatarUrl || '',
skinId: m.skinId || '',
ready: m.ready, ready: m.ready,
isBot: m.isBot, isBot: m.isBot,
isLeader: m.playerId === this.leaderId, isLeader: m.playerId === this.leaderId,
@@ -342,6 +456,9 @@ class TeamRoom {
})), })),
teamB: this.teamB.map(m => ({ teamB: this.teamB.map(m => ({
playerId: m.playerId, playerId: m.playerId,
nickname: m.nickname || '',
avatarUrl: m.avatarUrl || '',
skinId: m.skinId || '',
ready: m.ready, ready: m.ready,
isBot: m.isBot, isBot: m.isBot,
connected: m.isBot || (m.ws && m.ws.readyState === 1), connected: m.isBot || (m.ws && m.ws.readyState === 1),
@@ -449,7 +566,7 @@ function handleCreateRoom(ws, data) {
const roomCode = generateRoomCode(); const roomCode = generateRoomCode();
// Create a TeamRoom in 1v1 mode instead of a legacy Room // Create a TeamRoom in 1v1 mode instead of a legacy Room
const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1'); const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(roomCode, teamRoom); teamRooms.set(roomCode, teamRoom);
playerInfo.teamId = roomCode; playerInfo.teamId = roomCode;
@@ -498,7 +615,7 @@ function handleJoinRoom(ws, data) {
} }
// Join as team B // Join as team B
teamRoom.addToTeamB(ws, playerInfo.playerId); teamRoom.addToTeamB(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
playerInfo.teamId = roomId; playerInfo.teamId = roomId;
console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`);
@@ -567,12 +684,15 @@ function handleCreateTeam(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId}`); console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId} (mode: ${battleMode})`);
sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState());
} }
@@ -592,7 +712,7 @@ function handleJoinTeam(ws, data) {
// Team was cleaned up (e.g. leader disconnected during dev-tool reload). // Team was cleaned up (e.g. leader disconnected during dev-tool reload).
// Auto-create a new room with the same ID so the invite link still works. // Auto-create a new room with the same ID so the invite link still works.
console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`);
teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, (data && data.battleMode) || '3v3', playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState());
@@ -614,7 +734,7 @@ function handleJoinTeam(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
teamRoom.addToTeamA(ws, playerInfo.playerId); teamRoom.addToTeamA(ws, playerInfo.playerId, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`); console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`);
@@ -772,8 +892,10 @@ function handleMatchStart(ws, data) {
teamRoom.state = 'matching'; teamRoom.state = 'matching';
teamRoom.matchStartTime = Date.now(); teamRoom.matchStartTime = Date.now();
// Add to match pool // Add to match pool (dedup guard)
if (!teamMatchPool.includes(teamRoom)) {
teamMatchPool.push(teamRoom); teamMatchPool.push(teamRoom);
}
console.log(`[Server] Team ${teamRoom.id} entered matching pool`); console.log(`[Server] Team ${teamRoom.id} entered matching pool`);
@@ -813,16 +935,21 @@ function handleSoloMatch(ws, data) {
handleLeaveTeam(ws, {}); handleLeaveTeam(ws, {});
} }
// Read battleMode from client data, fall back to '3v3' if not provided
const battleMode = (data && data.battleMode) || '3v3';
// Create a solo team room for this player // Create a solo team room for this player
const teamId = generateTeamId(); const teamId = generateTeamId();
const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId, battleMode, playerInfo.nickname || '', playerInfo.avatarUrl || '', playerInfo.skinId || '');
teamRoom.state = 'matching'; teamRoom.state = 'matching';
teamRoom.matchStartTime = Date.now(); teamRoom.matchStartTime = Date.now();
teamRooms.set(teamId, teamRoom); teamRooms.set(teamId, teamRoom);
playerInfo.teamId = teamId; playerInfo.teamId = teamId;
// Add to solo match pool // Add to solo match pool (dedup guard)
if (!soloMatchPool.includes(ws)) {
soloMatchPool.push(ws); soloMatchPool.push(ws);
}
console.log(`[Server] Player ${playerInfo.playerId} entered solo match pool (team ${teamId})`); console.log(`[Server] Player ${playerInfo.playerId} entered solo match pool (team ${teamId})`);
@@ -886,9 +1013,11 @@ function tryMatchTeams() {
const teamA_room = teamMatchPool.shift(); const teamA_room = teamMatchPool.shift();
const teamB_room = teamMatchPool.shift(); const teamB_room = teamMatchPool.shift();
// Merge team B members into team A room as opponents // Merge team B room members into team A room as opponents
for (const member of teamB_room.teamA) { // Both teamA and teamB of teamB_room should be moved
teamA_room.addToTeamB(member.ws, member.playerId); const allBMembers = [...teamB_room.teamA, ...teamB_room.teamB];
for (const member of allBMembers) {
teamA_room.addToTeamB(member.ws, member.playerId, member.nickname || '', member.avatarUrl || '', member.skinId || '');
if (member.ws) { if (member.ws) {
const info = players.get(member.ws); const info = players.get(member.ws);
if (info) info.teamId = teamA_room.id; if (info) info.teamId = teamA_room.id;
@@ -917,8 +1046,15 @@ function tryMatchTeams() {
}); });
if (availableSolos.length >= 2) { if (availableSolos.length >= 2) {
// Take up to 10 solo players and form a game // Deduplicate by playerId — same player should not appear twice
const gamePlayers = availableSolos.splice(0, Math.min(10, availableSolos.length)); const seenIds = new Set();
const gamePlayersRaw = availableSolos.splice(0, Math.min(10, availableSolos.length));
const gamePlayers = gamePlayersRaw.filter(ws => {
const info = players.get(ws);
if (!info || seenIds.has(info.playerId)) return false;
seenIds.add(info.playerId);
return true;
});
// Remove from solo pool // Remove from solo pool
for (const ws of gamePlayers) { for (const ws of gamePlayers) {
@@ -951,11 +1087,17 @@ function tryMatchTeams() {
info.teamId = gameRoom.id; info.teamId = gameRoom.id;
// Alternate: odd index -> team A, even index -> team B // Alternate teams: first remaining player → teamB, next → teamA, etc.
if (i % 2 === 1 && !gameRoom.isTeamAFull()) { // (player at index 0 is already in teamA as the room creator)
gameRoom.addToTeamA(ws, info.playerId); const isTeamBSlot = (i % 2 === 1);
if (isTeamBSlot && !gameRoom.isTeamBFull()) {
gameRoom.addToTeamB(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else if (!isTeamBSlot && !gameRoom.isTeamAFull()) {
gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else if (!gameRoom.isTeamBFull()) {
gameRoom.addToTeamB(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} else { } else {
gameRoom.addToTeamB(ws, info.playerId); gameRoom.addToTeamA(ws, info.playerId, info.nickname || '', info.avatarUrl || '', info.skinId || '');
} }
} }
@@ -963,6 +1105,10 @@ function tryMatchTeams() {
gameRoom.fillWithBots(); gameRoom.fillWithBots();
console.log(`[Server] Solo players matched into team ${gameRoom.id}`); console.log(`[Server] Solo players matched into team ${gameRoom.id}`);
// Send MATCH_FOUND to all players before starting the game
gameRoom.broadcast(NET_MSG.MATCH_FOUND, {});
startTeamGame(gameRoom); startTeamGame(gameRoom);
} }
} }
@@ -986,8 +1132,8 @@ function startTeamGame(teamRoom) {
const gameData = { const gameData = {
mapId: teamRoom.mapId, mapId: teamRoom.mapId,
teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, isBot: m.isBot })), teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot, skinId: m.skinId || '' })),
teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, isBot: m.isBot })), teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, nickname: m.nickname || '', isBot: m.isBot, skinId: m.skinId || '' })),
teamABaseHp: teamRoom.teamABaseHp, teamABaseHp: teamRoom.teamABaseHp,
teamBBaseHp: teamRoom.teamBBaseHp, teamBBaseHp: teamRoom.teamBBaseHp,
battleMode: teamRoom.battleMode, battleMode: teamRoom.battleMode,
@@ -995,6 +1141,8 @@ function startTeamGame(teamRoom) {
}; };
console.log(`[Server] ${teamRoom.battleMode} game starting in room ${teamRoom.id}`); console.log(`[Server] ${teamRoom.battleMode} game starting in room ${teamRoom.id}`);
console.log(`[Server] teamA: ${JSON.stringify(gameData.teamA.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
console.log(`[Server] teamB: ${JSON.stringify(gameData.teamB.map(m => ({ playerId: m.playerId, isBot: m.isBot })))}`);
// For 1v1, use GAME_START message (client RoomScene listens for it) // For 1v1, use GAME_START message (client RoomScene listens for it)
// For 3v3, use TEAM_GAME_START message (client TeamRoomScene listens for it) // For 3v3, use TEAM_GAME_START message (client TeamRoomScene listens for it)
@@ -1306,7 +1454,7 @@ function handleMessage(ws, rawData) {
return; return;
} }
const { type, data, playerId } = msg; const { type, data, playerId, nickname, avatarUrl, skinId } = msg;
// Update player info // Update player info
const playerInfo = players.get(ws); const playerInfo = players.get(ws);
@@ -1315,6 +1463,54 @@ function handleMessage(ws, rawData) {
if (playerId && !playerInfo.playerId) { if (playerId && !playerInfo.playerId) {
playerInfo.playerId = playerId; playerInfo.playerId = playerId;
} }
// Refresh nickname on every message (it may be granted mid-session).
if (typeof nickname === 'string' && nickname) {
if (playerInfo.nickname !== nickname) {
playerInfo.nickname = nickname;
// Also propagate into any active team room member entry.
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.nickname !== nickname) {
member.nickname = nickname;
// Broadcast regardless of room state (forming / matching / playing)
// so that peers always render the latest display name — in 3v3 a
// player may only tap the UserInfoButton AFTER the match starts.
tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState());
}
}
}
}
}
// Refresh avatarUrl on every message (it may be granted mid-session).
if (typeof avatarUrl === 'string' && avatarUrl && playerInfo.avatarUrl !== avatarUrl) {
playerInfo.avatarUrl = avatarUrl;
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.avatarUrl !== avatarUrl) {
member.avatarUrl = avatarUrl;
tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState());
}
}
}
}
// Refresh skinId on every message (equipped skin may change mid-session).
if (typeof skinId === 'string' && skinId && playerInfo.skinId !== skinId) {
playerInfo.skinId = skinId;
if (playerInfo.teamId) {
const tr = teamRooms.get(playerInfo.teamId);
if (tr) {
const member = tr.getMemberByWs(ws);
if (member && member.skinId !== skinId) {
member.skinId = skinId;
tr.broadcast(NET_MSG.TEAM_STATE, tr.getTeamState());
}
}
}
}
} }
switch (type) { switch (type) {
@@ -1333,15 +1529,27 @@ function handleMessage(ws, rawData) {
// Relay gameplay messages // Relay gameplay messages
case NET_MSG.PLAYER_INPUT: case NET_MSG.PLAYER_INPUT:
// Messages where playerId is the sender themselves — override to prevent spoofing
case NET_MSG.PLAYER_INPUT:
if (playerInfo && playerInfo.teamId) {
relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId });
} else if (playerInfo && playerInfo.roomId) {
relayToOpponent(ws, type, data || {});
}
break;
// Messages where playerId/victimId/killerId refer to specific entities (bots, etc.)
// — must NOT be overwritten with the sender's playerId
case NET_MSG.PLAYER_STATE: case NET_MSG.PLAYER_STATE:
case NET_MSG.BULLET_FIRE: case NET_MSG.BULLET_FIRE:
case NET_MSG.BULLET_HIT: case NET_MSG.BULLET_HIT:
case NET_MSG.PLAYER_HIT: case NET_MSG.PLAYER_HIT:
case NET_MSG.PLAYER_KILLED: case NET_MSG.PLAYER_KILLED:
case NET_MSG.GAME_OVER: case NET_MSG.GAME_OVER:
// All modes now use teamRoom relay case NET_MSG.TERRAIN_CHANGE:
case NET_MSG.BOT_STATE:
if (playerInfo && playerInfo.teamId) { if (playerInfo && playerInfo.teamId) {
relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId }); relayToTeamRoom(ws, type, data || {});
} else if (playerInfo && playerInfo.roomId) { } else if (playerInfo && playerInfo.roomId) {
relayToOpponent(ws, type, data || {}); relayToOpponent(ws, type, data || {});
} }
@@ -1538,9 +1746,25 @@ setInterval(() => {
}, 300000); // Every 5 minutes }, 300000); // Every 5 minutes
// ============================================================ // ============================================================
// WebSocket Server
// ============================================================ // ============================================================
const wss = new WebSocketServer({ host: HOST, port: PORT }); // WebSocket Server (noServer mode, shares HTTP server with express)
// ============================================================
// Use noServer mode so the WS upgrade only fires on the configured path.
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
// Only upgrade on the configured WebSocket path; reject any other path.
const pathname = (req.url || '').split('?')[0];
if (pathname !== WS_PATH) {
console.warn(`[Server] Rejected WebSocket upgrade on unexpected path: ${req.url}`);
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress; const ip = req.socket.remoteAddress;
@@ -1603,5 +1827,9 @@ setInterval(() => {
// ============================================================ // ============================================================
// Startup // Startup
// ============================================================ // ============================================================
server.listen(PORT, HOST, () => {
console.log(`[Tank War Server] Running on ${HOST}:${PORT}`); console.log(`[Tank War Server] Running on ${HOST}:${PORT}`);
console.log(`[Tank War Server] WebSocket URL: ws://${HOST}:${PORT}`); console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`);
console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`);
console.log(`[Tank War Server] HTTP API URL: http://${HOST}:${PORT}`);
});
+138
View File
@@ -0,0 +1,138 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: tankwar-server-config
data:
NODE_ENV: "production"
PORT: "3000"
HOST: "0.0.0.0"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tankwar-server
labels:
app: tankwar-server
spec:
replicas: 1
selector:
matchLabels:
app: tankwar-server
template:
metadata:
labels:
app: tankwar-server
spec:
containers:
- name: tankwar-server
image: tankwar-server:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: tankwar-server-config
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Namespace
metadata:
name: tankwar
labels:
app.kubernetes.io/part-of: tankwar
---
apiVersion: v1
kind: ConfigMap
metadata:
name: tankwar-server-config
namespace: tankwar
data:
NODE_ENV: "production"
PORT: "3000"
HOST: "0.0.0.0"
# WebSocket path must match Nginx location and client SERVER_URL
WS_PATH: "/tankwar/ws"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tankwar-server
namespace: tankwar
labels:
app: tankwar-server
spec:
replicas: 1
selector:
matchLabels:
app: tankwar-server
template:
metadata:
labels:
app: tankwar-server
spec:
containers:
- name: tankwar-server
image: tankwar/tankwar-server:latest
imagePullPolicy: Never
ports:
- containerPort: 3000
name: http-ws
envFrom:
- configMapRef:
name: tankwar-server-config
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: tankwar-server
namespace: tankwar
labels:
app: tankwar-server
spec:
# ClusterIP: only exposed internally, external traffic comes through
# warmcheck-namespace Nginx at game.igeek.site -> /tankwar/ws
type: ClusterIP
selector:
app: tankwar-server
ports:
- name: http-ws
port: 3000
targetPort: 3000
protocol: TCP
type: LoadBalancer
+126
View File
@@ -0,0 +1,126 @@
{"timestamp":"2026-05-11T15:29:48.530Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:48.533Z","type":"violation","userId":"test_user_1778513388533","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:48.538Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:29:57.828Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:57.831Z","type":"violation","userId":"test_user_1778513397831","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"mute_test_1778513397832","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:57.836Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:33:57.494Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"test_user_1778513637497","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"mute_test_1778513637497","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:33:57.501Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:34:09.377Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:34:09.380Z","type":"violation","userId":"test_user_1778513649380","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"mute_test_1778513649381","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:34:09.385Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:38:42.680Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"test_user_1778513922683","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:38:42.687Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:42:44.943Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:42:44.946Z","type":"violation","userId":"test_user_1778514164946","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"mute_test_1778514164947","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:42:44.949Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:42:44.950Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:45:46.119Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"test_user_1778514346122","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:45:46.126Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:46:37.383Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"test_user_1778514397392","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.393Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.394Z","type":"violation","userId":"mute_test_1778514397392","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.395Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:46:37.398Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:46:37.400Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:54:21.418Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"test_user_1778514861422","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.423Z","type":"violation","userId":"mute_test_1778514861422","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:54:21.426Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
+817 -4
View File
@@ -4,11 +4,824 @@
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"node_modules/ws": { "node_modules/accepts": {
"version": "8.20.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT", "license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://mirrors.tencent.com/npm/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
+6 -15
View File
@@ -1,22 +1,13 @@
'use strict'; 'use strict';
const createWebSocketStream = require('./lib/stream');
const extension = require('./lib/extension');
const PerMessageDeflate = require('./lib/permessage-deflate');
const Receiver = require('./lib/receiver');
const Sender = require('./lib/sender');
const subprotocol = require('./lib/subprotocol');
const WebSocket = require('./lib/websocket'); const WebSocket = require('./lib/websocket');
const WebSocketServer = require('./lib/websocket-server');
WebSocket.createWebSocketStream = createWebSocketStream; WebSocket.createWebSocketStream = require('./lib/stream');
WebSocket.extension = extension; WebSocket.Server = require('./lib/websocket-server');
WebSocket.PerMessageDeflate = PerMessageDeflate; WebSocket.Receiver = require('./lib/receiver');
WebSocket.Receiver = Receiver; WebSocket.Sender = require('./lib/sender');
WebSocket.Sender = Sender;
WebSocket.Server = WebSocketServer;
WebSocket.subprotocol = subprotocol;
WebSocket.WebSocket = WebSocket; WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocketServer; WebSocket.WebSocketServer = WebSocket.Server;
module.exports = WebSocket; module.exports = WebSocket;
-1
View File
@@ -7,7 +7,6 @@ if (hasBlob) BINARY_TYPES.push('blob');
module.exports = { module.exports = {
BINARY_TYPES, BINARY_TYPES,
CLOSE_TIMEOUT: 30000,
EMPTY_BUFFER: Buffer.alloc(0), EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob, hasBlob,
+6 -20
View File
@@ -37,9 +37,6 @@ class PerMessageDeflate {
* acknowledge disabling of client context takeover * acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent * @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib * calls to zlib
* @param {Boolean} [options.isServer=false] Create the instance in either
* server or client mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size * use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
@@ -50,13 +47,16 @@ class PerMessageDeflate {
* deflate * deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate * inflate
* @param {Boolean} [isServer=false] Create the instance in either server or
* client mode
* @param {Number} [maxPayload=0] The maximum allowed message length
*/ */
constructor(options) { constructor(options, isServer, maxPayload) {
this._maxPayload = maxPayload | 0;
this._options = options || {}; this._options = options || {};
this._threshold = this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024; this._options.threshold !== undefined ? this._options.threshold : 1024;
this._maxPayload = this._options.maxPayload | 0; this._isServer = !!isServer;
this._isServer = !!this._options.isServer;
this._deflate = null; this._deflate = null;
this._inflate = null; this._inflate = null;
@@ -494,14 +494,6 @@ function inflateOnData(chunk) {
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009; this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData); this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset(); this.reset();
} }
@@ -517,12 +509,6 @@ function inflateOnError(err) {
// closed when an error is emitted. // closed when an error is emitted.
// //
this[kPerMessageDeflate]._inflate = null; this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007; err[kStatusCode] = 1007;
this[kCallback](err); this[kCallback](err);
} }
+1 -1
View File
@@ -551,7 +551,7 @@ class Sender {
/** /**
* Sends a frame. * Sends a frame.
* *
* @param {(Buffer | String)[]} list The frame to send * @param {Buffer[]} list The frame to send
* @param {Function} [cb] Callback * @param {Function} [cb] Callback
* @private * @private
*/ */
-2
View File
@@ -1,7 +1,5 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict'; 'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream'); const { Duplex } = require('stream');
/** /**
+10 -24
View File
@@ -11,7 +11,7 @@ const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate'); const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol'); const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket'); const WebSocket = require('./websocket');
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants'); const { GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/; const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
@@ -38,9 +38,6 @@ class WebSocketServer extends EventEmitter {
* pending connections * pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to * @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients * track clients
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
* wait for the closing handshake to finish after `websocket.close()` is
* called
* @param {Function} [options.handleProtocols] A hook to handle protocols * @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server * @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message * @param {Number} [options.maxPayload=104857600] The maximum allowed message
@@ -70,7 +67,6 @@ class WebSocketServer extends EventEmitter {
perMessageDeflate: false, perMessageDeflate: false,
handleProtocols: null, handleProtocols: null,
clientTracking: true, clientTracking: true,
closeTimeout: CLOSE_TIMEOUT,
verifyClient: null, verifyClient: null,
noServer: false, noServer: false,
backlog: null, // use default (511 as implemented in net.js) backlog: null, // use default (511 as implemented in net.js)
@@ -260,11 +256,9 @@ class WebSocketServer extends EventEmitter {
return; return;
} }
if (version !== 13 && version !== 8) { if (version !== 8 && version !== 13) {
const message = 'Missing or invalid Sec-WebSocket-Version header'; const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
'Sec-WebSocket-Version': '13, 8'
});
return; return;
} }
@@ -293,11 +287,11 @@ class WebSocketServer extends EventEmitter {
this.options.perMessageDeflate && this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined secWebSocketExtensions !== undefined
) { ) {
const perMessageDeflate = new PerMessageDeflate({ const perMessageDeflate = new PerMessageDeflate(
...this.options.perMessageDeflate, this.options.perMessageDeflate,
isServer: true, true,
maxPayload: this.options.maxPayload this.options.maxPayload
}); );
try { try {
const offers = extension.parse(secWebSocketExtensions); const offers = extension.parse(secWebSocketExtensions);
@@ -532,23 +526,15 @@ function abortHandshake(socket, code, message, headers) {
* @param {Duplex} socket The socket of the upgrade request * @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code * @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body * @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private * @private
*/ */
function abortHandshakeOrEmitwsClientError( function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) {
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) { if (server.listenerCount('wsClientError')) {
const err = new Error(message); const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req); server.emit('wsClientError', err, socket, req);
} else { } else {
abortHandshake(socket, code, message, headers); abortHandshake(socket, code, message);
} }
} }
+15 -20
View File
@@ -18,7 +18,6 @@ const { isBlob } = require('./validation');
const { const {
BINARY_TYPES, BINARY_TYPES,
CLOSE_TIMEOUT,
EMPTY_BUFFER, EMPTY_BUFFER,
GUID, GUID,
kForOnEventAttribute, kForOnEventAttribute,
@@ -33,6 +32,7 @@ const {
const { format, parse } = require('./extension'); const { format, parse } = require('./extension');
const { toBuffer } = require('./buffer-util'); const { toBuffer } = require('./buffer-util');
const closeTimeout = 30 * 1000;
const kAborted = Symbol('kAborted'); const kAborted = Symbol('kAborted');
const protocolVersions = [8, 13]; const protocolVersions = [8, 13];
const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
@@ -88,7 +88,6 @@ class WebSocket extends EventEmitter {
initAsClient(this, address, protocols, options); initAsClient(this, address, protocols, options);
} else { } else {
this._autoPong = options.autoPong; this._autoPong = options.autoPong;
this._closeTimeout = options.closeTimeout;
this._isServer = true; this._isServer = true;
} }
} }
@@ -630,8 +629,6 @@ module.exports = WebSocket;
* times in the same tick * times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to * @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping * automatically send a pong in response to a ping
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to wait
* for the closing handshake to finish after `websocket.close()` is called
* @param {Function} [options.finishRequest] A function which can be used to * @param {Function} [options.finishRequest] A function which can be used to
* customize the headers of each http request before it is sent * customize the headers of each http request before it is sent
* @param {Boolean} [options.followRedirects=false] Whether or not to follow * @param {Boolean} [options.followRedirects=false] Whether or not to follow
@@ -658,7 +655,6 @@ function initAsClient(websocket, address, protocols, options) {
const opts = { const opts = {
allowSynchronousEvents: true, allowSynchronousEvents: true,
autoPong: true, autoPong: true,
closeTimeout: CLOSE_TIMEOUT,
protocolVersion: protocolVersions[1], protocolVersion: protocolVersions[1],
maxPayload: 100 * 1024 * 1024, maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false, skipUTF8Validation: false,
@@ -677,7 +673,6 @@ function initAsClient(websocket, address, protocols, options) {
}; };
websocket._autoPong = opts.autoPong; websocket._autoPong = opts.autoPong;
websocket._closeTimeout = opts.closeTimeout;
if (!protocolVersions.includes(opts.protocolVersion)) { if (!protocolVersions.includes(opts.protocolVersion)) {
throw new RangeError( throw new RangeError(
@@ -693,7 +688,7 @@ function initAsClient(websocket, address, protocols, options) {
} else { } else {
try { try {
parsedUrl = new URL(address); parsedUrl = new URL(address);
} catch { } catch (e) {
throw new SyntaxError(`Invalid URL: ${address}`); throw new SyntaxError(`Invalid URL: ${address}`);
} }
} }
@@ -713,7 +708,7 @@ function initAsClient(websocket, address, protocols, options) {
if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) {
invalidUrlMessage = invalidUrlMessage =
'The URL\'s protocol must be one of "ws:", "wss:", ' + 'The URL\'s protocol must be one of "ws:", "wss:", ' +
'"http:", "https:", or "ws+unix:"'; '"http:", "https", or "ws+unix:"';
} else if (isIpcUrl && !parsedUrl.pathname) { } else if (isIpcUrl && !parsedUrl.pathname) {
invalidUrlMessage = "The URL's pathname is empty"; invalidUrlMessage = "The URL's pathname is empty";
} else if (parsedUrl.hash) { } else if (parsedUrl.hash) {
@@ -755,11 +750,11 @@ function initAsClient(websocket, address, protocols, options) {
opts.timeout = opts.handshakeTimeout; opts.timeout = opts.handshakeTimeout;
if (opts.perMessageDeflate) { if (opts.perMessageDeflate) {
perMessageDeflate = new PerMessageDeflate({ perMessageDeflate = new PerMessageDeflate(
...opts.perMessageDeflate, opts.perMessageDeflate !== true ? opts.perMessageDeflate : {},
isServer: false, false,
maxPayload: opts.maxPayload opts.maxPayload
}); );
opts.headers['Sec-WebSocket-Extensions'] = format({ opts.headers['Sec-WebSocket-Extensions'] = format({
[PerMessageDeflate.extensionName]: perMessageDeflate.offer() [PerMessageDeflate.extensionName]: perMessageDeflate.offer()
}); });
@@ -1295,7 +1290,7 @@ function senderOnError(err) {
function setCloseTimer(websocket) { function setCloseTimer(websocket) {
websocket._closeTimer = setTimeout( websocket._closeTimer = setTimeout(
websocket._socket.destroy.bind(websocket._socket), websocket._socket.destroy.bind(websocket._socket),
websocket._closeTimeout closeTimeout
); );
} }
@@ -1313,23 +1308,23 @@ function socketOnClose() {
websocket._readyState = WebSocket.CLOSING; websocket._readyState = WebSocket.CLOSING;
let chunk;
// //
// The close frame might not have been received or the `'end'` event emitted, // The close frame might not have been received or the `'end'` event emitted,
// for example, if the socket was destroyed due to an error. Ensure that the // for example, if the socket was destroyed due to an error. Ensure that the
// `receiver` stream is closed after writing any remaining buffered data to // `receiver` stream is closed after writing any remaining buffered data to
// it. If the readable side of the socket is in flowing mode then there is no // it. If the readable side of the socket is in flowing mode then there is no
// buffered data as everything has been already written. If instead, the // buffered data as everything has been already written and `readable.read()`
// socket is paused, any possible buffered data will be read as a single // will return `null`. If instead, the socket is paused, any possible buffered
// chunk. // data will be read as a single chunk.
// //
if ( if (
!this._readableState.endEmitted && !this._readableState.endEmitted &&
!websocket._closeFrameReceived && !websocket._closeFrameReceived &&
!websocket._receiver._writableState.errorEmitted && !websocket._receiver._writableState.errorEmitted &&
this._readableState.length !== 0 (chunk = websocket._socket.read()) !== null
) { ) {
const chunk = this.read(this._readableState.length);
websocket._receiver.write(chunk); websocket._receiver.write(chunk);
} }
+4 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "ws", "name": "ws",
"version": "8.20.0", "version": "8.18.0",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [ "keywords": [
"HyBi", "HyBi",
@@ -55,13 +55,12 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"benchmark": "^2.1.4", "benchmark": "^2.1.4",
"bufferutil": "^4.0.1", "bufferutil": "^4.0.1",
"eslint": "^10.0.1", "eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"globals": "^17.0.0", "globals": "^15.0.0",
"mocha": "^8.4.0", "mocha": "^8.4.0",
"nyc": "^15.0.0", "nyc": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
+1 -14
View File
@@ -1,21 +1,8 @@
import createWebSocketStream from './lib/stream.js'; import createWebSocketStream from './lib/stream.js';
import extension from './lib/extension.js';
import PerMessageDeflate from './lib/permessage-deflate.js';
import Receiver from './lib/receiver.js'; import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js'; import Sender from './lib/sender.js';
import subprotocol from './lib/subprotocol.js';
import WebSocket from './lib/websocket.js'; import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js'; import WebSocketServer from './lib/websocket-server.js';
export { export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer };
createWebSocketStream,
extension,
PerMessageDeflate,
Receiver,
Sender,
subprotocol,
WebSocket,
WebSocketServer
};
export default WebSocket; export default WebSocket;
+820 -5
View File
@@ -8,14 +8,829 @@
"name": "tankwar-server", "name": "tankwar-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"ws": "^8.16.0" "express": "^5.2.1",
"multer": "^2.1.1",
"ws": "8.18.0"
} }
}, },
"node_modules/ws": { "node_modules/accepts": {
"version": "8.20.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT", "license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://mirrors.tencent.com/npm/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },
+5 -2
View File
@@ -5,9 +5,12 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"dev": "node index.js" "dev": "node index.js",
"test": "node --test test/"
}, },
"dependencies": { "dependencies": {
"ws": "^8.16.0" "express": "^5.2.1",
"multer": "^2.1.1",
"ws": "8.18.0"
} }
} }
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# ============================================================
# Tankwar server K8s deploy script
# Syncs server source -> Master node -> builds Docker image ->
# distributes image to Worker nodes via ctr -> applies K8s resources
# -> rolls out the tankwar-server deployment.
# ============================================================
set -e
LOG="/tmp/tankwar-k8s-deploy.log"
> "$LOG"
exec > >(tee -a "$LOG") 2>&1
SERVER_DIR="/Users/hanchengxi/workspace/tankwar_proj/server"
MASTER="root@host_172.16.16.16"
WORKERS_IP=("10.1.0.6" "172.16.32.10" "172.16.32.16")
REMOTE_BUILD_DIR="/tmp/tankwar-build"
IMAGE_NAME="tankwar/tankwar-server:latest"
ts() { echo "[$(date '+%H:%M:%S')]"; }
# ------------------------------------------------------------
# Step 0: Sync server source to master node
# ------------------------------------------------------------
echo "$(ts) ===== Syncing tankwar server source to master node ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "mkdir -p $REMOTE_BUILD_DIR"
rsync -az --delete --exclude='.git' \
-e "ssh -o StrictHostKeyChecking=no" \
"$SERVER_DIR/" "${MASTER}:${REMOTE_BUILD_DIR}/server/"
echo "$(ts) ✓ Source synced"
# ------------------------------------------------------------
# Step 1: Ensure docker is available on master
# ------------------------------------------------------------
echo "$(ts) ===== Checking docker on master ====="
if ! ssh -o StrictHostKeyChecking=no "$MASTER" "which docker >/dev/null 2>&1"; then
echo "$(ts) Docker not found on master. Installing..."
ssh -o StrictHostKeyChecking=no "$MASTER" "curl -fsSL https://get.docker.com | sh"
fi
ssh -o StrictHostKeyChecking=no "$MASTER" "docker version --format '{{.Server.Version}}' 2>/dev/null || systemctl start docker"
echo "$(ts) ✓ Docker ready on master"
# ------------------------------------------------------------
# Step 2: Build image on master
# ------------------------------------------------------------
echo "$(ts) ===== Building $IMAGE_NAME on master ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"cd $REMOTE_BUILD_DIR/server && docker build -t $IMAGE_NAME -f Dockerfile ."
echo "$(ts) ✓ Image built"
# ------------------------------------------------------------
# Step 3: Distribute image to workers (containerd / ctr)
# ------------------------------------------------------------
echo "$(ts) ===== Distributing $IMAGE_NAME to workers ====="
# Master itself may also be a worker; import locally first so master pods can use it.
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ctr -n k8s.io images import -"
for w in "${WORKERS_IP[@]}"; do
echo "$(ts) -> $w"
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ssh -o StrictHostKeyChecking=no root@$w 'ctr -n k8s.io images import -'"
done
echo "$(ts) ✓ Image distributed"
# ------------------------------------------------------------
# Step 4: Apply K8s manifests (namespace + configmap + deploy + svc)
# ------------------------------------------------------------
echo "$(ts) ===== Applying K8s manifests ====="
cat "$SERVER_DIR/k8s-deployment.yaml" | \
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl apply -f -"
echo "$(ts) ✓ Manifests applied"
# ------------------------------------------------------------
# Step 5: Restart deployment to pick up the new image
# ------------------------------------------------------------
echo "$(ts) ===== Restarting tankwar-server deployment ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n tankwar rollout restart deployment/tankwar-server" || true
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n tankwar rollout status deployment/tankwar-server --timeout=120s" || true
# ------------------------------------------------------------
# Step 6: Show final status
# ------------------------------------------------------------
echo "$(ts) ===== Final Status ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n tankwar get pods -o wide"
echo ""
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n tankwar get svc"
echo ""
echo "$(ts) ===== ALL DONE ====="
echo "$(ts) Public endpoint (via warmcheck Nginx): wss://www.igeek.site/games/wx/tankwar/ws"
echo "$(ts) Internal endpoint: tankwar-server.tankwar.svc.cluster.local:3000"
echo "$(ts) Remember to redeploy warmcheck Nginx too (run WarmCheck_proj/backend/deploy/k8s/scripts/run-deploy.sh)"
# Cleanup
ssh -o StrictHostKeyChecking=no "$MASTER" "rm -rf $REMOTE_BUILD_DIR" 2>/dev/null || true
+199
View File
@@ -0,0 +1,199 @@
/**
* Audit Logger
* Logs content security audit events with content desensitization.
* Logs are retained for at least 180 days.
*
* Features:
* - Content desensitization: only first 3 and last 3 chars kept, middle replaced with ***
* - Access Token never logged in plaintext
* - Structured JSON log format
* - Automatic log rotation
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// Configuration
// ============================================================
const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit');
const LOG_RETENTION_DAYS = 180;
const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily
class AuditLogger {
constructor() {
this._ensureLogDir();
this._currentLogFile = this._getLogFilePath(new Date());
this._rotationTimer = null;
this._startRotation();
}
/**
* Log an audit event.
* @param {object} entry
* @param {string} entry.userId - User identifier (openid)
* @param {string} entry.contentType - 'text' or 'image'
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @param {string} entry.result - 'pass', 'reject', 'error', 'rejected'
* @param {string} entry.reason - Reason for the result
* @param {number} [entry.duration] - Duration of the check in ms
*/
logAudit(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
userId: entry.userId || 'unknown',
contentType: entry.contentType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
scene: entry.scene || 0,
result: entry.result || 'unknown',
reason: entry.reason || '',
duration: entry.duration || 0,
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write log:', err.message);
}
}
/**
* Log a violation event.
* @param {object} entry
* @param {string} entry.userId - User identifier
* @param {string} entry.violationType - Type of violation
* @param {string} entry.contentSummary - Desensitized content
* @param {string} entry.action - Action taken (e.g., 'mute_24h')
*/
logViolation(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'violation',
userId: entry.userId || 'unknown',
violationType: entry.violationType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
action: entry.action || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write violation log:', err.message);
}
}
/**
* Log a report event.
* @param {object} entry
* @param {string} entry.reporterId - Reporter's user ID (kept confidential)
* @param {string} entry.targetUserId - Reported user's ID
* @param {string} entry.contentSummary - Desensitized reported content
* @param {string} entry.reason - Report reason
*/
logReport(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'report',
reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential
targetUserId: entry.targetUserId || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
reason: entry.reason || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write report log:', err.message);
}
}
/**
* Ensure the log directory exists.
*/
_ensureLogDir() {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
} catch (err) {
console.error('[AuditLogger] Failed to create log directory:', err.message);
}
}
/**
* Get the log file path for a given date.
* @param {Date} date
* @returns {string}
*/
_getLogFilePath(date) {
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
return path.join(LOG_DIR, `audit-${dateStr}.log`);
}
/**
* Start daily log rotation.
*/
_startRotation() {
this._rotationTimer = setInterval(() => {
const newLogFile = this._getLogFilePath(new Date());
if (newLogFile !== this._currentLogFile) {
this._currentLogFile = newLogFile;
console.log('[AuditLogger] Rotated to new log file:', newLogFile);
}
// Clean up old logs
this._cleanOldLogs();
}, LOG_ROTATION_INTERVAL_MS);
}
/**
* Remove log files older than the retention period.
*/
_cleanOldLogs() {
try {
const files = fs.readdirSync(LOG_DIR);
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(LOG_DIR, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log('[AuditLogger] Deleted old log file:', file);
}
}
} catch (err) {
console.error('[AuditLogger] Failed to clean old logs:', err.message);
}
}
/**
* Sanitize content: keep only first 3 and last 3 chars, replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
/**
* Clean up resources.
*/
destroy() {
if (this._rotationTimer) {
clearInterval(this._rotationTimer);
this._rotationTimer = null;
}
console.log('[AuditLogger] Destroyed');
}
}
module.exports = AuditLogger;
+279
View File
@@ -0,0 +1,279 @@
/**
* Content Security API Routes
* Express routes for content security checking, sensitive words, and reporting.
*/
const express = require('express');
const multer = require('multer');
const ContentSecurityService = require('./contentSecurityService');
const ViolationService = require('./violationService');
const ReportService = require('./reportService');
const sensitiveWords = require('./sensitiveWords');
// Configure multer for image uploads (memory storage, max 1MB)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 }, // 1MB max
});
/**
* Create and return the content security router.
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @returns {express.Router}
*/
function createContentSecurityRouter(options = {}) {
const router = express.Router();
const contentSecurity = new ContentSecurityService({
tokenManager: options.tokenManager,
logger: options.logger,
});
const violationService = options.violationService || new ViolationService({
logger: options.logger,
});
const reportService = options.reportService || new ReportService({
logger: options.logger,
violationService,
});
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
// Validate required fields
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40001,
errmsg: 'openid is required',
});
}
if (!content || typeof content !== 'string') {
return res.status(400).json({
errcode: 40002,
errmsg: 'content is required',
});
}
if (typeof scene !== 'number' || ![1, 2, 3, 4].includes(scene)) {
return res.status(400).json({
errcode: 40003,
errmsg: 'scene must be 1(nickname), 2(chat), 3(signature), or 4(description)',
});
}
try {
// First, do server-side local sensitive word check
const localCheck = sensitiveWords.checkText(content);
if (localCheck.hasViolation) {
// Auto-record violation
violationService.recordViolation({
userId: openid,
violationType: 'local_sensitive_word',
contentSummary: content.substring(0, 20),
scene,
});
return res.json({
pass: false,
errcode: 0,
errmsg: '内容违规,请修改',
suggest: 'risky',
label: 20000,
localCheck: true,
categories: localCheck.categories,
});
}
// Then, call WeChat msgSecCheck API
const result = await contentSecurity.checkTextContent(openid, content, scene);
// Auto-record violation if msgSecCheck rejects
if (!result.pass && result.label > 0) {
violationService.recordViolation({
userId: openid,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json(result);
} catch (err) {
console.error('[API] check-text error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// POST /api/content/check-image - Image content security check
// ============================================================
router.post('/check-image', upload.single('image'), async (req, res) => {
if (!req.file) {
return res.status(400).json({
errcode: 40004,
errmsg: 'image file is required',
});
}
// Check file size (multer already enforces this, but double-check)
if (req.file.size > 1024 * 1024) {
return res.status(400).json({
errcode: 40005,
errmsg: '图片大小不能超过1MB',
});
}
try {
const result = await contentSecurity.checkImageContent(req.file.buffer);
// Auto-record violation if image is rejected
if (!result.pass && req.body && req.body.openid) {
violationService.recordViolation({
userId: req.body.openid,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json(result);
} catch (err) {
console.error('[API] check-image error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// GET /api/content/sensitive-words - Get sensitive word list for client caching
// ============================================================
router.get('/sensitive-words', (req, res) => {
const version = req.query.version;
// If client sends current version and it matches, return 304
if (version && version === sensitiveWords.getVersion()) {
return res.status(304).json({
version: sensitiveWords.getVersion(),
updated: false,
});
}
return res.json({
version: sensitiveWords.getVersion(),
updated: true,
words: sensitiveWords.getAllWords(),
categories: sensitiveWords.getWordsByCategory(),
});
});
// ============================================================
// GET /api/user/mute-status - Check if a user is muted
// ============================================================
router.get('/user/mute-status', (req, res) => {
const openid = req.query.openid;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid);
return res.json(status);
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid);
return res.json(summary);
});
// ============================================================
// POST /api/content/report - Submit a content report
// ============================================================
router.post('/report', express.json(), (req, res) => {
const { contentId, targetUserId, contentType, contentSummary, reporterId, reason } = req.body;
// Validate required fields
if (!contentId || !targetUserId || !contentType || !reporterId || !reason) {
return res.status(400).json({
errcode: 40007,
errmsg: 'Missing required fields: contentId, targetUserId, contentType, reporterId, reason',
});
}
// Validate reason
const validReasons = Object.values(ReportService.REPORT_REASONS);
if (!validReasons.includes(reason)) {
return res.status(400).json({
errcode: 40008,
errmsg: `Invalid reason. Must be one of: ${validReasons.join(', ')}`,
});
}
const result = reportService.submitReport({
contentId,
targetUserId,
contentType,
contentSummary: contentSummary || '',
reporterId,
reason,
});
if (!result.success) {
if (result.reportCount === 0) {
return res.status(400).json({
errcode: 40009,
errmsg: '举报提交失败',
});
}
// Already reported
return res.json({
success: false,
message: '您已举报过该内容',
reportCount: result.reportCount,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
});
});
// ============================================================
// POST /api/content/check-text - Auto-record violation on reject
// (Extended: records violation when content is rejected)
// ============================================================
// This is already handled in the check-text route above,
// but we also add violation recording here for server-side content.
// The check-text endpoint above will auto-record violations
// when msgSecCheck returns a rejection.
return router;
}
module.exports = { createContentSecurityRouter };
+475
View File
@@ -0,0 +1,475 @@
/**
* Content Security Service
* Provides unified methods for text and image content security checking
* via WeChat's msgSecCheck and imgSecCheck APIs.
*
* Features:
* - checkTextContent(openid, content, scene): Text content audit
* - checkImageContent(imageBuffer): Image content audit
* - 3-second timeout for each API call
* - Rate limiting queue (5000 requests/minute for msgSecCheck)
*/
const https = require('https');
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check';
const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check';
const API_TIMEOUT_MS = 3000; // 3 seconds timeout
const RATE_LIMIT_PER_MINUTE = 5000;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
// Scene mapping for msgSecCheck
const SCENE_MAP = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
class ContentSecurityService {
/**
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {object} [options.logger] - Optional custom logger
*/
constructor(options = {}) {
this.tokenManager = options.tokenManager;
this.logger = options.logger || new AuditLogger();
// Rate limiting
this._requestTimestamps = [];
this._requestQueue = [];
this._isProcessingQueue = false;
}
/**
* Check text content for security violations using msgSecCheck.
* @param {string} openid - User's openid
* @param {string} content - Text content to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
const startTime = Date.now();
// Validate scene value
if (!Object.values(SCENE_MAP).includes(scene)) {
const result = {
pass: false,
errcode: -1,
errmsg: 'Invalid scene value',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'invalid_scene',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`;
const postData = JSON.stringify({
openid,
scene,
version: 2,
content,
});
const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass';
const result = {
pass,
errcode: apiResult.errcode || 0,
errmsg: apiResult.errmsg || '',
suggest: apiResult.result ? apiResult.result.suggest : 'risky',
label: apiResult.result ? apiResult.result.label : 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: pass ? 'pass' : 'reject',
reason: pass ? 'content_safe' : `label_${result.label}`,
duration,
});
return result;
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] msgSecCheck error:', err.message);
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'error',
reason: err.message,
duration,
});
// On error, reject the content (fail-closed: safety over availability)
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content for security violations using imgSecCheck.
* @param {Buffer} imageBuffer - Image data buffer
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(imageBuffer) {
const startTime = Date.now();
// Check image size (max 1MB)
if (imageBuffer.length > 1024 * 1024) {
const result = {
pass: false,
errcode: -1,
errmsg: '图片大小不能超过1MB',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'image_too_large',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`;
// Build multipart form data for imgSecCheck
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`;
const suffix = `\r\n--${boundary}--\r\n`;
const buffer = Buffer.concat([
Buffer.from(prefix),
imageBuffer,
Buffer.from(suffix),
]);
const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0;
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: pass ? 'pass' : 'reject',
reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`,
duration,
});
return {
pass,
errcode: apiResult.errcode || 0,
errmsg: pass ? 'ok' : '图片内容违规,请更换',
};
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] imgSecCheck error:', err.message);
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'error',
reason: err.message,
duration,
});
// Fail-closed
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Call WeChat API with JSON POST data.
* @param {string} url
* @param {string} postData
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPI(url, postData, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(postData);
req.end();
});
}
/**
* Call WeChat API with multipart form data (for image upload).
* @param {string} url
* @param {Buffer} buffer
* @param {string} boundary
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPIWithBuffer(url, buffer, boundary, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': buffer.length,
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(buffer);
req.end();
});
}
/**
* Enforce rate limiting: queue requests if exceeding 5000/min.
* @returns {Promise<void>}
*/
async _enforceRateLimit() {
const now = Date.now();
// Clean old timestamps outside the current window
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) {
this._requestTimestamps.push(now);
return;
}
// Rate limited - wait until a slot opens
return new Promise((resolve) => {
this._requestQueue.push(resolve);
this._processQueue();
});
}
/**
* Process queued requests as rate limit slots become available.
*/
_processQueue() {
if (this._isProcessingQueue || this._requestQueue.length === 0) return;
this._isProcessingQueue = true;
const processNext = () => {
const now = Date.now();
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) {
this._requestTimestamps.push(now);
const nextResolve = this._requestQueue.shift();
nextResolve();
processNext();
} else if (this._requestQueue.length > 0) {
// Wait for the oldest timestamp to expire
const oldestTs = this._requestTimestamps[0];
const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100;
setTimeout(processNext, waitTime);
} else {
this._isProcessingQueue = false;
}
};
processNext();
}
/**
* Sanitize content for logging: keep only first 3 and last 3 chars,
* replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
}
// Export scene mapping for external use
ContentSecurityService.SCENE_MAP = SCENE_MAP;
module.exports = ContentSecurityService;

Some files were not shown because too many files have changed in this diff Show More