Files
gen1recomp/tests/game3_trainer_sight_test.lua
T
1jamie 6874e3705b feat(game3): extract ROM trainers, line-of-sight sight check, dialogues, and battle rewards
- Add full 743 trainer ROM extraction from gTrainers with 4-tier party struct support
- Account for flat IV scaling math ((rawIv * 31) / 255) and enforce 0 EVs for trainer Pokémon
- Decompose 32-bit AI script flags for runtime execution
- Implement backwards-search fallback for boss dialogue disconnect (0x1, 0x3, 0x9)
- Implement TrainerSight line-of-sight raycast with elevation & ledge masking and spinning trainer turn hooks
- Support pre-battle intro dialogue, encounter music, and in-battle defeat quotes
- Implement authentic prize money calculation and reward triggering
- Fix move name resolution for table-wrapped party moves in battle UI and party menus
2026-09-09 18:19:45 -05:00

285 lines
9.1 KiB
Lua

#!/usr/bin/env luajit
-- Comprehensive Game 3 Trainer Line of Sight Test Suite
-- Tests all 5 Game Freak overworld quirks:
-- 1. Elevation & Ledge Masking
-- 2. The Spinning Trainer Hook
-- 3. Menu Dismissal Frame Trap
-- 4. Zero-Distance Walk-Ups (dist = 1)
-- 5. Simultaneous Spot Prioritization
package.path = "./?.lua;./?/init.lua;" .. package.path
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local TrainerSight = require("src.core.game3.trainer_sight")
local Objects = require("src.core.game3.objects")
local Player = require("src.core.game3.player")
local Field = require("src.core.game3.field")
local FieldEffects = require("src.core.game3.field_effects")
local Collision = require("src.core.game3.collision")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
local Vm = require("src.core.game3.scripting.vm")
local dummyGame = {
data = {
maps = {
TEST_MAP = {
width = 30,
height = 30,
midLayout = nil,
}
}
}
}
-- Setup clean mock store & bundle
local store = Flags.newStore()
Space.store = store
local scripts = {
["trainer_battle_01"] = {
{ op = "trainerbattle", type = 0, trainer = 10, introText = "t1", defeatText = "t2" },
{ op = "end" },
},
["trainer_battle_02"] = {
{ op = "trainerbattle", type = 0, trainer = 20, introText = "t1", defeatText = "t2" },
{ op = "end" },
},
}
Space.bundle = {
scripts = scripts,
text = {},
movements = {},
events = {
TEST_MAP = {
objects = {},
}
}
}
Space.vm = Vm.new({
store = store,
scripts = scripts,
adapters = { log = function() end },
})
print("[test] 1. Directional Line of Sight Raycasting (Down, Up, Left, Right)")
local eo = {
localId = 1,
cellX = 10,
cellY = 10,
px = 160,
py = 160,
facing = "down",
sight = 4,
elevation = 0,
visible = true,
hidden = false,
moving = false,
frozen = false,
scriptBusy = false,
scriptKey = "trainer_battle_01",
}
-- Mock Collision.canEnter to default true
Collision.canEnter = function(_g, _x, _y, _opts) return true end
Collision.isLedge = function(_x, _y) return false end
Collision.cell = function(_x, _y) return 0x00 end
Player.reset(10, 13, "up") -- 3 tiles down from eo (within sight 4)
local spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true and dist == 3, "Spotted 3 tiles down")
Player.reset(10, 15, "up") -- 5 tiles down (out of sight 4)
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == false, "Not spotted out of range (5 tiles)")
Player.reset(11, 13, "up") -- Misaligned X
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == false, "Not spotted when misaligned")
-- Test other facings
eo.facing = "up"
Player.reset(10, 8, "down") -- 2 tiles up
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true and dist == 2, "Spotted 2 tiles up")
eo.facing = "left"
Player.reset(6, 10, "right") -- 4 tiles left
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true and dist == 4, "Spotted 4 tiles left")
eo.facing = "right"
Player.reset(14, 10, "left") -- 4 tiles right
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true and dist == 4, "Spotted 4 tiles right")
print("[test] 2. Elevation and Ledge Masking")
eo.facing = "down"
Player.reset(10, 13, "up")
-- Different elevation (eo on bridge elevation 4 vs player on ground 3)
eo.elevation = 4
Player.elevation = 3
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == false, "Blocked by elevation mismatch (eo=4 bridge, player=3 ground)")
eo.elevation = 3
Player.elevation = 3
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true, "Spotted when elevations match (eo=3, player=3)")
eo.elevation = 0
Player.elevation = 0
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true, "Spotted when default ground elevations match (eo=0, player=0)")
-- Ledge masking: tile (10, 12) has a one-way ledge hop (MB_JUMP_SOUTH = 0x38)
Collision.cell = function(x, y)
if x == 10 and y == 12 then return 0x38 end
return 0x00
end
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == false, "Blocked by one-way ledge boundary byte (0x38)")
-- Solid obstacle (wall/tree) at (10, 11)
Collision.cell = function() return 0x00 end
Collision.canEnter = function(_g, x, y, _opts)
if x == 10 and y == 11 then return false end
return true
end
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == false, "Blocked by solid obstacle tile at (10, 11)")
Collision.canEnter = function() return true end
print("[test] 3. Defeated Trainer Flag Check")
eo.facing = "down"
Player.reset(10, 12, "up")
check(TrainerSight.isDefeated(eo, store, Space.vm.ctx) == false, "Trainer not defeated yet")
-- Mark trainer 10 as defeated
local flagId = Flags.trainerFlagId(10)
Flags.setFlag(store, nil, flagId, true)
check(TrainerSight.isDefeated(eo, store, Space.vm.ctx) == true, "Trainer is defeated after flag set")
-- Clear flag for subsequent tests
Flags.setFlag(store, nil, flagId, false)
check(TrainerSight.isDefeated(eo, store, Space.vm.ctx) == false, "Trainer flag cleared")
print("[test] 4. Zero-Distance Walk-Ups (dist = 1)")
Player.reset(10, 11, "left") -- Player directly in front of trainer (dist = 1)
spotted, dist = TrainerSight.checkLineOfSight(eo, Player, dummyGame)
check(spotted == true and dist == 1, "Spotted at dist = 1")
Field.locked = false
Objects.clear()
Objects._byId[1] = eo
Objects._order = { 1 }
local scriptFired = false
Space.startScript = function(key, localId)
scriptFired = true
check(key == "trainer_battle_01", "Script key matches")
check(localId == 1, "Local ID matches")
end
check(TrainerSight.check(dummyGame) == true, "TrainerSight.check engaged")
check(Field.locked == true, "Field locked on engagement")
-- Drain field effect anim
for _ = 1, 40 do
FieldEffects.step()
end
check(scriptFired == true, "Battle script fired instantly without walk hang")
check(Player.facing == "up", "Player turned to face trainer (up)")
print("[test] 5. Simultaneous Spot Prioritization")
-- Place two trainers both spotting the player on the same frame:
-- Trainer 1 (localId = 1) at (10, 8) facing DOWN (dist = 3)
-- Trainer 2 (localId = 2) at (12, 11) facing LEFT (dist = 2)
Field.locked = false
scriptFired = false
FieldEffects.invalidate()
Objects.clearMovements()
local trainer1 = {
localId = 1, cellX = 10, cellY = 8, px = 160, py = 128, facing = "down", sight = 4,
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
scriptKey = "trainer_battle_01",
}
local trainer2 = {
localId = 2, cellX = 12, cellY = 11, px = 192, py = 176, facing = "left", sight = 4,
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
scriptKey = "trainer_battle_02",
}
Objects.clear()
Objects._byId[1] = trainer1
Objects._byId[2] = trainer2
Objects._order = { 1, 2 }
Player.reset(10, 11, "down")
local engaged = TrainerSight.check(dummyGame)
check(engaged == true, "Sight check engaged")
check(Field.locked == true, "Field locked")
check(trainer1.scriptBusy == true, "Trainer 1 (lower localId) claimed engagement")
check(trainer2.scriptBusy == false, "Trainer 2 suppressed")
print("[test] 6. The Spinning Trainer Hook")
Field.locked = false
FieldEffects.invalidate()
Objects.clearMovements()
trainer1.scriptBusy = false
trainer1.frozen = false
trainer1.moving = false
trainer1.cellX = 10
trainer1.cellY = 8
trainer1.facing = "left" -- Currently looking away from player at (10, 11)
trainer1.movement = "LOOK"
trainer1.range = "ANY_DIR"
trainer1.idleTimer = 1
Objects._byId[1] = trainer1
Objects._order = { 1 }
Player.reset(10, 11, "down")
local spottedFromSpin = false
Space.startScript = function(key, localId)
spottedFromSpin = true
end
-- Force idleTick turn to "down"
trainer1.facing = "down"
TrainerSight.check(dummyGame, trainer1)
check(Field.locked == true, "Field locked from spinning trainer turn")
for _ = 1, 40 do FieldEffects.step() end
for _ = 1, 60 do Objects.update(dummyGame) end
check(spottedFromSpin == true, "Script started from spinning trainer spot")
print("[test] 7. Menu Dismissal Frame Trap")
Field.locked = false
FieldEffects.invalidate()
Objects.clearMovements()
trainer1.scriptBusy = false
trainer1.frozen = false
trainer1.moving = false
trainer1.facing = "down"
Player.reset(10, 12, "right") -- 4 tiles down (within sight 4)
local mockInput = {
wasPressed = function(_, k) return k == "up" end,
isDown = function(_, k) return k == "up" end,
}
-- Player.update runs when menu dismisses
Player.update(dummyGame, mockInput)
check(Field.locked == true, "Player.update trapped by trainer sight before D-pad step")
check(Player.moving == false, "Player prevented from moving away")
if failed > 0 then
print(string.format("\n%d FAILURE(S)", failed))
os.exit(1)
end
print("\nAll trainer sight checks passed successfully!")