Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
S
srvpro2
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Locked Files
Issues
0
Issues
0
List
Boards
Labels
Service Desk
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Security & Compliance
Security & Compliance
Dependency List
License Compliance
Packages
Packages
List
Container Registry
Analytics
Analytics
CI / CD
Code Review
Insights
Issues
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
nanahira
srvpro2
Commits
8caf4b7d
Commit
8caf4b7d
authored
Feb 14, 2026
by
nanahira
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
reconnect feature
parent
302b5d5d
Pipeline
#43202
passed with stages
in 1 minute and 31 seconds
Changes
8
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
951 additions
and
41 deletions
+951
-41
config.example.yaml
config.example.yaml
+2
-0
src/client/client.ts
src/client/client.ts
+8
-3
src/config.ts
src/config.ts
+2
-0
src/constants/trans.ts
src/constants/trans.ts
+14
-0
src/feats/feats-module.ts
src/feats/feats-module.ts
+2
-0
src/feats/reconnect.ts
src/feats/reconnect.ts
+829
-0
src/room/room.ts
src/room/room.ts
+74
-38
src/utility/deck-compare.ts
src/utility/deck-compare.ts
+20
-0
No files found.
config.example.yaml
View file @
8caf4b7d
...
...
@@ -20,6 +20,8 @@ DECK_SIDE_MAX: "15"
DECK_MAX_COPIES
:
"
3"
OCGCORE_DEBUG_LOG
:
"
"
WELCOME
:
"
"
NO_RECONNECT
:
"
"
RECONNECT_TIMEOUT
:
"
180000"
HOSTINFO_LFLIST
:
"
0"
HOSTINFO_RULE
:
"
0"
HOSTINFO_MODE
:
"
0"
...
...
src/client/client.ts
View file @
8caf4b7d
import
{
filter
,
merge
,
Observable
,
of
,
Subject
}
from
'
rxjs
'
;
import
{
map
,
share
,
take
,
takeUntil
}
from
'
rxjs/operators
'
;
import
{
map
,
share
,
take
,
takeUntil
,
tap
}
from
'
rxjs/operators
'
;
import
{
Context
}
from
'
../app
'
;
import
{
YGOProCtos
,
...
...
@@ -55,7 +55,13 @@ export class Client {
.
asObservable
()
.
pipe
(
map
(()
=>
({
bySystem
:
true
}))),
this
.
_onDisconnect
().
pipe
(
map
(()
=>
({
bySystem
:
false
}))),
).
pipe
(
take
(
1
),
share
());
).
pipe
(
take
(
1
),
tap
(()
=>
{
this
.
disconnected
=
new
Date
();
}),
share
(),
);
this
.
receive$
=
this
.
_receive
().
pipe
(
YGOProProtoPipe
(
YGOProCtos
,
{
onError
:
(
error
)
=>
{
...
...
@@ -85,7 +91,6 @@ export class Client {
disconnected
?:
Date
;
disconnect
():
undefined
{
this
.
disconnected
=
new
Date
();
this
.
disconnectSubject
.
next
();
this
.
disconnectSubject
.
complete
();
this
.
_disconnect
().
then
();
...
...
src/config.ts
View file @
8caf4b7d
...
...
@@ -30,6 +30,8 @@ export const defaultConfig = {
DECK_MAX_COPIES
:
'
3
'
,
OCGCORE_DEBUG_LOG
:
''
,
WELCOME
:
''
,
NO_RECONNECT
:
''
,
RECONNECT_TIMEOUT
:
'
180000
'
,
...(
Object
.
fromEntries
(
Object
.
entries
(
DefaultHostinfo
).
map
(([
key
,
value
])
=>
[
`HOSTINFO_
${
key
.
toUpperCase
()}
`
,
...
...
src/constants/trans.ts
View file @
8caf4b7d
...
...
@@ -13,6 +13,13 @@ export const TRANSLATIONS = {
watch_join
:
'
joined as spectator.
'
,
quit_watch
:
'
quited spectating
'
,
left_game
:
'
quited game
'
,
disconnect_from_game
:
'
disconnected from the game
'
,
reconnect_to_game
:
'
reconnected to the game
'
,
reconnect_kicked
:
"
You are kicked out because you're logged in on other devices.
"
,
pre_reconnecting_to_room
:
'
You will be reconnected to your previous game. Please pick your previous deck.
'
,
deck_incorrect_reconnect
:
'
Please pick your previous deck.
'
,
reconnect_failed
:
'
Reconnect failed.
'
,
reconnecting_to_room
:
'
Reconnecting to server...
'
,
},
'
zh-CN
'
:
{
update_required
:
'
请更新你的客户端版本
'
,
...
...
@@ -27,5 +34,12 @@ export const TRANSLATIONS = {
watch_join
:
'
加入了观战
'
,
quit_watch
:
'
退出了观战
'
,
left_game
:
'
离开了游戏
'
,
disconnect_from_game
:
'
断开了连接
'
,
reconnect_to_game
:
'
重新连接了
'
,
reconnect_kicked
:
'
你的账号已经在其他设备登录,你被迫下线。
'
,
pre_reconnecting_to_room
:
'
你有未完成的对局,即将重新连接,请选择你在本局决斗中使用的卡组并准备。
'
,
deck_incorrect_reconnect
:
'
请选择你在本局决斗中使用的卡组。
'
,
reconnect_failed
:
'
重新连接失败。
'
,
reconnecting_to_room
:
'
正在重新连接到服务器……
'
,
},
};
src/feats/feats-module.ts
View file @
8caf4b7d
...
...
@@ -3,9 +3,11 @@ import { ClientVersionCheck } from './client-version-check';
import
{
ContextState
}
from
'
../app
'
;
import
{
Welcome
}
from
'
./welcome
'
;
import
{
PlayerStatusNotify
}
from
'
./player-status-notify
'
;
import
{
Reconnect
}
from
'
./reconnect
'
;
export
const
FeatsModule
=
createAppContext
<
ContextState
>
()
.
provide
(
ClientVersionCheck
)
.
provide
(
Welcome
)
.
provide
(
PlayerStatusNotify
)
.
provide
(
Reconnect
)
.
define
();
src/feats/reconnect.ts
0 → 100644
View file @
8caf4b7d
import
{
ChatColor
,
NetPlayerType
,
OcgcoreScriptConstants
,
YGOProCtosBase
,
YGOProCtosJoinGame
,
YGOProCtosUpdateDeck
,
YGOProMsgHint
,
YGOProMsgNewPhase
,
YGOProMsgNewTurn
,
YGOProMsgStart
,
YGOProMsgWaiting
,
YGOProStocDuelStart
,
YGOProStocGameMsg
,
YGOProStocJoinGame
,
YGOProStocTypeChange
,
YGOProStocHsPlayerEnter
,
YGOProStocHsPlayerChange
,
YGOProStocSelectHand
,
YGOProStocSelectTp
,
YGOProStocChangeSide
,
ErrorMessageType
,
YGOProStocErrorMsg
,
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
Client
}
from
'
../client
'
;
import
{
DuelStage
}
from
'
../room/duel-stage
'
;
import
{
Room
}
from
'
../room
'
;
import
{
RoomManager
}
from
'
../room/room-manager
'
;
import
{
getSpecificFields
}
from
'
../utility/metadata
'
;
import
{
YGOProCtosDisconnect
}
from
'
../utility/ygopro-ctos-disconnect
'
;
import
{
isUpdateDeckPayloadEqual
}
from
'
../utility/deck-compare
'
;
interface
DisconnectInfo
{
roomName
:
string
;
clientPos
:
number
;
playerName
:
string
;
disconnectTime
:
Date
;
oldClient
:
Client
;
timeout
:
NodeJS
.
Timeout
;
}
type
ReconnectType
=
'
normal
'
|
'
kick
'
;
declare
module
'
../client
'
{
interface
Client
{
preReconnecting
?:
boolean
;
reconnectType
?:
ReconnectType
;
preReconnectRoomName
?:
string
;
// 临时保存重连的目标房间名
}
}
export
class
Reconnect
{
private
disconnectList
=
new
Map
<
string
,
DisconnectInfo
>
();
private
isLooseReconnectRule
=
false
;
// 宽松匹配模式,日后可能配置支持
private
reconnectTimeout
=
parseInt
(
this
.
ctx
.
getConfig
(
'
RECONNECT_TIMEOUT
'
,
''
)
||
'
180000
'
,
10
,
);
// 超时时间,单位:毫秒(默认 180000ms = 3分钟)
constructor
(
private
ctx
:
Context
)
{
// 检查是否禁用断线重连
if
(
this
.
ctx
.
getConfig
(
'
NO_RECONNECT
'
,
''
))
{
return
;
}
// 拦截所有 CTOS 消息,过滤 pre_reconnecting 状态下的非法消息
// 使用 true 参数确保这个 middleware 优先执行
this
.
ctx
.
middleware
(
YGOProCtosBase
,
async
(
event
,
client
,
next
)
=>
{
// 如果客户端处于 pre_reconnecting 状态
if
(
client
.
preReconnecting
)
{
// 只允许 UPDATE_DECK 消息通过
if
(
event
instanceof
YGOProCtosUpdateDeck
)
{
return
next
();
}
// 其他消息全部拒绝,不做任何处理
return
;
}
return
next
();
},
true
,
// 优先执行
);
// 拦截 DISCONNECT 消息
this
.
ctx
.
middleware
(
YGOProCtosDisconnect
,
async
(
msg
,
client
,
next
)
=>
{
// 如果是系统断线(如被踢),不允许重连
if
(
msg
.
bySystem
)
{
return
next
();
// 正常断线处理
}
if
(
!
this
.
canReconnect
(
client
))
{
return
next
();
// 正常断线处理
}
await
this
.
registerDisconnect
(
client
);
// 不调用 next(),阻止踢人
});
// 拦截 JOIN_GAME 消息
this
.
ctx
.
middleware
(
YGOProCtosJoinGame
,
async
(
msg
,
client
,
next
)
=>
{
if
(
await
this
.
tryPreReconnect
(
client
,
msg
))
{
return
;
// 进入 pre_reconnect 状态
}
return
next
();
// 正常加入流程
});
// 拦截 UPDATE_DECK 消息
this
.
ctx
.
middleware
(
YGOProCtosUpdateDeck
,
async
(
msg
,
client
,
next
)
=>
{
if
(
client
.
preReconnecting
)
{
await
this
.
handleReconnectDeck
(
client
,
msg
);
return
;
// 处理完毕
}
return
next
();
// 正常更新卡组流程
});
}
private
canReconnect
(
client
:
Client
):
boolean
{
const
room
=
this
.
getClientRoom
(
client
);
if
(
!
room
)
{
return
false
;
}
return
(
!
client
.
isInternal
&&
// 不是内部虚拟客户端
client
.
pos
<
NetPlayerType
.
OBSERVER
&&
// 是玩家
room
.
duelStage
!==
DuelStage
.
Begin
// 游戏已开始
);
}
private
async
registerDisconnect
(
client
:
Client
)
{
const
room
=
this
.
getClientRoom
(
client
)
!
;
const
key
=
this
.
getAuthorizeKey
(
client
);
// 通知房间
await
room
.
sendChat
(
`
${
client
.
name
}
#{disconnect_from_game}`
,
ChatColor
.
LIGHTBLUE
,
);
// 保存断线信息
const
timeout
=
setTimeout
(()
=>
{
this
.
handleTimeout
(
key
);
},
this
.
reconnectTimeout
);
this
.
disconnectList
.
set
(
key
,
{
roomName
:
room
.
name
,
clientPos
:
client
.
pos
,
playerName
:
client
.
name
,
disconnectTime
:
new
Date
(),
oldClient
:
client
,
timeout
,
});
}
private
async
tryPreReconnect
(
newClient
:
Client
,
msg
:
YGOProCtosJoinGame
,
):
Promise
<
boolean
>
{
const
key
=
this
.
getAuthorizeKey
(
newClient
);
const
disconnectInfo
=
this
.
disconnectList
.
get
(
key
);
let
room
:
Room
|
undefined
;
let
oldClient
:
Client
|
undefined
;
let
reconnectType
:
ReconnectType
|
undefined
;
// 1. 尝试正常断线重连
if
(
disconnectInfo
)
{
// 验证房间名(msg.pass 就是房间名)
if
(
msg
.
pass
!==
disconnectInfo
.
roomName
)
{
return
false
;
}
// 获取房间
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
room
=
roomManager
.
findByName
(
disconnectInfo
.
roomName
);
if
(
!
room
)
{
// 房间已不存在,清理断线记录
this
.
clearDisconnectInfo
(
disconnectInfo
);
return
false
;
}
oldClient
=
disconnectInfo
.
oldClient
;
reconnectType
=
'
normal
'
;
}
// 2. 尝试踢人重连
if
(
!
room
)
{
const
kickTarget
=
this
.
findKickReconnectTarget
(
newClient
);
if
(
kickTarget
)
{
room
=
this
.
getClientRoom
(
kickTarget
)
!
;
oldClient
=
kickTarget
;
reconnectType
=
'
kick
'
;
}
}
if
(
!
room
||
!
oldClient
||
!
reconnectType
)
{
return
false
;
// 两种模式都不匹配
}
// 进入 pre_reconnect 阶段
await
this
.
sendPreReconnectInfo
(
newClient
,
room
,
oldClient
,
reconnectType
);
return
true
;
}
private
async
handleReconnectDeck
(
client
:
Client
,
msg
:
YGOProCtosUpdateDeck
)
{
const
reconnectType
=
client
.
reconnectType
;
if
(
!
reconnectType
)
{
// 不应该发生
await
client
.
sendChat
(
'
#{reconnect_failed}
'
,
ChatColor
.
RED
);
return
client
.
disconnect
();
}
// 验证卡组
const
isValid
=
await
this
.
verifyReconnectDeck
(
client
,
msg
,
reconnectType
);
if
(
!
isValid
)
{
// 卡组不匹配
await
client
.
sendChat
(
'
#{deck_incorrect_reconnect}
'
,
ChatColor
.
RED
);
// 发送 HS_PLAYER_CHANGE (status = pos << 4 | 0xa)
// 0xa = NOTREADY with deck error flag
await
client
.
send
(
new
YGOProStocHsPlayerChange
().
fromPartial
({
playerPosition
:
client
.
pos
,
playerState
:
(
client
.
pos
<<
4
)
|
0xa
,
}),
);
await
client
.
send
(
new
YGOProStocErrorMsg
().
fromPartial
({
msg
:
ErrorMessageType
.
DECKERROR
,
code
:
0
,
}),
);
return
;
}
// 卡组验证通过,执行真正的重连
// 获取房间(可能房间已不存在)
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
room
=
client
.
preReconnectRoomName
?
roomManager
.
findByName
(
client
.
preReconnectRoomName
)
:
undefined
;
if
(
!
room
)
{
// 房间已不存在
await
client
.
sendChat
(
'
#{reconnect_failed}
'
,
ChatColor
.
RED
);
client
.
preReconnecting
=
false
;
client
.
reconnectType
=
undefined
;
client
.
preReconnectRoomName
=
undefined
;
return
client
.
disconnect
();
}
client
.
preReconnecting
=
false
;
client
.
reconnectType
=
undefined
;
client
.
preReconnectRoomName
=
undefined
;
if
(
reconnectType
===
'
normal
'
)
{
const
key
=
this
.
getAuthorizeKey
(
client
);
const
disconnectInfo
=
this
.
disconnectList
.
get
(
key
);
if
(
!
disconnectInfo
)
{
await
client
.
sendChat
(
'
#{reconnect_failed}
'
,
ChatColor
.
RED
);
return
client
.
disconnect
();
}
await
this
.
performReconnect
(
client
,
disconnectInfo
.
oldClient
,
room
);
// 通知房间
await
room
.
sendChat
(
`
${
client
.
name
}
#{reconnect_to_game}`
,
ChatColor
.
LIGHTBLUE
,
);
// 清理旧客户端
disconnectInfo
.
oldClient
.
roomName
=
undefined
;
disconnectInfo
.
oldClient
.
pos
=
-
1
;
disconnectInfo
.
oldClient
.
disconnect
();
// 清理断线记录
this
.
clearDisconnectInfo
(
disconnectInfo
);
}
else
{
// kick reconnect
const
oldClient
=
room
.
playingPlayers
.
find
(
(
p
)
=>
p
.
name
===
client
.
name
&&
p
!==
client
,
);
if
(
!
oldClient
)
{
await
client
.
sendChat
(
'
#{reconnect_failed}
'
,
ChatColor
.
RED
);
return
client
.
disconnect
();
}
await
this
.
performReconnect
(
client
,
oldClient
,
room
);
// 通知房间
await
room
.
sendChat
(
`
${
client
.
name
}
#{reconnect_to_game}`
,
ChatColor
.
LIGHTBLUE
,
);
// 清理旧客户端
oldClient
.
roomName
=
undefined
;
oldClient
.
pos
=
-
1
;
// kick reconnect 的区别:通知旧客户端被踢(不 await)
oldClient
.
sendChat
(
'
#{reconnect_kicked}
'
,
ChatColor
.
RED
)
.
then
(()
=>
oldClient
.
disconnect
());
}
}
private
async
sendPreReconnectInfo
(
client
:
Client
,
room
:
Room
,
oldClient
:
Client
,
reconnectType
:
ReconnectType
,
)
{
// 设置 pre_reconnecting 状态
client
.
preReconnecting
=
true
;
client
.
reconnectType
=
reconnectType
;
client
.
preReconnectRoomName
=
room
.
name
;
// 保存目标房间名
client
.
pos
=
oldClient
.
pos
;
// 发送房间信息
await
client
.
sendChat
(
'
#{pre_reconnecting_to_room}
'
,
ChatColor
.
BABYBLUE
);
await
client
.
send
(
room
.
joinGameMessage
);
// 发送 TYPE_CHANGE
const
typeChangePos
=
oldClient
.
isHost
?
oldClient
.
pos
|
0x10
:
oldClient
.
pos
;
await
client
.
send
(
new
YGOProStocTypeChange
().
fromPartial
({
type
:
typeChangePos
,
}),
);
// 发送其他玩家信息
for
(
const
player
of
room
.
players
)
{
if
(
player
)
{
await
client
.
send
(
new
YGOProStocHsPlayerEnter
().
fromPartial
({
name
:
player
.
name
,
pos
:
player
.
pos
,
}),
);
}
}
}
private
async
verifyReconnectDeck
(
client
:
Client
,
msg
:
YGOProCtosUpdateDeck
,
reconnectType
:
ReconnectType
,
):
Promise
<
boolean
>
{
if
(
reconnectType
===
'
normal
'
)
{
// 正常重连:验证 disconnectInfo 中的 startDeck
const
key
=
this
.
getAuthorizeKey
(
client
);
const
disconnectInfo
=
this
.
disconnectList
.
get
(
key
);
if
(
!
disconnectInfo
)
{
return
false
;
}
const
oldStartDeck
=
disconnectInfo
.
oldClient
.
startDeck
;
if
(
!
oldStartDeck
)
{
return
false
;
}
// 比较卡组
return
isUpdateDeckPayloadEqual
(
msg
.
deck
,
oldStartDeck
);
}
else
{
// 踢人重连:验证房间内玩家的 startDeck
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
room
=
client
.
preReconnectRoomName
?
roomManager
.
findByName
(
client
.
preReconnectRoomName
)
:
undefined
;
if
(
!
room
)
{
return
false
;
}
const
oldClient
=
room
.
playingPlayers
.
find
(
(
p
)
=>
p
.
name
===
client
.
name
&&
p
!==
client
,
);
if
(
!
oldClient
?.
startDeck
)
{
return
false
;
}
// 比较卡组
return
isUpdateDeckPayloadEqual
(
msg
.
deck
,
oldClient
.
startDeck
);
}
}
private
async
performReconnect
(
newClient
:
Client
,
oldClient
:
Client
,
room
:
Room
,
)
{
// 1. 数据迁移(@ClientRoomField)
this
.
importClientData
(
newClient
,
oldClient
,
room
);
// 2. 通知客户端正在重连
await
newClient
.
sendChat
(
'
#{reconnecting_to_room}
'
,
ChatColor
.
BABYBLUE
);
// 3. 根据 duelStage 发送不同的消息
switch
(
room
.
duelStage
)
{
case
DuelStage
.
Finger
:
await
this
.
performReconnectFinger
(
newClient
,
room
);
break
;
case
DuelStage
.
FirstGo
:
await
this
.
performReconnectFirstGo
(
newClient
,
room
);
break
;
case
DuelStage
.
Siding
:
await
this
.
performReconnectSiding
(
newClient
,
room
);
break
;
case
DuelStage
.
Dueling
:
await
this
.
performReconnectDueling
(
newClient
,
room
);
break
;
default
:
// Begin 或 End 阶段不应该重连
break
;
}
}
private
async
performReconnectFinger
(
newClient
:
Client
,
room
:
Room
)
{
// Finger 阶段:猜拳
await
newClient
.
send
(
new
YGOProStocDuelStart
());
await
newClient
.
send
(
room
.
prepareStocDeckCount
(
newClient
.
pos
));
// 检查是否需要发送 SELECT_HAND
// 判断方法:getDuelPosPlayers 本端是第一个
const
duelPos
=
room
.
getDuelPos
(
newClient
);
const
duelPosPlayers
=
room
.
getDuelPosPlayers
(
duelPos
);
const
isFirstPlayer
=
duelPosPlayers
[
0
]
===
newClient
;
// 检查是否已经猜过拳
const
hasSelected
=
room
.
handResult
&&
room
.
handResult
[
duelPos
]
!==
0
;
// 只有每方的第一个玩家猜拳,并且没有猜过拳
if
(
isFirstPlayer
&&
!
hasSelected
)
{
await
newClient
.
send
(
new
YGOProStocSelectHand
());
}
}
private
async
performReconnectFirstGo
(
newClient
:
Client
,
room
:
Room
)
{
// FirstGo 阶段:选先后手
await
newClient
.
send
(
new
YGOProStocDuelStart
());
await
newClient
.
send
(
room
.
prepareStocDeckCount
(
newClient
.
pos
));
// 检查是否是该玩家选先后手(duelPos 的第一个玩家)
const
duelPos
=
room
.
getDuelPos
(
newClient
);
if
(
duelPos
===
room
.
firstgoPos
)
{
const
firstgoPlayers
=
room
.
getDuelPosPlayers
(
duelPos
);
if
(
newClient
===
firstgoPlayers
[
0
])
{
await
newClient
.
send
(
new
YGOProStocSelectTp
());
}
}
}
private
async
performReconnectSiding
(
newClient
:
Client
,
room
:
Room
)
{
// Siding 阶段:更换副卡组
await
newClient
.
send
(
new
YGOProStocDuelStart
());
// 检查玩家是否已经提交过卡组
// Siding 阶段无论有没有换完都不发 DeckCount
if
(
!
newClient
.
deck
)
{
// 还没有提交,发送 CHANGE_SIDE
await
newClient
.
send
(
new
YGOProStocChangeSide
());
}
}
private
async
performReconnectDueling
(
newClient
:
Client
,
room
:
Room
)
{
// Dueling 阶段:决斗中
// 这是原来的完整重连逻辑
await
newClient
.
send
(
new
YGOProStocDuelStart
());
// Dueling 阶段不发 DeckCount
// 发送 MSG_START,卡组数量全部为 0(重连时不显示卡组数量)
const
playerType
=
room
.
getIngameDuelPos
(
newClient
);
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
new
YGOProMsgStart
().
fromPartial
({
playerType
,
duelRule
:
room
.
hostinfo
.
duel_rule
,
startLp0
:
room
.
hostinfo
.
start_lp
,
startLp1
:
room
.
hostinfo
.
start_lp
,
player0
:
{
deckCount
:
0
,
extraCount
:
0
,
},
player1
:
{
deckCount
:
0
,
extraCount
:
0
,
},
}),
}),
);
// 发送回合/阶段消息
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
new
YGOProMsgNewTurn
().
fromPartial
({
player
:
room
.
turnIngamePos
,
}),
}),
);
if
(
room
.
phase
!=
null
)
{
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
new
YGOProMsgNewPhase
().
fromPartial
({
phase
:
room
.
phase
,
}),
}),
);
}
// 发送 MSG_RELOAD_FIELD(核心状态重建)
await
newClient
.
send
(
await
this
.
requestField
(
room
));
// 发送刷新消息
await
this
.
sendRefreshMessages
(
newClient
,
room
);
// 判断是否需要重发响应请求
const
needResendRequest
=
room
.
hostinfo
.
time_limit
>
0
&&
// 有计时器
this
.
isReconnectingPlayerOperating
(
newClient
,
room
);
// 重连玩家在操作
if
(
needResendRequest
)
{
// 重发 lastHintMsg(从 watchMessages 找)
const
lastHint
=
this
.
findLastHintForClient
(
newClient
,
room
);
if
(
lastHint
)
{
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
lastHint
,
}),
);
}
// 重发 lastResponseRequestMsg
if
(
room
.
lastResponseRequestMsg
)
{
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
room
.
lastResponseRequestMsg
.
playerView
(
room
.
getIngameDuelPos
(
newClient
),
),
}),
);
}
}
else
{
// 不是重连玩家操作,发送 WAITING
await
newClient
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
new
YGOProMsgWaiting
(),
}),
);
}
}
private
importClientData
(
newClient
:
Client
,
oldClient
:
Client
,
room
:
Room
)
{
// 获取所有 @ClientRoomField 装饰的字段
const
fields
=
getSpecificFields
(
'
clientRoomField
'
,
oldClient
);
// 迁移数据
for
(
const
{
key
}
of
fields
)
{
(
newClient
as
any
)[
key
]
=
(
oldClient
as
any
)[
key
];
}
// 替换 room 中的引用
this
.
replaceClientReferences
(
room
,
oldClient
,
newClient
);
}
private
replaceClientReferences
(
room
:
Room
,
oldClient
:
Client
,
newClient
:
Client
,
)
{
// 替换 players 数组中的引用
const
playerIndex
=
room
.
players
.
indexOf
(
oldClient
);
if
(
playerIndex
!==
-
1
)
{
room
.
players
[
playerIndex
]
=
newClient
;
}
// 替换 watchers Set 中的引用(虽然重连只针对玩家,但以防万一)
if
(
room
.
watchers
.
has
(
oldClient
))
{
room
.
watchers
.
delete
(
oldClient
);
room
.
watchers
.
add
(
newClient
);
}
}
private
async
requestField
(
room
:
Room
):
Promise
<
YGOProStocGameMsg
>
{
if
(
!
room
.
ocgcore
)
{
throw
new
Error
(
'
OCGCore not initialized
'
);
}
const
info
=
await
room
.
ocgcore
.
queryFieldInfo
();
// info.field 已经是 YGOProMsgReloadField 对象
return
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
info
.
field
,
});
}
private
async
sendRefreshMessages
(
client
:
Client
,
room
:
Room
)
{
// 参考 ygopro RequestField 的逻辑,刷新各个区域
// 使用 0xefffff queryFlag(重连专用,包含更完整的信息)
const
queryFlag
=
0xefffff
;
// 按照 ygopro RequestField 的顺序刷新
// 先对方,后自己(使用 ingame pos)
const
selfIngamePos
=
room
.
getIngameDuelPosByDuelPos
(
client
.
pos
);
const
opponentIngamePos
=
1
-
selfIngamePos
;
// RefreshMzone
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_MZONE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_MZONE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
// RefreshSzone
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_SZONE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_SZONE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
// RefreshHand
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_HAND
,
},
{
queryFlag
,
sendToClient
:
client
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_HAND
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
// RefreshGrave
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_GRAVE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_GRAVE
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
// RefreshExtra
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_EXTRA
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_EXTRA
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
// RefreshRemoved
await
room
.
refreshLocations
(
{
player
:
opponentIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_REMOVED
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
await
room
.
refreshLocations
(
{
player
:
selfIngamePos
,
location
:
OcgcoreScriptConstants
.
LOCATION_REMOVED
,
},
{
queryFlag
,
sendToClient
:
client
,
useCache
:
0
},
);
}
private
isReconnectingPlayerOperating
(
client
:
Client
,
room
:
Room
):
boolean
{
// 检查重连玩家是否是当前操作玩家
const
ingameDuelPos
=
room
.
getIngameDuelPosByDuelPos
(
client
.
pos
);
const
operatingPlayer
=
room
.
getIngameOperatingPlayer
(
ingameDuelPos
);
return
operatingPlayer
===
client
;
}
private
findLastHintForClient
(
client
:
Client
,
room
:
Room
,
):
YGOProMsgHint
|
undefined
{
const
watchMessages
=
room
.
lastDuelRecord
?.
watchMessages
;
if
(
!
watchMessages
)
{
return
undefined
;
}
// 提前计算 ingame pos
const
clientIngamePos
=
room
.
getIngameDuelPosByDuelPos
(
client
.
pos
);
// 从后往前找
for
(
let
i
=
watchMessages
.
length
-
1
;
i
>=
0
;
i
--
)
{
const
msg
=
watchMessages
[
i
];
// 只找 Hint 消息
if
(
!
(
msg
instanceof
YGOProMsgHint
))
{
continue
;
}
// 检查 getSendTargets 是否包含重连玩家
try
{
const
targets
=
msg
.
getSendTargets
();
// 返回 number[] (ingame pos 数组)
if
(
targets
.
includes
(
clientIngamePos
))
{
return
msg
.
playerView
(
clientIngamePos
);
}
}
catch
{
// getSendTargets 可能失败,忽略
continue
;
}
}
return
undefined
;
}
private
getAuthorizeKey
(
client
:
Client
):
string
{
// 参考 srvpro 逻辑
// 如果有 vpass 且不是宽松匹配模式,优先用 name_vpass
if
(
!
this
.
isLooseReconnectRule
&&
client
.
vpass
)
{
return
client
.
name_vpass
;
}
// 宽松匹配模式或内部客户端
if
(
this
.
isLooseReconnectRule
)
{
return
client
.
name
||
client
.
ip
||
'
undefined
'
;
}
// 默认:ip:name
return
`
${
client
.
ip
}
:
${
client
.
name
}
`
;
}
private
getClientRoom
(
client
:
Client
):
Room
|
undefined
{
if
(
!
client
.
roomName
)
{
return
undefined
;
}
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
return
roomManager
.
findByName
(
client
.
roomName
);
}
private
handleTimeout
(
key
:
string
)
{
const
disconnectInfo
=
this
.
disconnectList
.
get
(
key
);
if
(
!
disconnectInfo
)
{
return
;
}
// 先清理断线记录,避免重复处理
this
.
disconnectList
.
delete
(
key
);
// 然后重新 dispatch 带 bySystem 的 Disconnect 事件
const
msg
=
new
YGOProCtosDisconnect
();
msg
.
bySystem
=
true
;
// 标记为系统断线,防止再次进入重连逻辑
this
.
ctx
.
dispatch
(
msg
,
disconnectInfo
.
oldClient
);
}
private
clearDisconnectInfo
(
disconnectInfo
:
DisconnectInfo
)
{
clearTimeout
(
disconnectInfo
.
timeout
);
const
key
=
this
.
getAuthorizeKey
(
disconnectInfo
.
oldClient
);
this
.
disconnectList
.
delete
(
key
);
}
private
findKickReconnectTarget
(
newClient
:
Client
):
Client
|
undefined
{
const
roomManager
=
this
.
ctx
.
get
(()
=>
RoomManager
);
const
allRooms
=
roomManager
.
allRooms
();
for
(
const
room
of
allRooms
)
{
// 只在游戏进行中的房间查找
if
(
room
.
duelStage
===
DuelStage
.
Begin
)
{
continue
;
}
// 查找符合条件的在线玩家
for
(
const
player
of
room
.
playingPlayers
)
{
// if (player.disconnected) {
// continue; // 跳过已断线的玩家
// }
// 名字必须匹配
if
(
player
.
name
!==
newClient
.
name
)
{
continue
;
}
// 宽松模式或匹配条件
const
matchCondition
=
this
.
isLooseReconnectRule
||
player
.
ip
===
newClient
.
ip
||
(
newClient
.
vpass
&&
newClient
.
vpass
===
player
.
vpass
);
if
(
matchCondition
)
{
return
player
;
}
}
}
return
undefined
;
}
}
src/room/room.ts
View file @
8caf4b7d
...
...
@@ -210,7 +210,7 @@ export class Room {
}
}
private
get
joinGameMessage
()
{
get
joinGameMessage
()
{
return
new
YGOProStocJoinGame
().
fromPartial
({
info
:
{
...
this
.
hostinfo
,
...
...
@@ -297,9 +297,20 @@ export class Room {
private
sendPostWatchMessages
(
client
:
Client
)
{
client
.
send
(
new
YGOProStocDuelStart
());
// 在 SelectHand / SelectTp 阶段发送 DeckCount
// Siding 阶段不发 DeckCount
if
(
this
.
duelStage
===
DuelStage
.
Finger
||
this
.
duelStage
===
DuelStage
.
FirstGo
)
{
client
.
send
(
this
.
prepareStocDeckCount
(
client
.
pos
));
}
if
(
this
.
duelStage
===
DuelStage
.
Siding
)
{
client
.
send
(
new
YGOProStocWaitingSide
());
}
else
if
(
this
.
duelStage
===
DuelStage
.
Dueling
)
{
// Dueling 阶段不发 DeckCount,直接发送观战消息
this
.
lastDuelRecord
?.
watchMessages
.
forEach
((
message
)
=>
{
client
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
message
.
observerView
()
}),
...
...
@@ -755,6 +766,7 @@ export class Room {
this
.
allPlayers
.
forEach
((
p
)
=>
p
.
send
(
changeMsg
));
}
else
if
(
this
.
duelStage
===
DuelStage
.
Siding
)
{
// In Siding stage, send DUEL_START to the player who submitted deck
// Siding 阶段不发 DeckCount
client
.
send
(
new
YGOProStocDuelStart
());
// Check if all players have submitted their decks
...
...
@@ -809,13 +821,54 @@ export class Room {
return
Promise
.
all
(
this
.
allPlayers
.
map
((
p
)
=>
p
.
sendChat
(
msg
,
type
)));
}
firstgoPlayer
?:
Client
;
private
handResult
=
[
0
,
0
];
firstgoPos
?:
number
;
handResult
=
[
0
,
0
];
prepareStocDeckCount
(
pos
:
number
)
{
const
toDeckCount
=
(
d
:
YGOProDeck
|
undefined
)
=>
{
const
res
=
new
YGOProStocDeckCount_DeckInfo
();
if
(
!
d
)
{
res
.
main
=
0
;
res
.
extra
=
0
;
res
.
side
=
0
;
}
else
{
res
.
main
=
d
.
main
.
length
;
res
.
extra
=
d
.
extra
.
length
;
res
.
side
=
d
.
side
.
length
;
}
return
res
;
};
const
displayCountDecks
:
(
YGOProDeck
|
undefined
)[]
=
[
0
,
1
].
map
((
p
)
=>
{
const
player
=
this
.
getDuelPosPlayers
(
p
)[
0
];
// 优先使用 deck,如果不存在则使用 startDeck 兜底
return
player
?.
deck
||
player
?.
startDeck
;
});
// 如果是观战者或者其他特殊位置,直接按顺序显示
if
(
pos
>=
NetPlayerType
.
OBSERVER
)
{
return
new
YGOProStocDeckCount
().
fromPartial
({
player0DeckCount
:
toDeckCount
(
displayCountDecks
[
0
]),
player1DeckCount
:
toDeckCount
(
displayCountDecks
[
1
]),
});
}
// 对于玩家,自己的卡组在前,对方的在后
const
duelPos
=
this
.
getDuelPos
(
pos
);
const
selfDeck
=
displayCountDecks
[
duelPos
];
const
otherDeck
=
displayCountDecks
[
1
-
duelPos
];
return
new
YGOProStocDeckCount
().
fromPartial
({
player0DeckCount
:
toDeckCount
(
selfDeck
),
player1DeckCount
:
toDeckCount
(
otherDeck
),
});
}
private
async
toFirstGo
(
firstgoPos
:
number
)
{
this
.
firstgoP
layer
=
this
.
getDuelPosPlayers
(
firstgoPos
)[
0
]
;
this
.
firstgoP
os
=
firstgoPos
;
this
.
duelStage
=
DuelStage
.
FirstGo
;
this
.
firstgoPlayer
.
send
(
new
YGOProStocSelectTp
());
const
firstgoPlayer
=
this
.
getDuelPosPlayers
(
firstgoPos
)[
0
];
firstgoPlayer
.
send
(
new
YGOProStocSelectTp
());
}
private
async
toFinger
()
{
...
...
@@ -901,36 +954,9 @@ export class Room {
}
if
(
this
.
duelRecords
.
length
===
0
)
{
this
.
allPlayers
.
forEach
((
p
)
=>
p
.
send
(
new
YGOProStocDuelStart
()));
const
displayCountDecks
=
[
0
,
1
].
map
(
(
p
)
=>
this
.
getDuelPosPlayers
(
p
)[
0
].
deck
!
,
);
const
toDeckCount
=
(
d
:
YGOProDeck
)
=>
{
const
res
=
new
YGOProStocDeckCount_DeckInfo
();
res
.
main
=
d
.
main
.
length
;
res
.
extra
=
d
.
extra
.
length
;
res
.
side
=
d
.
side
.
length
;
return
res
;
};
[
0
,
1
].
forEach
((
p
)
=>
{
const
selfDeck
=
displayCountDecks
[
p
];
const
otherDeck
=
displayCountDecks
[
1
-
p
];
this
.
getDuelPosPlayers
(
p
).
forEach
((
c
)
=>
{
c
.
send
(
new
YGOProStocDeckCount
().
fromPartial
({
player0DeckCount
:
toDeckCount
(
selfDeck
),
player1DeckCount
:
toDeckCount
(
otherDeck
),
}),
);
});
});
this
.
watchers
.
forEach
((
c
)
=>
{
c
.
send
(
new
YGOProStocDeckCount
().
fromPartial
({
player0DeckCount
:
toDeckCount
(
displayCountDecks
[
0
]),
player1DeckCount
:
toDeckCount
(
displayCountDecks
[
1
]),
}),
);
this
.
allPlayers
.
forEach
((
p
)
=>
{
p
.
send
(
new
YGOProStocDuelStart
());
p
.
send
(
this
.
prepareStocDeckCount
(
p
.
pos
));
});
}
...
...
@@ -1089,7 +1115,13 @@ export class Room {
@
RoomMethod
({
allowInDuelStages
:
DuelStage
.
FirstGo
})
private
async
onDuelStart
(
client
:
Client
,
msg
:
YGOProCtosTpResult
)
{
if
(
client
!==
this
.
firstgoPlayer
)
{
// 检查是否是该玩家选先后手(duelPos 的第一个玩家)
const
duelPos
=
this
.
getDuelPos
(
client
);
if
(
duelPos
!==
this
.
firstgoPos
)
{
return
;
}
const
firstgoPlayers
=
this
.
getDuelPosPlayers
(
duelPos
);
if
(
client
!==
firstgoPlayers
[
0
])
{
return
;
}
this
.
isPosSwapped
=
...
...
@@ -1294,7 +1326,11 @@ export class Room {
async
refreshLocations
(
refresh
:
RequireQueryLocation
,
options
:
{
queryFlag
?:
number
;
sendToClient
?:
MayBeArray
<
Client
>
}
=
{},
options
:
{
queryFlag
?:
number
;
sendToClient
?:
MayBeArray
<
Client
>
;
useCache
?:
number
;
}
=
{},
)
{
if
(
!
this
.
ocgcore
)
{
return
;
...
...
@@ -1305,7 +1341,7 @@ export class Room {
player
:
refresh
.
player
,
location
,
queryFlag
:
options
.
queryFlag
??
getZoneQueryFlag
(
location
),
useCache
:
1
,
useCache
:
options
.
useCache
??
1
,
});
await
this
.
dispatchGameMsg
(
new
YGOProMsgUpdateData
().
fromPartial
({
...
...
src/utility/deck-compare.ts
0 → 100644
View file @
8caf4b7d
import
YGOProDeck
from
'
ygopro-deck-encode
'
;
/**
* 比较两个卡组是否相等
* 使用 toUpdateDeckPayload 转换为 buffer 然后比较
* 这是与 srvpro 一致的比较方法
*/
export
function
isUpdateDeckPayloadEqual
(
deck1
:
YGOProDeck
,
deck2
:
YGOProDeck
,
):
boolean
{
const
uint8Array1
=
deck1
.
toUpdateDeckPayload
();
const
uint8Array2
=
deck2
.
toUpdateDeckPayload
();
// 将 Uint8Array 转换为 Buffer 再比较
const
buffer1
=
Buffer
.
from
(
uint8Array1
);
const
buffer2
=
Buffer
.
from
(
uint8Array2
);
return
buffer1
.
equals
(
buffer2
);
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment