Preact minor updates batch 19
Some checks are pending
Node.js CI / build (22.x) (push) Waiting to run

Battles
- Fix move types in battle controls
- Warn about Gen 1 Substitute self-KOs in tooltips
- Hide controls until animations are finished

Teambuilder
- Implement uploading teams
- Fix adding pokemon in teambuilder text mode
- Uploaded team management
- Automatic Atk/Spe IVs
  - IV spread chooser also has an auto button

Trivial
- Constrain teambuilder width better
This commit is contained in:
Guangcong Luo 2025-05-10 01:12:03 +00:00
parent f77b3fd7dc
commit bfb2813c72
10 changed files with 463 additions and 79 deletions

View File

@ -1232,6 +1232,7 @@ export class Move implements Effect {
readonly pressureTarget: MoveTarget;
readonly flags: Readonly<MoveFlags>;
readonly critRatio: number;
readonly damage?: number | 'level' | false | null;
readonly desc: string;
readonly shortDesc: string;
@ -1273,6 +1274,7 @@ export class Move implements Effect {
this.pressureTarget = data.pressureTarget || this.target;
this.flags = data.flags || {};
this.critRatio = data.critRatio === 0 ? 0 : (data.critRatio || 1);
this.damage = data.damage;
// TODO: move to text.js
this.desc = data.desc;

View File

@ -41,11 +41,10 @@ class TeamEditorState extends PSModel {
formeLegality: 'normal' | 'hackmons' | 'custom' = 'normal';
abilityLegality: 'normal' | 'hackmons' = 'normal';
defaultLevel = 100;
readonly: boolean;
constructor(team: Team, readonly = false) {
readonly = false;
constructor(team: Team) {
super();
this.team = team;
this.readonly = readonly;
this.sets = PSTeambuilder.unpackTeam(team.packedTeam);
this.setFormat(team.format);
window.search = this.search;
@ -213,7 +212,7 @@ class TeamEditorState extends PSModel {
this.searchIndex--;
}
}
getResultValue(result: SearchRow) {
getResultValue(result: SearchRow): string {
switch (result[0]) {
case 'pokemon':
return this.dex.species.get(result[1]).name;
@ -231,17 +230,19 @@ class TeamEditorState extends PSModel {
return result[1];
}
}
canAdd() {
canAdd(): boolean {
return this.sets.length < 6 || this.team.isBox;
}
getHPType(set: Dex.PokemonSet): Dex.TypeName {
if (set.hpType) return set.hpType as Dex.TypeName;
if (!set.ivs) return this.getHPMove(set) || 'Dark';
const hpMove = set.ivs ? null : this.getHPMove(set);
if (hpMove) return hpMove;
const hpTypes = [
'Fighting', 'Flying', 'Poison', 'Ground', 'Rock', 'Bug', 'Ghost', 'Steel', 'Fire', 'Water', 'Grass', 'Electric', 'Psychic', 'Ice', 'Dragon', 'Dark',
] as const;
if (this.gen <= 2) {
if (!set.ivs) return 'Dark';
// const hpDV = Math.floor(set.ivs.hp / 2);
const atkDV = Math.floor(set.ivs.atk / 2);
const defDV = Math.floor(set.ivs.def / 2);
@ -254,19 +255,20 @@ class TeamEditorState extends PSModel {
// }
return hpTypes[4 * (atkDV % 4) + (defDV % 4)];
} else {
const ivs = set.ivs || this.defaultIVs(set);
let hpTypeX = 0;
let i = 1;
// n.b. this is not our usual order (Spe and SpD are flipped)
const statOrder = ['hp', 'atk', 'def', 'spe', 'spa', 'spd'] as const;
for (const s of statOrder) {
if (set.ivs[s] === undefined) set.ivs[s] = 31;
hpTypeX += i * (set.ivs[s] % 2);
if (ivs[s] === undefined) ivs[s] = 31;
hpTypeX += i * (ivs[s] % 2);
i *= 2;
}
return hpTypes[Math.floor(hpTypeX * 15 / 63)];
}
};
hpTypeMatters(set: Dex.PokemonSet) {
hpTypeMatters(set: Dex.PokemonSet): boolean {
if (this.gen < 2) return false;
if (this.gen > 7) return false;
for (const move of set.moves) {
@ -288,6 +290,101 @@ class TeamEditorState extends PSModel {
}
return null;
}
getIVs(set: Dex.PokemonSet) {
const ivs = this.defaultIVs(set);
if (set.ivs) Object.assign(ivs, set.ivs);
return ivs;
}
defaultIVs(set: Dex.PokemonSet, noGuess = !!set.ivs): Record<Dex.StatName, number> {
const useIVs = this.gen > 2;
const defaultIVs = { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
if (!useIVs) {
for (const stat of Dex.statNames) defaultIVs[stat] = 15;
}
if (noGuess) return defaultIVs;
const hpType = this.getHPMove(set);
const hpModulo = (useIVs ? 2 : 4);
const { minAtk, minSpe } = this.prefersMinStats(set);
if (minAtk) defaultIVs['atk'] = 0;
if (minSpe) defaultIVs['spe'] = 0;
if (!useIVs) {
const hpDVs = hpType ? this.dex.types.get(hpType).HPdvs : null;
if (hpDVs) {
for (const stat in hpDVs) defaultIVs[stat as Dex.StatName] = hpDVs[stat as Dex.StatName]!;
}
} else {
const hpIVs = hpType ? this.dex.types.get(hpType).HPivs : null;
if (hpIVs) {
if (this.canHyperTrain(set)) {
if (minSpe) defaultIVs['spe'] = hpIVs['spe'] ?? 31;
if (minAtk) defaultIVs['atk'] = hpIVs['atk'] ?? 31;
} else {
for (const stat in hpIVs) defaultIVs[stat as Dex.StatName] = hpIVs[stat as Dex.StatName]!;
}
}
}
if (hpType) {
if (minSpe) defaultIVs['spe'] %= hpModulo;
if (minAtk) defaultIVs['atk'] %= hpModulo;
}
if (minAtk && useIVs) {
// min Atk
if (['Gouging Fire', 'Iron Boulder', 'Iron Crown', 'Raging Bolt'].includes(set.species)) {
// only available with 20 Atk IVs
defaultIVs['atk'] = 20;
} else if (set.species.startsWith('Terapagos')) {
// only available with 15 Atk IVs
defaultIVs['atk'] = 15;
}
}
return defaultIVs;
}
defaultHappiness(set: Dex.PokemonSet) {
if (set.moves.includes('Return')) return 255;
if (set.moves.includes('Frustration')) return 0;
return undefined;
}
prefersMinStats(set: Dex.PokemonSet) {
let minSpe = !set.evs?.spe && set.moves.includes('Gyro Ball');
let minAtk = !set.evs?.atk;
// only available through an event with 31 Spe IVs
if (set.species.startsWith('Terapagos')) minSpe = false;
if (this.format === 'gen7hiddentype') return { minAtk, minSpe };
if (this.format.includes('1v1')) return { minAtk, minSpe };
// only available through an event with 31 Atk IVs
if (set.ability === 'Battle Bond' || ['Koraidon', 'Miraidon'].includes(set.species)) {
minAtk = false;
return { minAtk, minSpe };
}
if (!set.moves.length) minAtk = false;
for (const moveName of set.moves) {
if (!moveName) continue;
const move = this.dex.moves.get(moveName);
if (move.id === 'transform') {
const hasMoveBesidesTransform = set.moves.length > 1;
if (!hasMoveBesidesTransform) minAtk = false;
} else if (
move.category === 'Physical' && !move.damage && !move.ohko &&
!['foulplay', 'endeavor', 'counter', 'bodypress', 'seismictoss', 'bide', 'metalburst', 'superfang'].includes(move.id) &&
!(this.gen < 8 && move.id === 'rapidspin')
) {
minAtk = false;
} else if (
['metronome', 'assist', 'copycat', 'mefirst', 'photongeyser', 'shellsidearm', 'terablast'].includes(move.id) ||
(this.gen === 5 && move.id === 'naturepower')
) {
minAtk = false;
}
}
return { minAtk, minSpe };
}
getNickname(set: Dex.PokemonSet) {
return set.name || this.dex.species.get(set.species).baseSpecies || '';
}
@ -336,7 +433,7 @@ class TeamEditorState extends PSModel {
return null;
}
}
getStat(stat: StatName, set: Dex.PokemonSet, evOverride?: number, natureOverride?: number) {
getStat(stat: StatName, set: Dex.PokemonSet, ivOverride: number, evOverride?: number, natureOverride?: number) {
const team = this.team;
const supportsEVs = !team.format.includes('letsgo');
@ -350,8 +447,7 @@ class TeamEditorState extends PSModel {
const level = set.level || this.defaultLevel;
const baseStat = species.baseStats[stat];
let iv = set.ivs?.[stat] ?? 31;
if (this.gen <= 2) iv &= 30;
const iv = ivOverride;
const ev = evOverride ?? set.evs?.[stat] ?? (this.gen > 2 ? 0 : 252);
if (stat === 'hp') {
@ -553,7 +649,8 @@ export class TeamEditor extends preact.Component<{
</details>;
}
override render() {
this.editor ||= new TeamEditorState(this.props.team, this.props.readonly);
this.editor ||= new TeamEditorState(this.props.team);
this.editor.readonly = !!this.props.readonly;
this.editor.narrow = this.props.narrow ?? document.body.offsetWidth < 500;
if (this.props.team.format !== this.editor.format) {
this.editor.setFormat(this.props.team.format);
@ -619,19 +716,27 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?:
}
input = () => this.updateText();
keyUp = () => this.updateText(true);
click = (ev: MouseEvent | KeyboardEvent) => {
if (ev.altKey || ev.ctrlKey || ev.metaKey) return;
contextMenu = (ev: MouseEvent) => {
if (!ev.shiftKey) {
if (this.closeMenu() || this.openInnerFocus()) {
ev.preventDefault();
ev.stopImmediatePropagation();
}
}
};
openInnerFocus() {
const oldRange = this.selection?.lineRange;
this.updateText(true, true);
if (this.selection) {
// this shouldn't actually update anything, so the reference comparison is enough
if (this.selection.lineRange === oldRange) return;
if (this.selection.lineRange === oldRange) return !!this.innerFocus;
if (this.textbox.selectionStart === this.textbox.selectionEnd) {
const range = this.getSelectionTypeRange();
if (range) this.textbox.setSelectionRange(range[0], range[1]);
}
}
};
return !!this.innerFocus;
}
keyDown = (ev: KeyboardEvent) => {
const editor = this.editor;
switch (ev.keyCode) {
@ -679,6 +784,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?:
case 9: // tab
case 13: // enter
if (ev.keyCode === 13 && ev.shiftKey) return;
if (ev.altKey || ev.metaKey) return;
if (!this.innerFocus) {
if (
this.textbox.selectionStart === this.textbox.value.length &&
@ -686,7 +792,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?:
) {
this.addPokemon();
} else {
this.click(ev);
this.openInnerFocus();
}
ev.stopImmediatePropagation();
ev.preventDefault();
@ -1040,8 +1146,14 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?:
}
}
getSetRange(index: number) {
const start = this.setInfo[index]?.index ?? this.textbox.value.length;
const end = this.setInfo[index + 1]?.index ?? this.textbox.value.length;
if (!this.setInfo[index]) {
if (this.innerFocus?.setIndex === index) {
return this.innerFocus.range;
}
return [this.textbox.value.length, this.textbox.value.length];
}
const start = this.setInfo[index].index;
const end = this.setInfo[index + 1].index;
return [start, end];
}
changeCompat = (ev: Event) => {
@ -1239,7 +1351,7 @@ class TeamTextbox extends preact.Component<{ editor: TeamEditorState, onChange?:
<div class="teameditor-text">
<textarea
class="textbox teamtextbox" style={`padding-left:${editor.narrow ? '50px' : '100px'}`}
onInput={this.input} onClick={this.click} onKeyUp={this.keyUp} onKeyDown={this.keyDown}
onInput={this.input} onContextMenu={this.contextMenu} onKeyUp={this.keyUp} onKeyDown={this.keyDown}
readOnly={editor.readonly}
/>
<textarea
@ -1816,10 +1928,11 @@ class StatForm extends preact.Component<{
static renderStatGraph(set: Dex.PokemonSet, editor: TeamEditorState, evs?: boolean) {
// const supportsEVs = !team.format.includes('letsgo');
const defaultEV = (editor.gen > 2 ? 0 : 252);
const ivs = editor.getIVs(set);
return Dex.statNames.map(statID => {
if (statID === 'spd' && editor.gen === 1) return null;
const stat = editor.getStat(statID, set);
const stat = editor.getStat(statID, set, ivs[statID]);
let ev: number | string = set.evs?.[statID] ?? defaultEV;
let width = stat * 75 / 504;
if (statID === 'hp') width = stat * 75 / 704;
@ -1849,9 +1962,12 @@ class StatForm extends preact.Component<{
const hpType = editor.getHPMove(set);
const hpIVdata = hpType && !editor.canHyperTrain(set) && editor.getHPIVs(hpType) || null;
const autoSpread = set.ivs && editor.defaultIVs(set, false);
const autoSpreadValue = autoSpread && Object.values(autoSpread).join('/');
if (!hpIVdata) {
return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
<option value="" selected>IV spreads</option>
{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
<optgroup label="min Atk">
<option value="31/0/31/31/31/31">31/0/31/31/31/31</option>
</optgroup>
@ -1871,6 +1987,7 @@ class StatForm extends preact.Component<{
return <select name="ivspread" class="button" onChange={this.changeIVSpread}>
<option value="" selected>Hidden Power {hpType} IVs</option>
{autoSpreadValue && <option value="auto">Auto ({autoSpreadValue})</option>}
<optgroup label="min Atk">
{hpIVs.map(ivs => {
const spread = ivs.map((iv, i) => (i === 1 ? minStat : 30) + iv).join('/');
@ -2168,7 +2285,12 @@ class StatForm extends preact.Component<{
const statID = target.name.split('-')[1] as StatName;
const value = this.dvToIv(target.value);
if (value === null) {
if (set.ivs) delete set.ivs[statID];
if (set.ivs) {
delete set.ivs[statID];
if (Object.values(set.ivs).every(iv => iv === undefined)) {
set.ivs = undefined;
}
}
} else {
set.ivs ||= { hp: 31, atk: 31, def: 31, spa: 31, spd: 31, spe: 31 };
set.ivs[statID] = value;
@ -2191,8 +2313,12 @@ class StatForm extends preact.Component<{
const { set } = this.props;
if (!target.value) return;
const [hp, atk, def, spa, spd, spe] = target.value.split('/').map(Number);
set.ivs = { hp, atk, def, spa, spd, spe };
if (target.value === 'auto') {
set.ivs = undefined;
} else {
const [hp, atk, def, spa, spd, spe] = target.value.split('/').map(Number);
set.ivs = { hp, atk, def, spa, spd, spe };
}
this.props.onChange();
};
maxEVs() {
@ -2227,8 +2353,9 @@ class StatForm extends preact.Component<{
};
if (editor.gen === 1) statNames.spa = 'Special';
const ivs = editor.getIVs(set);
const stats = Dex.statNames.filter(statID => editor.gen > 1 || statID !== 'spd').map(statID => [
statID, statNames[statID], editor.getStat(statID, set),
statID, statNames[statID], editor.getStat(statID, set, ivs[statID]),
] as const);
let remaining = null;
@ -2243,6 +2370,7 @@ class StatForm extends preact.Component<{
}
remaining ||= null;
}
const defaultIVs = editor.defaultIVs(set);
return <div style="font-size:10pt" role="dialog" aria-label="Stats">
<div class="resultheader"><h3>EVs, IVs, and Nature</h3></div>
@ -2273,7 +2401,7 @@ class StatForm extends preact.Component<{
onInput={this.changeEV} onChange={this.changeEV}
/></td>
<td><input
name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={useIVs ? '31' : '15'} style="width:40px"
name={`iv-${statID}`} min={0} max={useIVs ? 31 : 15} placeholder={`${defaultIVs[statID]}`} style="width:40px"
type="number" class="textbox default-placeholder" onInput={this.changeIV} onChange={this.changeIV}
/></td>
<td style="text-align:right"><strong>{stat}</strong></td>
@ -2296,7 +2424,7 @@ class StatForm extends preact.Component<{
</select>
</p>}
{editor.gen >= 3 && <p>
<small><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> next to a stat.</small>
<small><em>Protip:</em> You can also set natures by typing <kbd>+</kbd> and <kbd>-</kbd> in the EV box.</small>
</p>}
{editor.gen >= 3 && this.renderStatOptimizer()}
</div>

View File

@ -15,7 +15,7 @@ import { BattleLog } from "./battle-log";
import { Move, BattleNatures } from "./battle-dex-data";
import { BattleTextParser } from "./battle-text-parser";
class ModifiableValue {
export class ModifiableValue {
value = 0;
maxValue = 0;
comment: string[];
@ -787,10 +787,17 @@ export class BattleTooltips {
hpValues.push(hp - 256);
}
}
let failMessage = hpValues.length ? `Will fail if current HP is ${hpValues.join(' or ')}.` : '';
let failMessage = hpValues.length ? `Fails if current HP is ${hpValues.join(' or ')}.` : '';
if (hpValues.includes(serverPokemon.hp)) failMessage = `<strong class="message-error">${failMessage}</strong>`;
if (failMessage) text += `<p>${failMessage}</p>`;
}
if (this.battle.gen === 1 && !toID(this.battle.tier).includes('stadium') &&
move.id === 'substitute') {
const selfKO = serverPokemon.maxhp % 4 === 0 ? serverPokemon.maxhp / 4 : null;
let failMessage = selfKO ? `KOs yourself if current HP is exactly ${selfKO}.` : '';
if (selfKO === serverPokemon.hp) failMessage = `<strong class="message-error">${failMessage}</strong>`;
if (failMessage) text += `<p>${failMessage}</p>`;
}
}
return text;
}

View File

@ -1744,7 +1744,8 @@ export const PS = new class extends PSModel {
* they're dropped.
*/
dragging: { type: 'room', roomid: RoomID, foreground?: boolean } |
{ type: 'team', team: Team, folder: string | null } |
{ type: 'team', team: Team | number, folder: string | null } |
{ type: '?' } | // just a note not to try to figure out what type the dragged thing is
null = null;
lastMessageTime = '';

View File

@ -18,6 +18,7 @@ import {
type BattleRequest, type BattleMoveRequest, type BattleSwitchRequest, type BattleTeamRequest,
} from "./battle-choices";
import type { Args } from "./battle-text-parser";
import { ModifiableValue } from "./battle-tooltips";
type BattleDesc = {
id: RoomID,
@ -261,6 +262,8 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
static readonly id = 'battle';
static readonly routes = ['battle-*'];
static readonly Model = BattleRoom;
/** last displayed team. will not show the most recent request until the last one is gone. */
team: ServerPokemon[] | null = null;
send = (text: string, elem?: HTMLElement) => {
this.props.room.send(text, elem);
};
@ -495,8 +498,13 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
</div>;
}
renderMoveControls(active: BattleRequestActivePokemon, choices: BattleChoiceBuilder) {
const dex = this.props.room.battle.dex;
const battle = this.props.room.battle;
const dex = battle.dex;
const pokemonIndex = choices.index();
const activeIndex = battle.mySide.n > 1 ? pokemonIndex + battle.pokemonControlled : pokemonIndex;
const serverPokemon = choices.request.side!.pokemon[pokemonIndex];
const valueTracker = new ModifiableValue(battle, battle.nearSide.active[activeIndex]!, serverPokemon);
const tooltips = (battle.scene as BattleScene).tooltips;
if (choices.current.max || (active.maxMoves && !active.canDynamax)) {
if (!active.maxMoves) {
@ -505,9 +513,11 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
return active.moves.map((moveData, i) => {
const move = dex.moves.get(moveData.name);
const maxMoveData = active.maxMoves![i];
const gmaxTooltip = maxMoveData.id.startsWith('gmax') ? `|${maxMoveData.id}` : ``;
const gmax = maxMoveData.id.startsWith('gmax') ? dex.moves.get(maxMoveData.id) : null;
const gmaxTooltip = gmax ? `|${maxMoveData.id}` : ``;
const tooltip = `maxmove|${moveData.name}|${pokemonIndex}${gmaxTooltip}`;
return <MoveButton cmd={`/move ${i + 1} max`} type={move.type} tooltip={tooltip} moveData={moveData}>
const moveType = tooltips.getMoveType(move, valueTracker, gmax || true)[0];
return <MoveButton cmd={`/move ${i + 1} max`} type={moveType} tooltip={tooltip} moveData={moveData}>
{maxMoveData.name}
</MoveButton>;
});
@ -518,13 +528,15 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
return <div class="message-error">No Z moves</div>;
}
return active.moves.map((moveData, i) => {
const move = dex.moves.get(moveData.name);
const zMoveData = active.zMoves![i];
if (!zMoveData) {
return <button class="movebutton" disabled>&nbsp;</button>;
}
const specialMove = dex.moves.get(zMoveData.name);
const move = specialMove.exists ? specialMove : dex.moves.get(moveData.name);
const moveType = tooltips.getMoveType(move, valueTracker)[0];
const tooltip = `zmove|${moveData.name}|${pokemonIndex}`;
return <MoveButton cmd={`/move ${i + 1} zmove`} type={move.type} tooltip={tooltip} moveData={{ pp: 1, maxpp: 1 }}>
return <MoveButton cmd={`/move ${i + 1} zmove`} type={moveType} tooltip={tooltip} moveData={{ pp: 1, maxpp: 1 }}>
{zMoveData.name}
</MoveButton>;
});
@ -533,8 +545,9 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
const special = choices.moveSpecial(choices.current);
return active.moves.map((moveData, i) => {
const move = dex.moves.get(moveData.name);
const moveType = tooltips.getMoveType(move, valueTracker)[0];
const tooltip = `move|${moveData.name}|${pokemonIndex}`;
return <MoveButton cmd={`/move ${i + 1}${special}`} type={move.type} tooltip={tooltip} moveData={moveData}>
return <MoveButton cmd={`/move ${i + 1}${special}`} type={moveType} tooltip={tooltip} moveData={moveData}>
{move.name}
</MoveButton>;
});
@ -614,7 +627,7 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
});
}
renderTeamList() {
const team = this.props.room.battle.myPokemon;
const team = this.team;
if (!team) return;
return <div class="switchcontrols">
<h3 class="switchselect">Team</h3>
@ -702,8 +715,19 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
}
return buf;
}
renderPlayerWaitingControls() {
return <div class="controls">
<div class="whatdo">
<button class="button" data-cmd="/ffto end">Skip animation <i class="fa fa-fast-forward" aria-hidden></i></button>
</div>
{this.renderTeamList()}
</div>;
}
renderPlayerControls(request: BattleRequest) {
const room = this.props.room;
const atEnd = room.battle.atQueueEnd;
if (!atEnd) return this.renderPlayerWaitingControls();
let choices = room.choices;
if (!choices) return 'Error: Missing BattleChoiceBuilder';
if (choices.request !== request) {
@ -722,7 +746,10 @@ class BattlePanel extends PSRoomPanel<BattleRoom> {
{this.renderTeamList()}
</div>;
}
if (request.side) room.battle.myPokemon = request.side.pokemon;
if (request.side) {
room.battle.myPokemon = request.side.pokemon;
this.team = request.side.pokemon;
}
switch (request.requestType) {
case 'move': {
const index = choices.index();

View File

@ -391,12 +391,32 @@ export class MainMenuRoom extends PSRoom {
break;
case 'teamupload':
if (PS.teams.uploading) {
PS.teams.uploading.uploaded = {
const team = PS.teams.uploading;
team.uploaded = {
teamid: response.teamid,
notLoaded: false,
private: response.private,
};
PS.rooms[`team-${team.key}`]?.update(null);
PS.rooms.teambuilder?.update(null);
PS.teams.uploading = null;
}
break;
case 'teamupdate':
for (const team of PS.teams.list) {
if (team.teamid === response.teamid) {
team.uploaded = {
teamid: response.teamid,
notLoaded: false,
private: response.private,
};
PS.rooms[`team-${team.key}`]?.update(null);
PS.rooms.teambuilder?.update(null);
PS.teams.uploading = null;
break;
}
}
break;
}
}
}

View File

@ -9,7 +9,6 @@ import { PS, PSRoom, type RoomOptions, type Team } from "./client-main";
import { PSPanelWrapper, PSRoomPanel } from "./panels";
import { toID } from "./battle-dex";
import { BattleLog } from "./battle-log";
import { FormatDropdown } from "./panel-mainmenu";
import { TeamEditor } from "./battle-team-editor";
import { Net } from "./client-connection";
@ -45,6 +44,25 @@ class TeamRoom extends PSRoom {
this.update(null);
});
}
upload(isPrivate: boolean) {
const team = this.team;
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, isPrivate ? 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(', ')}`);
this.uploaded = true;
this.update(null);
}
save() {
PS.teams.save();
const title = `[Team] ${this.team?.name || 'Team'}`;
@ -96,25 +114,7 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
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();
room.upload(PS.prefs.uploadprivacy);
};
changePrivacyPref = (ev: Event) => {
@ -161,25 +161,29 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
<i class="fa fa-chevron-left" aria-hidden></i> Teams
</a> {}
{team.uploaded?.private ? (
<button class="button cur" disabled>
<button class="button" data-href={`teamstorage-${team.key}`}>
<i class="fa fa-cloud"></i> Account
</button>
) : team.uploaded ? (
<button class="button cur" disabled>
<button class="button" data-href={`teamstorage-${team.key}`}>
<i class="fa fa-globe"></i> Account (public)
</button>
) : team.teamid ? (
<button class="button cur" disabled>
<button class="button" data-href={`teamstorage-${team.key}`}>
<i class="fa fa-plug"></i> Disconnected (wrong account?)
</button>
) : (
<button class="button cur" disabled>
<button class="button" data-href={`teamstorage-${team.key}`}>
<i class="fa fa-laptop"></i> Local
</button>
)}
<div style="float:right"><FormatDropdown
format={team.format} placeholder="" selectType="teambuilder" onChange={this.handleChangeFormat}
/></div>
<div style="float:right"><button
name="format" value={team.format} data-selecttype="teambuilder"
class="button" data-href="/formatdropdown" onChange={this.handleChangeFormat}
>
<i class="fa fa-folder-o"></i> {BattleLog.formatName(team.format)} {}
{team.format.length <= 4 && <em>(uncategorized)</em>} <i class="fa fa-caret-down"></i>
</button></div>
<label class="label teamname">
Team name:{}
<input
@ -232,4 +236,80 @@ class TeamPanel extends PSRoomPanel<TeamRoom> {
}
}
PS.addRoomType(TeamPanel);
type TeamStorage = 'account' | 'public' | 'disconnected' | 'local';
class TeamStoragePanel extends PSRoomPanel {
static readonly id = "teamstorage";
static readonly routes = ["teamstorage-*"];
static readonly location = "semimodal-popup";
static readonly noURL = true;
chooseOption = (ev: MouseEvent) => {
const storage = (ev.currentTarget as HTMLButtonElement).value as TeamStorage;
const room = this.props.room;
const team = this.team();
if (storage === 'local' && team.uploaded) {
PS.mainmenu.send(`/teams delete ${team.uploaded.teamid}`);
team.uploaded = undefined;
team.teamid = undefined;
PS.teams.save();
(room.getParent() as TeamRoom).update(null);
} else if (storage === 'public' && team.uploaded?.private) {
PS.mainmenu.send(`/teams setprivacy ${team.uploaded.teamid},no`);
} else if (storage === 'account' && team.uploaded?.private === null) {
PS.mainmenu.send(`/teams setprivacy ${team.uploaded.teamid},yes`);
} else if (storage === 'public' && !team.teamid) {
(room.getParent() as TeamRoom).upload(false);
} else if (storage === 'account' && !team.teamid) {
(room.getParent() as TeamRoom).upload(true);
}
ev.stopImmediatePropagation();
ev.preventDefault();
this.close();
};
team() {
const teamKey = this.props.room.id.slice(12);
const team = PS.teams.byKey[teamKey]!;
return team;
}
override render() {
const room = this.props.room;
const team = this.team();
const storage: TeamStorage = team.uploaded?.private ? (
'account'
) : team.uploaded ? (
'public'
) : team.teamid ? (
'disconnected'
) : (
'local'
);
if (storage === 'disconnected') {
return <PSPanelWrapper room={room} width={280}><div class="pad">
<div><button class="option cur" data-cmd="/close">
<i class="fa fa-plug"></i> <strong>Disconnected</strong><br />
Not found in the Teams database. Maybe you uploaded it on a different account?
</button></div>
</div></PSPanelWrapper>;
}
return <PSPanelWrapper room={room} width={280}><div class="pad">
<div><button class={`option${storage === 'local' ? ' cur' : ''}`} onClick={this.chooseOption} value="local">
<i class="fa fa-laptop"></i> <strong>Local</strong><br />
Stored in cookies on your computer. Warning: Your browser might delete these. Make sure to use backups.
</button></div>
<div><button class={`option${storage === 'account' ? ' cur' : ''}`} onClick={this.chooseOption} value="account">
<i class="fa fa-cloud"></i> <strong>Account</strong><br />
Uploaded to the Teams database. You can share with the URL.
</button></div>
<div><button class={`option${storage === 'public' ? ' cur' : ''}`} onClick={this.chooseOption} value="public">
<i class="fa fa-globe"></i> <strong>Account (public)</strong><br />
Uploaded to the Teams database publicly. Share with the URL or people can find it by searching.
</button></div>
</div></PSPanelWrapper>;
}
}
PS.addRoomType(TeamPanel, TeamStoragePanel);

View File

@ -7,7 +7,7 @@
import { PS, PSRoom, type Team } from "./client-main";
import { PSPanelWrapper, PSRoomPanel } from "./panels";
import { TeamBox } from "./panel-teamdropdown";
import { PSTeambuilder, TeamBox } from "./panel-teamdropdown";
import { Dex, PSUtils, toID, type ID } from "./battle-dex";
class TeambuilderRoom extends PSRoom {
@ -121,20 +121,52 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
button.value = '';
this.forceUpdate();
};
/** undefined: not dragging, null: dragging a new team */
getDraggedTeam(ev: DragEvent): Team | number | null {
if (PS.dragging?.type === 'team') return PS.dragging.team;
const dataTransfer = ev.dataTransfer;
if (!dataTransfer) return null;
PS.dragging = { type: '?' };
console.log(`dragging: ${dataTransfer.types as any} | ${[...dataTransfer.files]?.map(file => file.name) as any}`);
if (!dataTransfer.types.includes?.('Files')) return null;
// MDN says files will be empty except on a Drop event, but the spec says no such thing
// in practice, Chrome gives this info but Firefox doesn't
if (dataTransfer.files[0] && !dataTransfer.files[0].name.endsWith('.txt')) return null;
// We're dragging a file! It might be a team!
PS.dragging = {
type: 'team',
team: 0,
folder: null,
};
return PS.dragging.team;
}
dragEnterTeam = (ev: DragEvent) => {
if (PS.dragging?.type !== 'team') return;
const draggedTeam = this.getDraggedTeam(ev);
if (draggedTeam === null) return;
const value = (ev.currentTarget as HTMLElement)?.getAttribute('data-teamkey');
const team = value ? PS.teams.byKey[value] : null;
if (!team || team === PS.dragging.team) return;
const iDragged = PS.teams.list.indexOf(PS.dragging.team);
if (!team || team === draggedTeam) return;
const iOver = PS.teams.list.indexOf(team);
if (typeof draggedTeam === 'number') {
if (iOver >= draggedTeam) (PS.dragging as any).team = iOver + 1;
(PS.dragging as any).team = iOver;
this.forceUpdate();
return;
}
const iDragged = PS.teams.list.indexOf(draggedTeam);
if (iDragged < 0 || iOver < 0) return; // shouldn't happen
PS.teams.list.splice(iDragged, 1);
// by coincidence, splicing into iOver works in both directions
// before: Dragged goes before Over, splice at i
// after: Dragged goes after Over, splice at i - 1 + 1
PS.teams.list.splice(iOver, 0, PS.dragging.team);
PS.teams.list.splice(iOver, 0, draggedTeam);
this.forceUpdate();
};
dragEnterFolder = (ev: DragEvent) => {
@ -153,20 +185,96 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
if (PS.dragging.folder === value) PS.dragging.folder = null;
this.forceUpdate();
};
extractDraggedTeam(ev: DragEvent): Promise<Team | null> {
const file = ev.dataTransfer?.files?.[0];
if (!file) return Promise.resolve(null);
let name = file.name;
if (name.slice(-4).toLowerCase() !== '.txt') {
PS.alert(`Your file "${file.name}" is not a valid team. Team files are ".txt" files.`);
return Promise.resolve(null);
}
name = name.slice(0, -4);
return file.text?.()?.then(result => {
let sets;
try {
sets = PSTeambuilder.importTeam(result);
} catch {
PS.alert(`Your file "${file.name}" is not a valid team.`);
return null;
}
let format = '';
const bracketIndex = name.indexOf(']');
let isBox = false;
if (bracketIndex >= 0) {
format = name.slice(1, bracketIndex);
if (!format.startsWith('gen')) format = 'gen6' + format;
if (format.endsWith('-box')) {
format = format.slice(0, -4);
isBox = true;
}
name = $.trim(name.substr(bracketIndex + 1));
}
return {
name,
format: format as ID,
folder: '',
packedTeam: PSTeambuilder.packTeam(sets),
iconCache: null,
key: '',
isBox,
} satisfies Team;
});
}
addDraggedTeam(ev: DragEvent, folder?: string) {
let index: number = (PS.dragging as any)?.team;
if (typeof index !== 'number') index = 0;
this.extractDraggedTeam(ev).then(team => {
if (!team) {
return;
}
if (folder?.endsWith('/')) {
team.folder = folder.slice(0, -1);
} else if (folder) {
team.format = folder as ID;
}
PS.teams.push(team);
PS.teams.list.pop();
PS.teams.list.splice(index, 0, team);
PS.teams.save();
this.forceUpdate();
});
}
dropFolder = (ev: DragEvent) => {
const value = (ev.currentTarget as HTMLElement)?.getAttribute('data-value') || null;
if (value === null || PS.dragging?.type !== 'team') return;
if (value === '++' || value === '') return;
PS.dragging.folder = null;
let team = PS.dragging.team;
if (typeof team === 'number') {
return this.addDraggedTeam(ev, value);
}
if (value.endsWith('/')) {
PS.dragging.team.folder = value.slice(0, -1);
team.folder = value.slice(0, -1);
} else {
PS.dragging.team.format = value as ID;
team.format = value as ID;
}
PS.teams.save();
ev.stopImmediatePropagation();
this.forceUpdate();
};
dropPanel = (ev: DragEvent) => {
if (PS.dragging?.type !== 'team') return;
let team = PS.dragging.team;
if (typeof team === 'number') {
return this.addDraggedTeam(ev, this.props.room.curFolder);
}
};
renderFolder(value: string) {
const { room } = this.props;
const cur = room.curFolder === value;
@ -311,7 +419,11 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
const room = this.props.room;
let teams: (Team | null)[] = PS.teams.list.slice();
if (PS.teams.deletedTeams.length) {
let isDragging = false;
if (PS.dragging?.type === 'team' && typeof PS.dragging.team === 'number') {
teams.splice(PS.dragging.team, 0, null);
isDragging = true;
} else if (PS.teams.deletedTeams.length) {
const undeleteIndex = PS.teams.deletedTeams[PS.teams.deletedTeams.length - 1][1];
teams.splice(undeleteIndex, 0, null);
}
@ -332,7 +444,7 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
<div class="folderpane">
{this.renderFolderList()}
</div>
<div class="teampane">
<div class="teampane" onDrop={this.dropPanel}>
{filterFolder ? (
<h2>
<i class="fa fa-folder-open" aria-hidden></i> {filterFolder} {}
@ -371,6 +483,10 @@ class TeambuilderPanel extends PSRoomPanel<TeambuilderRoom> {
null
)}
</li>
) : isDragging ? (
<li key="dragging">
<div class="team"></div>
</li>
) : (
<li key="undelete">
<button data-cmd="/undeleteteam" class="option">

View File

@ -683,10 +683,12 @@ class TeamDropdownPanel extends PSRoomPanel {
return true;
});
}
setFormat = (e: MouseEvent) => {
const target = e.currentTarget as HTMLButtonElement;
setFormat = (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLButtonElement;
this.format = (target.name === 'format' && target.value) || '';
this.gen = (target.name === 'gen' && target.value) || '';
ev.preventDefault();
ev.stopImmediatePropagation();
this.forceUpdate();
};
click = (e: MouseEvent) => {

View File

@ -530,6 +530,7 @@ you can't delete it by pressing Backspace */
.set-button {
position: relative;
max-width: 660px;
}
.set-button table {
border: 0;