Compare commits
8 Commits
main
...
292a4181ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 292a4181ce | |||
| 84ce67b9be | |||
| 1b15748d0d | |||
| f97f1ffdbc | |||
| d4e217b11b | |||
| a5c833c769 | |||
| fcb9a02c68 | |||
| bb3b55f69b |
4
.env.development
Normal file
4
.env.development
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
VITE_GAME_WS_URL=/ws
|
||||||
|
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
|
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
606
chengdu-mahjong-features.md
Normal file
606
chengdu-mahjong-features.md
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
# 成都麻将功能整理
|
||||||
|
|
||||||
|
本文基于当前项目 `mahjong-server` 的实际代码实现整理,目的是给前端或测试同学做逐项对照测试。这里写的是“当前代码已经实现/暴露出来的功能”,不是传统成都麻将的完整规则说明。
|
||||||
|
|
||||||
|
## 1. 当前玩法范围
|
||||||
|
|
||||||
|
### 1.1 当前后端只支持成都麻将
|
||||||
|
|
||||||
|
- 创建房间时如果不传 `game_type`,默认就是 `chengdu`
|
||||||
|
- 如果传入其他玩法,服务端直接返回错误:`only chengdu mahjong is supported currently`
|
||||||
|
- 虽然引擎里预留了 `xueliu`、`hongzhong` 的规则工厂,但房间服务层目前只允许创建 `chengdu`
|
||||||
|
|
||||||
|
### 1.2 当前牌集范围
|
||||||
|
|
||||||
|
- 只使用三门牌:万(`W`)、条(`T`)、筒(`B`)
|
||||||
|
- 每门 `1-9` 各 4 张,共 `108` 张
|
||||||
|
- 当前不带字牌、风牌、箭牌、花牌
|
||||||
|
- 成都玩法配置里明确 `HasHongZhong() == false`
|
||||||
|
|
||||||
|
## 2. 房间功能
|
||||||
|
|
||||||
|
### 2.1 创建房间
|
||||||
|
|
||||||
|
HTTP 接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/game/mahjong/room/create`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "房间名",
|
||||||
|
"game_type": "chengdu",
|
||||||
|
"max_players": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 必须登录鉴权
|
||||||
|
- 创建者自动成为房主
|
||||||
|
- 创建者自动进入房间,座位索引固定为 `0`
|
||||||
|
- `max_players` 为空时,使用配置默认值
|
||||||
|
- 当前只允许 `4` 人房
|
||||||
|
- 房间名为空时,自动生成类似 `成都麻将-xxxxxxxx`
|
||||||
|
- 初始房间状态为 `waiting`
|
||||||
|
|
||||||
|
返回重点字段:
|
||||||
|
|
||||||
|
- `room_id`
|
||||||
|
- `name`
|
||||||
|
- `game_type`
|
||||||
|
- `owner_id`
|
||||||
|
- `max_players`
|
||||||
|
- `player_count`
|
||||||
|
- `players`
|
||||||
|
- `status`
|
||||||
|
|
||||||
|
### 2.2 房间列表
|
||||||
|
|
||||||
|
HTTP 接口:
|
||||||
|
|
||||||
|
- `GET /api/v1/game/mahjong/room/list`
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
- `status`
|
||||||
|
- `game_type`
|
||||||
|
- `page`
|
||||||
|
- `size`
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 需要登录
|
||||||
|
- 支持按房间状态筛选
|
||||||
|
- 支持按玩法类型筛选
|
||||||
|
- 支持分页
|
||||||
|
- `size` 超过配置上限会被裁剪
|
||||||
|
|
||||||
|
### 2.3 加入房间
|
||||||
|
|
||||||
|
HTTP 接口:
|
||||||
|
|
||||||
|
- `POST /api/v1/game/mahjong/room/join`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_id": "xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 需要登录
|
||||||
|
- 只能加入 `waiting` 状态房间
|
||||||
|
- 人数满 4 人后不可继续加入
|
||||||
|
- 已在房间内再次加入,不会重复加人
|
||||||
|
- 如果玩家名之前为空,再次进入时会补写玩家名
|
||||||
|
|
||||||
|
加入房间成功后,除了 HTTP 返回房间摘要,还会额外推送 WS 事件:
|
||||||
|
|
||||||
|
- `room_joined`
|
||||||
|
- `room_member_joined`(仅首次加入触发)
|
||||||
|
- `room_player_update`
|
||||||
|
|
||||||
|
### 2.4 查询房间详情
|
||||||
|
|
||||||
|
HTTP 接口:
|
||||||
|
|
||||||
|
- `GET /api/v1/game/mahjong/room/:room_id`
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 需要登录
|
||||||
|
- 必须是房间内玩家才能看该房间详情
|
||||||
|
- 返回 `summary + public`
|
||||||
|
|
||||||
|
其中 `public` 可用于前端牌桌展示,包含:
|
||||||
|
|
||||||
|
- 当前阶段 `phase`
|
||||||
|
- 当前轮到谁 `current_turn_player`
|
||||||
|
- 是否必须先摸牌 `need_draw`
|
||||||
|
- 最近弃牌人 `last_discard_by`
|
||||||
|
- 最近弃牌 `last_discard_tile`
|
||||||
|
- 牌墙剩余数 `wall_count`
|
||||||
|
- 本局胡牌玩家 `winners`
|
||||||
|
- 本局分数 `scores`
|
||||||
|
- 当前响应窗口 `pending_claim`
|
||||||
|
- 每个玩家的公开状态
|
||||||
|
|
||||||
|
### 2.5 离开房间
|
||||||
|
|
||||||
|
离开房间是走 WS 动作,不是 HTTP 接口。
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 只有 `waiting` 状态下允许离开
|
||||||
|
- `playing` 状态下离开会失败
|
||||||
|
- 普通玩家离开后,剩余玩家座位索引会重新整理
|
||||||
|
- 房主离开后,房主身份自动转移给当前列表中的第一个玩家
|
||||||
|
- 如果最后一个玩家离开,房间会直接删除
|
||||||
|
|
||||||
|
对应事件:
|
||||||
|
|
||||||
|
- `room_left`
|
||||||
|
- `room_member_left`
|
||||||
|
- `room_owner_changed`(房主变更时)
|
||||||
|
- `room_player_update`
|
||||||
|
- `room_closed`(最后一人离开时)
|
||||||
|
|
||||||
|
## 3. 开局和基础对局流程
|
||||||
|
|
||||||
|
### 3.1 开始游戏
|
||||||
|
|
||||||
|
开始游戏走 WS 动作:
|
||||||
|
|
||||||
|
- `type = "start_game"`
|
||||||
|
|
||||||
|
实现规则:
|
||||||
|
|
||||||
|
- 只有房主能开始
|
||||||
|
- 只有满 4 人时才能开始
|
||||||
|
- 房间状态从 `waiting` 变成 `playing`
|
||||||
|
- 内部固定按当前房间玩家顺序创建 4 个玩家
|
||||||
|
- 庄家固定为座位 `0`
|
||||||
|
- 发牌后庄家 14 张,其余玩家 13 张
|
||||||
|
|
||||||
|
开始后会推送:
|
||||||
|
|
||||||
|
- 广播 `room_state`
|
||||||
|
- 给每个玩家单独推送 `my_hand`
|
||||||
|
|
||||||
|
### 3.2 基础轮转规则
|
||||||
|
|
||||||
|
- 庄家开局先打牌,不需要先摸
|
||||||
|
- 非当前操作者不能操作
|
||||||
|
- 非庄家进入自己回合后,必须先 `draw` 才能 `discard`
|
||||||
|
- 每次打牌后,系统检查其他三家是否可 `hu / peng / gang`
|
||||||
|
- 如果没人可响应,则轮到下家摸牌
|
||||||
|
- 如果所有可响应玩家都 `pass`,也轮到出牌者下家摸牌
|
||||||
|
|
||||||
|
### 3.3 牌墙耗尽
|
||||||
|
|
||||||
|
- 当需要摸牌但牌墙为空时,直接流局结束
|
||||||
|
- 打完牌后如果后续无人响应,且牌墙为空,也直接结束
|
||||||
|
- 摸到最后一张牌后,如果牌墙已空且无人可胡,也会结束
|
||||||
|
- 结束后阶段 `phase = over`
|
||||||
|
- 房间状态同步变为 `finished`
|
||||||
|
|
||||||
|
## 4. 对局动作功能
|
||||||
|
|
||||||
|
当前对局动作都通过 WS -> ws-gateway -> grpc -> mahjong-game 转发执行。
|
||||||
|
|
||||||
|
支持的动作类型:
|
||||||
|
|
||||||
|
- `draw`
|
||||||
|
- `discard`
|
||||||
|
- `peng`
|
||||||
|
- `gang`
|
||||||
|
- `hu`
|
||||||
|
- `pass`
|
||||||
|
|
||||||
|
### 4.1 摸牌 `draw`
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
|
||||||
|
- 必须轮到当前玩家
|
||||||
|
- 当前状态必须 `need_draw = true`
|
||||||
|
|
||||||
|
效果:
|
||||||
|
|
||||||
|
- 从牌墙头部摸 1 张
|
||||||
|
- 手牌加 1
|
||||||
|
- `need_draw = false`
|
||||||
|
- 记录最近摸牌玩家
|
||||||
|
- 如果是杠后补牌,会记录 `LastDrawFromGang = true`
|
||||||
|
- 如果摸到的是最后一张,会记录 `LastDrawIsLastTile = true`
|
||||||
|
|
||||||
|
### 4.2 出牌 `discard`
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
|
||||||
|
- 必须轮到当前玩家
|
||||||
|
- 当前不能处于“必须先摸牌”的状态
|
||||||
|
- 必须指定要打出的牌,并且该牌确实在玩家手里
|
||||||
|
|
||||||
|
效果:
|
||||||
|
|
||||||
|
- 从手牌移除该张牌
|
||||||
|
- 追加到玩家弃牌区 `out_tiles`
|
||||||
|
- 记录 `last_discard_by`
|
||||||
|
- 记录 `last_discard_tile`
|
||||||
|
- 然后生成一个 `pending_claim` 响应窗口,给其他玩家判断能否 `hu / peng / gang`
|
||||||
|
|
||||||
|
### 4.3 碰牌 `peng`
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
|
||||||
|
- 当前必须存在 `pending_claim`
|
||||||
|
- 该玩家必须在响应窗口中,并且 `CanPeng = true`
|
||||||
|
- 玩家手里要有与目标弃牌同牌面的 2 张牌
|
||||||
|
|
||||||
|
效果:
|
||||||
|
|
||||||
|
- 手牌移除 2 张同牌
|
||||||
|
- 明牌区新增一组 3 张碰牌
|
||||||
|
- 当前回合转给碰牌玩家
|
||||||
|
- 碰牌后不需要先摸,直接由碰牌玩家出牌
|
||||||
|
|
||||||
|
### 4.4 杠牌 `gang`
|
||||||
|
|
||||||
|
当前只实现两种杠:
|
||||||
|
|
||||||
|
- 明杠:别人打出的牌,你手里正好有 3 张相同牌
|
||||||
|
- 暗杠:当前轮到自己时,手里本来就有 4 张相同牌
|
||||||
|
|
||||||
|
实现效果:
|
||||||
|
|
||||||
|
- 组成 4 张杠牌放入明牌区
|
||||||
|
- 杠后立即补 1 张牌
|
||||||
|
- 杠后补牌来自牌墙头部
|
||||||
|
- 补牌后当前回合仍归杠牌玩家
|
||||||
|
|
||||||
|
当前未见实现:
|
||||||
|
|
||||||
|
- 碰后补杠
|
||||||
|
- 抢杠流程本身
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 代码里有 `QiangGangHu` 计分位,但当前动作流程没有真正把“抢杠胡窗口”建出来
|
||||||
|
|
||||||
|
### 4.5 胡牌 `hu`
|
||||||
|
|
||||||
|
支持两类:
|
||||||
|
|
||||||
|
- 自摸胡:当前轮到自己时胡自己的手牌
|
||||||
|
- 点炮胡:在 `pending_claim` 窗口里胡别人刚打出的牌
|
||||||
|
|
||||||
|
共同前提:
|
||||||
|
|
||||||
|
- 必须满足成都规则的胡牌校验
|
||||||
|
- 必须满足“缺一门”
|
||||||
|
|
||||||
|
胡牌后效果:
|
||||||
|
|
||||||
|
- 玩家 `HasHu = true`
|
||||||
|
- `winners` 增加该玩家
|
||||||
|
- 按规则计算本次番数并累计到 `scores`
|
||||||
|
- 成都玩法当前不是血流玩法,所以有人胡后本局直接结束
|
||||||
|
- `phase = over`
|
||||||
|
- 房间状态会被服务层更新为 `finished`
|
||||||
|
|
||||||
|
### 4.6 过牌 `pass`
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
|
||||||
|
- 当前必须有 `pending_claim`
|
||||||
|
- 该玩家必须在当前响应窗口里
|
||||||
|
|
||||||
|
效果:
|
||||||
|
|
||||||
|
- 从当前 `pending_claim.options` 删除该玩家
|
||||||
|
- 如果还有其他玩家待响应,则继续等待
|
||||||
|
- 如果所有玩家都过,则轮到弃牌者下家摸牌
|
||||||
|
|
||||||
|
## 5. 成都麻将胡牌判定规则
|
||||||
|
|
||||||
|
### 5.1 缺一门
|
||||||
|
|
||||||
|
当前代码里,“缺一门”的实现方式是:
|
||||||
|
|
||||||
|
- 手牌 + 副露中最多只能出现两种花色
|
||||||
|
- 如果出现万、条、筒三门同时存在,则不能胡
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 这里只做了“结果校验”
|
||||||
|
- 没有单独实现“开局定缺”流程
|
||||||
|
- 也没有前端/协议层的“选缺门”动作
|
||||||
|
|
||||||
|
这意味着当前代码更接近“胡牌时校验是否缺一门”,而不是完整的成都定缺流程。
|
||||||
|
|
||||||
|
### 5.2 支持的胡型
|
||||||
|
|
||||||
|
当前规则代码支持以下胡型:
|
||||||
|
|
||||||
|
- 平胡:`1` 番
|
||||||
|
- 对对胡:`3` 番
|
||||||
|
- 七对:`8` 番
|
||||||
|
- 龙七对:`16` 番
|
||||||
|
- 清一色:`8` 番
|
||||||
|
- 清对:`16` 番
|
||||||
|
- 清七对:`32` 番
|
||||||
|
- 清龙七对:`64` 番
|
||||||
|
|
||||||
|
### 5.3 支持的附加番
|
||||||
|
|
||||||
|
- 杠上开花:`+2` 番
|
||||||
|
- 海底捞月:`+2` 番
|
||||||
|
- 抢杠胡:`+2` 番
|
||||||
|
|
||||||
|
### 5.4 胡牌组合方式
|
||||||
|
|
||||||
|
代码可识别:
|
||||||
|
|
||||||
|
- 标准胡:`4` 组面子 + `1` 对将
|
||||||
|
- 七对
|
||||||
|
- 龙七对
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 对对胡通过“全部由刻子/杠 + 1 对将”识别
|
||||||
|
- 清一色通过“所有牌同一花色”识别
|
||||||
|
|
||||||
|
## 6. 分数处理方式
|
||||||
|
|
||||||
|
当前代码的分数逻辑比较简单,适合先做基础功能联调,不等同于完整成都麻将结算。
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 每次胡牌时,按番型计算一个整数分数
|
||||||
|
- 这个整数直接累计到 `state.scores[winnerID]`
|
||||||
|
- 例如平胡就加 `1`,清龙七对就加 `64`
|
||||||
|
|
||||||
|
当前未实现或未体现:
|
||||||
|
|
||||||
|
- 输家扣分分摊
|
||||||
|
- 自摸三家付、点炮单家付
|
||||||
|
- 杠分结算
|
||||||
|
- 查花猪
|
||||||
|
- 查大叫
|
||||||
|
- 退税
|
||||||
|
- 荒庄查叫
|
||||||
|
- 局数/圈风/连庄
|
||||||
|
- 最终总结算
|
||||||
|
|
||||||
|
因此,当前 `scores` 更准确说是“本局胡牌番数累计”,不是正式货币化结算结果。
|
||||||
|
|
||||||
|
## 7. 前端可收到的核心事件
|
||||||
|
|
||||||
|
### 7.1 房间相关事件
|
||||||
|
|
||||||
|
- `room_joined`
|
||||||
|
- `room_member_joined`
|
||||||
|
- `room_player_update`
|
||||||
|
- `room_left`
|
||||||
|
- `room_member_left`
|
||||||
|
- `room_owner_changed`
|
||||||
|
- `room_closed`
|
||||||
|
|
||||||
|
### 7.2 对局相关事件
|
||||||
|
|
||||||
|
- `room_state`
|
||||||
|
- `my_hand`
|
||||||
|
|
||||||
|
### 7.3 `room_state` 关键字段
|
||||||
|
|
||||||
|
建议重点校验:
|
||||||
|
|
||||||
|
- `room_id`
|
||||||
|
- `phase`
|
||||||
|
- `status`
|
||||||
|
- `current_turn_player`
|
||||||
|
- `need_draw`
|
||||||
|
- `last_discard_by`
|
||||||
|
- `last_discard_tile`
|
||||||
|
- `wall_count`
|
||||||
|
- `winners`
|
||||||
|
- `scores`
|
||||||
|
- `pending_claim`
|
||||||
|
- `players`
|
||||||
|
|
||||||
|
其中每个公开玩家对象包含:
|
||||||
|
|
||||||
|
- `player_id`
|
||||||
|
- `hand_count`
|
||||||
|
- `melds`
|
||||||
|
- `out_tiles`
|
||||||
|
- `has_hu`
|
||||||
|
|
||||||
|
### 7.4 `my_hand` 关键字段
|
||||||
|
|
||||||
|
- `room_id`
|
||||||
|
- `hand`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `my_hand` 是定向事件,只发给对应玩家
|
||||||
|
- 前端应该只用它渲染自己的真实手牌
|
||||||
|
- 其他玩家只看 `hand_count`
|
||||||
|
|
||||||
|
## 8. 建议的 WebSocket 动作格式
|
||||||
|
|
||||||
|
从网关协议看,客户端 WS 消息结构大致如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "start_game",
|
||||||
|
"roomId": "room-xxx",
|
||||||
|
"requestId": "req-001",
|
||||||
|
"trace_id": "trace-001",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
通用字段:
|
||||||
|
|
||||||
|
- `type`
|
||||||
|
- `roomId`
|
||||||
|
- `requestId`
|
||||||
|
- `trace_id`
|
||||||
|
- `payload`
|
||||||
|
|
||||||
|
房间相关动作:
|
||||||
|
|
||||||
|
- `join_room`
|
||||||
|
- `leave_room`
|
||||||
|
- `start_game`
|
||||||
|
|
||||||
|
对局动作:
|
||||||
|
|
||||||
|
- `draw`
|
||||||
|
- `discard`
|
||||||
|
- `peng`
|
||||||
|
- `gang`
|
||||||
|
- `hu`
|
||||||
|
- `pass`
|
||||||
|
|
||||||
|
## 9. 当前代码中的已知限制/缺口
|
||||||
|
|
||||||
|
这一段非常重要,测试时请单独对照。
|
||||||
|
|
||||||
|
### 9.1 没有“定缺”流程
|
||||||
|
|
||||||
|
- 成都麻将通常有“定缺”
|
||||||
|
- 当前代码没有开局选缺门动作
|
||||||
|
- 只有胡牌时做“最多两门花色”的校验
|
||||||
|
|
||||||
|
### 9.2 没有“换三张”
|
||||||
|
|
||||||
|
- 项目内未看到换三张流程
|
||||||
|
- 没有对应状态、动作、事件
|
||||||
|
|
||||||
|
### 9.3 不是血战到底,也不是血流成河
|
||||||
|
|
||||||
|
- 成都规则实现里 `IsBloodFlow() == false`
|
||||||
|
- 一旦有人胡牌,本局直接结束
|
||||||
|
- 不支持“一炮多响后继续”或“胡后继续打”
|
||||||
|
|
||||||
|
### 9.4 结算远未完整
|
||||||
|
|
||||||
|
- 当前只给赢家加一个番数值
|
||||||
|
- 没有完整输赢结算模型
|
||||||
|
- 没有杠分、查叫、花猪、退税等成都特色结算
|
||||||
|
|
||||||
|
### 9.5 `discard` 动作当前存在接线缺口
|
||||||
|
|
||||||
|
这一点从代码看是高风险问题:
|
||||||
|
|
||||||
|
- 引擎层 `discard` 必须拿到具体 `tile`
|
||||||
|
- 但服务层 `toEngineAction(msg)` 当前只设置了 `Type` 和 `PlayerID`
|
||||||
|
- 没有把 `msg.Payload` 里的牌对象解析到 `types.Action.Tile`
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 如果前端通过现有 WS 通道发送 `discard`,很可能会因为缺少牌对象而失败
|
||||||
|
- 报错大概率是 `discard tile is required`
|
||||||
|
|
||||||
|
建议测试时优先验证这个点。
|
||||||
|
|
||||||
|
### 9.6 抢杠胡只有计分标志,没有完整流程
|
||||||
|
|
||||||
|
- 规则层支持 `QiangGangHu` 加番
|
||||||
|
- 但当前核心流程没有看到“补杠被抢”的动作分支和响应窗口
|
||||||
|
- 所以这个番目前更像预留能力,不一定能在真实流程里触发
|
||||||
|
|
||||||
|
### 9.7 优先级仲裁较简化
|
||||||
|
|
||||||
|
项目内 `engine/README.md` 还明确写了后续建议:
|
||||||
|
|
||||||
|
- 需要补充更明确的优先级处理 `hu > gang > peng`
|
||||||
|
|
||||||
|
当前代码行为是:
|
||||||
|
|
||||||
|
- 打牌后把所有可操作选项放进 `pending_claim`
|
||||||
|
- 谁先发起动作、且在 options 中合法,谁就能执行
|
||||||
|
|
||||||
|
因此如果多个玩家同时都可响应,前端和测试要特别注意是否存在“先到先得”而不是严格优先级裁决。
|
||||||
|
|
||||||
|
## 10. 建议测试清单
|
||||||
|
|
||||||
|
### 10.1 房间层
|
||||||
|
|
||||||
|
- 创建房间成功,默认玩法为 `chengdu`
|
||||||
|
- 非 `chengdu` 玩法创建失败
|
||||||
|
- 非 4 人房创建失败
|
||||||
|
- 房主自动入房
|
||||||
|
- 房间列表分页正常
|
||||||
|
- 房间未满时可加入
|
||||||
|
- 房间满员后加入失败
|
||||||
|
- 对局开始后不能再加入
|
||||||
|
- 房间内玩家可查看详情
|
||||||
|
- 非房间内玩家查询详情失败
|
||||||
|
- 普通玩家可离开等待中的房间
|
||||||
|
- 房主离开后房主转移
|
||||||
|
- 最后一人离开后房间关闭
|
||||||
|
- 游戏进行中离开房间失败
|
||||||
|
|
||||||
|
### 10.2 开局与轮转
|
||||||
|
|
||||||
|
- 房主才能开始游戏
|
||||||
|
- 必须满 4 人才能开始
|
||||||
|
- 开局庄家 14 张,其余 13 张
|
||||||
|
- 开始后收到 `room_state`
|
||||||
|
- 每位玩家都能收到自己的 `my_hand`
|
||||||
|
- 庄家首回合直接出牌
|
||||||
|
- 非庄家必须先摸再打
|
||||||
|
- 轮转顺序按座位顺延
|
||||||
|
|
||||||
|
### 10.3 响应动作
|
||||||
|
|
||||||
|
- 弃牌后正确生成 `pending_claim`
|
||||||
|
- 可碰玩家能执行 `peng`
|
||||||
|
- 碰后当前回合归碰牌玩家
|
||||||
|
- 手牌与副露数量变化正确
|
||||||
|
- 明杠成功后会补牌
|
||||||
|
- 暗杠成功后会补牌
|
||||||
|
- 所有可响应玩家都 `pass` 后轮到下家摸牌
|
||||||
|
|
||||||
|
### 10.4 胡牌与流局
|
||||||
|
|
||||||
|
- 平胡可胡且加 `1` 分
|
||||||
|
- 对对胡可胡且加 `3` 分
|
||||||
|
- 七对可胡且加 `8` 分
|
||||||
|
- 龙七对可胡且加 `16` 分
|
||||||
|
- 清一色可胡且加 `8` 分
|
||||||
|
- 清对可胡且加 `16` 分
|
||||||
|
- 清七对可胡且加 `32` 分
|
||||||
|
- 清龙七对可胡且加 `64` 分
|
||||||
|
- 杠上开花附加 `2` 分
|
||||||
|
- 海底捞月附加 `2` 分
|
||||||
|
- 三门齐全时不能胡
|
||||||
|
- 点炮胡后本局直接结束
|
||||||
|
- 自摸后本局直接结束
|
||||||
|
- 牌墙为空时本局流局结束
|
||||||
|
|
||||||
|
### 10.5 高风险专项
|
||||||
|
|
||||||
|
- WS `discard` 是否因未传/未解析 `tile` 失败
|
||||||
|
- 多家同时可响应时,是否符合预期优先级
|
||||||
|
- `QiangGangHu` 是否实际上无法走通
|
||||||
|
|
||||||
|
## 11. 结论
|
||||||
|
|
||||||
|
当前项目里的“成都麻将”更准确地说,是一个:
|
||||||
|
|
||||||
|
- 已具备房间管理
|
||||||
|
- 已具备 4 人基础发牌和回合流转
|
||||||
|
- 已具备碰/杠/胡/过等核心动作
|
||||||
|
- 已具备部分成都胡型与番数计算
|
||||||
|
- 但尚未实现完整成都特色流程与结算
|
||||||
|
|
||||||
|
如果你是拿它和“标准成都麻将产品需求”对照,当前更像“成都麻将基础可玩内核 + 房间联机骨架”,还不是完整商用品规。
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.25",
|
"vue": "^3.5.25",
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ function buildUrl(path: string): string {
|
|||||||
return normalizedPath
|
return normalizedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (API_BASE_URL.startsWith('/')) {
|
||||||
|
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
||||||
|
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
||||||
|
return normalizedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${basePath}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||||
try {
|
try {
|
||||||
const baseUrl = new URL(API_BASE_URL)
|
const baseUrl = new URL(API_BASE_URL)
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ function buildUrl(path: string): string {
|
|||||||
return normalizedPath
|
return normalizedPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (API_BASE_URL.startsWith('/')) {
|
||||||
|
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
||||||
|
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
||||||
|
return normalizedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${basePath}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = new URL(API_BASE_URL)
|
const baseUrl = new URL(API_BASE_URL)
|
||||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -21,6 +28,7 @@ body {
|
|||||||
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -31,6 +39,8 @@ p {
|
|||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page {
|
.auth-page {
|
||||||
@@ -423,36 +433,113 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-page {
|
.game-page {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-top: max(12px, env(safe-area-inset-top));
|
padding-top: max(12px, env(safe-area-inset-top));
|
||||||
padding-right: max(12px, env(safe-area-inset-right));
|
padding-right: max(12px, env(safe-area-inset-right));
|
||||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
padding-left: max(12px, env(safe-area-inset-left));
|
padding-left: max(12px, env(safe-area-inset-left));
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header {
|
.game-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(320px, auto) minmax(280px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid rgba(233, 199, 108, 0.16);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 47, 35, 0.86), rgba(8, 24, 18, 0.82)),
|
||||||
|
radial-gradient(circle at top, rgba(255, 219, 123, 0.08), transparent 38%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||||
|
0 16px 36px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-back-btn {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-room-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #f7e4b0;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-room-name {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #f6edd5;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: #f7e4b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header .sub-title {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #d7eadf;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-table-panel {
|
.game-table-panel {
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
min-height: 0;
|
min-height: 60px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-brief {
|
.room-brief {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 0;
|
||||||
padding: 8px 10px;
|
padding: 4px 2px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(194, 226, 208, 0.2);
|
border: 0;
|
||||||
background: rgba(7, 28, 20, 0.55);
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-brief-title {
|
.room-brief-title {
|
||||||
@@ -495,6 +582,356 @@ button:disabled {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-stack {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(7, 24, 17, 0.36);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #f6edd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #c4ddd0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(198, 223, 209, 0.18);
|
||||||
|
background: rgba(5, 24, 17, 0.42);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #9a6b6b;
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-dot.is-connected {
|
||||||
|
background: #62d78f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-dot.is-connecting {
|
||||||
|
background: #f0c46b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-dot.is-disconnected {
|
||||||
|
background: #d86f6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-desk {
|
||||||
|
display: block;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
width: min(100%, calc((100dvh - 220px) * 16 / 9));
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
border-radius: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.04),
|
||||||
|
0 20px 42px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-felt {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
width: min(100%, calc((100dvh - 220px) * 16 / 9));
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, rgba(30, 126, 70, 0.12), transparent 42%),
|
||||||
|
linear-gradient(180deg, rgba(0, 0, 0, 0.03), rgba(0, 0, 0, 0.12));
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-felt::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 22px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: radial-gradient(circle at center, rgba(35, 121, 68, 0.14), transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.felt-frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.felt-frame.inner {
|
||||||
|
inset: 38px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-watermark {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 24px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: rgba(244, 240, 220, 0.82);
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-watermark span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f7e4b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-watermark strong {
|
||||||
|
font-size: 26px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-watermark small {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #bdd8ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 148px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(244, 222, 163, 0.24);
|
||||||
|
background: rgba(8, 27, 20, 0.72);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.seat-top {
|
||||||
|
top: 76px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.seat-right {
|
||||||
|
right: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.seat-bottom {
|
||||||
|
bottom: 90px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.seat-left {
|
||||||
|
left: 24px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.is-turn {
|
||||||
|
border-color: rgba(244, 222, 163, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-badge.offline {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-card {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(145deg, #ecd995, #d3b767);
|
||||||
|
color: #1c2d23;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-meta p {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-meta strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #f7e4b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dealer-mark,
|
||||||
|
.missing-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dealer-mark {
|
||||||
|
background: rgba(236, 188, 84, 0.88);
|
||||||
|
color: #1c2d23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing-mark {
|
||||||
|
margin-left: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #d6eadf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall img {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-top,
|
||||||
|
.wall-bottom {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-left,
|
||||||
|
.wall-right {
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-top {
|
||||||
|
top: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-top img,
|
||||||
|
.wall-bottom img {
|
||||||
|
width: 24px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-right {
|
||||||
|
right: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-left {
|
||||||
|
left: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-left img,
|
||||||
|
.wall-right img {
|
||||||
|
width: 36px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wall-bottom {
|
||||||
|
bottom: 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-deck {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 42px);
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(8, 27, 20, 0.82);
|
||||||
|
border: 1px solid rgba(244, 222, 163, 0.28);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-deck strong {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #f7e4b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.table-tip {
|
.table-tip {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: #c1dfcf;
|
color: #c1dfcf;
|
||||||
@@ -614,11 +1051,22 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ws-panel {
|
.ws-panel {
|
||||||
margin-top: 10px;
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
margin-top: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(176, 216, 194, 0.22);
|
border: 1px solid rgba(176, 216, 194, 0.22);
|
||||||
background: rgba(5, 24, 17, 0.58);
|
background: rgba(5, 24, 17, 0.58);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-log {
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ws-panel-head {
|
.ws-panel-head {
|
||||||
@@ -662,7 +1110,8 @@ button:disabled {
|
|||||||
|
|
||||||
.ws-log {
|
.ws-log {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
max-height: 140px;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -802,6 +1251,35 @@ button:disabled {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-left {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-center,
|
||||||
|
.topbar-right {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-desk,
|
||||||
|
.table-felt,
|
||||||
|
.ws-panel {
|
||||||
|
grid-column: auto;
|
||||||
|
grid-row: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-panel {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -824,6 +1302,9 @@ button:disabled {
|
|||||||
padding-right: max(8px, env(safe-area-inset-right));
|
padding-right: max(8px, env(safe-area-inset-right));
|
||||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||||
padding-left: max(8px, env(safe-area-inset-left));
|
padding-left: max(8px, env(safe-area-inset-left));
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-mahjong-table {
|
.game-mahjong-table {
|
||||||
|
|||||||
12
src/components/game/BottomPlayerCard.vue
Normal file
12
src/components/game/BottomPlayerCard.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SeatPlayerCard from './SeatPlayerCard.vue'
|
||||||
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
player: SeatPlayerCardModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SeatPlayerCard seat-class="seat-bottom" :player="player" />
|
||||||
|
</template>
|
||||||
12
src/components/game/LeftPlayerCard.vue
Normal file
12
src/components/game/LeftPlayerCard.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SeatPlayerCard from './SeatPlayerCard.vue'
|
||||||
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
player: SeatPlayerCardModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SeatPlayerCard seat-class="seat-left" :player="player" />
|
||||||
|
</template>
|
||||||
12
src/components/game/RightPlayerCard.vue
Normal file
12
src/components/game/RightPlayerCard.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SeatPlayerCard from './SeatPlayerCard.vue'
|
||||||
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
player: SeatPlayerCardModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SeatPlayerCard seat-class="seat-right" :player="player" />
|
||||||
|
</template>
|
||||||
23
src/components/game/SeatPlayerCard.vue
Normal file
23
src/components/game/SeatPlayerCard.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
seatClass: string
|
||||||
|
player: SeatPlayerCardModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="player-badge"
|
||||||
|
:class="[seatClass, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
|
||||||
|
>
|
||||||
|
<div class="avatar-card">{{ player.avatar }}</div>
|
||||||
|
<div class="player-meta">
|
||||||
|
<p>{{ player.name }}</p>
|
||||||
|
<strong>{{ player.money }}</strong>
|
||||||
|
</div>
|
||||||
|
<span v-if="player.dealer" class="dealer-mark">庄</span>
|
||||||
|
<span class="missing-mark">{{ player.missingSuitLabel }}</span>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
12
src/components/game/TopPlayerCard.vue
Normal file
12
src/components/game/TopPlayerCard.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SeatPlayerCard from './SeatPlayerCard.vue'
|
||||||
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
player: SeatPlayerCardModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SeatPlayerCard seat-class="seat-top" :player="player" />
|
||||||
|
</template>
|
||||||
9
src/components/game/seat-player-card.ts
Normal file
9
src/components/game/seat-player-card.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface SeatPlayerCardModel {
|
||||||
|
avatar: string
|
||||||
|
name: string
|
||||||
|
money: string
|
||||||
|
dealer: boolean
|
||||||
|
isTurn: boolean
|
||||||
|
isOnline: boolean
|
||||||
|
missingSuitLabel: string
|
||||||
|
}
|
||||||
833
src/features/chengdu-game/useChengduGameRoom.ts
Normal file
833
src/features/chengdu-game/useChengduGameRoom.ts
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||||
|
import type { AuthSession } from '../../api/authed-request'
|
||||||
|
import { refreshAccessToken } from '../../api/auth'
|
||||||
|
import { getUserInfo } from '../../api/user'
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_PLAYERS,
|
||||||
|
activeRoomState,
|
||||||
|
destroyActiveRoomState,
|
||||||
|
mergeActiveRoomState,
|
||||||
|
resetActiveRoomState,
|
||||||
|
type GameState,
|
||||||
|
type RoomPlayerState,
|
||||||
|
type RoomState,
|
||||||
|
} from '../../state/active-room'
|
||||||
|
import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage'
|
||||||
|
import type { StoredAuth } from '../../types/session'
|
||||||
|
|
||||||
|
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
|
||||||
|
interface ActionEventLike {
|
||||||
|
type?: unknown
|
||||||
|
status?: unknown
|
||||||
|
requestId?: unknown
|
||||||
|
request_id?: unknown
|
||||||
|
roomId?: unknown
|
||||||
|
room_id?: unknown
|
||||||
|
payload?: unknown
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeatView {
|
||||||
|
key: SeatKey
|
||||||
|
player: RoomPlayerState | null
|
||||||
|
isSelf: boolean
|
||||||
|
isTurn: boolean
|
||||||
|
label: string
|
||||||
|
subLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChengduGameRoomModel {
|
||||||
|
auth: Ref<StoredAuth | null>
|
||||||
|
roomState: typeof activeRoomState
|
||||||
|
roomId: ComputedRef<string>
|
||||||
|
roomName: ComputedRef<string>
|
||||||
|
currentUserId: ComputedRef<string>
|
||||||
|
loggedInUserName: ComputedRef<string>
|
||||||
|
wsStatus: Ref<'disconnected' | 'connecting' | 'connected'>
|
||||||
|
wsError: Ref<string>
|
||||||
|
wsMessages: Ref<string[]>
|
||||||
|
startGamePending: Ref<boolean>
|
||||||
|
leaveRoomPending: Ref<boolean>
|
||||||
|
canStartGame: ComputedRef<boolean>
|
||||||
|
seatViews: ComputedRef<SeatView[]>
|
||||||
|
connectWs: () => Promise<void>
|
||||||
|
sendStartGame: () => void
|
||||||
|
backHall: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
||||||
|
|
||||||
|
export function useChengduGameRoom(
|
||||||
|
route: RouteLocationNormalizedLoaded,
|
||||||
|
router: Router,
|
||||||
|
): ChengduGameRoomModel {
|
||||||
|
const auth = ref(readStoredAuth())
|
||||||
|
const ws = ref<WebSocket | null>(null)
|
||||||
|
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
|
||||||
|
const wsError = ref('')
|
||||||
|
const wsMessages = ref<string[]>([])
|
||||||
|
const startGamePending = ref(false)
|
||||||
|
const lastStartRequestId = ref('')
|
||||||
|
const leaveRoomPending = ref(false)
|
||||||
|
const lastLeaveRoomRequestId = ref('')
|
||||||
|
const leaveHallAfterAck = ref(false)
|
||||||
|
|
||||||
|
const roomId = computed(() => {
|
||||||
|
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomName = computed(() => {
|
||||||
|
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUserId = computed(() => {
|
||||||
|
const user = auth.value?.user as Record<string, unknown> | undefined
|
||||||
|
const candidate = user?.id ?? user?.userID ?? user?.user_id
|
||||||
|
if (typeof candidate === 'string') {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||||||
|
return String(candidate)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loggedInUserName = computed(() => {
|
||||||
|
if (!auth.value?.user) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth.value.user.nickname ?? auth.value.user.username ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomState = activeRoomState
|
||||||
|
|
||||||
|
const isRoomFull = computed(() => {
|
||||||
|
return (
|
||||||
|
roomState.value.maxPlayers > 0 &&
|
||||||
|
roomState.value.playerCount === roomState.value.maxPlayers
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canStartGame = computed(() => {
|
||||||
|
return (
|
||||||
|
Boolean(roomState.value.id) &&
|
||||||
|
roomState.value.status === 'waiting' &&
|
||||||
|
isRoomFull.value &&
|
||||||
|
Boolean(currentUserId.value) &&
|
||||||
|
roomState.value.ownerId === currentUserId.value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const seatViews = computed<SeatView[]>(() => {
|
||||||
|
const seats: Record<SeatKey, RoomPlayerState | null> = {
|
||||||
|
top: null,
|
||||||
|
right: null,
|
||||||
|
bottom: null,
|
||||||
|
left: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
|
||||||
|
const hasSelf = players.some((player) => player.playerId === currentUserId.value)
|
||||||
|
if (currentUserId.value && roomState.value.id && !hasSelf) {
|
||||||
|
players.unshift({
|
||||||
|
index: 0,
|
||||||
|
playerId: currentUserId.value,
|
||||||
|
ready: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
|
||||||
|
const anchorIndex = me?.index ?? players[0]?.index ?? 0
|
||||||
|
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
|
||||||
|
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
|
||||||
|
seats[seat] = player
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnSeat =
|
||||||
|
roomState.value.currentTurnIndex === null
|
||||||
|
? null
|
||||||
|
: clockwiseSeatByDelta[
|
||||||
|
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
|
||||||
|
] ?? null
|
||||||
|
|
||||||
|
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
|
||||||
|
return order.map((seat) => {
|
||||||
|
const player = seats[seat]
|
||||||
|
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
|
||||||
|
return {
|
||||||
|
key: seat,
|
||||||
|
player,
|
||||||
|
isSelf,
|
||||||
|
isTurn: turnSeat === seat,
|
||||||
|
label: player ? (isSelf ? '你' : player.playerId) : '空位',
|
||||||
|
subLabel: player ? `座位 ${player.index}` : '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function backHall(): void {
|
||||||
|
if (leaveRoomPending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sent = sendLeaveRoom()
|
||||||
|
if (!sent) {
|
||||||
|
pushWsMessage('[client] Leave room request was not sent')
|
||||||
|
}
|
||||||
|
|
||||||
|
leaveHallAfterAck.value = false
|
||||||
|
disconnectWs()
|
||||||
|
destroyActiveRoomState()
|
||||||
|
void router.push('/hall')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushWsMessage(text: string): void {
|
||||||
|
const line = `[${new Date().toLocaleTimeString()}] ${text}`
|
||||||
|
wsMessages.value.unshift(line)
|
||||||
|
if (wsMessages.value.length > 80) {
|
||||||
|
wsMessages.value.length = 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWsSend(message: unknown): void {
|
||||||
|
console.log('[WS][client] 发送:', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWsReceive(kind: string, payload?: unknown): void {
|
||||||
|
const now = new Date().toLocaleTimeString()
|
||||||
|
if (payload === undefined) {
|
||||||
|
console.log(`[WS][${now}] 收到${kind}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[WS][${now}] 收到${kind}:`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWs(): void {
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close()
|
||||||
|
ws.value = null
|
||||||
|
}
|
||||||
|
wsStatus.value = 'disconnected'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringOrEmpty(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
|
||||||
|
return {
|
||||||
|
token: source.token,
|
||||||
|
tokenType: source.tokenType,
|
||||||
|
refreshToken: source.refreshToken,
|
||||||
|
expiresIn: source.expiresIn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAuth(next: AuthSession): void {
|
||||||
|
if (!auth.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = {
|
||||||
|
...auth.value,
|
||||||
|
token: next.token,
|
||||||
|
tokenType: next.tokenType ?? auth.value.tokenType,
|
||||||
|
refreshToken: next.refreshToken ?? auth.value.refreshToken,
|
||||||
|
expiresIn: next.expiresIn,
|
||||||
|
}
|
||||||
|
writeStoredAuth(auth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCurrentUserId(): Promise<void> {
|
||||||
|
if (currentUserId.value || !auth.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = await getUserInfo(toSession(auth.value), syncAuth)
|
||||||
|
const payload = userInfo as Record<string, unknown>
|
||||||
|
const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id)
|
||||||
|
if (!resolvedId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = {
|
||||||
|
...auth.value,
|
||||||
|
user: {
|
||||||
|
...(auth.value.user ?? {}),
|
||||||
|
id: resolvedId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeStoredAuth(auth.value)
|
||||||
|
} catch {
|
||||||
|
wsError.value = '获取当前用户 ID 失败,部分操作可能不可用'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWsAuth(): Promise<string | null> {
|
||||||
|
const currentAuth = auth.value
|
||||||
|
if (!currentAuth?.token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentAuth.refreshToken) {
|
||||||
|
return currentAuth.token
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken({
|
||||||
|
token: currentAuth.token,
|
||||||
|
tokenType: currentAuth.tokenType,
|
||||||
|
refreshToken: currentAuth.refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextAuth = {
|
||||||
|
...currentAuth,
|
||||||
|
token: refreshed.token,
|
||||||
|
tokenType: refreshed.tokenType ?? currentAuth.tokenType,
|
||||||
|
refreshToken: refreshed.refreshToken ?? currentAuth.refreshToken,
|
||||||
|
expiresIn: refreshed.expiresIn,
|
||||||
|
user: refreshed.user ?? currentAuth.user,
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = nextAuth
|
||||||
|
writeStoredAuth(nextAuth)
|
||||||
|
return nextAuth.token
|
||||||
|
} catch {
|
||||||
|
return currentAuth.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoolean(value: unknown): boolean {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true' || normalized === 'yes'
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScores(value: unknown): Record<string, number> {
|
||||||
|
const record = toRecord(value)
|
||||||
|
if (!record) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores: Record<string, number> = {}
|
||||||
|
for (const [key, score] of Object.entries(record)) {
|
||||||
|
const parsed = toFiniteNumber(score)
|
||||||
|
if (parsed !== null) {
|
||||||
|
scores[key] = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scores
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
|
||||||
|
const player = toRecord(input)
|
||||||
|
if (!player) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
|
||||||
|
if (!playerId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
|
||||||
|
return {
|
||||||
|
index: seatIndex ?? fallbackIndex,
|
||||||
|
playerId,
|
||||||
|
ready: Boolean(player.ready),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
||||||
|
const game = toRecord(value.game)
|
||||||
|
const gameState = toRecord(game?.state)
|
||||||
|
const keys = [
|
||||||
|
gameState?.currentTurn,
|
||||||
|
gameState?.current_turn,
|
||||||
|
gameState?.currentTurnIndex,
|
||||||
|
gameState?.current_turn_index,
|
||||||
|
value.currentTurnIndex,
|
||||||
|
value.current_turn_index,
|
||||||
|
value.currentPlayerIndex,
|
||||||
|
value.current_player_index,
|
||||||
|
value.turnIndex,
|
||||||
|
value.turn_index,
|
||||||
|
value.activePlayerIndex,
|
||||||
|
value.active_player_index,
|
||||||
|
]
|
||||||
|
for (const key of keys) {
|
||||||
|
const parsed = toFiniteNumber(key)
|
||||||
|
if (parsed !== null) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGame(input: unknown): GameState | null {
|
||||||
|
const game = toRecord(input)
|
||||||
|
if (!game) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = toRecord(game.rule)
|
||||||
|
const rawState = toRecord(game.state)
|
||||||
|
const playersRaw =
|
||||||
|
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
|
||||||
|
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
|
||||||
|
[]
|
||||||
|
|
||||||
|
const normalizedPlayers = playersRaw
|
||||||
|
.map((item, index) => normalizePlayer(item, index))
|
||||||
|
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: rule
|
||||||
|
? {
|
||||||
|
name: toStringOrEmpty(rule.name),
|
||||||
|
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
|
||||||
|
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
state: rawState
|
||||||
|
? {
|
||||||
|
phase: toStringOrEmpty(rawState.phase),
|
||||||
|
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
|
||||||
|
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
|
||||||
|
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
|
||||||
|
players: normalizedPlayers,
|
||||||
|
wall: Array.isArray(rawState.wall)
|
||||||
|
? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
|
||||||
|
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
|
||||||
|
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
|
||||||
|
winners: Array.isArray(rawState.winners)
|
||||||
|
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
scores: normalizeScores(rawState.scores),
|
||||||
|
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
|
||||||
|
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
|
||||||
|
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
|
||||||
|
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoom(input: unknown): RoomState | null {
|
||||||
|
const source = toRecord(input)
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let room = source
|
||||||
|
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||||
|
if (!id) {
|
||||||
|
const nestedRoom = toRecord(room.data)
|
||||||
|
if (nestedRoom) {
|
||||||
|
room = nestedRoom
|
||||||
|
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPlayers =
|
||||||
|
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
|
||||||
|
const playersRaw =
|
||||||
|
(Array.isArray(room.players) ? room.players : null) ??
|
||||||
|
(Array.isArray(room.playerList) ? room.playerList : null) ??
|
||||||
|
(Array.isArray(room.player_list) ? room.player_list : null) ??
|
||||||
|
[]
|
||||||
|
const playerIdsRaw =
|
||||||
|
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
|
||||||
|
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
|
||||||
|
[]
|
||||||
|
|
||||||
|
const players = playersRaw
|
||||||
|
.map((item, index) => normalizePlayer(item, index))
|
||||||
|
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
const playersFromIds = playerIdsRaw
|
||||||
|
.map((item, index) => ({
|
||||||
|
index,
|
||||||
|
playerId: toStringOrEmpty(item),
|
||||||
|
ready: false,
|
||||||
|
}))
|
||||||
|
.filter((item) => Boolean(item.playerId))
|
||||||
|
const resolvedPlayers = players.length > 0 ? players : playersFromIds
|
||||||
|
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
|
||||||
|
const game = normalizeGame(room.game)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: toStringOrEmpty(room.name) || roomState.value.name,
|
||||||
|
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
|
||||||
|
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
|
||||||
|
maxPlayers,
|
||||||
|
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
|
||||||
|
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
||||||
|
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
|
||||||
|
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
|
||||||
|
game: game ?? roomState.value.game,
|
||||||
|
players: resolvedPlayers,
|
||||||
|
currentTurnIndex: extractCurrentTurnIndex(room),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRoomState(next: RoomState): void {
|
||||||
|
if (roomId.value && next.id !== roomId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mergeActiveRoomState(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeGameEvent(raw: string): void {
|
||||||
|
let parsed: unknown = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = toRecord(parsed) as ActionEventLike | null
|
||||||
|
if (!event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = toRecord(event.payload)
|
||||||
|
const data = toRecord(event.data)
|
||||||
|
const eventType = toStringOrEmpty(event.type)
|
||||||
|
const eventStatus = toStringOrEmpty(event.status)
|
||||||
|
const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id)
|
||||||
|
const eventRequestId = toStringOrEmpty(
|
||||||
|
event.requestId ??
|
||||||
|
event.request_id ??
|
||||||
|
payload?.requestId ??
|
||||||
|
payload?.request_id ??
|
||||||
|
data?.requestId ??
|
||||||
|
data?.request_id,
|
||||||
|
)
|
||||||
|
const payloadPlayerIds = Array.isArray(payload?.player_ids)
|
||||||
|
? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||||
|
: Array.isArray(payload?.playerIds)
|
||||||
|
? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||||
|
: null
|
||||||
|
const leaveByRequestIdMatched = Boolean(
|
||||||
|
eventRequestId && eventRequestId === lastLeaveRoomRequestId.value,
|
||||||
|
)
|
||||||
|
const leaveByPlayerUpdateMatched =
|
||||||
|
leaveRoomPending.value &&
|
||||||
|
eventType === 'room_player_update' &&
|
||||||
|
eventStatus === 'ok' &&
|
||||||
|
eventRoomId === (roomState.value.id || roomId.value) &&
|
||||||
|
Array.isArray(payloadPlayerIds) &&
|
||||||
|
Boolean(currentUserId.value) &&
|
||||||
|
!payloadPlayerIds.includes(currentUserId.value)
|
||||||
|
|
||||||
|
if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) {
|
||||||
|
leaveRoomPending.value = false
|
||||||
|
lastLeaveRoomRequestId.value = ''
|
||||||
|
if (event.status === 'error') {
|
||||||
|
leaveHallAfterAck.value = false
|
||||||
|
wsError.value = '退出房间失败,请稍后重试'
|
||||||
|
pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`)
|
||||||
|
} else {
|
||||||
|
if (leaveByPlayerUpdateMatched) {
|
||||||
|
pushWsMessage('[client] 已确认退出房间 player_update')
|
||||||
|
} else {
|
||||||
|
pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`)
|
||||||
|
}
|
||||||
|
if (leaveHallAfterAck.value) {
|
||||||
|
leaveHallAfterAck.value = false
|
||||||
|
void router.push('/hall')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: unknown[] = [event.payload, event.data]
|
||||||
|
if (payload) {
|
||||||
|
candidates.push(payload.room, payload.state, payload.roomState, payload.data)
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
|
candidates.push(data.room, data.state, data.roomState, data.data)
|
||||||
|
}
|
||||||
|
candidates.push(event)
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeRoom(candidate)
|
||||||
|
if (normalized) {
|
||||||
|
mergeRoomState(normalized)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.status === 'error' &&
|
||||||
|
typeof event.requestId === 'string' &&
|
||||||
|
event.requestId === lastStartRequestId.value
|
||||||
|
) {
|
||||||
|
startGamePending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendStartGame(): void {
|
||||||
|
if (
|
||||||
|
!ws.value ||
|
||||||
|
ws.value.readyState !== WebSocket.OPEN ||
|
||||||
|
!canStartGame.value ||
|
||||||
|
startGamePending.value
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = currentUserId.value
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = createRequestId('start-game')
|
||||||
|
lastStartRequestId.value = requestId
|
||||||
|
startGamePending.value = true
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'start_game',
|
||||||
|
sender,
|
||||||
|
target: 'room',
|
||||||
|
roomId: roomState.value.id || roomId.value,
|
||||||
|
seq: Date.now(),
|
||||||
|
requestId,
|
||||||
|
trace_id: createRequestId('trace'),
|
||||||
|
payload: {},
|
||||||
|
}
|
||||||
|
logWsSend(message)
|
||||||
|
ws.value.send(JSON.stringify(message))
|
||||||
|
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendLeaveRoom(): boolean {
|
||||||
|
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||||||
|
wsError.value = 'WebSocket 未连接,无法退出房间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sender = currentUserId.value
|
||||||
|
const targetRoomId = roomState.value.id || roomId.value
|
||||||
|
if (!sender) {
|
||||||
|
wsError.value = '缺少当前用户 ID,无法退出房间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!targetRoomId) {
|
||||||
|
wsError.value = '缺少房间 ID,无法退出房间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = createRequestId('leave-room')
|
||||||
|
leaveRoomPending.value = true
|
||||||
|
lastLeaveRoomRequestId.value = requestId
|
||||||
|
const message = {
|
||||||
|
type: 'leave_room',
|
||||||
|
sender,
|
||||||
|
target: 'room',
|
||||||
|
roomId: targetRoomId,
|
||||||
|
seq: Date.now(),
|
||||||
|
requestId,
|
||||||
|
trace_id: createRequestId('trace'),
|
||||||
|
payload: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
logWsSend(message)
|
||||||
|
ws.value.send(JSON.stringify(message))
|
||||||
|
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWs(): Promise<void> {
|
||||||
|
wsError.value = ''
|
||||||
|
const token = await ensureWsAuth()
|
||||||
|
if (!token) {
|
||||||
|
wsError.value = '缺少 token,无法建立 WebSocket 连接'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectWs()
|
||||||
|
wsStatus.value = 'connecting'
|
||||||
|
|
||||||
|
const url = buildWsUrl(token)
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
ws.value = socket
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
wsStatus.value = 'connected'
|
||||||
|
pushWsMessage('WebSocket 已连接')
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
logWsReceive('文本消息', event.data)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data)
|
||||||
|
logWsReceive('JSON 消息', parsed)
|
||||||
|
|
||||||
|
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
|
||||||
|
} catch {
|
||||||
|
pushWsMessage(`[server] ${event.data}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeGameEvent(event.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logWsReceive('binary message')
|
||||||
|
pushWsMessage('[binary] message received')
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
wsError.value = 'WebSocket 连接异常'
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
wsStatus.value = 'disconnected'
|
||||||
|
startGamePending.value = false
|
||||||
|
if (leaveRoomPending.value) {
|
||||||
|
leaveRoomPending.value = false
|
||||||
|
lastLeaveRoomRequestId.value = ''
|
||||||
|
leaveHallAfterAck.value = false
|
||||||
|
wsError.value = '连接已断开,未收到退出房间确认'
|
||||||
|
pushWsMessage('[client] 连接断开,退出房间请求未确认')
|
||||||
|
}
|
||||||
|
pushWsMessage('WebSocket 已断开')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(token: string): string {
|
||||||
|
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
||||||
|
? new URL(WS_BASE_URL)
|
||||||
|
: new URL(
|
||||||
|
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
|
||||||
|
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
baseUrl.searchParams.set('token', token)
|
||||||
|
return baseUrl.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
roomId,
|
||||||
|
(nextRoomId) => {
|
||||||
|
const currentRoom = roomState.value
|
||||||
|
if (!nextRoomId) {
|
||||||
|
destroyActiveRoomState()
|
||||||
|
} else if (currentRoom.id !== nextRoomId) {
|
||||||
|
resetActiveRoomState({
|
||||||
|
id: nextRoomId,
|
||||||
|
name: roomName.value,
|
||||||
|
})
|
||||||
|
} else if (!currentRoom.name && roomName.value) {
|
||||||
|
roomState.value = { ...currentRoom, name: roomName.value }
|
||||||
|
}
|
||||||
|
startGamePending.value = false
|
||||||
|
lastStartRequestId.value = ''
|
||||||
|
leaveRoomPending.value = false
|
||||||
|
lastLeaveRoomRequestId.value = ''
|
||||||
|
leaveHallAfterAck.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(roomName, (next) => {
|
||||||
|
roomState.value = { ...roomState.value, name: next || roomState.value.name }
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[canStartGame, wsStatus],
|
||||||
|
([canStart, status]) => {
|
||||||
|
if (!canStart || status !== 'connected') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendStartGame()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => roomState.value.status,
|
||||||
|
(status) => {
|
||||||
|
if (status === 'playing' || status === 'finished') {
|
||||||
|
startGamePending.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await ensureCurrentUserId()
|
||||||
|
void connectWs()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectWs()
|
||||||
|
destroyActiveRoomState()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth,
|
||||||
|
roomState,
|
||||||
|
roomId,
|
||||||
|
roomName,
|
||||||
|
currentUserId,
|
||||||
|
loggedInUserName,
|
||||||
|
wsStatus,
|
||||||
|
wsError,
|
||||||
|
wsMessages,
|
||||||
|
startGamePending,
|
||||||
|
leaveRoomPending,
|
||||||
|
canStartGame,
|
||||||
|
seatViews,
|
||||||
|
connectWs,
|
||||||
|
sendStartGame,
|
||||||
|
backHall,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
53
tests/e2e/chengdu-flow.spec.ts
Normal file
53
tests/e2e/chengdu-flow.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
function uniqueUser() {
|
||||||
|
const stamp = Date.now().toString()
|
||||||
|
return {
|
||||||
|
username: `pw${stamp.slice(-8)}`,
|
||||||
|
phone: `13${stamp.slice(-9)}`,
|
||||||
|
email: `pw${stamp}@example.com`,
|
||||||
|
password: 'playwright123',
|
||||||
|
roomName: `pw-room-${stamp.slice(-6)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('register, login, create room, enter game, and back to hall', async ({ page }) => {
|
||||||
|
const user = uniqueUser()
|
||||||
|
|
||||||
|
await page.goto('/register')
|
||||||
|
|
||||||
|
const registerInputs = page.locator('.auth-card .form input')
|
||||||
|
await registerInputs.nth(0).fill(user.username)
|
||||||
|
await registerInputs.nth(1).fill(user.phone)
|
||||||
|
await registerInputs.nth(2).fill(user.email)
|
||||||
|
await registerInputs.nth(3).fill(user.password)
|
||||||
|
await registerInputs.nth(4).fill(user.password)
|
||||||
|
await page.locator('.auth-card .primary-btn[type="submit"]').click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/login/)
|
||||||
|
|
||||||
|
const loginInputs = page.locator('.auth-card .form input')
|
||||||
|
await expect(loginInputs.nth(0)).toHaveValue(user.phone)
|
||||||
|
await loginInputs.nth(1).fill(user.password)
|
||||||
|
await page.locator('.auth-card .primary-btn[type="submit"]').click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/hall/)
|
||||||
|
await expect(page.locator('.hall-page')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.room-actions-footer .primary-btn').click()
|
||||||
|
await expect(page.locator('.modal-card')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.modal-card .field input').first().fill(user.roomName)
|
||||||
|
await page.locator('.modal-card .modal-actions .primary-btn').click()
|
||||||
|
|
||||||
|
await expect(page.locator('.copy-line')).toHaveCount(2)
|
||||||
|
await page.locator('.modal-card .modal-actions .primary-btn').click()
|
||||||
|
|
||||||
|
await page.waitForURL(/\/game\/chengdu\//)
|
||||||
|
await expect(page.locator('.game-page')).toBeVisible()
|
||||||
|
await expect(page.locator('.table-felt')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.topbar-back-btn').click()
|
||||||
|
await page.waitForURL(/\/hall/)
|
||||||
|
await expect(page.locator('.hall-page')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||||
|
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
|
||||||
port: 3000,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: wsProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
},
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://127.0.0.1:19000',
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/api/v1/ws': {
|
},
|
||||||
target: 'ws://127.0.0.1:19000',
|
},
|
||||||
ws: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user