Commit 4012759d authored by nanahira's avatar nanahira

Merge branch 'multiserver'

parents 9d1505fc 4ce5ee7a
import { ErrorHandler, isDevMode, NgModule, Provider } from '@angular/core';
import { ErrorHandler, NgModule, Provider } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import {
MatAutocompleteModule,
......@@ -41,6 +41,7 @@ import * as Raven from 'raven-js';
import { DecksComponent } from './decks/decks.component';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { LogoutDialogComponent } from './logout-dialog/logout-dialog.component';
import { ServerSelectComponent } from './server-select/server-select.component';
export class RavenErrorHandler implements ErrorHandler {
handleError(err: any): void {
......@@ -69,7 +70,8 @@ if (environment.production) {
ResultDialogComponent,
DecksComponent,
ConfirmDialogComponent,
LogoutDialogComponent
LogoutDialogComponent,
ServerSelectComponent
],
imports: [
BrowserModule,
......
......@@ -46,16 +46,16 @@
<br>娱乐匹配
</button>
</mat-grid-tile>
<mat-grid-tile><a mat-raised-button color="primary" routerLink="/ygopro/rooms">
<mat-grid-tile><a mat-raised-button color="primary" (click)='reloadServers(["custom"])' routerLink="/ygopro/rooms">
<mat-icon>games</mat-icon>
<br>房间列表</a></mat-grid-tile>
<mat-grid-tile><a mat-raised-button color="primary" routerLink="/ygopro/rooms/new">
<mat-grid-tile><a mat-raised-button color="primary" (click)='reloadServers(["custom"])' routerLink="/ygopro/rooms/new">
<mat-icon>&#xe146;</mat-icon><!--add_box-->
<br>创建房间</a></mat-grid-tile>
<mat-grid-tile><a mat-raised-button routerLink="/ygopro/windbot">
<mat-grid-tile><a mat-raised-button (click)='reloadServers(["windbot"])' routerLink="/ygopro/windbot">
<mat-icon>&#xe195;</mat-icon><!-- airplanemode_active -->
<br>单人模式</a></mat-grid-tile>
<mat-grid-tile><a mat-raised-button routerLink="/ygopro/watch">
<mat-grid-tile><a mat-raised-button (click)='reloadServers(["replay"])' routerLink="/ygopro/watch">
<mat-icon>&#xe417;</mat-icon><!--remove_red_eye-->
<br>观战</a></mat-grid-tile>
<!--<mat-grid-tile>-->
......
......@@ -4,13 +4,14 @@ import { environment } from '../../environments/environment';
import { LoginService } from '../login.service';
import { routerTransition } from '../router.animations';
import { StorageService } from '../storage.service';
import { YGOProService } from '../ygopro.service';
import { YGOProService, Server } from '../ygopro.service';
import { HttpClient } from '@angular/common/http';
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/internal/operators';
import { MatDialog } from '@angular/material';
import { LogoutDialogComponent } from '../logout-dialog/logout-dialog.component';
@Component({
selector: 'app-lobby',
templateUrl: 'lobby.component.html',
......@@ -57,6 +58,10 @@ export class LobbyComponent {
}
}
reloadServers(fields: (keyof Server)[]) {
this.ygopro.reloadSelectableServers(s => fields.some((field) => !!s[field]));
}
search(key: string) {
const url = new URL('http://www.ourocg.cn/S.aspx');
url.searchParams.set('key', key);
......
......@@ -15,7 +15,7 @@ export class RoomListComponent implements OnInit {
@HostBinding('@routerTransition') animation;
displayedColumns = ['title', 'users', 'mode', 'extra'];
dataSource = new RoomListDataSource(this.ygopro.servers.filter(server => server.custom!));
dataSource = new RoomListDataSource(this.ygopro);
constructor(public login: LoginService, public ygopro: YGOProService, private changeDetector: ChangeDetectorRef) {}
......
mat-form-field {
width: 100px;
}
mat-select::ng-deep .mat-select-arrow-wrapper {
transform: initial;
}
mat-form-field::ng-deep .mat-form-field-underline {
display: none;
}
mat-form-field::ng-deep .mat-form-field-wrapper, mat-form-field::ng-deep .mat-form-field-flex {
padding: initial;
}
mat-form-field::ng-deep .mat-form-field-infix {
padding: initial;
border: initial;
}
环境:
<mat-form-field appearance='standard'>
<mat-select [formControl]='ygopro.serverForm' [(ngModel)]='ygopro.currentServer'>
<mat-option *ngFor='let server of ygopro.selectableServers' [value]='server'>
{{server.name}}
</mat-option>
</mat-select>
</mat-form-field>
import { Component, OnInit } from '@angular/core';
import { YGOProService } from '../ygopro.service';
@Component({
selector: 'app-server-select',
templateUrl: './server-select.component.html',
styleUrls: ['./server-select.component.css']
})
export class ServerSelectComponent implements OnInit {
constructor(public ygopro: YGOProService) {
}
ngOnInit() {
}
}
......@@ -2,3 +2,7 @@ button {
margin-left: -8px;
margin-right: 24px;
}
span {
flex: 1
}
......@@ -3,4 +3,5 @@
<mat-icon>arrow_back</mat-icon>
</button>
<span><ng-content></ng-content></span>
<app-server-select></app-server-select>
</mat-toolbar>
......@@ -14,7 +14,7 @@ export class WatchComponent implements OnInit {
@HostBinding('@routerTransition') animation;
displayedColumns = ['mode', 'title', 'users', 'extra'];
dataSource = new RoomListDataSource(this.ygopro.servers, 'started');
dataSource = new RoomListDataSource(this.ygopro, 'started');
constructor(public login: LoginService, public ygopro: YGOProService, private changeDetector: ChangeDetectorRef) {}
......
......@@ -6,7 +6,7 @@
<mat-icon mat-list-icon>&#xe195;</mat-icon><!-- airplanemode_active -->
<h4 mat-line>随机</h4>
</mat-list-item>
<mat-list-item *ngFor="let windbot of ygopro.windbot | async" (click)="ygopro.join_windbot(windbot)">
<mat-list-item *ngFor="let windbot of (ygopro.currentServer ? ygopro.currentServer.windbot : [])" (click)="ygopro.join_windbot(windbot)">
<img mat-list-icon [src]="login.avatar(windbot)">
<h4 mat-line>{{windbot}}</h4>
</mat-list-item>
......
......@@ -9,8 +9,9 @@ import { MatchDialogComponent } from './match-dialog/match-dialog.component';
import { ResultDialogComponent } from './result-dialog/result-dialog.component';
import { StorageService } from './storage.service';
import { HttpClient } from '@angular/common/http';
import { catchError, filter, map, mergeMap, publishLast, refCount, scan, startWith } from 'rxjs/internal/operators';
import { catchError, filter, map, mergeMap, publishLast, refCount, scan, startWith, switchMap, tap } from 'rxjs/internal/operators';
import { webSocket } from 'rxjs/webSocket';
import { FormControl } from '@angular/forms';
export interface User {
admin: boolean;
......@@ -119,8 +120,8 @@ export interface Points {
export class YGOProService {
news: Promise<News[]>;
topics: Promise<any[]>;
windbot: Promise<string[]>;
points = new BehaviorSubject<Points | undefined>(undefined);
serverForm = new FormControl();
readonly default_options: Options = {
mode: 1,
......@@ -136,27 +137,25 @@ export class YGOProService {
time_limit: 180,
auto_death: false
};
readonly servers: Server[] = [
{
id: 'tiramisu',
url: 'wss://tiramisu.mycard.moe:7923',
address: 'tiramisu.mycard.moe',
port: 7911,
custom: true,
replay: true
},
{
id: 'tiramisu-athletic',
url: 'wss://tiramisu.mycard.moe:8923',
address: 'tiramisu.mycard.moe',
port: 8911,
custom: false,
replay: true
}
];
serversPromise: Promise<Server[]>;
servers: Server[] = [];
selectableServers: Server[] = [];
currentServer: Server;
constructor(private login: LoginService, private http: HttpClient, private dialog: MatDialog, private storage: StorageService) {
const app = this.http.get<App[]>('https://sapi.moecube.com:444/apps.json').pipe(map(apps => apps.find(_app => _app.id === 'ygopro')!), publishLast(), refCount());
const app = this.http.get<App[]>('https://sapi.moecube.com:444/apps.json').pipe(
map(apps => apps.find(_app => _app.id === 'ygopro')!),
publishLast(),
refCount()
);
this.serversPromise = app.pipe(map(_app => _app.data.servers)).toPromise();
this.serversPromise.then(servers => {
this.servers = servers;
this.reloadSelectableServers();
});
this.news = app
.pipe(
map(_app =>
......@@ -171,7 +170,7 @@ export class YGOProService {
)
)
.toPromise();
this.windbot = app.pipe(map(_app => (<YGOProData>_app.data).windbot['zh-CN'])).toPromise();
// this.windbot = app.pipe(map(_app => (<YGOProData>_app.data).windbot['zh-CN'])).toPromise();
this.topics = this.http
.get<TopResponse>('https://ygobbs.com/top/quarterly.json')
......@@ -246,6 +245,19 @@ export class YGOProService {
});
}
reloadSelectableServers(condition: (server: Server) => boolean = () => true) {
this.selectableServers = this.servers.filter(s => {
if (s.hidden) {
return false;
}
return condition(s);
});
if (!this.currentServer || !this.selectableServers.includes(this.currentServer)) {
this.currentServer = this.selectableServers[0];
this.serverForm.setValue(this.currentServer);
}
}
async request_match(arena: string) {
const data = await this.dialog
.open(MatchDialogComponent, { data: arena, disableClose: true })
......@@ -259,15 +271,14 @@ export class YGOProService {
create_room(room: Room, host_password: string) {
const options_buffer = Buffer.alloc(6);
// 建主密码 https://docs.google.com/document/d/1rvrCGIONua2KeRaYNjKBLqyG9uybs9ZI-AmzZKNftOI/edit
options_buffer.writeUInt8(((room.private ? 2 : 1) << 4) |
(room.options.duel_rule << 1) |
(room.options.auto_death ? 0x1 : 0), 1);
options_buffer.writeUInt8(((room.private ? 2 : 1) << 4) | (room.options.duel_rule << 1) | (room.options.auto_death ? 0x1 : 0), 1);
options_buffer.writeUInt8(
room.options.rule << 5 |
room.options.mode << 3 |
(room.options.no_check_deck ? 1 << 1 : 0) |
(room.options.no_shuffle_deck ? 1 : 0)
, 2);
(room.options.rule << 5) |
(room.options.mode << 3) |
(room.options.no_check_deck ? 1 << 1 : 0) |
(room.options.no_shuffle_deck ? 1 : 0),
2
);
options_buffer.writeUInt16LE(room.options.start_lp, 3);
options_buffer.writeUInt8((room.options.start_hand << 4) | room.options.draw_count, 5);
let checksum = 0;
......@@ -276,7 +287,7 @@ export class YGOProService {
}
options_buffer.writeUInt8(checksum & 0xff, 0);
const secret = this.login.user.external_id % 65535 + 1;
const secret = (this.login.user.external_id % 65535) + 1;
for (let i = 0; i < options_buffer.length; i += 2) {
options_buffer.writeUInt16LE(options_buffer.readUInt16LE(i) ^ secret, i);
}
......@@ -291,7 +302,7 @@ export class YGOProService {
// body: `房间密码是 ${this.host_password}, 您的对手可在自定义游戏界面输入密码与您对战。`
// });
// }
this.join(password, this.servers[0]);
this.join(password, this.currentServer);
}
join_room(room: Room) {
......@@ -303,7 +314,7 @@ export class YGOProService {
}
options_buffer.writeUInt8(checksum & 0xff, 0);
const secret = this.login.user.external_id % 65535 + 1;
const secret = (this.login.user.external_id % 65535) + 1;
for (let i = 0; i < options_buffer.length; i += 2) {
options_buffer.writeUInt16LE(options_buffer.readUInt16LE(i) ^ secret, i);
}
......@@ -322,21 +333,21 @@ export class YGOProService {
}
options_buffer.writeUInt8(checksum & 0xff, 0);
const secret = this.login.user.external_id % 65535 + 1;
const secret = (this.login.user.external_id % 65535) + 1;
for (let i = 0; i < options_buffer.length; i += 2) {
options_buffer.writeUInt16LE(options_buffer.readUInt16LE(i) ^ secret, i);
}
const name = options_buffer.toString('base64') + password.replace(/\s/, String.fromCharCode(0xfeff));
this.join(name, this.servers[0]);
this.join(name, this.currentServer);
}
async join_windbot(name?: string) {
if (!name) {
name = sample(await this.windbot);
name = sample(this.currentServer.windbot!);
}
return this.join('AI#' + name, this.servers[0]);
return this.join('AI#' + name, this.currentServer);
}
join(password: string, server: Server) {
......@@ -414,6 +425,10 @@ export class YGOProService {
alert(JSON.stringify({ method: 'share', params: [text] }));
}
}
isRoomAvailableToDisplay(r: Room) {
return (r.arena && this.currentServer && this.currentServer.id === 'tiramisu') || r.server === this.currentServer;
}
}
type Message =
......@@ -427,41 +442,47 @@ export class RoomListDataSource extends DataSource<Room> {
empty = new EventEmitter();
error = new EventEmitter();
constructor(private servers: Server[], private type = 'waiting') {
constructor(private ygopro: YGOProService, private type = 'waiting') {
super();
}
/** Connect function called by the table to retrieve one stream containing the data to render. */
connect(): Observable<Room[]> {
this.loading.emit(true);
return combineLatest(
this.servers.map(server => {
const url = new URL(server.url!);
url.searchParams.set('filter', this.type);
// 协议处理
return webSocket<Message>(url.toString()).pipe(
scan((rooms: Room[], message: Message, index: number) => {
switch (message.event) {
case 'init':
return message.data.map(room => ({ server: server, ...room }));
case 'create':
return rooms.concat({ server: server, ...message.data });
case 'update':
Object.assign(rooms.find(room => room.id === message.data.id), message.data);
return rooms;
case 'delete':
return rooms.filter(room => room.id !== message.data);
}
}, [])
);
})
).pipe(
return this.ygopro.serverForm.valueChanges.pipe(
this.ygopro.serverForm.value ? startWith(this.ygopro.serverForm.value) : tap(),
switchMap(env =>
combineLatest(
[env].filter(s => s.url && (s.custom || s.replay)).map(server => {
const url = new URL(server.url!);
url.searchParams.set('filter', this.type);
// 协议处理
return webSocket<Message>(url.toString()).pipe(
scan((rooms: Room[], message: Message, index: number) => {
switch (message.event) {
case 'init':
return message.data.map(room => ({ server: server, ...room }));
case 'create':
return rooms.concat({ server: server, ...message.data });
case 'update':
Object.assign(rooms.find(room => room.id === message.data.id), message.data);
return rooms;
case 'delete':
return rooms.filter(room => room.id !== message.data);
}
}, [])
);
})
)
),
// 把多个服务器的数据拼接起来
map((sources: Room[][]) => (<Room[]>[]).concat(...sources)),
// 筛选一下房间,只扔进去当前房间或者竞技匹配的
// 房间排序
map(rooms =>
sortBy(rooms, room => {
// TODO: reload server on change
sortBy(rooms.filter(r => this.ygopro.isRoomAvailableToDisplay(r)), room => {
if (room.arena === 'athletic') {
return 0;
} else if (room.arena === 'entertain') {
......@@ -474,10 +495,9 @@ export class RoomListDataSource extends DataSource<Room> {
})
),
// loading、empty、error
filter(rooms => {
tap(rooms => {
this.loading.emit(false);
this.empty.emit(rooms.length === 0);
return true;
}),
catchError(error => {
this.loading.emit(false);
......
<svg class="lds-spinner" width="200px" height="200px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="background: none;"><g transform="rotate(0 50 50)">
<svg class='lds-spinner' width='200px' height='200px' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'
preserveAspectRatio='xMidYMid' style='background: none;'><g transform="rotate(0 50 50)">
<rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#4b99ea">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="-0.9166666666666666s" repeatCount="indefinite"></animate>
</rect>
......@@ -46,4 +47,4 @@
<rect x="47" y="24" rx="9.4" ry="4.8" width="6" height="12" fill="#4b99ea">
<animate attributeName="opacity" values="1;0" keyTimes="0;1" dur="1s" begin="0s" repeatCount="indefinite"></animate>
</rect>
</g></svg>
\ No newline at end of file
</g></svg>
......@@ -44,6 +44,10 @@
// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
// import 'core-js/es7/reflect';
import 'core-js/fn/symbol/async-iterator';
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/**
* Required to support Web Animations `@angular/platform-browser/animations`.
......@@ -66,11 +70,6 @@ import 'core-js/fn/symbol/async-iterator';
*/
// (window as any).__Zone_enable_cross_context_check = true;
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
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