Compare commits

...

8 Commits

Author SHA1 Message Date
Bertie690
cb24d21070
Merge ca7947c770 into 79576ad117 2025-08-09 00:51:36 -05:00
NightKev
79576ad117
[GitHub] Update .github/CODEOWNERS file (#6240)
* [GitHub] Update `.github/CODEOWNERS` file

`@pagefaultgames/senior-dev-team` added to
`package.json` and `pnpm-lock.yaml`

`@pagefaultgames/balance-team` added to `/src/data/trainers`

* Move senior dev team entries to the bottom of the file
2025-08-09 03:18:40 +00:00
Madmadness65
f0a56a3049
[Sprite] Add unique trainer sprite for Rocket Boss Giovanni (#6235) 2025-08-09 02:15:33 +00:00
Madmadness65
7316628448 [Misc] Fix comment formatting nitpick in ability-id
Adds a space between every `/**` and `{@link`.
2025-08-08 20:31:51 -05:00
Bertie690
ca7947c770 fixed stupid whirlwind tests 2025-08-03 19:13:48 -04:00
Bertie690
6a284b7ab3 Added MessageAttr; cleaned up a lot of other jank move attrs 2025-08-03 18:31:08 -04:00
Bertie690
c2db0ed397 Fixed key for locales text 2025-08-02 17:09:35 -04:00
Bertie690
5f29e4acf2 Added Laser Focus locales 2025-08-02 16:58:23 -04:00
9 changed files with 441 additions and 399 deletions

10
.github/CODEOWNERS vendored
View File

@ -3,9 +3,6 @@
# everything (whole code-base) - Junior Devs
* @pagefaultgames/junior-dev-team
# github actions/templates etc. - Dev Leads
/.github @pagefaultgames/senior-dev-team
# Art Team
/public/**/*.png @pagefaultgames/art-team
/public/**/*.json @pagefaultgames/art-team
@ -20,3 +17,10 @@
# Balance Files; contain actual code logic and must also be owned by dev team
/src/data/balance @pagefaultgames/balance-team @pagefaultgames/junior-dev-team
/src/data/trainers @pagefaultgames/balance-team @pagefaultgames/junior-dev-team
# GitHub actions/templates etc. - Senior Devs
# Should be defined last in the file to make sure these always override all other definitions
/.github @pagefaultgames/senior-dev-team
package.json @pagefaultgames/senior-dev-team
pnpm-lock.yaml @pagefaultgames/senior-dev-team

View File

@ -0,0 +1,41 @@
{
"textures": [
{
"image": "rocket_boss_giovanni_1.png",
"format": "RGBA8888",
"size": {
"w": 79,
"h": 79
},
"scale": 1,
"frames": [
{
"filename": "0001.png",
"rotated": false,
"trimmed": false,
"sourceSize": {
"w": 39,
"h": 79
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 39,
"h": 79
},
"frame": {
"x": 0,
"y": 0,
"w": 39,
"h": 79
}
}
]
}
],
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "3.0",
"smartupdate": "$TexturePacker:SmartUpdate:d6c5e1804414106d43a7c46f83468d39:1f3f7898a58950988acac6ee7167e012:5f742cbdaafcd5ae864f18ec2af7512a$"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

View File

@ -1,13 +1,24 @@
import type { Pokemon } from "#field/pokemon";
import type {
AttackMove,
ChargingAttackMove,
ChargingSelfStatusMove,
Move,
MoveAttr,
MoveAttrConstructorMap,
SelfStatusMove,
StatusMove,
} from "#moves/move";
/**
* A generic function producing a message during a Move's execution.
* @param user - The {@linkcode Pokemon} using the move
* @param target - The {@linkcode Pokemon} targeted by the move
* @param move - The {@linkcode Move} being used
* @returns a string
*/
export type MoveMessageFunc = (user: Pokemon, target: Pokemon, move: Move) => string;
export type MoveAttrFilter = (attr: MoveAttr) => boolean;
export type * from "#moves/move";

View File

@ -1670,6 +1670,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr {
constructor(
private newType: PokemonType,
private powerMultiplier: number,
// TODO: all moves with this attr solely check the move being used...
private condition?: PokemonAttackCondition,
) {
super(false);

View File

@ -86,7 +86,7 @@ import { PokemonHealPhase } from "#phases/pokemon-heal-phase";
import { SwitchSummonPhase } from "#phases/switch-summon-phase";
import type { AttackMoveResult } from "#types/attack-move-result";
import type { Localizable } from "#types/locales";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString } from "#types/move-types";
import type { ChargingMove, MoveAttrMap, MoveAttrString, MoveClassMap, MoveKindString, MoveMessageFunc } from "#types/move-types";
import type { TurnMove } from "#types/turn-move";
import { BooleanHolder, type Constructor, isNullOrUndefined, NumberHolder, randSeedFloat, randSeedInt, randSeedItem, toDmgValue } from "#utils/common";
import { getEnumValues } from "#utils/enums";
@ -1357,20 +1357,20 @@ export class MoveHeaderAttr extends MoveAttr {
/**
* Header attribute to queue a message at the beginning of a turn.
* @see {@link MoveHeaderAttr}
*/
export class MessageHeaderAttr extends MoveHeaderAttr {
private message: string | ((user: Pokemon, move: Move) => string);
/** The message to display, or a function producing one. */
private message: string | MoveMessageFunc;
constructor(message: string | ((user: Pokemon, move: Move) => string)) {
constructor(message: string | MoveMessageFunc) {
super();
this.message = message;
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
const message = typeof this.message === "string"
? this.message
: this.message(user, move);
: this.message(user, target, move);
if (message) {
globalScene.phaseManager.queueMessage(message);
@ -1418,21 +1418,21 @@ export class BeakBlastHeaderAttr extends AddBattlerTagHeaderAttr {
*/
export class PreMoveMessageAttr extends MoveAttr {
/** The message to display or a function returning one */
private message: string | ((user: Pokemon, target: Pokemon, move: Move) => string | undefined);
private message: string | MoveMessageFunc;
/**
* Create a new {@linkcode PreMoveMessageAttr} to display a message before move execution.
* @param message - The message to display before move use, either as a string or a function producing one.
* @param message - The message to display before move use, either` a literal string or a function producing one.
* @remarks
* If {@linkcode message} evaluates to an empty string (`''`), no message will be displayed
* If {@linkcode message} evaluates to an empty string (`""`), no message will be displayed
* (though the move will still succeed).
*/
constructor(message: string | ((user: Pokemon, target: Pokemon, move: Move) => string)) {
constructor(message: string | MoveMessageFunc) {
super();
this.message = message;
}
apply(user: Pokemon, target: Pokemon, move: Move, _args: any[]): boolean {
apply(user: Pokemon, target: Pokemon, move: Move): boolean {
const message = typeof this.message === "function"
? this.message(user, target, move)
: this.message;
@ -1453,18 +1453,17 @@ export class PreMoveMessageAttr extends MoveAttr {
* @extends MoveAttr
*/
export class PreUseInterruptAttr extends MoveAttr {
protected message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string);
protected overridesFailedMessage: boolean;
protected message: string | MoveMessageFunc;
protected conditionFunc: MoveConditionFunc;
/**
* Create a new MoveInterruptedMessageAttr.
* @param message The message to display when the move is interrupted, or a function that formats the message based on the user, target, and move.
*/
constructor(message?: string | ((user: Pokemon, target: Pokemon, move: Move) => string), conditionFunc?: MoveConditionFunc) {
constructor(message: string | MoveMessageFunc, conditionFunc: MoveConditionFunc) {
super();
this.message = message;
this.conditionFunc = conditionFunc ?? (() => true);
this.conditionFunc = conditionFunc;
}
/**
@ -1485,11 +1484,9 @@ export class PreUseInterruptAttr extends MoveAttr {
*/
override getFailedText(user: Pokemon, target: Pokemon, move: Move): string | undefined {
if (this.message && this.conditionFunc(user, target, move)) {
const message =
typeof this.message === "string"
? (this.message as string)
return typeof this.message === "string"
? this.message
: this.message(user, target, move);
return message;
}
}
}
@ -1694,19 +1691,33 @@ export class SurviveDamageAttr extends ModifiedDamageAttr {
}
}
export class SplashAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:splash"));
/**
* Move attribute to display arbitrary text during a move's execution.
*/
export class MessageAttr extends MoveEffectAttr {
/** The message to display, either as a string or a function returning one. */
private message: string | MoveMessageFunc;
constructor(message: string | MoveMessageFunc, options?: MoveEffectAttrOptions) {
// TODO: Do we need to respect `selfTarget` if we're just displaying text?
super(false, options)
this.message = message;
}
override apply(user: Pokemon, target: Pokemon, move: Move): boolean {
const message = typeof this.message === "function"
? this.message(user, target, move)
: this.message;
// TODO: Consider changing if/when MoveAttr `apply` return values become significant
if (message) {
globalScene.phaseManager.queueMessage(message, 500);
return true;
}
return false;
}
}
export class CelebrateAttr extends MoveEffectAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username }));
return true;
}
}
export class RecoilAttr extends MoveEffectAttr {
private useHp: boolean;
@ -5931,38 +5942,6 @@ export class ProtectAttr extends AddBattlerTagAttr {
}
}
export class IgnoreAccuracyAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.IGNORE_ACCURACY, true, false, 2);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) }));
return true;
}
}
export class FaintCountdownAttr extends AddBattlerTagAttr {
constructor() {
super(BattlerTagType.PERISH_SONG, false, true, 4);
}
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
if (!super.apply(user, target, move, args)) {
return false;
}
globalScene.phaseManager.queueMessage(i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: this.turnCountMin - 1 }));
return true;
}
}
/**
* Attribute to remove all Substitutes from the field.
* @extends MoveEffectAttr
@ -6603,8 +6582,10 @@ export class ChillyReceptionAttr extends ForceSwitchOutAttr {
return (user, target, move) => globalScene.arena.weather?.weatherType !== WeatherType.SNOW || super.getSwitchOutCondition()(user, target, move);
}
}
export class RemoveTypeAttr extends MoveEffectAttr {
// TODO: Remove the message callback
private removedType: PokemonType;
private messageCallback: ((user: Pokemon) => void) | undefined;
@ -8299,8 +8280,6 @@ const MoveAttrs = Object.freeze({
RandomLevelDamageAttr,
ModifiedDamageAttr,
SurviveDamageAttr,
SplashAttr,
CelebrateAttr,
RecoilAttr,
SacrificialAttr,
SacrificialAttrOnHit,
@ -8443,8 +8422,7 @@ const MoveAttrs = Object.freeze({
RechargeAttr,
TrapAttr,
ProtectAttr,
IgnoreAccuracyAttr,
FaintCountdownAttr,
MessageAttr,
RemoveAllSubstitutesAttr,
HitsTagAttr,
HitsTagForDoubleDamageAttr,
@ -8938,7 +8916,7 @@ export function initMoves() {
new AttackMove(MoveId.PSYWAVE, PokemonType.PSYCHIC, MoveCategory.SPECIAL, -1, 100, 15, -1, 0, 1)
.attr(RandomLevelDamageAttr),
new SelfStatusMove(MoveId.SPLASH, PokemonType.NORMAL, -1, 40, -1, 0, 1)
.attr(SplashAttr)
.attr(MessageAttr, i18next.t("moveTriggers:splash"))
.condition(failOnGravityCondition),
new SelfStatusMove(MoveId.ACID_ARMOR, PokemonType.POISON, -1, 20, -1, 0, 1)
.attr(StatStageChangeAttr, [ Stat.DEF ], 2, true),
@ -9000,7 +8978,10 @@ export function initMoves() {
.attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1)
.reflectable(),
new StatusMove(MoveId.MIND_READER, PokemonType.NORMAL, -1, 5, -1, 0, 2)
.attr(IgnoreAccuracyAttr),
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2)
.attr(MessageAttr, (user, target) =>
i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })
),
new StatusMove(MoveId.NIGHTMARE, PokemonType.GHOST, 100, 15, -1, 0, 2)
.attr(AddBattlerTagAttr, BattlerTagType.NIGHTMARE)
.condition(targetSleptOrComatoseCondition),
@ -9088,7 +9069,9 @@ export function initMoves() {
return lastTurnMove.length === 0 || lastTurnMove[0].move !== move.id || lastTurnMove[0].result !== MoveResult.SUCCESS;
}),
new StatusMove(MoveId.PERISH_SONG, PokemonType.NORMAL, -1, 5, -1, 0, 2)
.attr(FaintCountdownAttr)
.attr(AddBattlerTagAttr, BattlerTagType.PERISH_SONG, false, true, 4)
.attr(MessageAttr, (_user, target) =>
i18next.t("moveTriggers:faintCountdown", { pokemonName: getPokemonNameWithAffix(target), turnCount: 3 }))
.ignoresProtect()
.soundBased()
.condition(failOnBossCondition)
@ -9104,7 +9087,10 @@ export function initMoves() {
.attr(MultiHitAttr)
.makesContact(false),
new StatusMove(MoveId.LOCK_ON, PokemonType.NORMAL, -1, 5, -1, 0, 2)
.attr(IgnoreAccuracyAttr),
.attr(AddBattlerTagAttr, BattlerTagType.IGNORE_ACCURACY, true, false, 2)
.attr(MessageAttr, (user, target) =>
i18next.t("moveTriggers:tookAimAtTarget", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target) })
),
new AttackMove(MoveId.OUTRAGE, PokemonType.DRAGON, MoveCategory.PHYSICAL, 120, 100, 10, -1, 0, 2)
.attr(FrenzyAttr)
.attr(MissEffectAttr, frenzyMissFunc)
@ -9331,8 +9317,8 @@ export function initMoves() {
&& (user.status.effect === StatusEffect.BURN || user.status.effect === StatusEffect.POISON || user.status.effect === StatusEffect.TOXIC || user.status.effect === StatusEffect.PARALYSIS) ? 2 : 1)
.attr(BypassBurnDamageReductionAttr),
new AttackMove(MoveId.FOCUS_PUNCH, PokemonType.FIGHTING, MoveCategory.PHYSICAL, 150, 100, 20, -1, -3, 3)
.attr(MessageHeaderAttr, (user, move) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(PreUseInterruptAttr, (user, target, move) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => !!user.turnData.attacksReceived.find(r => r.damage))
.attr(MessageHeaderAttr, (user) => i18next.t("moveTriggers:isTighteningFocus", { pokemonName: getPokemonNameWithAffix(user) }))
.attr(PreUseInterruptAttr, (user) => i18next.t("moveTriggers:lostFocus", { pokemonName: getPokemonNameWithAffix(user) }), user => user.turnData.attacksReceived.some(r => r.damage > 0))
.punchingMove(),
new AttackMove(MoveId.SMELLING_SALTS, PokemonType.NORMAL, MoveCategory.PHYSICAL, 70, 100, 10, -1, 0, 3)
.attr(MovePowerMultiplierAttr, (user, target, move) => target.status?.effect === StatusEffect.PARALYSIS ? 2 : 1)
@ -10433,7 +10419,8 @@ export function initMoves() {
new AttackMove(MoveId.DAZZLING_GLEAM, PokemonType.FAIRY, MoveCategory.SPECIAL, 80, 100, 10, -1, 0, 6)
.target(MoveTarget.ALL_NEAR_ENEMIES),
new SelfStatusMove(MoveId.CELEBRATE, PokemonType.NORMAL, -1, 40, -1, 0, 6)
.attr(CelebrateAttr),
// NB: This needs a lambda function as the user will not be logged in by the time the moves are initialized
.attr(MessageAttr, () => i18next.t("moveTriggers:celebrate", { playerName: loggedInUser?.username })),
new StatusMove(MoveId.HOLD_HANDS, PokemonType.NORMAL, -1, 40, -1, 0, 6)
.ignoresSubstitute()
.target(MoveTarget.NEAR_ALLY),
@ -10608,7 +10595,12 @@ export function initMoves() {
.attr(StatStageChangeAttr, [ Stat.SPD ], -1)
.reflectable(),
new SelfStatusMove(MoveId.LASER_FOCUS, PokemonType.NORMAL, -1, 30, -1, 0, 7)
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false),
.attr(AddBattlerTagAttr, BattlerTagType.ALWAYS_CRIT, true, false)
.attr(MessageAttr, (user) =>
i18next.t("battlerTags:laserFocusOnAdd", {
pokemonNameWithAffix: getPokemonNameWithAffix(user),
}),
),
new StatusMove(MoveId.GEAR_UP, PokemonType.STEEL, -1, 20, -1, 0, 7)
.attr(StatStageChangeAttr, [ Stat.ATK, Stat.SPATK ], 1, false, { condition: (user, target, move) => !![ AbilityId.PLUS, AbilityId.MINUS ].find(a => target.hasAbility(a, false)) })
.ignoresSubstitute()

View File

@ -223,9 +223,8 @@ export class TrainerConfig {
case TrainerType.LARRY_ELITE:
trainerType = TrainerType.LARRY;
break;
case TrainerType.ROCKET_BOSS_GIOVANNI_1:
case TrainerType.ROCKET_BOSS_GIOVANNI_2:
trainerType = TrainerType.GIOVANNI;
trainerType = TrainerType.ROCKET_BOSS_GIOVANNI_1;
break;
case TrainerType.MAXIE_2:
trainerType = TrainerType.MAXIE;
@ -895,7 +894,7 @@ export class TrainerConfig {
/**
* Helper function to check if a specialty type is set
* @returns true if specialtyType is defined and not Type.UNKNOWN
* @returns `true` if `specialtyType` is defined and not {@link PokemonType.UNKNOWN}
*/
hasSpecialtyType(): boolean {
return !isNullOrUndefined(this.specialtyType) && this.specialtyType !== PokemonType.UNKNOWN;

View File

@ -1,4 +1,3 @@
import { globalScene } from "#app/global-scene";
import { Status } from "#data/status-effect";
import { AbilityId } from "#enums/ability-id";
import { BattleType } from "#enums/battle-type";
@ -179,18 +178,13 @@ describe("Moves - Whirlwind", () => {
const eligibleEnemy = enemyParty.filter(p => p.hp > 0 && p.isAllowedInBattle());
expect(eligibleEnemy.length).toBe(1);
// Spy on the queueMessage function
const queueSpy = vi.spyOn(globalScene.phaseManager, "queueMessage");
// Player uses Whirlwind; opponent uses Splash
game.move.select(MoveId.WHIRLWIND);
await game.move.selectEnemyMove(MoveId.SPLASH);
await game.toNextTurn();
// Verify that the failure message is displayed for Whirlwind
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But it failed"));
// Verify the opponent's Splash message
expect(queueSpy).toHaveBeenCalledWith(expect.stringContaining("But nothing happened!"));
const player = game.field.getPlayerPokemon();
expect(player).toHaveUsedMove({ move: MoveId.WHIRLWIND, result: MoveResult.FAIL });
});
it("should not pull in the other trainer's pokemon in a partner trainer battle", async () => {