Commit 397a408a authored by nanahira's avatar nanahira

handle sendChat by koishi Element

parent 2b6ddbcc
import {
filter,
from,
lastValueFrom,
merge,
Observable,
of,
Subject,
} from 'rxjs';
import {
concatMap,
defaultIfEmpty,
ignoreElements,
map,
share,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { filter, merge, Observable, of, Subject } from 'rxjs';
import { map, share, take, takeUntil, tap } from 'rxjs/operators';
import { h } from 'koishi';
import { Context } from '../app';
import {
YGOProCtos,
......@@ -38,6 +22,14 @@ import { Chnroute } from './chnroute';
import YGOProDeck from 'ygopro-deck-encode';
import PQueue from 'p-queue';
import { ClientRoomField } from '../utility/decorators';
import {
collectKoishiTextTokens,
KoishiElement,
KoishiFragment,
OnSendChatElement,
resolveColoredMessages,
splitColoredMessagesByLine,
} from '../utility';
export class Client {
protected async _send(data: Buffer): Promise<void> {
......@@ -149,42 +141,47 @@ export class Client {
});
}
async sendChat(msg: string, type: number = ChatColor.BABYBLUE) {
async sendChat(msg: KoishiFragment, type: number = ChatColor.BABYBLUE) {
if (this.isInternal) {
return;
}
if (type <= NetPlayerType.OBSERVER) {
return this.send(
const normalizedType = typeof type === 'number' ? type : ChatColor.BABYBLUE;
const elements = h.normalize(msg) as KoishiElement[];
const tokens = await collectKoishiTextTokens(elements, (element) =>
this.resolveSendChatElement(element, normalizedType),
);
const messages = splitColoredMessagesByLine(
resolveColoredMessages(tokens, normalizedType),
);
if (!messages.length) {
return;
}
const locale = this.getLocale();
for (const message of messages) {
const line = await this.resolveChatLine(
message.text,
message.color,
locale,
);
await this.send(
new YGOProStocChat().fromPartial({
msg: msg,
player_type: type,
msg: line,
player_type: message.color,
}),
);
}
const locale = this.getLocale();
const lines = type <= NetPlayerType.OBSERVER ? [msg] : msg.split(/\r?\n/);
const sendTasks: Promise<unknown>[] = [];
}
await lastValueFrom(
from(lines).pipe(
concatMap((rawLine) => this.resolveChatLine(rawLine, type, locale)),
tap((line: string) => {
const sendTask = this.send(
new YGOProStocChat().fromPartial({
msg: line,
player_type: type,
}),
);
if (sendTask) {
sendTasks.push(sendTask);
}
}),
ignoreElements(),
defaultIfEmpty(undefined),
),
private async resolveSendChatElement(element: KoishiElement, type: number) {
const event = await this.ctx.dispatch(
new OnSendChatElement(this, type, element),
this,
);
await Promise.all(sendTasks);
if (!event || event.value === undefined) {
return undefined;
}
return event.value;
}
private async resolveChatLine(rawLine: string, type: number, locale: string) {
......
import { NetPlayerType, YGOProStocHsPlayerEnter } from 'ygopro-msg-encode';
import { h } from 'koishi';
import { Context } from '../app';
import { DuelStage, OnRoomGameStart, Room, RoomManager } from '../room';
import { DuelStage, OnRoomGameStart, RoomManager } from '../room';
import { Client } from '../client';
import { OnSendChatElement, PlayerNameClient } from '../utility';
declare module '../room' {
interface Room {
......@@ -41,15 +43,33 @@ export class HidePlayerNameProvider {
return `Player ${client.pos + 1}`;
}
getHidPlayerNameFactory(client: Pick<Client, 'pos' | 'name' | 'roomName'>) {
return (sightPlayer?: Client) => this.getHidPlayerName(client, sightPlayer);
}
async init() {
if (!this.enabled) {
return;
}
this.ctx.middleware(OnSendChatElement, async (event, client, next) => {
const element = event.value;
if (!element || element.type !== 'PlayerName') {
return next();
}
const sourceClient = element.attrs?.client as
| PlayerNameClient
| undefined;
if (!sourceClient) {
return next();
}
const hidPlayerName = this.getHidPlayerName(sourceClient, client);
event.use(
h(
'PlayerName',
{ client: sourceClient },
hidPlayerName || sourceClient.name || '',
),
);
return next();
});
this.ctx.middleware(YGOProStocHsPlayerEnter, async (msg, client, next) => {
const hidPlayerName = this.getHidPlayerName(msg, client);
if (hidPlayerName !== msg.name) {
......
......@@ -6,7 +6,7 @@ import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../../app';
import { RoomCheckDeck } from '../../room';
import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare';
import { HidePlayerNameProvider } from '../hide-player-name-provider';
import { PlayerName } from '../../utility';
import { LockDeckExpectedDeckCheck } from './lock-deck-check';
class SrvproDeckBadError extends YGOProLFListError {
......@@ -21,8 +21,6 @@ class SrvproDeckBadError extends YGOProLFListError {
}
export class LockDeckService {
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
constructor(private ctx: Context) {}
async init() {
......@@ -47,11 +45,10 @@ export class LockDeckService {
}
if (expectedDeck === null) {
const playerName = this.hidePlayerNameProvider.getHidPlayerName(
client,
client,
await client.sendChat(
[PlayerName(client), '#{deck_not_found}'],
ChatColor.RED,
);
await client.sendChat(`${playerName}#{deck_not_found}`, ChatColor.RED);
return msg.use(new SrvproDeckBadError());
}
......
import { ChatColor, NetPlayerType } from 'ygopro-msg-encode';
import { Context } from '../app';
import { HidePlayerNameProvider } from './hide-player-name-provider';
import { OnRoomJoinObserver } from '../room/room-event/on-room-join-observer';
import { OnRoomLeave } from '../room/room-event/on-room-leave';
import { PlayerName } from '../utility';
export class PlayerStatusNotify {
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
constructor(private ctx: Context) {}
async init() {
......@@ -14,8 +12,7 @@ export class PlayerStatusNotify {
this.ctx.middleware(OnRoomJoinObserver, async (event, client, next) => {
const room = event.room;
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{watch_join}`,
[PlayerName(client), ' #{watch_join}'],
ChatColor.LIGHTBLUE,
);
return next();
......@@ -27,15 +24,13 @@ export class PlayerStatusNotify {
if (client.pos === NetPlayerType.OBSERVER) {
// 观战者离开
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{quit_watch}`,
[PlayerName(client), ' #{quit_watch}'],
ChatColor.LIGHTBLUE,
);
} else {
// 玩家离开
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{left_game}`,
[PlayerName(client), ' #{left_game}'],
ChatColor.LIGHTBLUE,
);
}
......
......@@ -27,6 +27,7 @@ import { CanReconnectCheck } from '../reconnect';
import { WaitForPlayerProvider } from '../wait-for-player-provider';
import { ClientKeyProvider } from '../client-key-provider';
import { HidePlayerNameProvider } from '../hide-player-name-provider';
import { KoishiElement, PlayerName } from '../../utility';
import { RandomDuelScore } from './score.entity';
import {
formatRemainText,
......@@ -531,8 +532,7 @@ export class RandomDuelProvider {
}
if (abuseCount >= 5) {
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerName.getHidPlayerName(client, sightPlayer)} #{chat_banned}`,
[PlayerName(client), ' #{chat_banned}'],
ChatColor.RED,
);
await this.punishPlayer(client, 'ABUSE');
......@@ -751,11 +751,10 @@ export class RandomDuelProvider {
const clientScoreText = await this.getScoreDisplay(
this.getClientKey(client),
);
const nameFactory = this.hidePlayerName.getHidPlayerNameFactory(client);
for (const player of players) {
if (clientScoreText) {
await player.sendChat(
clientScoreText(nameFactory(player)),
clientScoreText(PlayerName(client)),
ChatColor.GREEN,
);
}
......@@ -767,7 +766,7 @@ export class RandomDuelProvider {
);
if (playerScoreText) {
await client.sendChat(
playerScoreText(this.hidePlayerName.getHidPlayerName(player, client)),
playerScoreText(PlayerName(player)),
ChatColor.GREEN,
);
}
......@@ -780,14 +779,14 @@ export class RandomDuelProvider {
return undefined;
}
const score = await repo.findOneBy({ name });
return (displayName: string) => {
return (displayName: string | KoishiElement) => {
if (!score) {
return `${displayName} #{random_score_blank}`;
return [displayName, ' #{random_score_blank}'];
}
const total = score.winCount + score.loseCount;
if (score.winCount < 2 && total < 3) {
return `${displayName} #{random_score_not_enough}`;
return [displayName, ' #{random_score_not_enough}'];
}
const safeTotal = total > 0 ? total : 1;
......@@ -795,9 +794,17 @@ export class RandomDuelProvider {
const fleeRate = Math.ceil((score.fleeCount / safeTotal) * 100);
if (score.winCombo >= 2) {
return `#{random_score_part1}${displayName} #{random_score_part2} ${winRate}#{random_score_part3} ${fleeRate}#{random_score_part4_combo}${score.winCombo}#{random_score_part5_combo}`;
return [
'#{random_score_part1}',
displayName,
` #{random_score_part2} ${winRate}#{random_score_part3} ${fleeRate}#{random_score_part4_combo}${score.winCombo}#{random_score_part5_combo}`,
];
}
return `#{random_score_part1}${displayName} #{random_score_part2} ${winRate}#{random_score_part3} ${fleeRate}#{random_score_part4}`;
return [
'#{random_score_part1}',
displayName,
` #{random_score_part2} ${winRate}#{random_score_part3} ${fleeRate}#{random_score_part4}`,
];
};
}
......
......@@ -22,9 +22,9 @@ import { DuelStage, Room, RoomManager } from '../../room';
import { getSpecificFields } from '../../utility/metadata';
import { YGOProCtosDisconnect } from '../../utility/ygopro-ctos-disconnect';
import { isUpdateDeckPayloadEqual } from '../../utility/deck-compare';
import { PlayerName } from '../../utility';
import { CanReconnectCheck } from './can-reconnect-check';
import { ClientKeyProvider } from '../client-key-provider';
import { HidePlayerNameProvider } from '../hide-player-name-provider';
import { RefreshFieldService } from './refresh-field-service';
interface DisconnectInfo {
......@@ -58,7 +58,6 @@ export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>();
private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
private clientKeyProvider = this.ctx.get(() => ClientKeyProvider);
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
private refreshFieldService = this.ctx.get(() => RefreshFieldService);
constructor(private ctx: Context) {}
......@@ -147,8 +146,7 @@ export class Reconnect {
// 通知房间
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{disconnect_from_game}`,
[PlayerName(client), ' #{disconnect_from_game}'],
ChatColor.LIGHTBLUE,
);
......@@ -286,8 +284,7 @@ export class Reconnect {
// 通知房间
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{reconnect_to_game}`,
[PlayerName(client), ' #{reconnect_to_game}'],
ChatColor.LIGHTBLUE,
);
......@@ -312,8 +309,7 @@ export class Reconnect {
// 通知房间
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{reconnect_to_game}`,
[PlayerName(client), ' #{reconnect_to_game}'],
ChatColor.LIGHTBLUE,
);
......@@ -328,8 +324,6 @@ export class Reconnect {
}
}
private hidePlayerName = this.ctx.get(() => HidePlayerNameProvider);
private async sendPreReconnectInfo(
client: Client,
room: Room,
......@@ -363,7 +357,7 @@ export class Reconnect {
if (player) {
await client.send(
new YGOProStocHsPlayerEnter().fromPartial({
name: this.hidePlayerNameProvider.getHidPlayerName(player, client),
name: player.name,
pos: player.pos,
}),
);
......
import { ChatColor, YGOProMsgNewTurn } from 'ygopro-msg-encode';
import { Context } from '../app';
import { RoomManager, DuelStage, OnRoomDuelStart, Room } from '../room';
import { HidePlayerNameProvider } from './hide-player-name-provider';
import { PlayerName } from '../utility';
const DEATH_WIN_REASON = 0x11;
......@@ -13,7 +13,6 @@ declare module '../room' {
export class RoomDeathService {
private roomManager = this.ctx.get(() => RoomManager);
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
constructor(private ctx: Context) {}
......@@ -57,18 +56,14 @@ export class RoomDeathService {
if (lp0 !== lp1 && room.turnCount > 1) {
const winner = lp0 > lp1 ? 0 : 1;
const winnerPlayer = room.getDuelPosPlayers(winner)[0];
await room.sendChat(
(sightPlayer) =>
`#{death_finish_part1}${
winnerPlayer
? this.hidePlayerNameProvider.getHidPlayerName(
winnerPlayer,
sightPlayer,
)
: ''
}#{death_finish_part2}`,
ChatColor.BABYBLUE,
);
const finishMessage = winnerPlayer
? [
'#{death_finish_part1}',
PlayerName(winnerPlayer),
'#{death_finish_part2}',
]
: '#{death_finish_part1}#{death_finish_part2}';
await room.sendChat(finishMessage, ChatColor.BABYBLUE);
await room.win({
player: room.getIngameDuelPosByDuelPos(winner),
type: DEATH_WIN_REASON,
......@@ -107,18 +102,14 @@ export class RoomDeathService {
) {
const winner = score0 > score1 ? 0 : 1;
const winnerPlayer = room.getDuelPosPlayers(winner)[0];
await room.sendChat(
(sightPlayer) =>
`#{death2_finish_part1}${
winnerPlayer
? this.hidePlayerNameProvider.getHidPlayerName(
winnerPlayer,
sightPlayer,
)
: ''
}#{death2_finish_part2}`,
ChatColor.BABYBLUE,
);
const finishMessage = winnerPlayer
? [
'#{death2_finish_part1}',
PlayerName(winnerPlayer),
'#{death2_finish_part2}',
]
: '#{death2_finish_part1}#{death2_finish_part2}';
await room.sendChat(finishMessage, ChatColor.BABYBLUE);
await room.win(
{
player: room.getIngameDuelPosByDuelPos(winner),
......
......@@ -9,9 +9,9 @@ import {
OnRoomSidingStart,
Room,
} from '../room';
import { HidePlayerNameProvider } from './hide-player-name-provider';
import { merge, Subscription, timer } from 'rxjs';
import { filter, finalize, share, take, takeUntil } from 'rxjs/operators';
import { PlayerName } from '../utility';
declare module '../room' {
interface Room {
......@@ -23,7 +23,6 @@ declare module '../room' {
export class SideTimeout {
private logger = this.ctx.createLogger('SideTimeout');
private sideTimeoutMinutes = this.ctx.config.getInt('SIDE_TIMEOUT_MINUTES');
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
private onSidingReady$ = this.ctx.event$(OnRoomSidingReady).pipe(share());
private onLeavePlayer$ = this.ctx.event$(OnRoomLeavePlayer).pipe(share());
private onGameStart$ = this.ctx.event$(OnRoomGameStart).pipe(share());
......@@ -150,8 +149,7 @@ export class SideTimeout {
if (remainMinutes <= 1) {
this.clearSideTimeout(room, pos);
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(client, sightPlayer)} #{side_overtime_room}`,
[PlayerName(client), ' #{side_overtime_room}'],
ChatColor.BABYBLUE,
);
await client.sendChat('#{side_overtime}', ChatColor.RED);
......
......@@ -20,8 +20,8 @@ import {
Room,
RoomManager,
} from '../room';
import { HidePlayerNameProvider } from './hide-player-name-provider';
import { OnClientWaitTimeout } from './random-duel/random-duel-events';
import { PlayerName } from '../utility';
export interface WaitForPlayerConfig {
roomFilter: (room: Room) => boolean;
......@@ -53,7 +53,6 @@ interface WaitForPlayerTickRuntime {
export class WaitForPlayerProvider {
private logger = this.ctx.createLogger(this.constructor.name);
private roomManager = this.ctx.get(() => RoomManager);
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
private tickRuntimes = new Map<number, WaitForPlayerTickRuntime>();
private nextTickId = 1;
......@@ -394,8 +393,7 @@ export class WaitForPlayerProvider {
) {
room.waitForPlayerReadyWarnRemain = remainSeconds;
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(target, sightPlayer)} ${remainSeconds} #{kick_count_down}`,
[PlayerName(target), ` ${remainSeconds} #{kick_count_down}`],
remainSeconds <= 9 ? ChatColor.RED : ChatColor.LIGHTBLUE,
);
}
......@@ -414,8 +412,7 @@ export class WaitForPlayerProvider {
return;
}
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(latestTarget, sightPlayer)} #{kicked_by_system}`,
[PlayerName(latestTarget), ' #{kicked_by_system}'],
ChatColor.RED,
);
await this.ctx.dispatch(
......@@ -466,8 +463,7 @@ export class WaitForPlayerProvider {
room.lastActiveTime = new Date(nowMs);
room.waitForPlayerHangWarnElapsed = undefined;
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(waitingPlayer, sightPlayer)} #{kicked_by_system}`,
[PlayerName(waitingPlayer), ' #{kicked_by_system}'],
ChatColor.RED,
);
await this.ctx.dispatch(
......@@ -490,8 +486,10 @@ export class WaitForPlayerProvider {
);
if (remainSeconds > 0) {
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(waitingPlayer, sightPlayer)} #{afk_warn_part1}${remainSeconds}#{afk_warn_part2}`,
[
PlayerName(waitingPlayer),
` #{afk_warn_part1}${remainSeconds}#{afk_warn_part2}`,
],
ChatColor.RED,
);
}
......
import {
Bot,
Context as KoishiContext,
Fragment as KoishiFragment,
Session as KoishiSession,
Universal,
h,
} from 'koishi';
import * as koishiHelpModule from '@koishijs/plugin-help';
import { ChatColor, YGOProCtosChat } from 'ygopro-msg-encode';
import { YGOProCtosChat } from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import { ClientKeyProvider } from '../feats';
import { I18nService } from '../client/i18n';
import { Room, RoomManager } from '../room';
type KoishiElement = h;
import { KoishiFragment } from '../utility';
const koishiHelp =
(koishiHelpModule as any).default || (koishiHelpModule as any);
......@@ -22,16 +20,6 @@ type KoishiReferrer = {
userId: string;
};
type ChatToken = {
text: string;
color?: number;
};
type ColoredChatMessage = {
text: string;
color: number;
};
type CommandContext = {
room: Room;
client: Client;
......@@ -310,22 +298,8 @@ export class KoishiContextService {
return [];
}
const messages = this.resolveColoredMessages(h.normalize(content));
if (!messages.length) {
return [];
}
const messageIds: string[] = [];
for (let i = 0; i < messages.length; i += 1) {
const message = messages[i];
await Promise.all(
targets.map((target) => target.sendChat(message.text, message.color)),
);
messageIds.push(
`${Date.now()}-${i}-${Math.random().toString(36).slice(2, 8)}`,
);
}
return messageIds;
await Promise.all(targets.map((target) => target.sendChat(content)));
return [`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`];
}
private resolveSendTargets(room: Room, session?: KoishiSession) {
......@@ -336,139 +310,4 @@ export class KoishiContextService {
const target = this.findClientByUserId(room, referrer.userId);
return target ? [target] : [];
}
private resolveColoredMessages(
elements: KoishiElement[],
): ColoredChatMessage[] {
const tokens = this.collectTextTokens(elements);
if (!tokens.length) {
return [];
}
const cleanedTokens = tokens.filter((token) => token.text.length > 0);
if (!cleanedTokens.length) {
return [];
}
const firstColoredIndex = cleanedTokens.findIndex(
(token) => typeof token.color === 'number',
);
if (firstColoredIndex === -1) {
return [
{
text: cleanedTokens.map((token) => token.text).join(''),
color: ChatColor.BABYBLUE,
},
];
}
let currentColor = cleanedTokens[firstColoredIndex].color as number;
let currentText = cleanedTokens
.slice(0, firstColoredIndex + 1)
.map((token) => token.text)
.join('');
const result: ColoredChatMessage[] = [];
for (let i = firstColoredIndex + 1; i < cleanedTokens.length; i += 1) {
const token = cleanedTokens[i];
if (typeof token.color === 'number' && token.color !== currentColor) {
if (currentText) {
result.push({
text: currentText,
color: currentColor,
});
}
currentColor = token.color;
currentText = token.text;
} else {
currentText += token.text;
}
}
if (currentText) {
result.push({
text: currentText,
color: currentColor,
});
}
if (!result.length) {
return [];
}
return result;
}
private collectTextTokens(
elements: KoishiElement[],
inheritedColor?: number,
): ChatToken[] {
const tokens: ChatToken[] = [];
for (const element of elements) {
if (!element) {
continue;
}
const color = this.resolveElementColor(element) ?? inheritedColor;
if (element.type === 'text') {
const content = element.attrs?.content;
if (typeof content === 'string' && content.length > 0) {
tokens.push({
text: content,
color,
});
}
} else if (element.type === 'br') {
tokens.push({
text: '\n',
color,
});
}
if (element.children?.length) {
tokens.push(
...this.collectTextTokens(element.children as KoishiElement[], color),
);
}
}
return tokens;
}
private resolveElementColor(element: KoishiElement): number | undefined {
const isChatElement =
typeof element.type === 'string' && element.type.toLowerCase() === 'chat';
const rawColor = isChatElement
? element.attrs?.color
: element.attrs?.chatColor;
if (rawColor == null) {
return undefined;
}
if (typeof rawColor === 'number') {
return this.normalizeChatColor(rawColor);
}
if (typeof rawColor !== 'string') {
return undefined;
}
const normalized = rawColor.replace(/[^a-z0-9]/gi, '').toUpperCase();
if (!normalized) {
return undefined;
}
const enumValue = (ChatColor as any)[normalized];
if (typeof enumValue === 'number') {
return enumValue;
}
const parsed = Number(rawColor);
if (Number.isFinite(parsed)) {
return this.normalizeChatColor(parsed);
}
return undefined;
}
private normalizeChatColor(value: number): number {
if (typeof (ChatColor as any)[value] === 'string') {
return value;
}
return ChatColor.BABYBLUE;
}
}
......@@ -2,12 +2,11 @@ import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { LegacyBanEntity } from './legacy-ban.entity';
import { RoomManager } from '../room';
import { HidePlayerNameProvider } from '../feats';
import { LegacyApiService } from './legacy-api-service';
import { PlayerName } from '../utility';
export class LegacyBanService {
private logger = this.ctx.createLogger('LegacyBanService');
private hidePlayerNameProvider = this.ctx.get(() => HidePlayerNameProvider);
constructor(private ctx: Context) {
this.ctx
......@@ -78,8 +77,7 @@ export class LegacyBanService {
}
await room.sendChat(
(sightPlayer) =>
`${this.hidePlayerNameProvider.getHidPlayerName(player, sightPlayer)} #{kicked_by_system}`,
[PlayerName(player), ' #{kicked_by_system}'],
ChatColor.RED,
);
await room.kick(player);
......
......@@ -108,6 +108,7 @@ import { OnRoomSelectTp } from './room-event/on-room-select-tp';
import { RoomCheckDeck } from './room-event/room-check-deck';
import cryptoRandomString from 'crypto-random-string';
import { RoomCurrentFieldInfo, RoomInfo } from './room-info';
import { KoishiFragment } from '../utility';
const { OcgcoreScriptConstants } = _OcgcoreConstants;
......@@ -959,19 +960,11 @@ export class Room {
return this.sendChat(msg.msg, this.getIngamePos(client));
}
async sendChat(
msg: string | ((p: Client) => Awaitable<string>),
type: number = ChatColor.BABYBLUE,
) {
async sendChat(msg: KoishiFragment, type: number = ChatColor.BABYBLUE) {
if (this.finalizing) {
return;
}
return Promise.all(
this.allPlayers.map(async (p) => {
const resolvedMessage = typeof msg === 'function' ? await msg(p) : msg;
return p.sendChat(resolvedMessage, type);
}),
);
return Promise.all(this.allPlayers.map((p) => p.sendChat(msg, type)));
}
firstgoPos?: number;
......
......@@ -2,3 +2,4 @@ export * from './panel-pagination';
export * from './base-time.entity';
export * from './bigint-transformer';
export * from './decorators';
export * from './koishi-chat';
import { Fragment as KoishiFragment, h } from 'koishi';
import { ChatColor } from 'ygopro-msg-encode';
import { ValueContainer } from './value-container';
import type { Client } from '../client';
export type KoishiElement = h;
export { KoishiFragment };
export type ChatToken = {
text: string;
color?: number;
};
export type ColoredChatMessage = {
text: string;
color: number;
};
export type PlayerNameClient = Pick<Client, 'pos' | 'name' | 'roomName'>;
export class OnSendChatElement extends ValueContainer<
KoishiElement | undefined
> {
constructor(
public sightPlayer: Client,
public type: number,
element: KoishiElement,
) {
super(element);
}
}
export function PlayerName(
client: PlayerNameClient,
content: KoishiFragment = client.name || '',
) {
return h('PlayerName', { client }, content);
}
export function normalizeChatColor(value: number) {
if (typeof (ChatColor as any)[value] === 'string') {
return value;
}
return ChatColor.BABYBLUE;
}
export function resolveElementChatColor(
element: KoishiElement,
): number | undefined {
const isChatElement =
typeof element.type === 'string' && element.type.toLowerCase() === 'chat';
const rawColor = isChatElement
? element.attrs?.color
: element.attrs?.chatColor;
if (rawColor == null) {
return undefined;
}
if (typeof rawColor === 'number') {
return normalizeChatColor(rawColor);
}
if (typeof rawColor !== 'string') {
return undefined;
}
const normalized = rawColor.replace(/[^a-z0-9]/gi, '').toUpperCase();
if (!normalized) {
return undefined;
}
const enumValue = (ChatColor as any)[normalized];
if (typeof enumValue === 'number') {
return enumValue;
}
const parsed = Number(rawColor);
if (Number.isFinite(parsed)) {
return normalizeChatColor(parsed);
}
return undefined;
}
export async function collectKoishiTextTokens(
elements: KoishiElement[],
resolveElement?: (
element: KoishiElement,
) => Promise<KoishiElement | undefined>,
inheritedColor?: number,
): Promise<ChatToken[]> {
const tokens: ChatToken[] = [];
for (const rawElement of elements) {
if (!rawElement) {
continue;
}
const element = resolveElement
? await resolveElement(rawElement)
: rawElement;
if (!element) {
continue;
}
const color = resolveElementChatColor(element) ?? inheritedColor;
if (element.type === 'text') {
const content = element.attrs?.content;
if (typeof content === 'string' && content.length > 0) {
tokens.push({
text: content,
color,
});
}
} else if (element.type === 'br') {
tokens.push({
text: '\n',
color,
});
}
if (element.children?.length) {
tokens.push(
...(await collectKoishiTextTokens(
element.children as KoishiElement[],
resolveElement,
color,
)),
);
}
}
return tokens;
}
export function resolveColoredMessages(
tokens: ChatToken[],
defaultColor = ChatColor.BABYBLUE,
): ColoredChatMessage[] {
const cleanedTokens = tokens.filter((token) => token.text.length > 0);
if (!cleanedTokens.length) {
return [];
}
let currentColor = normalizeChatColor(defaultColor);
let currentText = '';
const result: ColoredChatMessage[] = [];
for (const token of cleanedTokens) {
const tokenColor =
typeof token.color === 'number'
? normalizeChatColor(token.color)
: currentColor;
if (tokenColor !== currentColor && currentText) {
result.push({
text: currentText,
color: currentColor,
});
currentText = '';
}
currentColor = tokenColor;
currentText += token.text;
}
if (currentText) {
result.push({
text: currentText,
color: currentColor,
});
}
return result;
}
export function splitColoredMessagesByLine(messages: ColoredChatMessage[]) {
const result: ColoredChatMessage[] = [];
let previousEndedWithNewline = false;
for (const message of messages) {
const lines = message.text.split(/\r?\n/);
const endedWithNewline = /\r?\n$/.test(message.text);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
const isFirst = i === 0;
const isLast = i === lines.length - 1;
if (isLast && line.length === 0 && endedWithNewline) {
continue;
}
if (
isFirst &&
line.length === 0 &&
!previousEndedWithNewline &&
result.length > 0
) {
continue;
}
result.push({
text: line,
color: message.color,
});
}
previousEndedWithNewline = endedWithNewline;
}
return result;
}
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