Preact: Support remote teams (#2390)
Some checks are pending
Node.js CI / build (22.x) (push) Waiting to run

---------

Co-authored-by: Guangcong Luo <guangcongluo@gmail.com>
This commit is contained in:
Mia 2025-05-07 22:34:08 -05:00 committed by GitHub
parent 96fb9a314a
commit ba9f3a529e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 231 additions and 27 deletions

View File

@ -105,7 +105,7 @@ export const PSLoginServer = new class {
() => null
);
}
query(act: string, data: PostData): Promise<{ [k: string]: any } | null> {
query(act: string, data: PostData = {}): Promise<{ [k: string]: any } | null> {
return this.rawQuery(act, data).then(
res => res ? JSON.parse(res.slice(1)) : null
).catch(

View File

@ -17,6 +17,7 @@ import type { MainMenuRoom } from './panel-mainmenu';
import { Dex, toID, type ID } from './battle-dex';
import { BattleTextParser, type Args } from './battle-text-parser';
import type { BattleRoom } from './panel-battle';
import { PSTeambuilder } from './panel-teamdropdown';
declare const BattleTextAFD: any;
declare const BattleTextNotAFD: any;
@ -116,6 +117,7 @@ class PSPrefs extends PSStreamModel<string | null> {
effectvolume = 50;
musicvolume = 50;
notifvolume = 50;
uploadprivacy = false;
afd: boolean | 'sprites' = false;
@ -267,11 +269,29 @@ class PSPrefs extends PSStreamModel<string | null> {
export interface Team {
name: string;
format: ID;
packedTeam: string;
folder: string;
/** note that this can be wrong if .uploaded?.loaded === false */
packedTeam: string;
/** The icon cache must be cleared (to `null`) whenever `packedTeam` is modified */
iconCache: preact.ComponentChildren;
key: string;
/** `uploaded` will only exist if you're logged into the correct account. otherwise teamid is still tracked */
teamid?: number;
uploaded?: {
teamid: number,
notLoaded: boolean | Promise<void>,
/** password, if private. null = public, undefined = unknown, not loaded yet */
private?: string | null,
};
}
interface UploadedTeam {
name: string;
teamid: number;
format: ID;
/** comma-separated list of species, for generating the icon cache */
team: string;
/** password, if private */
private?: string | null;
}
if (!window.BattleFormats) window.BattleFormats = {};
@ -284,6 +304,7 @@ class PSTeams extends PSStreamModel<'team' | 'format'> {
list: Team[] = [];
byKey: { [key: string]: Team | undefined } = {};
deletedTeams: [Team, number][] = [];
uploading: Team | null = null;
constructor() {
super();
try {
@ -356,7 +377,10 @@ class PSTeams extends PSStreamModel<'team' | 'format'> {
}
packAll(teams: Team[]) {
return teams.map(team => (
(team.format ? `${team.format}]` : ``) + (team.folder ? `${team.folder}/` : ``) + team.name + `|` + team.packedTeam
(team.teamid ? `${team.teamid}[` : '') +
(team.format ? `${team.format}]` : ``) +
(team.folder ? `${team.folder}/` : ``) +
team.name + `|` + team.packedTeam
)).join('\n');
}
save() {
@ -366,15 +390,21 @@ class PSTeams extends PSStreamModel<'team' | 'format'> {
this.update('team');
}
unpackLine(line: string): Team | null {
let pipeIndex = line.indexOf('|');
const pipeIndex = line.indexOf('|');
if (pipeIndex < 0) return null;
let bracketIndex = line.indexOf(']');
if (bracketIndex > pipeIndex) bracketIndex = -1;
let leftBracketIndex = line.indexOf('[');
if (leftBracketIndex < 0) leftBracketIndex = 0;
const isBox = line.slice(0, bracketIndex).endsWith('-box');
let slashIndex = line.lastIndexOf('/', pipeIndex);
if (slashIndex < 0) slashIndex = bracketIndex; // line.slice(slashIndex + 1, pipeIndex) will be ''
let format = bracketIndex > 0 ? line.slice(0, bracketIndex) : 'gen7';
let format = bracketIndex > 0 ? line.slice(
(leftBracketIndex ? leftBracketIndex + 1 : 0), isBox ? bracketIndex - 4 : bracketIndex
) : 'gen9';
if (!format.startsWith('gen')) format = 'gen6' + format;
const name = line.slice(slashIndex + 1, pipeIndex);
const teamid = leftBracketIndex > 0 ? Number(line.slice(0, leftBracketIndex)) : undefined;
return {
name,
format: format as ID,
@ -382,8 +412,122 @@ class PSTeams extends PSStreamModel<'team' | 'format'> {
packedTeam: line.slice(pipeIndex + 1),
iconCache: null,
key: '',
teamid,
};
}
loadRemoteTeams() {
PSLoginServer.query('getteams').then(data => {
if (!data) return;
if (data.actionerror) {
return PS.alert('Error loading uploaded teams: ' + data.actionerror);
}
const teams: { [key: string]: UploadedTeam } = {};
for (const team of data.teams) {
teams[team.teamid] = team;
}
// find exact teamid matches
for (const localTeam of this.list) {
if (localTeam.teamid) {
const team = teams[localTeam.teamid];
if (!team) {
continue;
}
const compare = this.compareTeams(team, localTeam);
if (compare !== true) {
if (!localTeam.name.endsWith(' (local version)')) localTeam.name += ' (local version)';
continue;
}
localTeam.uploaded = {
teamid: team.teamid,
notLoaded: true,
private: team.private,
};
delete teams[localTeam.teamid];
}
}
// do best-guess matches for teams that don't have a local team with matching teamid
for (const team of Object.values(teams)) {
let matched = false;
for (const localTeam of this.list) {
if (localTeam.teamid) continue;
const compare = this.compareTeams(team, localTeam);
if (compare === 'rename') {
if (!localTeam.name.endsWith(' (local version)')) localTeam.name += ' (local version)';
} else if (compare) {
// prioritize locally saved teams over remote
// as so to not overwrite changes
matched = true;
localTeam.teamid = team.teamid;
localTeam.uploaded = {
teamid: team.teamid,
notLoaded: true,
private: team.private,
};
break;
}
}
if (!matched) {
const mons = team.team.split(',').map((m: string) => ({ species: m, moves: [] }));
const newTeam: Team = {
name: team.name,
format: team.format,
folder: '',
packedTeam: PSTeambuilder.packTeam(mons),
iconCache: null,
key: this.getKey(team.name),
uploaded: {
teamid: team.teamid,
notLoaded: true,
private: team.private,
},
};
this.push(newTeam);
}
}
});
}
loadTeam(team: Team | undefined | null, ifNeeded: true): void | Promise<void>;
loadTeam(team: Team | undefined | null): Promise<void>;
loadTeam(team: Team | undefined | null, ifNeeded?: boolean): void | Promise<void> {
if (!team) return ifNeeded ? undefined : Promise.resolve();
if (!team.uploaded?.notLoaded) return ifNeeded ? undefined : Promise.resolve();
if (team.uploaded.notLoaded !== true) return team.uploaded.notLoaded;
return (team.uploaded.notLoaded = PSLoginServer.query('getteam', {
teamid: team.uploaded.teamid,
}).then(data => {
if (!team.uploaded) return;
if (!data?.team) {
PS.alert(`Failed to load team: ${data?.actionerror || "Error unknown. Try again later."}`);
return;
}
team.uploaded.notLoaded = false;
team.packedTeam = data.team;
PS.teams.save();
}));
}
compareTeams(serverTeam: UploadedTeam, localTeam: Team) {
// TODO: decide if we want this
// if (serverTeam.teamid === localTeam.teamid && localTeam.teamid) return true;
// if titles match exactly and mons are the same, assume they're the same team
// if they don't match, it might be edited, but we'll go ahead and add it to the user's
// teambuilder since they may want that old version around. just go ahead and edit the name
let sanitize = (name: string) => (name || "").replace(/\s+\(server version\)/g, '').trim();
const nameMatches = sanitize(serverTeam.name) === sanitize(localTeam.name);
if (!(nameMatches && serverTeam.format === localTeam.format)) {
return false;
}
// if it's been edited since, invalidate the team id on this one (count it as new)
// and load from server
const mons = serverTeam.team.split(',').map(toID).sort().join(',');
const otherMons = PSTeambuilder.packedTeamSpecies(localTeam.packedTeam).map(toID).sort().join(',');
if (mons !== otherMons) return 'rename';
return true;
}
}
/**********************************************************************

View File

@ -128,6 +128,7 @@ export class MainMenuRoom extends PSRoom {
const named = namedCode === '1';
if (named) PS.user.initializing = false;
PS.user.setName(fullName, named, avatar);
PS.teams.loadRemoteTeams();
return;
} case 'updatechallenges': {
const [, challengesBuf] = args;
@ -388,6 +389,14 @@ export class MainMenuRoom extends PSRoom {
}
}
break;
case 'teamupload':
if (PS.teams.uploading) {
PS.teams.uploading.uploaded = {
teamid: response.teamid,
notLoaded: false,
private: response.private,
};
}
}
}
}
@ -727,22 +736,20 @@ export class TeamForm extends preact.Component<{
changeFormat = (ev: Event) => {
this.setState({ format: (ev.target as HTMLButtonElement).value });
};
submit = (ev: Event) => {
submit = (ev: Event, validate?: 'validate') => {
ev.preventDefault();
const format = this.state.format;
const teamKey = this.base!.querySelector<HTMLButtonElement>('button[name=team]')!.value;
const team = teamKey ? PS.teams.byKey[teamKey] : undefined;
this.props.onSubmit?.(ev, format, team);
PS.teams.loadTeam(team).then(() => {
(validate === 'validate' ? this.props.onValidate : this.props.onSubmit)?.(ev, format, team);
});
};
handleClick = (ev: Event) => {
let target = ev.target as HTMLButtonElement | null;
while (target && target !== this.base) {
if (target.tagName === 'BUTTON' && target.name === 'validate') {
ev.preventDefault();
const format = this.state.format;
const teamKey = this.base!.querySelector<HTMLButtonElement>('button[name=team]')!.value;
const team = teamKey ? PS.teams.byKey[teamKey] : undefined;
this.props.onSubmit?.(ev, format, team);
this.submit(ev, 'validate');
return;
}
target = target.parentNode as HTMLButtonElement | null;

View File

@ -17,17 +17,25 @@ class TeamRoom extends PSRoom {
/** Doesn't _literally_ always exist, but does in basically all code
* and constantly checking for its existence is legitimately annoying... */
team!: Team;
uploaded = false;
constructor(options: RoomOptions) {
super(options);
const team = PS.teams.byKey[this.id.slice(5)] || null;
this.team = team!;
this.title = `[Team] ${this.team?.name || 'Error'}`;
if (team) this.setFormat(team.format);
this.uploaded = !!team?.uploaded;
this.load();
}
setFormat(format: string) {
const team = this.team;
team.format = toID(format);
}
load() {
PS.teams.loadTeam(this.team, true)?.then(() => {
this.update(null);
});
}
save() {
PS.teams.save();
const title = `[Team] ${this.team?.name || 'Team'}`;
@ -81,6 +89,35 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
room.team.name = textbox.value.trim();
room.save();
};
uploadTeam = (ev: Event) => {
const room = this.props.room;
const team = PS.teams.byKey[room.id.slice(5)];
if (!team) return;
const cmd = team.uploaded ? 'update' : 'save';
// teamName, formatid, rawPrivacy, rawTeam
const buf = [];
if (team.uploaded) {
buf.push(team.uploaded.teamid);
} else if (team.teamid) {
return PS.alert(`This team is for a different account. Please log into the correct account to update it.`);
}
buf.push(team.name, team.format, PS.prefs.uploadprivacy ? 1 : 0);
const exported = team.packedTeam;
if (!exported) return PS.alert(`Add a Pokemon to your team before uploading it.`);
buf.push(exported);
PS.teams.uploading = team;
PS.send(`|/teams ${cmd} ${buf.join(', ')}`);
room.uploaded = true;
this.forceUpdate();
};
changePrivacyPref = (ev: Event) => {
this.props.room.uploaded = false;
PS.prefs.uploadprivacy = !(ev.currentTarget as HTMLInputElement).checked;
PS.prefs.save();
};
handleChangeFormat = (ev: Event) => {
const dropdown = ev.currentTarget as HTMLButtonElement;
const room = this.props.room;
@ -91,6 +128,8 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
};
save = () => {
this.props.room.save();
this.props.room.uploaded = false;
this.forceUpdate();
};
override render() {
@ -137,6 +176,23 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
</p>
</div>
)}
<p>
<label class="checkbox" style="display: inline-block">
<input
name="teamprivacy" checked={!PS.prefs.uploadprivacy}
type="checkbox" onChange={this.changePrivacyPref}
/> Public
</label>
{room.uploaded ? (
<button class="button exportbutton" disabled>
<i class="fa fa-check"></i> Saved to your account
</button>
) : (
<button class="button exportbutton" onClick={this.uploadTeam}>
<i class="fa fa-upload"></i> Save to my account (use on other devices)
</button>
)}
</p>
</div></PSPanelWrapper>;
}
}

View File

@ -561,7 +561,7 @@ export class PSTeambuilder {
return teams;
}
static packedTeamNames(buf: string) {
static packedTeamSpecies(buf: string) {
if (!buf) return [];
const team = [];
@ -604,25 +604,21 @@ export function TeamBox(props: { team: Team | null, noLink?: boolean, button?: b
const team = props.team;
let contents;
if (team) {
let icons = team.iconCache;
if (!icons) {
if (!team.packedTeam) {
icons = <em>(empty team)</em>;
} else {
icons = PSTeambuilder.packedTeamNames(team.packedTeam).map(pokemon =>
// can't use <PSIcon>, weird interaction with iconCache
// don't try this at home; I'm a trained professional
PSIcon({ pokemon })
);
}
team.iconCache = icons;
}
team.iconCache ||= team.packedTeam ? (
PSTeambuilder.packedTeamSpecies(team.packedTeam).map(
// can't use <PSIcon>, weird interaction with iconCache
// don't try this at home; I'm a trained professional
pokemon => PSIcon({ pokemon })
)
) : (
<em>(empty team)</em>
);
let format = team.format as string;
if (format.startsWith('gen8')) format = format.slice(4);
format = (format ? `[${format}] ` : ``) + (team.folder ? `${team.folder}/` : ``);
contents = [
<strong>{format && <span>{format}</span>}{team.name}</strong>,
<small>{icons}</small>,
<small>{team.iconCache}</small>,
];
} else {
contents = [
@ -680,6 +676,7 @@ class TeamDropdownPanel extends PSRoomPanel {
}
if (!target) return;
PS.teams.loadTeam(PS.teams.byKey[target.value], true);
this.chooseParentValue(target.value);
};
override render() {