diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 9654029a..e623b61c 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -33,6 +33,15 @@ end local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 } local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" } +-- Strings.source, not Strings: harvested at require time so the catalog +-- generator can see the literal, same pattern as MoveEffects.lua's +-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument +-- the harvester can't discover. +local STAT_LABEL = { + attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"), + speed = Strings.source("SPEED"), +} + -- The trainer's ai_classes record from the merged registry; the direct -- require covers battles built without a loader. A trainer record's -- aiClass field picks a record other than its own id. @@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item) elseif X_STAT[item] then local stat = X_STAT[item] enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1) - table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper())) + table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat]))) elseif item == "GUARD_SPEC" then enemy.mist = true table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy))) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 05ea1c2e..e2d5a47e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -2429,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker) if (side.lightScreen or 0) > 0 then return fail(self) end side.lightScreen = Battle.SCREEN_TURNS self:emit({ kind = "message", - text = self:monName(attacker) .. "'s SPCL.DEF rose!" }) + text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) }) end Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) @@ -2437,7 +2437,7 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker) if (side.reflect or 0) > 0 then return fail(self) end side.reflect = Battle.SCREEN_TURNS self:emit({ kind = "message", - text = self:monName(attacker) .. "'s DEFENSE rose!" }) + text = Strings("%s's DEFENSE rose!", self:monName(attacker)) }) end -- engine/battle/move_effects/safeguard.asm:1 diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 691b859d..0ef0c6ef 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -59,6 +59,16 @@ local STONES = { LEAF_STONE = true, MOON_STONE = true, } +-- Strings.source, not Strings: harvested at require time so the catalog +-- generator can see the literal, same pattern as MoveEffects.lua's +-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument +-- the harvester can't discover. +local STAT_LABEL = { + hp = Strings.source("HP"), attack = Strings.source("ATTACK"), + defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"), + special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"), +} + -- vitamins: stat-exp boosters (ItemUseVitamin) local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense", CARBOS = "speed", CALCIUM = "special" } @@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) "Nothing happened!") } end b.stages[stat] = cur + 1 - return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) } + return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) } end -- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume -- the item, even when it is already active @@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- Spanish ROM puts the stat before the name), so the extracted line -- cannot be filled positionally; the engine wording stands return "consumed", { Strings("%s's %s\nrose!", monName(data, target), - vitaminStat == "hp" and "HP" or vitaminStat:upper()) } + Strings(STAT_LABEL[vitaminStat])) } end -- PP UP boosts the move the player picked (ItemUsePPUp's move menu) diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua new file mode 100644 index 00000000..83b4da0c --- /dev/null +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -0,0 +1,141 @@ +-- The stat name substituted into X-item/vitamin "rose!" messages, and +-- Gold's whole Light Screen / Reflect "rose!" messages, must reach a +-- translation catalog, not just the surrounding sentence template (RBY: +-- src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua; Gold: +-- src/battle/gen2/Battle.lua). With no catalog loaded the message stays +-- English (the existing baseline); with one loaded that translates the +-- relevant word(s), the substitution must change too -- that is the +-- actual bug this suite guards against, which passing/failing sentences +-- alone (as other suites already check) cannot tell apart from text that +-- never reached Strings() at all. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local ItemEffects = require("src.inventory.ItemEffects") +local TrainerAI = require("src.battle.TrainerAI") +local Strings = require("src.core.Strings") + +local function withCatalog(catalog, fn) + Strings.load({ strings = catalog }) + local ok, err = pcall(fn) + Strings.load(nil) + if not ok then error(err, 0) end +end + +-- ------------------------------------------------------- player X-item + +local save = SaveData.newGame() +local player = { name = "FIXMON", stages = {} } +local xBattle = { player = player, kind = "wild" } + +local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) +T.check(baseline[1]:find("ATTACK", 1, true) ~= nil, + "X ATTACK's rose! message names the stat in English with no catalog") + +withCatalog({ ATTACK = "ATTAQUE" }, function() + player.stages.attack = nil + local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle) + T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil, + "a catalog translating ATTACK reaches the X ATTACK rose! message") + T.check(msgs[1]:find("ATTACK", 1, true) == nil, + "...and the untranslated English stat name is gone") +end) + +-- --------------------------------------------------------- player vitamin + +local target = Pokemon.new(Data, "FIXMON_A", 10) +withCatalog({ DEFENSE = "DEFENSE_FR" }, function() + local _, msgs = ItemEffects.use(Data, save, "IRON", target) + T.check(msgs[1]:find("DEFENSE_FR", 1, true) ~= nil, + "a catalog translating DEFENSE reaches the IRON (vitamin) rose! message") +end) + +local hpTarget = Pokemon.new(Data, "FIXMON_A", 10) +withCatalog({ HP = "PV" }, function() + local _, msgs = ItemEffects.use(Data, save, "HP_UP", hpTarget) + T.check(msgs[1]:find("PV", 1, true) ~= nil, + "a catalog translating HP reaches the HP UP rose! message") +end) + +-- ------------------------------------------------------- AI trainer X-item + +local enemy = { name = "FOE", stages = {} } +local aiBattle = { enemy = enemy, trainer = { name = "TRAINER" }, data = Data } + +withCatalog({ SPEED = "VITESSE" }, function() + local msgs = TrainerAI.useItem(aiBattle, "X_SPEED") + T.check(msgs[2]:find("VITESSE", 1, true) ~= nil, + "a catalog translating SPEED reaches the AI trainer's X SPEED rose! message") +end) + +-- ------------------------------------------------------- Gold: Light Screen / Reflect + +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") + +local GEN2_DATA = { + pokemon = { + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + }, + type_chart = { types = { NORMAL = { id = "NORMAL", index = 0, + category = "physical" } }, matchups = {} }, + items = {}, +} +local perfectDvs = { attack = 15, defense = 15, speed = 15, special = 15 } +perfectDvs.hp = Gen2Mon.hpDV(perfectDvs) + +local function newGen2Battle() + local player = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs }) + player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local wild = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + return Gen2Battle.new({ data = GEN2_DATA, party = { player }, wild = wild }) +end + +withCatalog({ ["%s's SPCL.DEF rose!"] = "%s voit sa DEF.SPÉ augmenter !" }, + function() + local lsBattle = newGen2Battle() + Gen2Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN(lsBattle, lsBattle.player) + local events = lsBattle:takeEvents() + local found = false + for _, event in ipairs(events) do + if event.kind == "message" + and event.text:find("DEF.SPÉ augmenter", 1, true) then + found = true + end + end + T.check(found, + "a catalog translating Light Screen's rose! message reaches it") + end) + +withCatalog({ ["%s's DEFENSE rose!"] = "%s voit sa DEFENSE augmenter !" }, + function() + local refBattle = newGen2Battle() + Gen2Battle.MOVE_EFFECTS.EFFECT_REFLECT(refBattle, refBattle.player) + local events = refBattle:takeEvents() + local found = false + for _, event in ipairs(events) do + if event.kind == "message" + and event.text:find("DEFENSE augmenter", 1, true) then + found = true + end + end + T.check(found, + "a catalog translating Reflect's rose! message reaches it") + end) + +T.finish("stat rise message translation")