Preact: Refactor client commands
Some checks are pending
Node.js CI / build (22.x) (push) Waiting to run

At this point we've probably outgrown the "huge switch" pattern. So
instead, here's a client command syntax a lot like server's.

I've also added an `add` command that works more like server's and
2013 client's, as the default way to respond to client commands.
This commit is contained in:
Guangcong Luo 2025-04-14 00:55:26 +00:00
parent 8f41ceb4da
commit 0740eb5148
9 changed files with 288 additions and 266 deletions

View File

@ -316,7 +316,7 @@ export const defaultRulesES3 = {
ignoreRestSiblings: true,
}],
"no-restricted-syntax": ["error",
{ selector: "TaggedTemplateExpression", message: "Hard to compile down to ES3" },
{ selector: "TaggedTemplateExpression", message: "Not supported by ES3" },
{ selector: "CallExpression[callee.name='Symbol']", message: "Annoying to serialize, just use a string" },
],
@ -344,7 +344,7 @@ export const defaultRulesES3TSChecked = {
...defaultRulesTSChecked,
"radix": "off",
"no-restricted-globals": ["error", "Proxy", "Reflect", "Symbol", "WeakSet", "WeakMap", "Set", "Map"],
"no-restricted-syntax": ["error", "TaggedTemplateExpression", "YieldExpression", "AwaitExpression", "BigIntLiteral"],
"no-restricted-syntax": ["error", "YieldExpression", "AwaitExpression", "BigIntLiteral"],
};
/**

View File

@ -1072,6 +1072,18 @@ export class BattleLog {
if (jsEscapeToo) str = str.replace(/\\/g, '\\\\').replace(/'/g, '\\\'');
return str;
}
/**
* Template string tag function for escaping HTML
*/
static html(strings: TemplateStringsArray | string[], ...args: any) {
let buf = strings[0];
let i = 0;
while (i < args.length) {
buf += this.escapeHTML(args[i]);
buf += strings[++i];
}
return buf;
}
static unescapeHTML(str: string) {
str = (str ? '' + str : '');
@ -1165,7 +1177,7 @@ export class BattleLog {
name = name.substr(1);
}
const colorStyle = ` style="color:${BattleLog.usernameColor(toID(name))}"`;
const clickableName = `<small>${BattleLog.escapeHTML(group)}</small><span class="username">${BattleLog.escapeHTML(name)}</span>`;
const clickableName = `<small class="groupsymbol">${BattleLog.escapeHTML(group)}</small><span class="username">${BattleLog.escapeHTML(name)}</span>`;
let hlClass = isHighlighted ? ' highlighted' : '';
let isMine = (window.app?.user?.get('name') === name) || (window.PS?.user.name === name);
let mineClass = isMine ? ' mine' : '';

View File

@ -626,6 +626,14 @@ interface PSNotificationState {
noAutoDismiss: boolean;
}
type ClientCommands<RoomT extends PSRoom> = {
[command: Lowercase<string>]: (this: RoomT, target: string, cmd: string) => string | boolean | null | void,
};
/** The command signature is a lie but TypeScript and string validation amirite? */
type ParsedClientCommands = {
[command: `parsed${string}`]: (this: PSRoom, target: string, cmd: string) => string | boolean | null | void,
};
/**
* As a PSStreamModel, PSRoom can emit `Args` to mean "we received a message",
* and `null` to mean "tell Preact to re-render this room"
@ -744,38 +752,83 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
}
}
/**
* Handles outgoing messages, like `/logout`. Return `true` to prevent
* the line from being sent to servers.
* Used only by commands; messages from the server go directly from
* `PS.receive` to `room.receiveLine`
*/
handleSend(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return false;
const spaceIndex = line.indexOf(' ');
const cmd = spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1);
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
switch (cmd) {
case 'logout':
add(line: string) {
if (this.type !== 'chat' && this.type !== 'battle') {
PS.mainmenu.handlePM(PS.user.userid, PS.user.userid);
PS.rooms['dm-' as RoomID]?.receiveLine(BattleTextParser.parseLine(line));
} else {
this.receiveLine(BattleTextParser.parseLine(line));
}
}
parseClientCommands(commands: ClientCommands<this>) {
const parsedCommands: ParsedClientCommands = {};
for (const cmd in commands) {
const names = cmd.split(',').map(name => name.trim());
for (const name of names) {
if (name.includes(' ')) throw new Error(`Client command names cannot contain spaces: ${name}`);
// good luck convincing TypeScript that these types are compatible
parsedCommands[name as 'parsed'] = commands[cmd as 'cmd'] as any;
}
}
return parsedCommands;
}
globalClientCommands = this.parseClientCommands({
'j,join'(target) {
const roomid = /[^a-z0-9-]/.test(target) ? toID(target) as any as RoomID : target as RoomID;
PS.join(roomid);
},
'part,leave,close'(target) {
const roomid = /[^a-z0-9-]/.test(target) ? toID(target) as any as RoomID : target as RoomID;
PS.leave(roomid || this.id);
},
'logout'() {
PS.user.logOut();
return true;
case 'cancelsearch':
},
'cancelsearch'() {
PS.mainmenu.cancelSearch();
return true;
case 'nick':
},
'nick'(target) {
if (target) {
PS.user.changeName(target);
} else {
PS.join('login' as RoomID);
}
return true;
case 'open':
case 'user': {
},
'open,user'(target) {
let roomid = `user-${toID(target)}` as RoomID;
PS.join(roomid, {
args: { username: target },
});
return true;
}
case 'showjoins': {
},
'ignore'(target) {
const ignore = PS.prefs.ignore || {};
if (!target) return true;
if (toID(target) === PS.user.userid) {
this.add(`||You are not able to ignore yourself.`);
} else if (ignore[toID(target)]) {
this.add(`||User '${target}' is already on your ignore list. ` +
`(Moderator messages will not be ignored.)`);
} else {
ignore[toID(target)] = 1;
this.add(`||User '${target}' ignored. (Moderator messages will not be ignored.)`);
PS.prefs.set("ignore", ignore);
}
},
'unignore'(target) {
const ignore = PS.prefs.ignore || {};
if (!target) return true;
if (!ignore[toID(target)]) {
this.add(`||User '${target}' isn't on your ignore list.`);
} else {
ignore[toID(target)] = 0;
this.add(`||User '${target}' no longer ignored.`);
PS.prefs.set("ignore", ignore);
}
},
'showjoins'(target) {
let showjoins = PS.prefs.showjoins || {};
let serverShowjoins = showjoins[PS.server.id] || {};
if (target) {
@ -785,17 +838,15 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
} else {
serverShowjoins[room] = 1;
}
this.receiveLine([`c`, "", `Join/leave messages on room ${room}: ALWAYS ON`]);
this.add(`||Join/leave messages in room ${room}: ALWAYS ON`);
} else {
serverShowjoins = { global: 1 };
this.receiveLine([`c`, "", `Join/leave messages: ALWAYS ON`]);
this.add(`||Join/leave messages: ALWAYS ON`);
}
showjoins[PS.server.id] = serverShowjoins;
PS.prefs.set("showjoins", showjoins);
return true;
}
case 'hidejoins': {
},
'hidejoins'(target) {
let showjoins = PS.prefs.showjoins || {};
let serverShowjoins = showjoins[PS.server.id] || {};
if (target) {
@ -805,28 +856,41 @@ export class PSRoom extends PSStreamModel<Args | null> implements RoomOptions {
} else {
serverShowjoins[room] = 0;
}
this.receiveLine([`c`, "", `Join/leave messages on room ${room}: OFF`]);
this.add(`||Join/leave messages on room ${room}: OFF`);
} else {
serverShowjoins = { global: 0 };
this.receiveLine([`c`, "", `Join/leave messages: OFF`]);
this.add(`||Join/leave messages: OFF`);
}
showjoins[PS.server.id] = serverShowjoins;
PS.prefs.set('showjoins', showjoins);
return true;
},
});
clientCommands: ParsedClientCommands | null = null;
/**
* Handles outgoing messages, like `/logout`. Return `true` to prevent
* the line from being sent to servers.
*/
handleSend(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return line;
const spaceIndex = line.indexOf(' ');
const cmd = (spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1)) as 'parsed';
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
}
const cmdHandler = this.globalClientCommands[cmd] || this.clientCommands?.[cmd];
if (!cmdHandler) return line;
}
return false;
const cmdResult = cmdHandler.call(this, target, cmd);
if (cmdResult === true) return line;
return cmdResult || null;
}
send(msg: string) {
send(msg: string | null) {
if (!msg) return;
msg = this.handleSend(msg);
if (!msg) return;
if (this.handleSend(msg)) return;
this.sendDirect(msg);
}
sendDirect(msg: string) {
PS.send(this.id + '|' + msg);
PS.send(`${this.id}|${msg}`);
}
destroy() {
if (this.connected) {

View File

@ -115,7 +115,7 @@ class BattlesPanel extends PSRoomPanel<BattlesRoom> {
}
}
class BattleRoom extends ChatRoom {
export class BattleRoom extends ChatRoom {
override readonly classType = 'battle';
declare pmTarget: null;
declare challengeMenuOpen: false;
@ -127,73 +127,6 @@ class BattleRoom extends ChatRoom {
side: BattleRequestSideInfo | null = null;
request: BattleRequest | null = null;
choices: BattleChoiceBuilder | null = null;
/**
* @return true to prevent line from being sent to server
*/
override handleSend(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return false;
const spaceIndex = line.indexOf(' ');
const cmd = spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1);
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
switch (cmd) {
case 'play': {
if (this.battle.atQueueEnd) {
this.battle.reset();
}
this.battle.play();
this.update(null);
return true;
} case 'pause': {
this.battle.pause();
this.update(null);
return true;
} case 'ffto': case 'fastfowardto': {
let turnNum = Number(target);
if (target.startsWith('+') || turnNum < 0) {
turnNum += this.battle.turn;
if (turnNum < 0) turnNum = 0;
} else if (target === 'end') {
turnNum = Infinity;
}
if (isNaN(turnNum)) {
this.receiveLine([`error`, `/ffto - Invalid turn number: ${target}`]);
return true;
}
this.battle.seekTurn(turnNum);
this.update(null);
return true;
} case 'switchsides': {
this.battle.switchViewpoint();
return true;
} case 'cancel': case 'undo': {
if (!this.choices || !this.request) {
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
return true;
}
if (this.choices.isDone() || this.choices.isEmpty()) {
this.sendDirect('/undo');
}
this.choices = new BattleChoiceBuilder(this.request);
this.update(null);
return true;
} case 'move': case 'switch': case 'team': case 'pass': case 'shift': case 'choose': {
if (!this.choices) {
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
return true;
}
const possibleError = this.choices.addChoice(line.slice(cmd === 'choose' ? 8 : 1));
if (possibleError) {
this.receiveLine([`error`, possibleError]);
return true;
}
if (this.choices.isDone()) this.sendDirect(`/choose ${this.choices.toString()}`);
this.update(null);
return true;
}
}
return super.handleSend(line);
}
}
class BattleDiv extends preact.Component<{ room: BattleRoom }> {
@ -799,18 +732,20 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
<i class="fa fa-step-backward"></i><br />Prev turn
</button>}
</p>
{room.side ?
{room.side ? (
<p>
<button class="button" name="closeAndMainMenu" onClick={() => this.close()}>
<button class="button" data-cmd="/close">
<strong>Main menu</strong><br /><small>(closes this battle)</small>
</button> {}
<button
class="button" name="cmd" value={`/closeandchallenge ${room.battle.farSide.id},${room.battle.tier}`}
>
<button class="button" data-cmd={`/closeandchallenge ${room.battle.farSide.id},${room.battle.tier}`}>
<strong>Rematch</strong><br /><small>(closes this battle)</small>
</button>
</p> :
<p><button class="button" name="cmd" value="/switchsides"><i class="fa fa-random"></i> Switch viewpoint</button></p>}
</p>
) : (
<p>
<button class="button" data-cmd="/switchsides"><i class="fa fa-random"></i> Switch viewpoint</button>
</p>
)}
</div>;
}

View File

@ -16,6 +16,8 @@ import { MiniEdit } from "./miniedit";
import { PSUtils, toID, type ID } from "./battle-dex";
import type { Args } from "./battle-text-parser";
import { PSLoginServer } from "./client-connection";
import type { BattleRoom } from "./panel-battle";
import { BattleChoiceBuilder } from "./battle-choices";
declare const formatText: any; // from js/server/chat-formatter.js
@ -68,21 +70,20 @@ export class ChatRoom extends PSRoom {
this.setUsers(count, usernames);
return;
case 'join': case 'j': case 'J': {
case 'join': case 'j': case 'J':
this.addUser(args[1]);
this.handleJoinLeave("join", args[1], args[0] === "J");
return true;
}
case 'leave': case 'l': case 'L': {
case 'leave': case 'l': case 'L':
this.removeUser(args[1]);
this.handleJoinLeave("leave", args[1], args[0] === "L");
return true;
}
case 'name': case 'n': case 'N':
this.renameUser(args[1], args[2]);
break;
case 'c':
if (`${args[2]} `.startsWith('/challenge ')) {
this.updateChallenge(args[1], args[2].slice(11));
@ -97,9 +98,10 @@ export class ChatRoom extends PSRoom {
super.receiveLine(args);
}
updateTarget(name?: string | null) {
const selfWithGroup = `${PS.user.group || ' '}${PS.user.name}`;
if (this.id === 'dm-') {
this.pmTarget = PS.user.name;
this.setUsers(1, [` ${PS.user.name}`]);
this.pmTarget = selfWithGroup;
this.setUsers(1, [selfWithGroup]);
this.title = `Console`;
} else if (this.id.startsWith('dm-')) {
const id = this.id.slice(3);
@ -111,79 +113,33 @@ export class ChatRoom extends PSRoom {
if (!PS.user.userid) {
this.setUsers(1, [nameWithGroup]);
} else {
this.setUsers(2, [nameWithGroup, ` ${PS.user.name}`]);
this.setUsers(2, [nameWithGroup, selfWithGroup]);
}
this.title = `[DM] ${nameWithGroup.trim()}`;
}
}
/**
* @return true to prevent line from being sent to server
*/
override handleSend(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return false;
const spaceIndex = line.indexOf(' ');
const cmd = spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1);
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
switch (cmd) {
case 'j': case 'join': {
const roomid = /[^a-z0-9-]/.test(target) ? toID(target) as any as RoomID : target as RoomID;
PS.join(roomid);
return true;
} case 'part': case 'leave': {
const roomid = /[^a-z0-9-]/.test(target) ? toID(target) as any as RoomID : target as RoomID;
PS.leave(roomid || this.id);
return true;
} case 'chall': case 'challenge': case 'closeandchallenge': {
override clientCommands = this.parseClientCommands({
'chall,challenge,closeandchallenge'(target, cmd) {
if (target) {
const [targetUser, format] = target.split(',');
PS.join(`challenge-${toID(targetUser)}` as RoomID);
if (cmd === 'closeandchallenge') PS.leave(this.id);
return true;
return;
}
this.openChallenge();
return true;
} case 'cchall': case 'cancelchallenge': {
},
'cchall,cancelchallenge'(target) {
this.cancelChallenge();
return true;
} case 'reject': {
},
'reject'(target) {
this.challenged = null;
this.update(null);
return false;
} case 'ignore': {
let ignore = PS.prefs.ignore || {};
if (!target) return true;
if (toID(target) === PS.user.userid) {
this.receiveLine(['', `You are not able to ignore yourself.`]);
} else if (ignore[toID(target)]) {
this.receiveLine(['', `User '${target}' is already on your ignore list. ` +
`(Moderator messages will not be ignored.)`]);
} else {
ignore[toID(target)] = 1;
this.receiveLine(['', `User '${target}' ignored. (Moderator messages will not be ignored.)`]);
PS.prefs.set("ignore", ignore);
}
return true;
} case 'unignore': {
let ignore = PS.prefs.ignore || {};
if (!target) return true;
if (!ignore[toID(target)]) {
this.receiveLine(['', `User '${target}' isn't on your ignore list.`]);
} else {
ignore[toID(target)] = 0;
this.receiveLine(['', `User '${target}' no longer ignored.`]);
PS.prefs.set("ignore", ignore);
}
return true;
}
case 'clear': {
},
'clear'() {
this.log?.reset();
this.update(null);
return true;
}
case 'rank':
case 'ranking':
case 'rating':
case 'ladder': {
},
'rank,ranking,rating,ladder'(target) {
let arg = target;
if (!arg) {
arg = PS.user.userid;
@ -192,10 +148,10 @@ export class ChatRoom extends PSRoom {
arg += ", " + this.id.split('-')[1];
}
let targets = arg.split(',');
const targets = arg.split(',');
let formatTargeting = false;
let formats: { [key: string]: number } = {};
let gens: { [key: string]: number } = {};
const formats: { [key: string]: number } = {};
const gens: { [key: string]: number } = {};
for (let i = 1, len = targets.length; i < len; i++) {
targets[i] = $.trim(targets[i]);
if (targets[i].length === 4 && targets[i].substr(0, 3) === 'gen') {
@ -209,24 +165,24 @@ export class ChatRoom extends PSRoom {
PSLoginServer.query("ladderget", {
user: targets[0],
}).then(data => {
if (!data || !Array.isArray(data)) return this.receiveLine(['raw', 'Error: corrupted ranking data']);
let buffer = '<div class="ladder"><table><tr><td colspan="9">User: <strong>' + toID(targets[0]) + '</strong></td></tr>';
if (!data || !Array.isArray(data)) return this.add(`|error|Error: corrupted ranking data`);
let buffer = `<div class="ladder"><table><tr><td colspan="9">User: <strong>${toID(targets[0])}</strong></td></tr>`;
if (!data.length) {
buffer += '<tr><td colspan="9"><em>This user has not played any ladder games yet.</em></td></tr>';
buffer += '</table></div>';
return this.receiveLine(['raw', buffer]);
return this.add(`|html|${buffer}`);
}
buffer += '<tr><th>Format</th><th><abbr title="Elo rating">Elo</abbr></th><th><abbr title="user\'s percentage chance of winning a random battle (aka GLIXARE)">GXE</abbr></th><th><abbr title="Glicko-1 rating: rating±deviation">Glicko-1</abbr></th><th>COIL</th><th>W</th><th>L</th><th>Total</th>';
buffer += '<tr><th>Format</th><th><abbr title="Elo rating">Elo</abbr></th><th><abbr title="user\'s percentage chance of winning a random battle (aka GLIXARE)">GXE</abbr></th><th><abbr title="Glicko-1 rating: rating &#177; deviation">Glicko-1</abbr></th><th>COIL</th><th>W</th><th>L</th><th>Total</th>';
let suspect = false;
for (let item of data) {
for (const item of data) {
if ('suspect' in item) suspect = true;
}
if (suspect) buffer += '<th>Suspect reqs possible?</th>';
buffer += '</tr>';
let hiddenFormats = [];
for (let row of data) {
if (!row) return this.receiveLine(['raw', 'Error: corrupted ranking data']);
let formatId = toID(row.formatid);
const hiddenFormats = [];
for (const row of data) {
if (!row) return this.add(`|error|Error: corrupted ranking data`);
const formatId = toID(row.formatid);
if (!formatTargeting ||
formats[formatId] ||
gens[formatId.slice(0, 4)] ||
@ -238,37 +194,36 @@ export class ChatRoom extends PSRoom {
}
// Validate all the numerical data
let values = [row.elo, row.rpr, row.rprd, row.gxe, row.w, row.l, row.t];
for (let value of values) {
if (typeof value !== 'number' && typeof value !== 'string' ||
isNaN(value as number)) return this.receiveLine(['raw', 'Error: corrupted ranking data']);
for (const value of [row.elo, row.rpr, row.rprd, row.gxe, row.w, row.l, row.t]) {
if (typeof value !== 'number' && typeof value !== 'string') {
return this.add(`|error|Error: corrupted ranking data`);
}
}
buffer += `<td> ${BattleLog.escapeFormat(formatId)} </td><td><strong> ${Math.round(row.elo)} </strong></td>'`;
buffer += `<td> ${BattleLog.escapeFormat(formatId)} </td><td><strong>${Math.round(row.elo)}</strong></td>`;
if (row.rprd > 100) {
// High rating deviation. Provisional rating.
buffer += `<td>&ndash;</td>`;
buffer += `<td><span><em> ${Math.round(row.rpr)} <small> &#177; ${Math.round(row.rprd)} </small></em> <small>(provisional)</small></span></td>`;
buffer += `<td><span style="color:#888"><em>${Math.round(row.rpr)} <small> &#177; ${Math.round(row.rprd)} </small></em> <small>(provisional)</small></span></td>`;
} else {
let gxe = Math.round(row.gxe * 10);
buffer += `<td> ${Math.floor(gxe / 10)} <small> ${(gxe % 10)}%</small></td>`;
buffer += `<td><em> ${Math.round(row.rpr)} <small> &#177; ${Math.round(row.rprd)}</small></em></td>`;
buffer += `<td>${Math.trunc(row.gxe)}<small>.${row.gxe.toFixed(1).slice(-1)}%</small></td>`;
buffer += `<td><em>${Math.round(row.rpr)} <small> &#177; ${Math.round(row.rprd)}</small></em></td>`;
}
let N = parseInt(row.w, 10) + parseInt(row.l, 10) + parseInt(row.t, 10);
let COIL_B = undefined;
const N = parseInt(row.w, 10) + parseInt(row.l, 10) + parseInt(row.t, 10);
const COIL_B = undefined;
// Uncomment this after LadderRoom logic is implemented
// COIL_B = LadderRoom?.COIL_B[formatId];
if (COIL_B) {
buffer += `<td> ${Math.round(40.0 * parseFloat(row.gxe) * 2.0 ** (-COIL_B / N))} </td>`;
buffer += `<td>${Math.round(40.0 * parseFloat(row.gxe) * 2.0 ** (-COIL_B / N))}</td>`;
} else {
buffer += '<td>--</td>';
buffer += '<td>&mdash;</td>';
}
buffer += `<td> ${row.w} </td><td> ${row.l} </td><td> ${N} </td>`;
if (suspect) {
if (typeof row.suspect === 'undefined') {
buffer += '<td>--</td>';
buffer += '<td>&mdash;</td>';
} else {
buffer += '<td>';
buffer += (row.suspect ? "Yes" : "No");
@ -279,27 +234,94 @@ export class ChatRoom extends PSRoom {
}
if (hiddenFormats.length) {
if (hiddenFormats.length === data.length) {
buffer += '<tr class="no-matches"><td colspan="8"><em>This user has not played any ladder games that match "' + BattleLog.escapeHTML(Object.keys(gens).concat(Object.keys(formats)).join(', ')) + '".</em></td></tr>';
const formatsText = Object.keys(gens).concat(Object.keys(formats)).join(', ');
buffer += `<tr class="no-matches"><td colspan="8">` +
BattleLog.html`<em>This user has not played any ladder games that match ${formatsText}.</em></td></tr>`;
}
buffer += `<tr><td colspan="8"><button name="showOtherFormats"> ${hiddenFormats.slice(0, 3).join(', ') + (hiddenFormats.length > 3 ? ' and ' + String(hiddenFormats.length - 3) + ' other formats' : '')} not shown</button></td></tr>'`;
const otherFormats = hiddenFormats.slice(0, 3).join(', ') +
(hiddenFormats.length > 3 ? ` and ${hiddenFormats.length - 3} other formats` : '');
buffer += `<tr><td colspan="8"><button name="showOtherFormats">` +
BattleLog.html`${otherFormats} not shown</button></td></tr>`;
}
let userid = toID(targets[0]);
let registered = PS.user.registered;
if (registered && PS.user.userid === userid) {
buffer += '<tr><td colspan="8" style="text-align:right"><a href="//' + PS.routes.users + '/' + userid + '">Reset W/L</a></tr></td>';
buffer += `<tr><td colspan="8" style="text-align:right"><a href="//${PS.routes.users}/${userid}">Reset W/L</a></tr></td>`;
}
buffer += '</table></div>';
this.receiveLine(['raw', buffer]);
this.add(`|html|${buffer}`);
});
return true;
}
},
}
return super.handleSend(line);
}
// battle-specific commands
// ------------------------
'play'() {
if (!this.battle) return this.add('|error|You are not in a battle');
if (this.battle.atQueueEnd) {
this.battle.reset();
}
this.battle.play();
this.update(null);
},
'pause'() {
if (!this.battle) return this.add('|error|You are not in a battle');
this.battle.pause();
this.update(null);
},
'ffto,fastfowardto'(target) {
if (!this.battle) return this.add('|error|You are not in a battle');
let turnNum = Number(target);
if (target.startsWith('+') || turnNum < 0) {
turnNum += this.battle.turn;
if (turnNum < 0) turnNum = 0;
} else if (target === 'end') {
turnNum = Infinity;
}
if (isNaN(turnNum)) {
this.receiveLine([`error`, `/ffto - Invalid turn number: ${target}`]);
return;
}
this.battle.seekTurn(turnNum);
this.update(null);
},
'switchsides'() {
if (!this.battle) return this.add('|error|You are not in a battle');
this.battle.switchViewpoint();
},
'cancel,undo'() {
if (!this.battle) return this.send('/cancelchallenge');
const room = this as any as BattleRoom;
if (!room.choices || !room.request) {
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
return;
}
if (room.choices.isDone() || room.choices.isEmpty()) {
this.sendDirect('/undo');
}
room.choices = new BattleChoiceBuilder(room.request);
this.update(null);
},
'move,switch,team,pass,shift,choose'(target, cmd) {
if (!this.battle) return this.add('|error|You are not in a battle');
const room = this as any as BattleRoom;
if (!room.choices) {
this.receiveLine([`error`, `/choose - You are not a player in this battle`]);
return;
}
if (cmd !== 'choose') target = `${cmd} ${target}`;
const possibleError = room.choices.addChoice(target);
if (possibleError) {
this.receiveLine([`error`, possibleError]);
return;
}
if (room.choices.isDone()) this.sendDirect(`/choose ${room.choices.toString()}`);
this.update(null);
},
});
openChallenge() {
if (!this.pmTarget) {
this.receiveLine([`error`, `Can only be used in a PM.`]);
this.add(`|error|Can only be used in a PM.`);
return;
}
this.challengeMenuOpen = true;
@ -307,7 +329,7 @@ export class ChatRoom extends PSRoom {
}
cancelChallenge() {
if (!this.pmTarget) {
this.receiveLine([`error`, `Can only be used in a PM.`]);
this.add(`|error|Can only be used in a PM.`);
return;
}
if (this.challenging) {
@ -381,18 +403,22 @@ export class ChatRoom extends PSRoom {
this.update(null);
}
addUser(username: string) {
if (!username) return;
const userid = toID(username);
if (!(userid in this.users)) this.userCount++;
this.users[userid] = username;
this.update(null);
}
removeUser(username: string, noUpdate?: boolean) {
if (!username) return;
const userid = toID(username);
if (userid in this.users) {
this.userCount--;
delete this.users[userid];
if (!noUpdate) this.update(null);
}
if (!noUpdate) this.update(null);
}
renameUser(username: string, oldUsername: string) {
this.removeUser(oldUsername, true);
@ -401,43 +427,37 @@ export class ChatRoom extends PSRoom {
}
handleJoinLeave(action: 'join' | 'leave', name: string, silent: boolean) {
if (!this.joinLeave) {
this.joinLeave = {
join: [],
leave: [],
messageId: 'joinleave-' + String(Date.now()),
};
}
if (!action) return;
if (action === 'join') {
this.addUser(name);
} else if (action === 'leave') {
this.removeUser(name);
}
let allShowjoins = PS.prefs.showjoins || {};
let showjoins = allShowjoins[PS.server.id];
if (silent && (!showjoins || (!showjoins['global'] && !showjoins[this.id]) || showjoins[this.id] === 0)) {
return;
}
let formattedUser = name;
if (action === 'join' && this.joinLeave['leave'].includes(formattedUser)) {
this.joinLeave['leave'].splice(this.joinLeave['leave'].indexOf(formattedUser), 1);
const showjoins = PS.prefs.showjoins?.[PS.server.id];
if (!(showjoins?.[this.id] ?? showjoins?.['global'] ?? !silent)) return;
this.joinLeave ||= {
join: [],
leave: [],
messageId: `joinleave-${Date.now()}`,
};
if (action === 'join' && this.joinLeave['leave'].includes(name)) {
this.joinLeave['leave'].splice(this.joinLeave['leave'].indexOf(name), 1);
} else if (action === 'leave' && this.joinLeave['join'].includes(name)) {
this.joinLeave['join'].splice(this.joinLeave['join'].indexOf(name), 1);
} else {
this.joinLeave[action].push(formattedUser);
this.joinLeave[action].push(name);
}
let message = '';
if (this.joinLeave['join'].length) {
message += this.formatJoinLeave(this.joinLeave['join'], 'joined');
}
if (this.joinLeave['leave'].length) {
if (this.joinLeave['join'].length) message += '; ';
message += this.formatJoinLeave(this.joinLeave['leave'], 'left') + '<br />';
}
this.receiveLine(['uhtml', this.joinLeave.messageId, `<small style="color: #555555"> ${message} </small>`]);
let message = this.formatJoinLeave(this.joinLeave['join'], 'joined');
if (this.joinLeave['join'].length && this.joinLeave['leave'].length) message += '; ';
message += this.formatJoinLeave(this.joinLeave['leave'], 'left');
this.add(`|uhtml|${this.joinLeave.messageId}|<small style="color: #555555">${message}</small>`);
}
formatJoinLeave(preList: string[], action: 'joined' | 'left') {
if (!preList.length) return '';
let message = '';
let list: string[] = [];
let named: { [key: string]: boolean } = {};
@ -461,7 +481,7 @@ export class ChatRoom extends PSRoom {
}
message += BattleLog.escapeHTML(list[j]);
}
return message + ' ' + action;
return `${message} ${action}`;
}
override destroy() {

View File

@ -189,7 +189,7 @@ class LadderFormatPanel extends PSRoomPanel<LadderFormatRoom> {
{row.username}
</span></td>
<td style={{ textAlign: 'center' }}><strong>{row.elo.toFixed(0)}</strong></td>
<td style={{ textAlign: 'center' }}>{row.gxe.toFixed(1)}<small>%</small></td>
<td style={{ textAlign: 'center' }}>{Math.trunc(row.gxe)}<small>.{row.gxe.toFixed(1).slice(-1)}%</small></td>
<td style={{ textAlign: 'center' }}><em>{row.rpr.toFixed(0)}<small> &plusmn; {row.rprd.toFixed(0)}</small></em></td>
{showCOIL && <td style={{ textAlign: 'center' }}>{row.coil?.toFixed(0)}</td>}
</tr>)}

View File

@ -297,7 +297,7 @@ export class MainMenuRoom extends PSRoom {
}
PS.teams.update('format');
}
handlePM(user1: string, user2: string, message: string) {
handlePM(user1: string, user2: string, message?: string) {
const userid1 = toID(user1);
const userid2 = toID(user2);
const pmTarget = PS.user.userid === userid1 ? user2 : user1;
@ -314,7 +314,7 @@ export class MainMenuRoom extends PSRoom {
} else {
room.updateTarget(pmTarget);
}
room.receiveLine([`c`, user1, message]);
if (message) room.receiveLine([`c`, user1, message]);
PS.update();
}
handleQueryResponse(id: ID, response: any) {

View File

@ -23,37 +23,25 @@ class TeambuilderRoom extends PSRoom {
curFolder = '';
curFolderKeep = '';
/**
* @return true to prevent line from being sent to server
*/
override handleSend(line: string) {
if (!line.startsWith('/') || line.startsWith('//')) return false;
const spaceIndex = line.indexOf(' ');
const cmd = spaceIndex >= 0 ? line.slice(1, spaceIndex) : line.slice(1);
const target = spaceIndex >= 0 ? line.slice(spaceIndex + 1) : '';
switch (cmd) {
case 'newteam': {
override clientCommands = this.parseClientCommands({
'newteam'(target) {
if (target === 'bottom') {
PS.teams.push(this.createTeam());
} else {
PS.teams.unshift(this.createTeam());
}
this.update(null);
return true;
} case 'deleteteam': {
},
'deleteteam'(target) {
const team = PS.teams.byKey[target];
if (team) PS.teams.delete(team);
this.update(null);
return true;
} case 'undeleteteam': {
},
'undeleteteam'() {
PS.teams.undelete();
this.update(null);
return true;
}
}
return super.handleSend(line);
}
},
});
override sendDirect(msg: string): void {
PS.alert(`Unrecognized command: ${msg}`);
}

View File

@ -601,6 +601,9 @@ input[type=range]:active::-webkit-slider-thumb {
.chat small {
font-weight: normal;
font-size: 8pt;
}
.chat strong small, /* <- for old client */
.chat .groupsymbol {
color: #888888;
}
.chat.timer {