Commit 6be2bb28 authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/observe' into 'main'

支持MC观战列表(目前只开放竞技匹配)

See merge request mycard/Neos!328
parents ec72a0d8 1be3db97
......@@ -5,6 +5,11 @@
"name":"koishi",
"ip":"koishi.momobako.com",
"port":"7211"
},
{
"name":"mycard-athletic",
"ip":"tiramisu.moecube.com",
"port":"8912"
}
],
"assetsPath":"/neos-assets",
......@@ -16,11 +21,13 @@
"loginUrl":"https://accounts.moecube.com/signin",
"logoutUrl":"https://accounts.moecube.com/signout",
"profileUrl":"https://accounts.moecube.com/profiles",
"athleticWatchUrl":"wss://tiramisu.moecube.com:8923",
"entertainWatchUrl":"wss://tiramisu.moecube.com:7923",
"userApi":"https://sapi.moecube.com:444/accounts/users/{username}.json",
"streamInterval":20,
"startDelay":1000,
"ui":{
"hint":{
"waitingDuration":1.5,
"maxCount":1
}
},
......
......@@ -5,6 +5,11 @@
"name":"koishi",
"ip":"koishi.momobako.com",
"port":"7211"
},
{
"name":"mycard-athletic",
"ip":"tiramisu.moecube.com",
"port":"8912"
}
],
"assetsPath":"/neos-assets",
......@@ -16,11 +21,13 @@
"loginUrl":"https://accounts.moecube.com/signin",
"logoutUrl":"https://accounts.moecube.com/signout",
"profileUrl":"https://accounts.moecube.com/profiles",
"athleticWatchUrl":"wss://tiramisu.moecube.com:8923",
"entertainWatchUrl":"wss://tiramisu.moecube.com:7923",
"userApi":"https://sapi.moecube.com:444/accounts/users/{username}.json",
"streamInterval":20,
"startDelay":1000,
"ui":{
"hint":{
"waitingDuration":1.5,
"maxCount":1
}
},
......
......@@ -26,6 +26,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-use-websocket": "^4.5.0",
"sql.js": "^1.8.0",
"u-reset.css": "^2.0.1",
"uuid": "^9.0.0",
......@@ -5934,6 +5935,15 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-use-websocket": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz",
"integrity": "sha512-oxYVLWM3Lv0InCfjW7hG/Hk0hkE0P1SiLd5/I3d5x0W4riAnDUkD4VEu7qNVAqxNjBF3nU7k0jLMOetLXpwfsA==",
"peerDependencies": {
"react": ">= 18.0.0",
"react-dom": ">= 18.0.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
......@@ -11106,6 +11116,12 @@
"react-router": "6.15.0"
}
},
"react-use-websocket": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.5.0.tgz",
"integrity": "sha512-oxYVLWM3Lv0InCfjW7hG/Hk0hkE0P1SiLd5/I3d5x0W4riAnDUkD4VEu7qNVAqxNjBF3nU7k0jLMOetLXpwfsA==",
"requires": {}
},
"reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
......
// Collection of APIs provided by MyCard
export * from "./account";
export * from "./match";
export * from "./user";
import { useConfig } from "@/config";
const { userApi } = useConfig();
interface WrappedUserInfo {
user: UserInfo;
}
export interface UserInfo {
id: number;
username: string;
name: string;
email: string;
active: boolean;
admin: boolean;
avatar: string;
locale: string;
}
export async function getUserInfo(
username: string,
): Promise<UserInfo | undefined> {
const resp = await fetch(userApi.replace("{username}", username));
if (resp.ok) {
return ((await resp.json()) as WrappedUserInfo).user;
} else {
console.error(`get ${username} info error`);
return undefined;
}
}
......@@ -40,7 +40,7 @@ export class YgoProPacket {
* 返回值可用于业务逻辑处理。
*
* */
static deserialize(array: ArrayBuffer): YgoProPacket {
static deserialize(array: ArrayBuffer): YgoProPacket[] {
try {
if (array.byteLength < PACKET_MIN_LEN) {
throw new Error(
......@@ -51,13 +51,29 @@ export class YgoProPacket {
console.error(e);
}
const dataView = new DataView(array);
// 由于srvpro实现问题,目前可能出现粘包的情况,因此这里做下解包
const packets = [];
const packetLen = dataView.getInt16(0, littleEndian);
const proto = dataView.getInt8(2);
const exData = array.slice(3, packetLen + 2);
let offset = 0;
while (true) {
const buffer = array.slice(offset);
return new YgoProPacket(packetLen, proto, new Uint8Array(exData));
if (buffer.byteLength < PACKET_MIN_LEN) {
// 解包结束
break;
}
const dataView = new DataView(buffer);
const packetLen = dataView.getInt16(0, littleEndian);
const proto = dataView.getInt8(2);
const exData = buffer.slice(3, packetLen + 2);
packets.push(new YgoProPacket(packetLen, proto, new Uint8Array(exData)));
offset += packetLen + 2;
}
return packets;
}
}
......
......@@ -17,10 +17,10 @@ export default (data: Uint8Array) => {
// TODO: use `BufferIO`
const pT = dataView.getUint8(0);
const playerType =
(pT & 0xf) <= 0
? ygopro.StocGameMessage.MsgStart.PlayerType.FirstStrike
: (pT & 0xf0) > 0
(pT & 0xf0) > 0
? ygopro.StocGameMessage.MsgStart.PlayerType.Observer
: (pT & 0xf) <= 0
? ygopro.StocGameMessage.MsgStart.PlayerType.FirstStrike
: ygopro.StocGameMessage.MsgStart.PlayerType.SecondStrike;
let offset = 1;
......
......@@ -38,87 +38,90 @@ export default async function handleSocketMessage(e: MessageEvent) {
}
async function _handle(e: MessageEvent) {
const packet = YgoProPacket.deserialize(e.data);
const pb = adaptStoc(packet);
const packets = YgoProPacket.deserialize(e.data);
switch (pb.msg) {
case "stoc_join_game": {
handleJoinGame(pb);
break;
}
case "stoc_chat": {
handleChat(pb);
break;
}
case "stoc_hs_player_change": {
handleHsPlayerChange(pb);
break;
}
case "stoc_hs_watch_change": {
handleHsWatchChange(pb);
break;
}
case "stoc_hs_player_enter": {
handleHsPlayerEnter(pb);
break;
}
case "stoc_type_change": {
handleTypeChange(pb);
break;
}
case "stoc_select_hand": {
handleSelectHand(pb);
break;
}
case "stoc_hand_result": {
handleHandResult(pb);
break;
}
case "stoc_select_tp": {
handleSelectTp(pb);
break;
}
case "stoc_deck_count": {
handleDeckCount(pb);
break;
}
case "stoc_duel_start": {
handleDuelStart(pb);
break;
}
case "stoc_duel_end": {
handleDuelEnd(pb);
break;
}
case "stoc_game_msg": {
if (!replayStore.isReplay) {
// 如果不是回放模式,则记录回放数据
replayStore.record(packet);
for (const packet of packets) {
const pb = adaptStoc(packet);
switch (pb.msg) {
case "stoc_join_game": {
handleJoinGame(pb);
break;
}
case "stoc_chat": {
handleChat(pb);
break;
}
case "stoc_hs_player_change": {
handleHsPlayerChange(pb);
break;
}
case "stoc_hs_watch_change": {
handleHsWatchChange(pb);
break;
}
case "stoc_hs_player_enter": {
handleHsPlayerEnter(pb);
break;
}
case "stoc_type_change": {
handleTypeChange(pb);
break;
}
await handleGameMsg(pb);
case "stoc_select_hand": {
handleSelectHand(pb);
break;
}
case "stoc_hand_result": {
handleHandResult(pb);
break;
}
case "stoc_select_tp": {
handleSelectTp(pb);
break;
}
case "stoc_deck_count": {
handleDeckCount(pb);
break;
}
case "stoc_duel_start": {
handleDuelStart(pb);
break;
}
case "stoc_duel_end": {
handleDuelEnd(pb);
break;
}
case "stoc_game_msg": {
if (!replayStore.isReplay) {
// 如果不是回放模式,则记录回放数据
replayStore.record(packet);
}
await handleGameMsg(pb);
break;
}
case "stoc_time_limit": {
handleTimeLimit(pb.stoc_time_limit);
break;
}
case "stoc_error_msg": {
await handleErrorMsg(pb.stoc_error_msg);
break;
}
case "stoc_change_side": {
handleChangeSide(pb.stoc_change_side);
break;
}
case "stoc_waiting_side": {
handleWaitingSide(pb.stoc_waiting_side);
break;
}
default: {
console.log(packet);
break;
}
case "stoc_time_limit": {
handleTimeLimit(pb.stoc_time_limit);
break;
}
case "stoc_error_msg": {
await handleErrorMsg(pb.stoc_error_msg);
break;
}
case "stoc_change_side": {
handleChangeSide(pb.stoc_change_side);
break;
}
case "stoc_waiting_side": {
handleWaitingSide(pb.stoc_waiting_side);
break;
}
default: {
console.log(packet);
break;
break;
}
}
}
}
.container {
display: flex;
flex-direction: column;
font: var(--theme-font);
font-size: 1rem;
.search {
margin-bottom: 1rem;
.input {
border: 1px solid #afbdd2;
background: transparent;
}
}
.item {
display: flex;
padding: 1rem 0;
.avatar {
flex: 1;
margin-left: 1rem;
}
.title {
flex: 2;
}
.mode {
flex: 1;
display: flex;
justify-content: center;
color: #5f6b7c;
}
}
.item:hover {
border: 2px solid #1059c6;
border-radius: 0.5rem;
cursor: pointer;
}
.item-selected {
border: 2px solid #1059c6;
border-radius: 0.5rem;
box-shadow: 0 0 0 2px #0087e6 inset;
}
.divider {
margin: 0;
}
}
import { SearchOutlined } from "@ant-design/icons";
import { App, Avatar, Button, Divider, Empty, Input } from "antd";
import classNames from "classnames";
import React, { useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { proxy, useSnapshot } from "valtio";
import { getUserInfo } from "@/api";
import { useConfig } from "@/config";
import { ScrollableArea } from "../Shared";
import styles from "./WatchContent.module.scss";
const { athleticWatchUrl } = useConfig();
interface Info {
event: "init" | "create" | "update" | "delete";
data: Room | Room[] | string;
}
interface Room {
id?: string;
title?: string;
users?: { username: string; position: number; avatar?: string }[];
options: Options;
}
interface Options {
mode: number;
rule: number;
start_lp: number;
start_lp_tag: number;
start_hand: number;
draw_count: number;
duel_rule: number;
no_check_deck: boolean;
no_shuffle_deck: boolean;
lflist?: number;
time_limit?: number;
auto_death: boolean;
}
export const watchStore = proxy<{ watchID: string | undefined }>({
watchID: undefined,
});
export const WatchContent: React.FC = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const { message } = App.useApp();
const { watchID } = useSnapshot(watchStore);
const [query, setQuery] = useState("");
// 暂时只支持竞技匹配的观战,TODO:后面需要加上娱乐匹配的支持
const url = new URL(athleticWatchUrl);
url.searchParams.set("filter", "started");
const { readyState } = useWebSocket(url.toString(), {
onOpen: () => console.log("watch websocket opened."),
onClose: () => console.log("watch websocket closed."),
onMessage: (event) => {
const info: Info = JSON.parse(event.data);
switch (info.event) {
case "init": {
//@ts-ignore
const rooms: Room[] = info.data;
rooms.forEach(
(room) =>
room.users?.forEach(
async (user) =>
(user.avatar = (await getUserInfo(user.username))?.avatar),
),
);
setRooms(rooms);
break;
}
case "create": {
//@ts-ignore
const room: Room = info.data;
room.users?.forEach(
async (user) =>
(user.avatar = (await getUserInfo(user.username))?.avatar),
);
setRooms((prev) => prev.concat(room));
break;
}
case "update": {
//@ts-ignore
const room: Room = info.data;
room.users?.forEach(
async (user) =>
(user.avatar = (await getUserInfo(user.username))?.avatar),
);
setRooms((prev) => {
const target = prev.find((item) => item.id === room.id);
if (target) {
Object.assign(target, info.data);
}
return prev;
});
break;
}
case "delete": {
//@ts-ignore
const id: string = info.data;
setRooms((prev) => {
prev.splice(
prev.findIndex((room) => room.id === id),
1,
);
return prev;
});
break;
}
}
},
onError: (_e) => message.error("Websocket Error!"),
});
return (
<div className={styles.container}>
<div className={styles.search}>
<Input
className={styles.input}
placeholder="通过玩家用户名搜索房间"
bordered={false}
suffix={<Button type="text" icon={<SearchOutlined />} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollableArea maxHeight="50vh">
{readyState === ReadyState.CONNECTING || rooms.length === 0 ? (
<Empty />
) : (
rooms
.filter(
(room) =>
room.users?.at(0)?.username.includes(query) ||
room.users?.at(1)?.username.includes(query),
)
.map((room) => (
<div key={room.id}>
<div
className={classNames(styles.item, {
[styles["item-selected"]]: watchID && watchID === room.id,
})}
onClick={() => (watchStore.watchID = room.id)}
>
<div className={styles.avatar}>
<Avatar src={room.users?.at(0)?.avatar} />
<Avatar src={room.users?.at(1)?.avatar} />
</div>
<div className={styles.title}>
{`${room.users?.at(0)?.username} 与 ${room.users?.at(1)
?.username} 的决斗`}
</div>
<div className={styles.mode}>竞技匹配</div>
</div>
<Divider className={styles.divider} />
</div>
))
)}
</ScrollableArea>
</div>
);
};
......@@ -17,9 +17,10 @@ import { Background, IconFont, Select } from "@/ui/Shared";
import styles from "./index.module.scss";
import { MatchModal, matchStore } from "./MatchModal";
import { ReplayModal, replayOpen } from "./ReplayModal";
import { connectSrvpro } from "./util";
import { connectSrvpro, getEncryptedPasswd } from "./util";
import { WatchContent, watchStore } from "./WatchContent";
const NeosConfig = useConfig();
const { servers: serverList } = useConfig();
export const loader: LoaderFunction = () => {
// 在加载这个页面之前先重置一些store,清掉上局游戏遗留的数据
......@@ -28,8 +29,7 @@ export const loader: LoaderFunction = () => {
};
export const Component: React.FC = () => {
const { message } = App.useApp();
const serverList = NeosConfig.servers;
const { message, modal } = App.useApp();
const server = `${serverList[0].ip}:${serverList[0].port}`;
const { decks } = deckStore;
const [deckName, setDeckName] = useState(decks.at(0)?.deckName ?? "");
......@@ -37,6 +37,7 @@ export const Component: React.FC = () => {
const { joined } = useSnapshot(roomStore);
const [singleLoading, setSingleLoading] = useState(false); // 单人模式的loading状态
const [matchLoading, setMatchLoading] = useState(false); // 匹配模式的loading状态
const [watchLoading, setWatchLoading] = useState(false); // 观战模式的loading状态
const navigate = useNavigate();
// 竞技匹配
......@@ -62,6 +63,46 @@ export const Component: React.FC = () => {
}
};
// MC观战
const onMCWatch = () => {
if (!user) {
message.error("请先登录萌卡账号");
} else {
modal.info({
icon: null,
width: "40vw",
okText: "进入观战",
onOk: async () => {
if (watchStore.watchID) {
setWatchLoading(true);
// 找到MC竞技匹配的Server
const mcServer = serverList.find(
(server) => server.name === "mycard-athletic",
);
if (mcServer) {
const passWd = getEncryptedPasswd(watchStore.watchID, user);
await connectSrvpro({
ip: mcServer.ip + ":" + mcServer.port,
player: user.username,
passWd,
});
} else {
message.error(
"Something unexpected happened, please contact <ccc@neos.moe> to fix",
);
}
} else {
message.error("请选择观战的房间");
}
},
centered: true,
maskClosable: true,
content: <WatchContent />,
});
}
};
// 单人模式
const onAIMatch = async () => {
setSingleLoading(true);
......@@ -77,13 +118,11 @@ export const Component: React.FC = () => {
// 自定义房间
const onCustomRoom = () => (matchStore.open = true);
// 观战列表
const onWatchList = () => message.error("开发中,敬请期待");
useEffect(() => {
if (joined) {
setSingleLoading(false);
setMatchLoading(false);
setWatchLoading(false);
navigate(`/waitroom`);
}
}, [joined]);
......@@ -144,8 +183,8 @@ export const Component: React.FC = () => {
<Mode
title="MC观战列表"
desc="观看萌卡MyCard上正在进行的决斗。"
icon={<PlayCircleFilled />}
onClick={onWatchList}
icon={watchLoading ? <LoadingOutlined /> : <PlayCircleFilled />}
onClick={onMCWatch}
/>
<Mode
title="单人模式"
......
......@@ -4,6 +4,7 @@ import { initStrings } from "@/api";
import { useConfig } from "@/config";
import socketMiddleWare, { socketCmd } from "@/middleware/socket";
import sqliteMiddleWare, { sqliteCmd } from "@/middleware/sqlite";
import { User } from "@/stores";
const NeosConfig = useConfig();
......@@ -49,3 +50,28 @@ export const connectSrvpro = async (params: {
});
}
};
export function getEncryptedPasswd(roomID: string, user: User): string {
const optionsBuffer = new Uint8Array(6);
optionsBuffer[1] = 3 << 4;
let checksum = 0;
for (let i = 1; i < optionsBuffer.length; i++) {
checksum -= optionsBuffer[i];
}
optionsBuffer[0] = checksum & 0xff;
const secret = (user.external_id % 65535) + 1;
for (let i = 0; i < optionsBuffer.length; i += 2) {
const value = (optionsBuffer[i + 1] << 8) | optionsBuffer[i];
const xorResult = value ^ secret;
optionsBuffer[i + 1] = (xorResult >> 8) & 0xff;
optionsBuffer[i] = xorResult & 0xff;
}
const base64String = btoa(String.fromCharCode(...optionsBuffer));
return base64String + roomID;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment