Commit b55ce242 authored by nanahira's avatar nanahira

cloud replay insert thing

parent 1696a557
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class BaseTimeEntity {
@CreateDateColumn({
type: 'timestamp',
})
createTime!: Date;
@UpdateDateColumn({
type: 'timestamp',
})
updateTime!: Date;
}
import { ValueTransformer } from 'typeorm';
export class BigintTransformer implements ValueTransformer {
from(dbValue: unknown) {
if (dbValue == null) {
return dbValue;
}
return Number.parseInt(String(dbValue), 10);
}
to(entityValue: unknown) {
return entityValue;
}
}
import cryptoRandomString from 'crypto-random-string';
import { Context } from '../../app';
import { ClientKeyProvider } from '../client-key-provider';
import { OnRoomCreate, OnRoomWin, Room } from '../../room';
import { DuelRecordEntity } from './duel-record.entity';
import { DuelRecordPlayer } from './duel-record-player.entity';
import { Client } from '../../client';
import {
encodeCurrentDeckBase64,
encodeDeckBase64,
encodeMessagesBase64,
encodeResponsesBase64,
encodeSeedBase64,
resolveCurrentDeckMainc,
resolvePlayerScore,
resolveStartDeckMainc,
} from './utility';
declare module '../../room' {
interface Room {
identifier?: string;
}
}
export class CloudReplayService {
private logger = this.ctx.createLogger(this.constructor.name);
private clientKeyProvider = this.ctx.get(() => ClientKeyProvider);
constructor(private ctx: Context) {
this.ctx.middleware(OnRoomCreate, async (event, _client, next) => {
event.room.identifier = this.createRoomIdentifier();
return next();
});
this.ctx.middleware(OnRoomWin, async (event, _client, next) => {
await this.saveDuelRecord(event);
return next();
});
}
private createRoomIdentifier() {
return cryptoRandomString({
length: 64,
type: 'alphanumeric',
});
}
private async saveDuelRecord(event: OnRoomWin) {
const database = this.ctx.database;
if (!database) {
return;
}
const room = event.room;
const duelRecord = room.lastDuelRecord;
if (!duelRecord) {
return;
}
const duelRecordRepo = database.getRepository(DuelRecordEntity);
try {
const now = new Date();
const record = duelRecordRepo.create({
startTime: duelRecord.date,
endTime: now,
name: room.name,
roomIdentifier: this.getRoomIdentifier(room),
hostInfo: room.hostinfo,
duelCount: room.duelRecords.length,
winReason: event.winMsg.type,
messages: encodeMessagesBase64(duelRecord.messages),
responses: encodeResponsesBase64(duelRecord.responses),
seed: encodeSeedBase64(duelRecord.seed),
players: room.playingPlayers.map((client) =>
this.buildPlayerRecord(room, client, event.winMsg.player),
),
});
await duelRecordRepo.save(record);
} catch (error) {
this.logger.warn(
{
roomName: room.name,
error: (error as Error).toString(),
},
'Failed saving duel record',
);
}
}
private buildPlayerRecord(room: Room, client: Client, winPlayer: number) {
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 = room.getIngameDuelPos(client) === 0;
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;
return player;
}
private getRoomIdentifier(room: Room) {
if (!room.identifier) {
room.identifier = this.createRoomIdentifier();
}
return room.identifier;
}
}
import {
Column,
Entity,
Generated,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
} from 'typeorm';
import { BaseTimeEntity } from './base-time.entity';
import { BigintTransformer } from './bigint-transformer';
import { DuelRecordEntity } from './duel-record.entity';
@Entity('duel_record_player')
export class DuelRecordPlayer extends BaseTimeEntity {
@PrimaryColumn({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
@Generated('increment')
id!: number;
@Column({
type: 'varchar',
length: 20,
})
name!: string; // client.name
@Column({
type: 'smallint',
})
pos!: number; // client.pos
@Index()
@Column({
type: 'varchar',
length: 20,
})
realName!: string; // client.name_vpass
@Column({
type: 'varchar',
length: 64,
})
ip!: string; // client.ip
@Index()
@Column({
type: 'varchar',
length: 60, // 21 + max IPv6 string length(39)
})
clientKey!: string; // getClientKey(client)
@Column('bool')
isFirst!: boolean; // 如果 room.getIngameDuelPos(client) === 0 就是 true
@Index()
@Column('smallint')
score!: number; // 就是 room.score 自己槽位
@Column('text', {})
startDeckBuffer!: string; // client.startDeck.toPayload() base64
@Column('smallint')
startDeckMainc!: number; // client.startDeck.main.length
@Column('text', {})
currentDeckBuffer!: string; // client.currentDeck.toPayload() base64
@Column('smallint')
currentDeckMainc!: number; // client.currentDeck.main.length
@Column('bool')
winner!: boolean;
@Column({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
duelRecordId!: number;
@ManyToOne(() => DuelRecordEntity, (duelRecord) => duelRecord.players, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'duelRecordId',
})
duelRecord!: DuelRecordEntity;
}
import { HostInfo } from 'ygopro-msg-encode';
import {
Column,
Entity,
Generated,
Index,
OneToMany,
PrimaryColumn,
} from 'typeorm';
import { BaseTimeEntity } from './base-time.entity';
import { BigintTransformer } from './bigint-transformer';
import { DuelRecordPlayer } from './duel-record-player.entity';
@Entity('duel_record')
export class DuelRecordEntity extends BaseTimeEntity {
@PrimaryColumn({
type: 'bigint',
unsigned: true,
transformer: new BigintTransformer(),
})
@Generated('increment')
id!: number;
@Index()
@Column('timestamp')
startTime!: Date; // duelRecord.time
@Column('timestamp')
endTime!: Date; // 入库时间
@Index()
@Column({
type: 'varchar',
length: 20,
})
name!: string; // room.name
@Index()
@Column({
type: 'char',
length: 64,
})
roomIdentifier!: string; // declare module 依赖合并声明 room.identifier,然后监听事件 OnRoomCreate 用 crypto-random-string 大小写字母数字 64 字符赋值
@Column({
type: 'jsonb',
})
hostInfo!: HostInfo; // room.hostInfo
@Index()
@Column('smallint', {
unsigned: true,
})
duelCount!: number; // room.duelCount.length
@Column('smallint')
winReason!: number; // OnRoomWin.winMsg.type
@Column({
type: 'text',
})
messages!: string; // duelRecord.messages 全部 map 成 YGOProStocGameMsg 然后全部 toFullPayload 拼接在一起然后 base64
@Column({
type: 'text',
})
responses!: string; // duelRecord.responses 直接拼接 base64
// 32 bytes binary seed => 44 chars base64.
@Column({
type: 'varchar',
length: 44,
})
seed!: string; // duelRecord.seed 每个数字当作 base64 直接拼接
@OneToMany(() => DuelRecordPlayer, (player) => player.duelRecord, {
cascade: true,
})
players!: DuelRecordPlayer[];
}
export * from './duel-record.entity';
export * from './duel-record-player.entity';
export * from './cloud-replay-service';
export * from './record-codec';
import YGOProDeck from 'ygopro-deck-encode';
import { YGOProMsgBase, YGOProStocGameMsg } from 'ygopro-msg-encode';
import { Client } from '../../../client';
import { Room } from '../../../room';
export function resolvePlayerScore(room: Room, client: Client) {
const duelPos = room.getIngameDuelPos(client);
return room.score[duelPos] || 0;
}
export function encodeMessagesBase64(messages: YGOProMsgBase[]) {
if (!messages.length) {
return '';
}
const payloads = messages.map((msg) =>
Buffer.from(
new YGOProStocGameMsg()
.fromPartial({
msg,
})
.toFullPayload(),
),
);
return Buffer.concat(payloads).toString('base64');
}
export function encodeResponsesBase64(responses: Buffer[]) {
if (!responses.length) {
return '';
}
return Buffer.concat(responses).toString('base64');
}
export function encodeSeedBase64(seed: number[]) {
const raw = Buffer.alloc(32, 0);
for (let i = 0; i < 8 && i < seed.length; i += 1) {
raw.writeUInt32LE(seed[i] >>> 0, i * 4);
}
return raw.toString('base64');
}
export function encodeDeckBase64(deck: YGOProDeck | undefined) {
if (!deck || typeof deck.toUpdateDeckPayload !== 'function') {
return '';
}
return Buffer.from(deck.toUpdateDeckPayload()).toString('base64');
}
export function resolveStartDeckMainc(client: Client) {
return client.startDeck?.main?.length || 0;
}
function resolveCurrentDeck(room: Room, client: Client) {
if (client.deck) {
return client.deck;
}
const ingamePos = room.getIngamePos(client);
const duelRecordPlayer = room.lastDuelRecord?.players[ingamePos];
return duelRecordPlayer?.deck;
}
export function resolveCurrentDeckMainc(room: Room, client: Client) {
return resolveCurrentDeck(room, client)?.main?.length || 0;
}
export function encodeCurrentDeckBase64(room: Room, client: Client) {
return encodeDeckBase64(resolveCurrentDeck(room, client));
}
...@@ -14,6 +14,7 @@ import { ClientKeyProvider } from './client-key-provider'; ...@@ -14,6 +14,7 @@ import { ClientKeyProvider } from './client-key-provider';
import { HidePlayerNameProvider } from './hide-player-name-provider'; import { HidePlayerNameProvider } from './hide-player-name-provider';
import { CommandsService, KoishiContextService } from '../koishi'; import { CommandsService, KoishiContextService } from '../koishi';
import { ChatgptService } from './chatgpt-service'; import { ChatgptService } from './chatgpt-service';
import { CloudReplayService } from './cloud-replay';
export const FeatsModule = createAppContext<ContextState>() export const FeatsModule = createAppContext<ContextState>()
.provide(ClientKeyProvider) .provide(ClientKeyProvider)
...@@ -24,6 +25,7 @@ export const FeatsModule = createAppContext<ContextState>() ...@@ -24,6 +25,7 @@ export const FeatsModule = createAppContext<ContextState>()
.provide(ClientVersionCheck) .provide(ClientVersionCheck)
.provide(Welcome) .provide(Welcome)
.provide(PlayerStatusNotify) .provide(PlayerStatusNotify)
.provide(CloudReplayService) // persist duel records
.provide(ChatgptService) // AI-room chat replies .provide(ChatgptService) // AI-room chat replies
.provide(RefreshFieldService) .provide(RefreshFieldService)
.provide(Reconnect) .provide(Reconnect)
......
export * from './client-version-check'; export * from './client-version-check';
export * from './client-key-provider'; export * from './client-key-provider';
export * from './chatgpt-service'; export * from './chatgpt-service';
export * from './cloud-replay';
export * from './hide-player-name-provider'; export * from './hide-player-name-provider';
export * from './menu-manager'; export * from './menu-manager';
export * from './welcome'; export * from './welcome';
......
...@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; ...@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
import { ConfigService } from './config'; import { ConfigService } from './config';
import { Logger } from './logger'; import { Logger } from './logger';
import { RandomDuelScore } from '../feats/random-duel'; import { RandomDuelScore } from '../feats/random-duel';
import { DuelRecordEntity, DuelRecordPlayer } from '../feats/cloud-replay';
export class TypeormLoader { export class TypeormLoader {
constructor(private ctx: AppContext) {} constructor(private ctx: AppContext) {}
...@@ -40,7 +41,7 @@ export const TypeormFactory = async (ctx: AppContext) => { ...@@ -40,7 +41,7 @@ export const TypeormFactory = async (ctx: AppContext) => {
password, password,
database, database,
synchronize, synchronize,
entities: [RandomDuelScore], entities: [RandomDuelScore, DuelRecordEntity, DuelRecordPlayer],
}); });
try { try {
......
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