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
3ac2c111
Commit
3ac2c111
authored
Feb 17, 2026
by
nanahira
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
add cloud replay
parent
b55ce242
Changes
12
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
819 additions
and
23 deletions
+819
-23
src/constants/trans.ts
src/constants/trans.ts
+23
-0
src/feats/cloud-replay/cloud-replay-service.ts
src/feats/cloud-replay/cloud-replay-service.ts
+602
-8
src/feats/cloud-replay/duel-record-player.entity.ts
src/feats/cloud-replay/duel-record-player.entity.ts
+7
-1
src/feats/cloud-replay/duel-record.entity.ts
src/feats/cloud-replay/duel-record.entity.ts
+1
-1
src/feats/cloud-replay/index.ts
src/feats/cloud-replay/index.ts
+1
-0
src/feats/cloud-replay/utility/record-codec.ts
src/feats/cloud-replay/utility/record-codec.ts
+150
-10
src/join-handlers/cloud-replay-join-handler.ts
src/join-handlers/cloud-replay-join-handler.ts
+20
-0
src/join-handlers/join-handler-module.ts
src/join-handlers/join-handler-module.ts
+2
-0
src/room/duel-record.ts
src/room/duel-record.ts
+1
-1
src/room/index.ts
src/room/index.ts
+1
-0
src/room/room-event/on-room-win.ts
src/room/room-event/on-room-win.ts
+1
-0
src/room/room.ts
src/room/room.ts
+10
-2
No files found.
src/constants/trans.ts
View file @
3ac2c111
...
...
@@ -115,6 +115,18 @@ export const TRANSLATIONS = {
koishi_ai_disabled
:
'
Windbot feature is disabled.
'
,
koishi_ai_disabled_random_room
:
'
AI is disabled in random duel rooms.
'
,
koishi_ai_room_full
:
'
Room is full, cannot add AI.
'
,
cloud_replay_no
:
'
Replay not found.
'
,
cloud_replay_error
:
'
Replay opening failed.
'
,
cloud_replay_playing
:
'
Accessing cloud replay
'
,
cloud_replay_hint
:
'
These are your recent cloud replays. Select one from the menu to continue.
'
,
cloud_replay_detail_time
:
'
Time:
'
,
cloud_replay_detail_players
:
'
Duel:
'
,
cloud_replay_detail_score
:
'
Score:
'
,
cloud_replay_detail_winner
:
'
Winner:
'
,
cloud_replay_menu_play
:
'
Play Cloud Replay
'
,
cloud_replay_menu_download_yrp
:
'
Download YRP Replay
'
,
cloud_replay_menu_back
:
'
Back
'
,
},
'
zh-CN
'
:
{
update_required
:
'
请更新你的客户端版本
'
,
...
...
@@ -224,5 +236,16 @@ export const TRANSLATIONS = {
koishi_ai_disabled
:
'
人机功能未开启。
'
,
koishi_ai_disabled_random_room
:
'
随机对战房间不允许使用 /ai。
'
,
koishi_ai_room_full
:
'
房间已满,无法添加AI。
'
,
cloud_replay_no
:
'
没有找到录像
'
,
cloud_replay_error
:
'
播放录像出错
'
,
cloud_replay_playing
:
'
正在观看云录像
'
,
cloud_replay_hint
:
'
以下是您近期的云录像,请在菜单中选择一条继续。
'
,
cloud_replay_detail_time
:
'
时间:
'
,
cloud_replay_detail_players
:
'
对局:
'
,
cloud_replay_detail_score
:
'
比分:
'
,
cloud_replay_detail_winner
:
'
胜者:
'
,
cloud_replay_menu_play
:
'
播放云录像
'
,
cloud_replay_menu_download_yrp
:
'
下载 YRP 录像
'
,
cloud_replay_menu_back
:
'
返回
'
,
},
};
src/feats/cloud-replay/cloud-replay-service.ts
View file @
3ac2c111
import
cryptoRandomString
from
'
crypto-random-string
'
;
import
YGOProDeck
from
'
ygopro-deck-encode
'
;
import
{
ChatColor
,
HostInfo
,
NetPlayerType
,
YGOProMsgResponseBase
,
YGOProMsgWin
,
YGOProStocDuelEnd
,
YGOProStocDuelStart
,
YGOProStocGameMsg
,
YGOProStocHsPlayerEnter
,
YGOProStocJoinGame
,
YGOProStocReplay
,
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../../app
'
;
import
{
Client
}
from
'
../../client
'
;
import
{
DuelRecord
,
OnRoomCreate
,
OnRoomWin
,
Room
}
from
'
../../room
'
;
import
{
ClientKeyProvider
}
from
'
../client-key-provider
'
;
import
{
OnRoomCreate
,
OnRoomWin
,
Room
}
from
'
../../room
'
;
import
{
MenuEntry
,
MenuManager
}
from
'
../menu-manager
'
;
import
{
DuelRecordEntity
}
from
'
./duel-record.entity
'
;
import
{
DuelRecordPlayer
}
from
'
./duel-record-player.entity
'
;
import
{
Client
}
from
'
../../client
'
;
import
{
decodeDeckBase64
,
decodeMessagesBase64
,
decodeResponsesBase64
,
decodeSeedBase64
,
encodeCurrentDeckBase64
,
encodeDeckBase64
,
encodeIngameDeckBase64
,
encodeMessagesBase64
,
encodeResponsesBase64
,
encodeSeedBase64
,
resolveCurrentDeckMainc
,
resolveIngameDeckMainc
,
resolveIngamePosBySeat
,
resolveIsFirstPlayer
,
resolvePlayerScore
,
resolveStartDeckMainc
,
}
from
'
./utility
'
;
type
ReplayPage
=
{
entries
:
DuelRecordEntity
[];
hasNext
:
boolean
;
nextCursor
?:
number
;
};
declare
module
'
../../room
'
{
interface
Room
{
identifier
?:
string
;
}
}
declare
module
'
../../client
'
{
interface
Client
{
cloudReplayPageCursors
?:
Array
<
number
|
null
>
;
cloudReplayPageIndex
?:
number
;
cloudReplaySelectedReplayId
?:
number
;
}
}
export
class
CloudReplayService
{
private
logger
=
this
.
ctx
.
createLogger
(
this
.
constructor
.
name
);
private
clientKeyProvider
=
this
.
ctx
.
get
(()
=>
ClientKeyProvider
);
private
menuManager
=
this
.
ctx
.
get
(()
=>
MenuManager
);
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
OnRoomCreate
,
async
(
event
,
_client
,
next
)
=>
{
...
...
@@ -38,6 +76,26 @@ export class CloudReplayService {
});
}
async
tryHandleJoinPass
(
pass
:
string
,
client
:
Client
)
{
const
normalized
=
(
pass
||
''
).
trim
().
toUpperCase
();
if
(
!
normalized
||
!
[
'
R
'
,
'
W
'
].
includes
(
normalized
))
{
return
false
;
}
if
(
!
this
.
ctx
.
database
)
{
await
client
.
die
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
return
true
;
}
if
(
normalized
===
'
W
'
)
{
await
this
.
playRandomReplay
(
client
);
return
true
;
}
await
this
.
openReplayListMenu
(
client
);
return
true
;
}
private
createRoomIdentifier
()
{
return
cryptoRandomString
({
length
:
64
,
...
...
@@ -73,7 +131,12 @@ export class CloudReplayService {
responses
:
encodeResponsesBase64
(
duelRecord
.
responses
),
seed
:
encodeSeedBase64
(
duelRecord
.
seed
),
players
:
room
.
playingPlayers
.
map
((
client
)
=>
this
.
buildPlayerRecord
(
room
,
client
,
event
.
winMsg
.
player
),
this
.
buildPlayerRecord
(
room
,
client
,
event
.
winMsg
.
player
,
event
.
wasSwapped
,
),
),
});
...
...
@@ -89,20 +152,27 @@ export class CloudReplayService {
}
}
private
buildPlayerRecord
(
room
:
Room
,
client
:
Client
,
winPlayer
:
number
)
{
private
buildPlayerRecord
(
room
:
Room
,
client
:
Client
,
winPlayer
:
number
,
wasSwapped
:
boolean
,
)
{
const
player
=
new
DuelRecordPlayer
();
player
.
name
=
client
.
name
;
player
.
pos
=
client
.
pos
;
player
.
realName
=
client
.
name_vpass
||
client
.
name
;
player
.
ip
=
client
.
ip
||
''
;
player
.
clientKey
=
this
.
clientKeyProvider
.
getClientKey
(
client
);
player
.
isFirst
=
r
oom
.
getIngameDuelPos
(
client
)
===
0
;
player
.
isFirst
=
r
esolveIsFirstPlayer
(
room
,
client
,
wasSwapped
)
;
player
.
score
=
resolvePlayerScore
(
room
,
client
);
player
.
startDeckBuffer
=
encodeDeckBase64
(
client
.
startDeck
);
player
.
startDeckMainc
=
resolveStartDeckMainc
(
client
);
player
.
currentDeckBuffer
=
encodeCurrentDeckBase64
(
room
,
client
);
player
.
currentDeckMainc
=
resolveCurrentDeckMainc
(
room
,
client
);
player
.
winner
=
room
.
getIngameDuelPos
(
client
)
===
winPlayer
;
player
.
currentDeckBuffer
=
encodeCurrentDeckBase64
(
room
,
client
,
wasSwapped
);
player
.
currentDeckMainc
=
resolveCurrentDeckMainc
(
room
,
client
,
wasSwapped
);
player
.
ingameDeckBuffer
=
encodeIngameDeckBase64
(
room
,
client
,
wasSwapped
);
player
.
ingameDeckMainc
=
resolveIngameDeckMainc
(
room
,
client
,
wasSwapped
);
player
.
winner
=
room
.
getDuelPos
(
client
)
===
winPlayer
;
return
player
;
}
...
...
@@ -112,4 +182,528 @@ export class CloudReplayService {
}
return
room
.
identifier
;
}
private
async
playRandomReplay
(
client
:
Client
)
{
const
replay
=
await
this
.
getRandomReplay
();
if
(
!
replay
)
{
await
client
.
die
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
return
;
}
await
this
.
playReplayStream
(
client
,
replay
,
true
);
}
private
async
openReplayListMenu
(
client
:
Client
)
{
await
client
.
sendChat
(
'
#{cloud_replay_hint}
'
,
ChatColor
.
BABYBLUE
);
client
.
cloudReplayPageCursors
=
[
null
];
client
.
cloudReplayPageIndex
=
0
;
client
.
cloudReplaySelectedReplayId
=
undefined
;
await
this
.
renderReplayListMenu
(
client
);
}
private
async
renderReplayListMenu
(
client
:
Client
)
{
const
page
=
await
this
.
getReplayPage
(
client
);
if
(
!
page
.
entries
.
length
)
{
await
client
.
die
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
return
;
}
const
menu
:
MenuEntry
[]
=
[];
if
(
!
this
.
isFirstReplayPage
(
client
))
{
menu
.
push
({
title
:
'
#{menu_prev_page}
'
,
callback
:
async
(
currentClient
)
=>
{
this
.
goToPrevReplayPage
(
currentClient
);
await
this
.
renderReplayListMenu
(
currentClient
);
},
});
}
for
(
const
replay
of
page
.
entries
)
{
menu
.
push
({
title
:
`R#
${
replay
.
id
}
`
,
callback
:
async
(
currentClient
)
=>
{
currentClient
.
cloudReplaySelectedReplayId
=
replay
.
id
;
await
this
.
renderReplayDetailMenu
(
currentClient
,
replay
.
id
);
},
});
}
if
(
page
.
hasNext
&&
page
.
nextCursor
!=
null
)
{
menu
.
push
({
title
:
'
#{menu_next_page}
'
,
callback
:
async
(
currentClient
)
=>
{
this
.
goToNextReplayPage
(
currentClient
,
page
.
nextCursor
!
);
await
this
.
renderReplayListMenu
(
currentClient
);
},
});
}
while
(
menu
.
length
<=
2
)
{
menu
.
push
({
title
:
''
,
callback
:
async
(
currentClient
)
=>
{
await
this
.
renderReplayListMenu
(
currentClient
);
},
});
}
await
this
.
menuManager
.
launchMenu
(
client
,
menu
);
}
private
async
renderReplayDetailMenu
(
client
:
Client
,
replayId
:
number
)
{
const
replay
=
await
this
.
findOwnedReplayById
(
client
,
replayId
);
if
(
!
replay
)
{
await
client
.
sendChat
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
await
this
.
renderReplayListMenu
(
client
);
return
;
}
await
this
.
sendReplayDetail
(
client
,
replay
);
const
menu
:
MenuEntry
[]
=
[
{
title
:
'
#{cloud_replay_menu_play}
'
,
callback
:
async
(
currentClient
)
=>
{
const
selectedReplay
=
await
this
.
findOwnedReplayById
(
currentClient
,
replayId
,
);
if
(
!
selectedReplay
)
{
await
currentClient
.
die
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
return
;
}
await
this
.
playReplayStream
(
currentClient
,
selectedReplay
,
false
);
},
},
{
title
:
'
#{cloud_replay_menu_download_yrp}
'
,
callback
:
async
(
currentClient
)
=>
{
const
selectedReplay
=
await
this
.
findOwnedReplayById
(
currentClient
,
replayId
,
);
if
(
!
selectedReplay
)
{
await
currentClient
.
die
(
'
#{cloud_replay_no}
'
,
ChatColor
.
RED
);
return
;
}
await
this
.
downloadReplayYrp
(
currentClient
,
selectedReplay
);
},
},
{
title
:
'
#{cloud_replay_menu_back}
'
,
callback
:
async
(
currentClient
)
=>
{
await
this
.
renderReplayListMenu
(
currentClient
);
},
},
];
await
this
.
menuManager
.
launchMenu
(
client
,
menu
);
}
private
async
sendReplayDetail
(
client
:
Client
,
replay
:
DuelRecordEntity
)
{
const
dateText
=
this
.
formatDate
(
replay
.
endTime
);
const
versus
=
this
.
formatReplayVersus
(
replay
);
const
score
=
this
.
formatReplayScore
(
replay
);
const
winners
=
this
.
formatReplayWinners
(
replay
);
await
client
.
sendChat
(
`#{cloud_replay_detail_time}
${
dateText
}
`
,
ChatColor
.
BABYBLUE
);
await
client
.
sendChat
(
`#{cloud_replay_detail_players}
${
versus
}
`
,
ChatColor
.
BABYBLUE
);
await
client
.
sendChat
(
`#{cloud_replay_detail_score}
${
score
}
`
,
ChatColor
.
BABYBLUE
);
await
client
.
sendChat
(
`#{cloud_replay_detail_winner}
${
winners
}
`
,
ChatColor
.
BABYBLUE
);
}
private
async
playReplayStream
(
client
:
Client
,
replay
:
DuelRecordEntity
,
withYrp
:
boolean
,
)
{
try
{
await
client
.
sendChat
(
`#{cloud_replay_playing} R#
${
replay
.
id
}
`
,
ChatColor
.
BABYBLUE
,
);
await
client
.
send
(
this
.
createJoinGamePacket
(
replay
));
await
this
.
sendReplayPlayers
(
client
,
replay
);
await
client
.
send
(
new
YGOProStocDuelStart
());
const
gameMessages
=
this
.
resolveReplayVisibleMessages
(
replay
.
messages
);
for
(
const
msg
of
gameMessages
)
{
await
client
.
send
(
msg
);
}
await
this
.
sendReplayWinMsg
(
client
,
replay
);
if
(
withYrp
)
{
await
client
.
send
(
this
.
createReplayPacket
(
replay
));
}
await
client
.
send
(
new
YGOProStocDuelEnd
());
client
.
disconnect
();
}
catch
(
error
)
{
this
.
logger
.
warn
(
{
replayId
:
replay
.
id
,
clientName
:
client
.
name
,
error
:
(
error
as
Error
).
toString
(),
},
'
Failed to play cloud replay
'
,
);
await
client
.
die
(
'
#{cloud_replay_error}
'
,
ChatColor
.
RED
);
}
}
private
resolveReplayVisibleMessages
(
messagesBase64
:
string
)
{
return
decodeMessagesBase64
(
messagesBase64
).
filter
((
packet
)
=>
{
const
msg
=
packet
.
msg
;
if
(
!
msg
)
{
return
false
;
}
if
(
msg
instanceof
YGOProMsgResponseBase
)
{
return
false
;
}
if
(
msg
instanceof
YGOProMsgWin
)
{
return
false
;
}
return
msg
.
getSendTargets
().
includes
(
NetPlayerType
.
OBSERVER
);
});
}
private
async
sendReplayWinMsg
(
client
:
Client
,
replay
:
DuelRecordEntity
)
{
const
player
=
this
.
resolveReplayWinPlayer
(
replay
);
if
(
player
==
null
)
{
return
;
}
await
client
.
send
(
new
YGOProStocGameMsg
().
fromPartial
({
msg
:
new
YGOProMsgWin
().
fromPartial
({
player
,
type
:
replay
.
winReason
,
}),
}),
);
}
private
resolveReplayWinPlayer
(
replay
:
DuelRecordEntity
)
{
const
winnerPlayer
=
replay
.
players
.
find
((
player
)
=>
player
.
winner
);
if
(
!
winnerPlayer
)
{
return
undefined
;
}
const
winnerDuelPos
=
this
.
resolveDuelPosBySeat
(
winnerPlayer
.
pos
,
replay
.
hostInfo
,
);
const
swapped
=
this
.
resolveReplaySwappedByIsFirst
(
replay
);
return
swapped
?
1
-
winnerDuelPos
:
winnerDuelPos
;
}
private
resolveDuelPosBySeat
(
pos
:
number
,
hostInfo
:
HostInfo
)
{
const
teamOffsetBit
=
this
.
isTagMode
(
hostInfo
)
?
1
:
0
;
return
(
pos
&
(
0x1
<<
teamOffsetBit
))
>>>
teamOffsetBit
;
}
private
resolveReplaySwappedByIsFirst
(
replay
:
DuelRecordEntity
)
{
const
pos0Player
=
replay
.
players
.
find
((
player
)
=>
player
.
pos
===
0
);
return
!
pos0Player
?.
isFirst
;
}
private
async
downloadReplayYrp
(
client
:
Client
,
replay
:
DuelRecordEntity
)
{
try
{
await
client
.
send
(
new
YGOProStocDuelStart
());
await
client
.
send
(
this
.
createReplayPacket
(
replay
));
await
client
.
send
(
new
YGOProStocDuelEnd
());
client
.
disconnect
();
}
catch
(
error
)
{
this
.
logger
.
warn
(
{
replayId
:
replay
.
id
,
clientName
:
client
.
name
,
error
:
(
error
as
Error
).
toString
(),
},
'
Failed to download cloud replay yrp
'
,
);
await
client
.
die
(
'
#{cloud_replay_error}
'
,
ChatColor
.
RED
);
}
}
private
async
sendReplayPlayers
(
client
:
Client
,
replay
:
DuelRecordEntity
)
{
const
seatCount
=
this
.
resolveSeatCount
(
replay
.
hostInfo
);
const
sortedPlayers
=
[...
replay
.
players
].
sort
((
a
,
b
)
=>
a
.
pos
-
b
.
pos
);
for
(
let
pos
=
0
;
pos
<
seatCount
;
pos
+=
1
)
{
const
player
=
sortedPlayers
.
find
((
entry
)
=>
entry
.
pos
===
pos
);
await
client
.
send
(
new
YGOProStocHsPlayerEnter
().
fromPartial
({
pos
,
name
:
player
?.
name
||
''
,
}),
);
}
}
private
createJoinGamePacket
(
replay
:
DuelRecordEntity
)
{
return
new
YGOProStocJoinGame
().
fromPartial
({
info
:
{
...
replay
.
hostInfo
,
},
});
}
private
createReplayPacket
(
replay
:
DuelRecordEntity
)
{
const
duelRecord
=
this
.
restoreDuelRecord
(
replay
);
return
new
YGOProStocReplay
().
fromPartial
({
replay
:
duelRecord
.
toYrp
({
hostinfo
:
replay
.
hostInfo
as
any
,
isTag
:
this
.
isTagMode
(
replay
.
hostInfo
),
}),
});
}
private
restoreDuelRecord
(
replay
:
DuelRecordEntity
)
{
const
isTag
=
this
.
isTagMode
(
replay
.
hostInfo
);
const
wasSwapped
=
this
.
resolveReplaySwappedByIsFirst
(
replay
);
const
seatCount
=
this
.
resolveSeatCount
(
replay
.
hostInfo
);
const
players
=
Array
.
from
({
length
:
seatCount
},
()
=>
({
name
:
''
,
deck
:
new
YGOProDeck
(),
}));
const
sortedPlayers
=
[...
replay
.
players
].
sort
((
a
,
b
)
=>
a
.
pos
-
b
.
pos
);
for
(
const
player
of
sortedPlayers
)
{
const
deckBuffer
=
player
.
ingameDeckBuffer
||
player
.
currentDeckBuffer
;
const
mainc
=
player
.
ingameDeckMainc
??
player
.
currentDeckMainc
??
0
;
const
ingamePos
=
resolveIngamePosBySeat
(
player
.
pos
,
isTag
,
wasSwapped
,
);
players
[
ingamePos
]
=
{
name
:
player
.
name
,
deck
:
decodeDeckBase64
(
deckBuffer
,
mainc
),
};
}
const
duelRecord
=
new
DuelRecord
(
decodeSeedBase64
(
replay
.
seed
),
players
);
duelRecord
.
responses
=
decodeResponsesBase64
(
replay
.
responses
);
return
duelRecord
;
}
private
async
getReplayPage
(
client
:
Client
):
Promise
<
ReplayPage
>
{
const
cursor
=
this
.
getReplayCursor
(
client
);
const
firstPage
=
this
.
isFirstReplayPage
(
client
);
const
take
=
firstPage
?
5
:
4
;
const
entries
=
await
this
.
getOwnedReplays
(
client
,
cursor
,
take
);
if
(
firstPage
)
{
if
(
entries
.
length
<=
4
)
{
return
{
entries
,
hasNext
:
false
,
};
}
return
{
entries
:
entries
.
slice
(
0
,
3
),
hasNext
:
true
,
nextCursor
:
entries
[
2
].
id
,
};
}
if
(
entries
.
length
<=
3
)
{
return
{
entries
,
hasNext
:
false
,
};
}
return
{
entries
:
entries
.
slice
(
0
,
2
),
hasNext
:
true
,
nextCursor
:
entries
[
1
].
id
,
};
}
private
async
getOwnedReplays
(
client
:
Client
,
cursor
:
number
|
null
,
take
:
number
,
)
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
[];
}
const
clientKey
=
this
.
clientKeyProvider
.
getClientKey
(
client
);
const
repo
=
database
.
getRepository
(
DuelRecordEntity
);
const
qb
=
repo
.
createQueryBuilder
(
'
replay
'
)
.
leftJoinAndSelect
(
'
replay.players
'
,
'
player
'
);
const
subQuery
=
qb
.
subQuery
()
.
select
(
'
1
'
)
.
from
(
DuelRecordPlayer
,
'
owned_player
'
)
.
where
(
'
owned_player.duelRecordId = replay.id
'
)
.
andWhere
(
'
owned_player.clientKey = :clientKey
'
)
.
getQuery
();
qb
.
where
(
`EXISTS
${
subQuery
}
`
,
{
clientKey
});
if
(
cursor
!=
null
)
{
qb
.
andWhere
(
'
replay.id < :cursor
'
,
{
cursor
});
}
return
qb
.
orderBy
(
'
replay.id
'
,
'
DESC
'
).
take
(
take
).
getMany
();
}
private
async
findOwnedReplayById
(
client
:
Client
,
replayId
:
number
)
{
const
replay
=
await
this
.
findReplayById
(
replayId
);
if
(
!
replay
)
{
return
undefined
;
}
const
clientKey
=
this
.
clientKeyProvider
.
getClientKey
(
client
);
const
hasOwnedPlayer
=
replay
.
players
.
some
(
(
player
)
=>
player
.
clientKey
===
clientKey
,
);
return
hasOwnedPlayer
?
replay
:
undefined
;
}
private
async
findReplayById
(
replayId
:
number
)
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
return
database
.
getRepository
(
DuelRecordEntity
).
findOne
({
where
:
{
id
:
replayId
,
},
relations
:
[
'
players
'
],
});
}
private
async
getRandomReplay
()
{
const
database
=
this
.
ctx
.
database
;
if
(
!
database
)
{
return
undefined
;
}
const
repo
=
database
.
getRepository
(
DuelRecordEntity
);
const
minMax
=
await
repo
.
createQueryBuilder
(
'
replay
'
)
.
select
(
'
MIN(replay.id)
'
,
'
minId
'
)
.
addSelect
(
'
MAX(replay.id)
'
,
'
maxId
'
)
.
getRawOne
<
{
minId
?:
string
;
maxId
?:
string
}
>
();
const
minId
=
Number
(
minMax
?.
minId
);
const
maxId
=
Number
(
minMax
?.
maxId
);
if
(
!
Number
.
isFinite
(
minId
)
||
!
Number
.
isFinite
(
maxId
)
||
minId
>
maxId
)
{
return
undefined
;
}
const
targetId
=
Math
.
floor
(
Math
.
random
()
*
(
maxId
-
minId
+
1
))
+
minId
;
let
replay
=
await
repo
.
createQueryBuilder
(
'
replay
'
)
.
leftJoinAndSelect
(
'
replay.players
'
,
'
player
'
)
.
where
(
'
replay.id >= :targetId
'
,
{
targetId
})
.
orderBy
(
'
replay.id
'
,
'
ASC
'
)
.
getOne
();
if
(
!
replay
)
{
replay
=
await
repo
.
createQueryBuilder
(
'
replay
'
)
.
leftJoinAndSelect
(
'
replay.players
'
,
'
player
'
)
.
where
(
'
replay.id <= :targetId
'
,
{
targetId
})
.
orderBy
(
'
replay.id
'
,
'
DESC
'
)
.
getOne
();
}
return
replay
||
undefined
;
}
private
getReplayCursor
(
client
:
Client
)
{
const
cursors
=
client
.
cloudReplayPageCursors
||
[
null
];
const
pageIndex
=
client
.
cloudReplayPageIndex
||
0
;
return
cursors
[
pageIndex
]
??
null
;
}
private
isFirstReplayPage
(
client
:
Client
)
{
return
(
client
.
cloudReplayPageIndex
||
0
)
===
0
;
}
private
goToNextReplayPage
(
client
:
Client
,
cursor
:
number
)
{
const
pageIndex
=
client
.
cloudReplayPageIndex
||
0
;
const
cursors
=
(
client
.
cloudReplayPageCursors
||
[
null
]).
slice
(
0
,
pageIndex
+
1
,
);
cursors
.
push
(
cursor
);
client
.
cloudReplayPageCursors
=
cursors
;
client
.
cloudReplayPageIndex
=
pageIndex
+
1
;
}
private
goToPrevReplayPage
(
client
:
Client
)
{
const
pageIndex
=
client
.
cloudReplayPageIndex
||
0
;
if
(
pageIndex
<=
0
)
{
return
;
}
client
.
cloudReplayPageIndex
=
pageIndex
-
1
;
}
private
formatDate
(
date
:
Date
)
{
const
normalized
=
new
Date
(
date
);
const
year
=
normalized
.
getFullYear
();
const
month
=
`
${
normalized
.
getMonth
()
+
1
}
`
.
padStart
(
2
,
'
0
'
);
const
day
=
`
${
normalized
.
getDate
()}
`
.
padStart
(
2
,
'
0
'
);
const
hour
=
`
${
normalized
.
getHours
()}
`
.
padStart
(
2
,
'
0
'
);
const
minute
=
`
${
normalized
.
getMinutes
()}
`
.
padStart
(
2
,
'
0
'
);
const
second
=
`
${
normalized
.
getSeconds
()}
`
.
padStart
(
2
,
'
0
'
);
return
`
${
year
}
-
${
month
}
-
${
day
}
${
hour
}
:
${
minute
}
:
${
second
}
`
;
}
private
formatReplayVersus
(
replay
:
DuelRecordEntity
)
{
const
[
team0
,
team1
]
=
this
.
resolveReplayTeams
(
replay
);
return
`
${
team0
.
join
(
'
+
'
)}
VS
${
team1
.
join
(
'
+
'
)}
`
;
}
private
formatReplayScore
(
replay
:
DuelRecordEntity
)
{
const
[
team0
,
team1
]
=
this
.
resolveReplayTeamPlayers
(
replay
);
const
score0
=
team0
[
0
]?.
score
||
0
;
const
score1
=
team1
[
0
]?.
score
||
0
;
return
`
${
score0
}
-
${
score1
}
`
;
}
private
formatReplayWinners
(
replay
:
DuelRecordEntity
)
{
const
[
team0
,
team1
]
=
this
.
resolveReplayTeamPlayers
(
replay
);
const
team0Won
=
team0
.
some
((
player
)
=>
player
.
winner
);
const
team1Won
=
team1
.
some
((
player
)
=>
player
.
winner
);
if
(
team0Won
===
team1Won
)
{
return
'
-
'
;
}
const
winners
=
(
team0Won
?
team0
:
team1
).
map
((
player
)
=>
player
.
name
);
return
winners
.
join
(
'
+
'
);
}
private
resolveReplayTeams
(
replay
:
DuelRecordEntity
)
{
const
[
team0
,
team1
]
=
this
.
resolveReplayTeamPlayers
(
replay
);
const
left
=
team0
.
map
((
player
)
=>
player
.
name
);
const
right
=
team1
.
map
((
player
)
=>
player
.
name
);
return
[
left
,
right
]
as
const
;
}
private
resolveReplayTeamPlayers
(
replay
:
DuelRecordEntity
)
{
const
sortedPlayers
=
[...
replay
.
players
].
sort
((
a
,
b
)
=>
a
.
pos
-
b
.
pos
);
const
isTag
=
this
.
isTagMode
(
replay
.
hostInfo
);
const
teamOffsetBit
=
isTag
?
1
:
0
;
const
team0
=
sortedPlayers
.
filter
(
(
player
)
=>
((
player
.
pos
&
(
0x1
<<
teamOffsetBit
))
>>
teamOffsetBit
)
===
0
,
);
const
team1
=
sortedPlayers
.
filter
(
(
player
)
=>
((
player
.
pos
&
(
0x1
<<
teamOffsetBit
))
>>
teamOffsetBit
)
===
1
,
);
return
[
team0
,
team1
]
as
const
;
}
private
isTagMode
(
hostInfo
:
HostInfo
)
{
return
(
hostInfo
.
mode
&
0x2
)
!==
0
;
}
private
resolveSeatCount
(
hostInfo
:
HostInfo
)
{
return
this
.
isTagMode
(
hostInfo
)
?
4
:
2
;
}
}
src/feats/cloud-replay/duel-record-player.entity.ts
View file @
3ac2c111
...
...
@@ -53,7 +53,7 @@ export class DuelRecordPlayer extends BaseTimeEntity {
clientKey
!
:
string
;
// getClientKey(client)
@
Column
(
'
bool
'
)
isFirst
!
:
boolean
;
//
如果 room.getIngameDuelPos(client) === 0 就是 true
isFirst
!
:
boolean
;
//
wasSwapped ? duelPos==1 : duelPos==0
@
Index
()
@
Column
(
'
smallint
'
)
...
...
@@ -71,6 +71,12 @@ export class DuelRecordPlayer extends BaseTimeEntity {
@
Column
(
'
smallint
'
)
currentDeckMainc
!
:
number
;
// client.currentDeck.main.length
@
Column
(
'
text
'
,
{})
ingameDeckBuffer
!
:
string
;
// duelRecord.players[x].deck.toPayload() base64
@
Column
(
'
smallint
'
)
ingameDeckMainc
!
:
number
;
// duelRecord.players[x].deck.main.length
@
Column
(
'
bool
'
)
winner
!
:
boolean
;
...
...
src/feats/cloud-replay/duel-record.entity.ts
View file @
3ac2c111
...
...
@@ -64,7 +64,7 @@ export class DuelRecordEntity extends BaseTimeEntity {
@
Column
({
type
:
'
text
'
,
})
responses
!
:
string
;
// duelRecord.responses
直接拼接
base64
responses
!
:
string
;
// duelRecord.responses
按 [uint8 len][payload]... 拼接再
base64
// 32 bytes binary seed => 44 chars base64.
@
Column
({
...
...
src/feats/cloud-replay/index.ts
View file @
3ac2c111
export
*
from
'
./duel-record.entity
'
;
export
*
from
'
./duel-record-player.entity
'
;
export
*
from
'
./cloud-replay-service
'
;
export
*
from
'
./utility
'
;
src/feats/cloud-replay/utility/record-codec.ts
View file @
3ac2c111
import
YGOProDeck
from
'
ygopro-deck-encode
'
;
import
{
YGOProMsgBase
,
YGOProStocGameMsg
}
from
'
ygopro-msg-encode
'
;
import
{
YGOProMsgBase
,
YGOProStoc
,
YGOProStocGameMsg
,
}
from
'
ygopro-msg-encode
'
;
import
{
Client
}
from
'
../../../client
'
;
import
{
Room
}
from
'
../../../room
'
;
const
RESPONSE_LENGTH_BYTES
=
1
;
export
function
resolvePlayerScore
(
room
:
Room
,
client
:
Client
)
{
const
duelPos
=
room
.
getIngameDuelPos
(
client
);
return
room
.
score
[
duelPos
]
||
0
;
}
function
resolveTeamOffsetBit
(
isTag
:
boolean
)
{
return
isTag
?
1
:
0
;
}
export
function
resolveIngamePosBySeat
(
pos
:
number
,
isTag
:
boolean
,
wasSwapped
:
boolean
,
)
{
if
(
!
wasSwapped
)
{
return
pos
;
}
return
pos
^
(
0x1
<<
resolveTeamOffsetBit
(
isTag
));
}
export
function
resolveRecordIngamePos
(
room
:
Room
,
client
:
Client
,
wasSwapped
:
boolean
,
)
{
return
resolveIngamePosBySeat
(
client
.
pos
,
room
.
isTag
,
wasSwapped
);
}
export
function
resolveIsFirstPlayer
(
room
:
Room
,
client
:
Client
,
wasSwapped
:
boolean
,
)
{
const
firstgoDuelPos
=
wasSwapped
?
1
:
0
;
return
room
.
getDuelPos
(
client
)
===
firstgoDuelPos
;
}
export
function
encodeMessagesBase64
(
messages
:
YGOProMsgBase
[])
{
if
(
!
messages
.
length
)
{
return
''
;
...
...
@@ -28,7 +66,13 @@ export function encodeResponsesBase64(responses: Buffer[]) {
if
(
!
responses
.
length
)
{
return
''
;
}
return
Buffer
.
concat
(
responses
).
toString
(
'
base64
'
);
const
payloads
=
responses
.
flatMap
((
response
)
=>
{
const
length
=
response
.
length
&
0xff
;
const
lengthBuffer
=
Buffer
.
alloc
(
RESPONSE_LENGTH_BYTES
,
0
);
lengthBuffer
.
writeUInt8
(
length
,
0
);
return
[
lengthBuffer
,
response
];
});
return
Buffer
.
concat
(
payloads
).
toString
(
'
base64
'
);
}
export
function
encodeSeedBase64
(
seed
:
number
[])
{
...
...
@@ -50,19 +94,115 @@ export function resolveStartDeckMainc(client: Client) {
return
client
.
startDeck
?.
main
?.
length
||
0
;
}
function
resolveCurrentDeck
(
room
:
Room
,
client
:
Client
)
{
function
resolveRecordDeck
(
room
:
Room
,
client
:
Client
,
wasSwapped
=
false
)
{
const
ingamePos
=
resolveRecordIngamePos
(
room
,
client
,
wasSwapped
);
const
duelRecordPlayer
=
room
.
lastDuelRecord
?.
players
[
ingamePos
];
return
duelRecordPlayer
?.
deck
;
}
function
resolveCurrentDeck
(
room
:
Room
,
client
:
Client
,
wasSwapped
=
false
)
{
if
(
client
.
deck
)
{
return
client
.
deck
;
}
const
ingamePos
=
room
.
getIngamePos
(
client
);
const
duelRecordPlayer
=
room
.
lastDuelRecord
?.
players
[
ingamePos
];
return
duelRecordPlayer
?.
deck
;
return
resolveRecordDeck
(
room
,
client
,
wasSwapped
);
}
export
function
resolveCurrentDeckMainc
(
room
:
Room
,
client
:
Client
,
wasSwapped
=
false
,
)
{
return
resolveCurrentDeck
(
room
,
client
,
wasSwapped
)?.
main
?.
length
||
0
;
}
export
function
encodeCurrentDeckBase64
(
room
:
Room
,
client
:
Client
,
wasSwapped
=
false
,
)
{
return
encodeDeckBase64
(
resolveCurrentDeck
(
room
,
client
,
wasSwapped
));
}
export
function
resolveCurrentDeckMainc
(
room
:
Room
,
client
:
Client
)
{
return
resolveCurrentDeck
(
room
,
client
)?.
main
?.
length
||
0
;
export
function
encodeIngameDeckBase64
(
room
:
Room
,
client
:
Client
,
wasSwapped
:
boolean
,
)
{
return
encodeDeckBase64
(
resolveRecordDeck
(
room
,
client
,
wasSwapped
));
}
export
function
encodeCurrentDeckBase64
(
room
:
Room
,
client
:
Client
)
{
return
encodeDeckBase64
(
resolveCurrentDeck
(
room
,
client
));
export
function
resolveIngameDeckMainc
(
room
:
Room
,
client
:
Client
,
wasSwapped
:
boolean
,
)
{
return
resolveRecordDeck
(
room
,
client
,
wasSwapped
)?.
main
?.
length
||
0
;
}
export
function
decodeMessagesBase64
(
messagesBase64
:
string
)
{
if
(
!
messagesBase64
)
{
return
[];
}
const
payload
=
Buffer
.
from
(
messagesBase64
,
'
base64
'
);
if
(
!
payload
.
length
)
{
return
[];
}
const
stocPackets
=
YGOProStoc
.
getInstancesFromPayload
(
payload
);
return
stocPackets
.
filter
(
(
packet
):
packet
is
YGOProStocGameMsg
=>
packet
instanceof
YGOProStocGameMsg
&&
!!
packet
.
msg
,
);
}
export
function
decodeResponsesBase64
(
responsesBase64
:
string
)
{
if
(
!
responsesBase64
)
{
return
[];
}
const
payload
=
Buffer
.
from
(
responsesBase64
,
'
base64
'
);
if
(
!
payload
.
length
)
{
return
[];
}
return
decodeLengthPrefixedResponses
(
payload
)
||
[];
}
function
decodeLengthPrefixedResponses
(
payload
:
Buffer
)
{
const
responses
:
Buffer
[]
=
[];
let
offset
=
0
;
while
(
offset
<
payload
.
length
)
{
if
(
offset
+
RESPONSE_LENGTH_BYTES
>
payload
.
length
)
{
return
undefined
;
}
const
length
=
payload
.
readUInt8
(
offset
);
offset
+=
RESPONSE_LENGTH_BYTES
;
if
(
offset
+
length
>
payload
.
length
)
{
return
undefined
;
}
responses
.
push
(
payload
.
subarray
(
offset
,
offset
+
length
));
offset
+=
length
;
}
return
responses
;
}
export
function
decodeSeedBase64
(
seedBase64
:
string
)
{
const
decoded
=
seedBase64
?
Buffer
.
from
(
seedBase64
,
'
base64
'
)
:
Buffer
.
alloc
(
0
);
const
raw
=
Buffer
.
alloc
(
32
,
0
);
decoded
.
copy
(
raw
,
0
,
0
,
Math
.
min
(
decoded
.
length
,
raw
.
length
));
const
seed
:
number
[]
=
[];
for
(
let
i
=
0
;
i
<
8
;
i
+=
1
)
{
seed
.
push
(
raw
.
readUInt32LE
(
i
*
4
)
>>>
0
);
}
return
seed
;
}
export
function
decodeDeckBase64
(
deckBase64
:
string
,
mainc
:
number
)
{
if
(
!
deckBase64
)
{
return
new
YGOProDeck
();
}
const
payload
=
Buffer
.
from
(
deckBase64
,
'
base64
'
);
if
(
!
payload
.
length
)
{
return
new
YGOProDeck
();
}
return
YGOProDeck
.
fromUpdateDeckPayload
(
payload
,
(
_code
,
index
)
=>
{
return
index
>=
mainc
;
});
}
src/join-handlers/cloud-replay-join-handler.ts
0 → 100644
View file @
3ac2c111
import
{
YGOProCtosJoinGame
}
from
'
ygopro-msg-encode
'
;
import
{
Context
}
from
'
../app
'
;
import
{
CloudReplayService
}
from
'
../feats
'
;
export
class
CloudReplayJoinHandler
{
private
cloudReplayService
=
this
.
ctx
.
get
(()
=>
CloudReplayService
);
constructor
(
private
ctx
:
Context
)
{
this
.
ctx
.
middleware
(
YGOProCtosJoinGame
,
async
(
msg
,
client
,
next
)
=>
{
const
pass
=
(
msg
.
pass
||
''
).
trim
();
if
(
!
pass
)
{
return
next
();
}
if
(
await
this
.
cloudReplayService
.
tryHandleJoinPass
(
pass
,
client
))
{
return
;
}
return
next
();
});
}
}
src/join-handlers/join-handler-module.ts
View file @
3ac2c111
...
...
@@ -4,6 +4,7 @@ import { ClientVersionCheck } from '../feats';
import
{
JoinWindbotAi
,
JoinWindbotToken
}
from
'
../feats/windbot
'
;
import
{
JoinRoom
}
from
'
./join-room
'
;
import
{
JoinRoomIp
}
from
'
./join-room-ip
'
;
import
{
CloudReplayJoinHandler
}
from
'
./cloud-replay-join-handler
'
;
import
{
JoinFallback
}
from
'
./fallback
'
;
import
{
JoinPrechecks
}
from
'
./join-prechecks
'
;
import
{
RandomDuelJoinHandler
}
from
'
./random-duel-join-handler
'
;
...
...
@@ -20,6 +21,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.
provide
(
RandomDuelJoinHandler
)
.
provide
(
JoinWindbotAi
)
.
provide
(
JoinRoomIp
)
.
provide
(
CloudReplayJoinHandler
)
.
provide
(
JoinRoom
)
.
provide
(
JoinBlankPassMenu
)
.
provide
(
JoinBlankPassRandomDuel
)
...
...
src/room/duel-record.ts
View file @
3ac2c111
...
...
@@ -33,7 +33,7 @@ export class DuelRecord {
});
}
toYrp
(
room
:
Room
)
{
toYrp
(
room
:
Pick
<
Room
,
'
hostinfo
'
|
'
isTag
'
>
)
{
const
isTag
=
room
.
isTag
;
// Create replay header
...
...
src/room/index.ts
View file @
3ac2c111
export
*
from
'
./room
'
;
export
*
from
'
./duel-record
'
;
export
*
from
'
./room-manager
'
;
export
*
from
'
./duel-stage
'
;
export
*
from
'
./room-event/on-room-create
'
;
...
...
src/room/room-event/on-room-win.ts
View file @
3ac2c111
...
...
@@ -7,6 +7,7 @@ export class OnRoomWin extends RoomEvent {
room
:
Room
,
public
winMsg
:
YGOProMsgWin
,
public
winMatch
=
false
,
public
wasSwapped
=
false
,
)
{
super
(
room
);
}
...
...
src/room/room.ts
View file @
3ac2c111
...
...
@@ -485,6 +485,7 @@ export class Room {
this
.
resetResponseState
();
this
.
disposeOcgcore
();
this
.
ocgcore
=
undefined
;
const
wasSwapped
=
this
.
isPosSwapped
;
if
(
this
.
duelStage
===
DuelStage
.
Siding
)
{
await
Promise
.
all
(
this
.
playingPlayers
...
...
@@ -525,7 +526,7 @@ export class Room {
await
this
.
changeSide
();
}
await
this
.
ctx
.
dispatch
(
new
OnRoomWin
(
this
,
exactWinMsg
,
winMatch
),
new
OnRoomWin
(
this
,
exactWinMsg
,
winMatch
,
wasSwapped
),
this
.
getDuelPosPlayers
(
duelPos
)[
0
],
);
if
(
winMatch
)
{
...
...
@@ -869,7 +870,14 @@ export class Room {
const
changeMsg
=
client
.
prepareChangePacket
();
this
.
allPlayers
.
forEach
((
p
)
=>
p
.
send
(
changeMsg
));
if
(
this
.
noHost
)
{
const
allReadyAndFull
=
this
.
players
.
every
((
player
)
=>
!!
player
?.
deck
);
let
allReadyAndFull
=
true
;
for
(
let
i
=
0
;
i
<
this
.
players
.
length
;
i
++
)
{
const
p
=
this
.
players
[
i
];
if
(
!
p
||
!
p
.
deck
)
{
allReadyAndFull
=
false
;
break
;
}
}
if
(
allReadyAndFull
)
{
await
this
.
startGame
();
}
...
...
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