Commit 0aaa67ec authored by Chunchi Che's avatar Chunchi Che

Merge branch 'feat/mc-match' into 'main'

支持萌卡服务器自定义房间

See merge request !346
parents 1b0f132d ffd1f4bb
......@@ -24,11 +24,12 @@ npm_ci:
rs_check_lint:
stage: rs_check_lint
image: rust:latest
tags:
- linux
before_script:
- curl https://sh.rustup.rs -sSf | sh -s -- -y
- source "$HOME/.cargo/env"
# before_script:
# - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# - source "$HOME/.cargo/env"
script:
- cd rust-src
- cargo check
......@@ -39,12 +40,13 @@ wasm_build:
tags:
- linux
stage: wasm
image: rust:latest
extends:
- .build_base
before_script:
- curl https://sh.rustup.rs -sSf | sh -s -- -y
- source "$HOME/.cargo/env"
- curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# - source "$HOME/.cargo/env"
- curl --proto '=https' --tlsv1.2 -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
script:
- wasm-pack build ./rust-src --target web
artifacts:
......
......@@ -10,6 +10,11 @@
"ip": "tiramisu.moecube.com",
"port": "8912"
},
{
"name": "mycard-custom",
"ip": "tiramisu.moecube.com",
"port": "7912"
},
{
"name": "pre-release",
"ip": "koishi.momobako.com",
......
......@@ -10,6 +10,11 @@
"ip": "tiramisu.moecube.com",
"port": "8912"
},
{
"name": "mycard-custom",
"ip": "tiramisu.moecube.com",
"port": "7912"
},
{
"name": "pre-release",
"ip": "koishi.momobako.com",
......
[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'tuna'
# replace-with = 'tuna'
[source.tuna]
registry = "https://mirrors.tuna.tsinghua.edu.cn/git/crates.io-index.git"
// Collection of APIs provided by MyCard
export * from "./account";
export * from "./match";
export * from "./options";
export * from "./room";
export * from "./user";
/* MC服房间的选项 */
export 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 defaultOptions: Options = {
mode: 1,
rule: 0,
// rule: this.settingsService.getLocale().startsWith('zh') ? 0 : 1,
start_lp: 8000,
start_lp_tag: 16000,
start_hand: 5,
draw_count: 1,
duel_rule: 5,
no_check_deck: false,
no_shuffle_deck: false,
lflist: 0,
time_limit: 180,
auto_death: false,
};
/* 一些MC服创建/加入房间相关的通用函数 */
import { Options } from "./options";
enum RoomAction {
CreatePublic = 1,
CreatePrivate = 2,
JoinPublic = 3,
JoinPrivate = 5,
}
export interface Room {
id?: string;
title?: string;
users?: { username: string; position: number; avatar?: string }[];
options: Options;
}
// 通过房间ID和external_id加密得出房间密码
//
// 用于加入MC服房间
export function getJoinRoomPasswd(
roomID: string,
external_id: number,
_private: boolean = false,
): string {
const optionsBuffer = new Uint8Array(6);
optionsBuffer[1] =
(_private ? RoomAction.JoinPrivate : RoomAction.JoinPublic) << 4;
encryptBuffer(optionsBuffer, external_id);
const base64String = btoa(String.fromCharCode(...optionsBuffer));
return base64String + roomID;
}
// 获取创建房间的密码
export function getCreateRoomPasswd(
options: Options,
roomID: string,
external_id: number,
_private: boolean = false,
) {
// ref: https://docs.google.com/document/d/1rvrCGIONua2KeRaYNjKBLqyG9uybs9ZI-AmzZKNftOI/edit
const optionsBuffer = new Uint8Array(6);
optionsBuffer[1] =
((_private ? RoomAction.CreatePrivate : RoomAction.CreatePublic) << 4) |
(options.duel_rule << 1) |
(options.auto_death ? 0x1 : 0);
optionsBuffer[2] =
(options.rule << 5) |
(options.mode << 3) |
(options.no_check_deck ? 1 << 1 : 0) |
(options.no_shuffle_deck ? 1 : 0);
writeUInt16LE(optionsBuffer, 3, options.start_lp);
optionsBuffer[5] = (options.start_hand << 4) | options.draw_count;
encryptBuffer(optionsBuffer, external_id);
const base64String = btoa(String.fromCharCode(...optionsBuffer));
return base64String + roomID;
}
// 填充校验码和加密
function encryptBuffer(buffer: Uint8Array, external_id: number) {
let checksum = 0;
for (let i = 1; i < buffer.length; i++) {
checksum -= buffer[i];
}
buffer[0] = checksum & 0xff;
const secret = (external_id % 65535) + 1;
for (let i = 0; i < buffer.length; i += 2) {
const value = readUInt16LE(buffer, i);
const xorResult = value ^ secret;
writeUInt16LE(buffer, i, xorResult);
}
}
// 获取私密房间ID
export function getPrivateRoomID(external_id: number): number {
return external_id ^ 0x54321;
}
/* 一些辅助函数 */
function readUInt16LE(buffer: Uint8Array, offset: number): number {
return (buffer[offset + 1] << 8) | buffer[offset];
}
function writeUInt16LE(buffer: Uint8Array, offset: number, value: number) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >> 8) & 0xff;
}
.container {
display: flex;
flex-direction: column;
font: var(--theme-font);
font-size: 1rem;
gap: 0.5rem;
margin-bottom: 1rem;
p {
font-size: 1.2rem;
}
.clipboard {
display: flex;
flex-direction: row;
.title {
margin-right: 3.5rem;
}
.input {
width: 50%;
}
}
.digit-option {
display: flex;
justify-content: space-between;
.input {
width: 60%;
}
}
.select-option {
display: flex;
flex-direction: row;
}
.check {
font-size: 1rem;
}
}
.footer {
text-align: right;
gap: 1rem;
.btn {
margin-right: 0.5rem;
}
}
import { CopyOutlined, KeyOutlined } from "@ant-design/icons";
import type { CheckboxProps } from "antd";
import { App, Button, Checkbox, Input } from "antd";
import React, { ChangeEvent, useEffect } from "react";
import { proxy, useSnapshot } from "valtio";
import { defaultOptions, getPrivateRoomID, Options } from "@/api";
import { accountStore } from "@/stores";
import { Select } from "@/ui/Shared";
import styles from "./index.module.scss";
interface CustomRoomProps {
options: Options;
isPrivate: boolean;
friendPrivateID?: number;
}
const defaultProps: CustomRoomProps = {
options: defaultOptions,
isPrivate: true, // 暂时只支持私密模式
};
export const mcCustomRoomStore = proxy<CustomRoomProps>(defaultProps);
// TODO: support public room
export const CustomRoomContent: React.FC = () => {
const { message } = App.useApp();
const user = useSnapshot(accountStore).user;
const { options, friendPrivateID } = useSnapshot(mcCustomRoomStore);
const privateRoomID = getPrivateRoomID(user?.external_id ?? 0);
useEffect(() => {
if (!user) {
message.error("请先登录萌卡账号!");
}
}, [user]);
const onCopy = async () => {
if (navigator.clipboard) {
await navigator.clipboard
.writeText(String(privateRoomID))
.then(() => {
message.info("房间密码复制成功!");
})
.catch((err) => {
message.error("复制到剪贴板失败:", err);
});
} else {
message.error("浏览器不支持 Clipboard API");
}
};
const onChangeStartLP = (event: ChangeEvent<HTMLInputElement>) => {
mcCustomRoomStore.options.start_lp = Number(event.target.value);
};
const onChangeStartHand = (event: ChangeEvent<HTMLInputElement>) => {
mcCustomRoomStore.options.start_hand = Number(event.target.value);
};
const onChangeDrawCount = (event: ChangeEvent<HTMLInputElement>) => {
mcCustomRoomStore.options.draw_count = Number(event.target.value);
};
const onChangeRule = (value: any) => {
mcCustomRoomStore.options.rule = value;
};
const onChangeMode = (value: any) => {
mcCustomRoomStore.options.mode = value;
};
const onChangeDuelRule = (value: any) => {
mcCustomRoomStore.options.duel_rule = value;
};
const onChangeNoCheckDeck: CheckboxProps["onChange"] = (e) => {
mcCustomRoomStore.options.no_check_deck = e.target.checked;
};
const onChangeNoShuffleDeck: CheckboxProps["onChange"] = (e) => {
mcCustomRoomStore.options.no_shuffle_deck = e.target.checked;
};
const onChangeAutoDeath: CheckboxProps["onChange"] = (e) => {
mcCustomRoomStore.options.auto_death = e.target.checked;
};
const onChangePrivateID = (event: ChangeEvent<HTMLInputElement>) => {
mcCustomRoomStore.friendPrivateID = Number(event.target.value);
};
return (
<div className={styles.container}>
<p>创建/加入私密房间</p>
<div className={styles.clipboard}>
<div className={styles.title}>
房间密码
<KeyOutlined />
</div>
<Input
className={styles.input}
value={privateRoomID}
type="number"
readOnly
/>
<Button icon={<CopyOutlined />} onClick={onCopy} />
</div>
<div className={styles["digit-option"]}>
<div className={styles.title}>初始LP</div>
<Input
className={styles.input}
value={options.start_lp}
onChange={onChangeStartLP}
type="number"
/>
</div>
<div className={styles["digit-option"]}>
<div className={styles.title}>初始手牌数</div>
<Input
className={styles.input}
value={options.start_hand}
onChange={onChangeStartHand}
type="number"
/>
</div>
<div className={styles["digit-option"]}>
<div className={styles.title}>每回合抽卡</div>
<Input
className={styles.input}
value={options.draw_count}
onChange={onChangeDrawCount}
type="number"
/>
</div>
<div className={styles["select-option"]}>
<Select
title="卡片允许"
value={options.rule}
options={[
{ value: 0, label: "OCG" },
{ value: 1, label: "TCG" },
{ value: 2, label: "简体中文" },
{ value: 3, label: "自制卡" },
{ value: 4, label: "专有卡禁止" },
{ value: 5, label: "所有卡片" },
]}
onChange={onChangeRule}
/>
</div>
<div className={styles["select-option"]}>
<Select
title="决斗模式"
value={options.mode}
options={[
{ value: 0, label: "单局模式" },
{ value: 1, label: "比赛模式" },
// {value: 2, label: "TAG"},
]}
onChange={onChangeMode}
/>
</div>
<div className={styles["select-option"]}>
<Select
title="决斗规则"
value={options.duel_rule}
options={[
{ value: 1, label: "大师规则1" },
{ value: 2, label: "大师规则2" },
{ value: 3, label: "大师规则3" },
{ value: 4, label: "新大师规则" },
{ value: 5, label: "大师规则2020" },
]}
onChange={onChangeDuelRule}
/>
</div>
<Checkbox
className={styles.check}
checked={options.no_check_deck}
onChange={onChangeNoCheckDeck}
>
不检查卡组
</Checkbox>
<Checkbox
className={styles.check}
checked={options.no_shuffle_deck}
onChange={onChangeNoShuffleDeck}
>
不切洗卡组
</Checkbox>
<Checkbox
className={styles.check}
checked={options.auto_death}
onChange={onChangeAutoDeath}
>
40分自动加时
</Checkbox>
<Input
value={friendPrivateID}
onChange={onChangePrivateID}
placeholder="在这输入你朋友的私密房间密码"
type="text"
/>
</div>
);
};
export const CustomRoomFooter: React.FC<{
onCreateRoom: () => void;
onJoinRoom: () => void;
}> = ({ onCreateRoom, onJoinRoom }) => {
return (
<div className={styles.footer}>
<Button className={styles.btn} onClick={onCreateRoom}>
创建私密房间
</Button>
<Button className={styles.btn} onClick={onJoinRoom}>
加入私密房间
</Button>
</div>
);
};
......@@ -7,12 +7,15 @@ import { useConfig } from "@/config";
import { accountStore, roomStore } from "@/stores";
import { Select } from "@/ui/Shared";
import styles from "./MatchModal.module.scss";
import { connectSrvpro } from "./util";
import { connectSrvpro } from "../util";
import styles from "./index.module.scss";
const NeosConfig = useConfig();
const serverConfig = NeosConfig.servers;
const KOISHI_INDEX = 0;
const PRERELEASE_INDEX = 3;
const {
defaults: { defaultPlayer, defaultPassword },
automation: { isAiMode },
......@@ -28,7 +31,6 @@ const defaultProps: Props = {
export const matchStore = proxy<Props>(defaultProps);
// TODO: support MC server
export const MatchModal: React.FC = ({}) => {
const { message } = App.useApp();
const { open } = useSnapshot(matchStore);
......@@ -40,13 +42,13 @@ export const MatchModal: React.FC = ({}) => {
const [confirmLoading, setConfirmLoading] = useState(false);
const navigate = useNavigate();
let handlePlayerChange = (event: ChangeEvent<HTMLInputElement>) => {
const handlePlayerChange = (event: ChangeEvent<HTMLInputElement>) => {
setPlayer(event.target.value);
};
let handleServerChange = (value: any) => {
const handleServerChange = (value: any) => {
setServerId(value);
};
let handlePasswdChange = (event: ChangeEvent<HTMLInputElement>) => {
const handlePasswdChange = (event: ChangeEvent<HTMLInputElement>) => {
setPasswd(event.target.value);
};
......@@ -102,11 +104,11 @@ export const MatchModal: React.FC = ({}) => {
value={serverId}
options={[
{
value: 0,
value: KOISHI_INDEX,
label: "Koishi服",
},
{
value: 2,
value: PRERELEASE_INDEX,
label: "超先行服",
},
]}
......
......@@ -5,8 +5,8 @@ import { proxy, useSnapshot } from "valtio";
import { replayStore } from "@/stores";
import { Uploader } from "../Shared";
import { connectSrvpro } from "./util";
import { Uploader } from "../../Shared";
import { connectSrvpro } from "../util";
const localStore = proxy({
open: false,
......
......@@ -5,11 +5,11 @@ import React, { useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { proxy, useSnapshot } from "valtio";
import { getUserInfo } from "@/api";
import { getUserInfo, Room } from "@/api";
import { useConfig } from "@/config";
import { ScrollableArea } from "../Shared";
import styles from "./WatchContent.module.scss";
import { ScrollableArea } from "../../Shared";
import styles from "./index.module.scss";
const { athleticWatchUrl } = useConfig();
......@@ -18,28 +18,6 @@ interface Info {
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,
});
......
......@@ -16,7 +16,7 @@
.mode-select {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(2, 1fr); /* 自动调整行高 */
width: 55rem;
// height: 33rem;
......
import {
BulbOutlined,
EditOutlined,
LoadingOutlined,
PlayCircleFilled,
SettingFilled,
} from "@ant-design/icons";
import { App, Button, Space } from "antd";
import { App, Button, Modal, Space } from "antd";
import { useEffect, useState } from "react";
import { LoaderFunction, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";
import { match } from "@/api";
import {
getCreateRoomPasswd,
getJoinRoomPasswd,
getPrivateRoomID,
match,
} from "@/api";
import { useConfig } from "@/config";
import { accountStore, deckStore, resetUniverse, roomStore } from "@/stores";
import { Background, IconFont, Select } from "@/ui/Shared";
import {
CustomRoomContent,
CustomRoomFooter,
mcCustomRoomStore,
} from "./CustomRoomContent";
import styles from "./index.module.scss";
import { MatchModal, matchStore } from "./MatchModal";
import { ReplayModal, replayOpen } from "./ReplayModal";
import { connectSrvpro, getEncryptedPasswd } from "./util";
import { connectSrvpro } from "./util";
import { WatchContent, watchStore } from "./WatchContent";
const { servers: serverList } = useConfig();
......@@ -69,6 +80,72 @@ export const Component: React.FC = () => {
// 娱乐匹配
const onEntertainMatch = async () => await onMatch("entertain");
// MC自定义房间
const onMCCustomRoom = () => {
if (!user) {
message.error("请先登录萌卡账号");
} else {
modal.info({
icon: null,
centered: true,
maskClosable: true,
content: <CustomRoomContent />,
footer: (
<CustomRoomFooter
onCreateRoom={onCreateMCRoom}
onJoinRoom={onJoinMCRoom}
/>
),
});
}
};
// 创建MC自定义房间
const onCreateMCRoom = async () => {
if (user) {
const mcServer = serverList.find(
(server) => server.name === "mycard-custom",
);
if (mcServer) {
const passWd = getCreateRoomPasswd(
mcCustomRoomStore.options,
String(getPrivateRoomID(user.external_id)),
user.external_id,
true,
);
await connectSrvpro({
ip: mcServer.ip + ":" + mcServer.port,
player: user.username,
passWd,
});
}
}
};
// 加入MC自定义房间
const onJoinMCRoom = async () => {
if (user) {
if (mcCustomRoomStore.friendPrivateID !== undefined) {
const mcServer = serverList.find(
(server) => server.name === "mycard-custom",
);
if (mcServer) {
const passWd = getJoinRoomPasswd(
String(mcCustomRoomStore.friendPrivateID),
user.external_id,
true,
);
await connectSrvpro({
ip: mcServer.ip + ":" + mcServer.port,
player: user.username,
passWd,
});
}
} else {
message.error("请输入朋友的私密房间密码!");
}
}
};
// MC观战
const onMCWatch = () => {
if (!user) {
......@@ -87,7 +164,10 @@ export const Component: React.FC = () => {
(server) => server.name === "mycard-athletic",
);
if (mcServer) {
const passWd = getEncryptedPasswd(watchStore.watchID, user);
const passWd = getJoinRoomPasswd(
watchStore.watchID,
user.external_id,
);
await connectSrvpro({
ip: mcServer.ip + ":" + mcServer.port,
player: user.username,
......@@ -130,6 +210,7 @@ export const Component: React.FC = () => {
setAthleticMatchLoading(false);
setEntertainMatchLoading(false);
setWatchLoading(false);
Modal.destroyAll(); // 销毁当前所有modal
navigate(`/waitroom`);
}
}, [joined]);
......@@ -193,6 +274,12 @@ export const Component: React.FC = () => {
}
onClick={onEntertainMatch}
/>
<Mode
title="MC服自定义房间"
desc="在MC服务器上创建或者加入自定义房间,与牌友约战。"
icon={<BulbOutlined />}
onClick={onMCCustomRoom}
/>
<Mode
title="MC观战列表"
desc="观看萌卡MyCard上正在进行的决斗。"
......@@ -223,6 +310,7 @@ export const Component: React.FC = () => {
icon={<IconFont type="icon-record" size={24} />}
onClick={replayOpen}
/>
<Mode title="开发中..." desc="其他功能敬请期待。" icon={null} />
</div>
</div>
</div>
......
......@@ -4,7 +4,6 @@ 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();
......@@ -53,28 +52,3 @@ 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