mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
462 lines
15 KiB
Lua
462 lines
15 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,
|
|
trainerType = 1,
|
|
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, 65 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, trainerType = 1,
|
|
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, trainerType = 1,
|
|
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, 65 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")
|
|
|
|
print("[test] 8. Trainer Spot Audio & SFX Choreography (SE_PIN + Encounter Music)")
|
|
Field.locked = false
|
|
FieldEffects.invalidate()
|
|
Objects.clearMovements()
|
|
|
|
local playedSeId = nil
|
|
local playedSongId = nil
|
|
local Audio = require("src.core.game3.audio")
|
|
Audio.playSe = function(id)
|
|
playedSeId = id
|
|
return true
|
|
end
|
|
Audio.playSong = function(id)
|
|
playedSongId = id
|
|
return true
|
|
end
|
|
|
|
local sightTrainer = {
|
|
localId = 5,
|
|
cellX = 10,
|
|
cellY = 10,
|
|
px = 160,
|
|
py = 160,
|
|
facing = "down",
|
|
sight = 4,
|
|
trainerType = 1,
|
|
elevation = 0,
|
|
visible = true,
|
|
hidden = false,
|
|
moving = false,
|
|
frozen = false,
|
|
scriptBusy = false,
|
|
trainerId = 10,
|
|
scriptKey = "trainer_battle_01",
|
|
}
|
|
Objects._byId[5] = sightTrainer
|
|
Objects._order = { 5 }
|
|
Player.reset(10, 14, "up") -- 4 tiles down
|
|
|
|
-- Spot trainer
|
|
TrainerSight.check(dummyGame)
|
|
check(playedSeId == 21, "SE_PIN (21) played immediately on spot")
|
|
check(playedSongId == 284 or playedSongId == 285 or playedSongId == 283, "Encounter theme started immediately on spot (song=" .. tostring(playedSongId) .. ")")
|
|
|
|
print("[test] 9. TRAINER_TYPE_NONE with leftover sight never engages (SSAnne_1F_Room2 Woman)")
|
|
scripts["ssanne_woman_msgbox"] = {
|
|
{ op = "msgbox", text = "cruising" },
|
|
{ op = "end" },
|
|
}
|
|
local function resetField()
|
|
Field.locked = false
|
|
FieldEffects.invalidate()
|
|
Objects.clearMovements()
|
|
playedSeId = nil
|
|
playedSongId = nil
|
|
end
|
|
local startedKey = nil
|
|
Space.startScript = function(key) startedKey = key end
|
|
|
|
resetField()
|
|
local woman = {
|
|
localId = 3, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
|
sight = 1, trainerType = 0,
|
|
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
|
scriptKey = "ssanne_woman_msgbox",
|
|
}
|
|
Objects.clear()
|
|
Objects._byId[3] = woman
|
|
Objects._order = { 3 }
|
|
Player.reset(2, 5, "down")
|
|
check(TrainerSight.checkLineOfSight(woman, Player, dummyGame) == true,
|
|
"Woman's leftover range-1 cone covers the player")
|
|
check(TrainerSight.isTrainerType ~= nil and TrainerSight.isTrainerType(woman) == false,
|
|
"trainerType 0 is not a trainer")
|
|
check(TrainerSight.check(dummyGame) == false, "Idle sight check does not engage the Woman")
|
|
check(Field.locked == false, "Field stays unlocked")
|
|
check(woman.scriptBusy == false, "Woman not claimed")
|
|
check(playedSeId == nil and playedSongId == nil, "No SE_PIN / encounter music")
|
|
resetField()
|
|
woman.scriptBusy = false
|
|
woman.frozen = false
|
|
woman.moving = false
|
|
woman.facing = "up"
|
|
startedKey = nil
|
|
Player.reset(2, 5, "down")
|
|
check(TrainerSight.check(dummyGame, woman) == false, "Spinning-hook path does not engage the Woman")
|
|
check(Field.locked == false and woman.scriptBusy == false and playedSeId == nil,
|
|
"Spinning-hook path leaves the field alone")
|
|
resetField()
|
|
woman.scriptBusy = false
|
|
woman.frozen = false
|
|
woman.moving = false
|
|
woman.facing = "up"
|
|
startedKey = nil
|
|
Player.reset(2, 5, "down")
|
|
for _ = 1, 3 do TrainerSight.check(dummyGame) end
|
|
check(Field.locked == false and startedKey == nil, "Repeated idle checks never loop the msgbox")
|
|
|
|
resetField()
|
|
local seeAll = {
|
|
localId = 4, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
|
sight = 1, trainerType = 2,
|
|
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
|
scriptKey = "trainer_battle_02",
|
|
}
|
|
Objects.clear()
|
|
Objects._byId[4] = seeAll
|
|
Objects._order = { 4 }
|
|
Player.reset(2, 5, "down")
|
|
check(TrainerSight.check(dummyGame) == false, "TRAINER_TYPE_SEE_ALL_DIRECTIONS (2) is not scanned")
|
|
|
|
resetField()
|
|
local buried = {
|
|
localId = 6, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
|
sight = 1, trainerType = 3,
|
|
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
|
scriptKey = "trainer_battle_02",
|
|
}
|
|
Objects.clear()
|
|
Objects._byId[6] = buried
|
|
Objects._order = { 6 }
|
|
Player.reset(2, 5, "down")
|
|
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_BURIED (3) still engages")
|
|
check(Field.locked == true and buried.scriptBusy == true, "Buried trainer claims the field")
|
|
|
|
resetField()
|
|
local normal = {
|
|
localId = 7, cellX = 2, cellY = 6, px = 32, py = 96, facing = "up",
|
|
sight = 1, def = { trainerType = 1 },
|
|
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
|
scriptKey = "trainer_battle_01",
|
|
}
|
|
Objects.clear()
|
|
Objects._byId[7] = normal
|
|
Objects._order = { 7 }
|
|
Player.reset(2, 5, "down")
|
|
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_NORMAL (1) from def engages")
|
|
resetField()
|
|
|
|
print("[test] 10. Wandering/Pacing Trainer (e.g. Route 3 Lass) Walk-Up and Permanent Stay Conversion")
|
|
local pacer = {
|
|
localId = 2, cellX = 40, cellY = 11, px = 640, py = 176, homeX = 40, homeY = 11,
|
|
facing = "down", sight = 3, trainerType = 1,
|
|
movement = "WALK", movementType = 0x03, range = "UP_DOWN", radius = { x = 1, y = 1 },
|
|
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
|
scriptKey = "trainer_battle_01",
|
|
def = { localId = 2, x = 40, y = 11, movementType = 0x03, movement = "WALK", range = "UP_DOWN" },
|
|
}
|
|
Objects.clear()
|
|
Objects._byId[2] = pacer
|
|
Objects._order = { 2 }
|
|
-- Player stands 3 tiles below pacer at (40, 14)
|
|
Player.reset(40, 14, "up")
|
|
check(TrainerSight.check(dummyGame) == true, "Pacing trainer spots player 3 tiles away")
|
|
check(Field.locked == true, "Field locked during exclamation and walk-up")
|
|
|
|
-- Simulate frames for exclamation effect and approach walk track
|
|
for _ = 1, 100 do
|
|
FieldEffects.step()
|
|
Objects.update(dummyGame)
|
|
end
|
|
|
|
check(pacer.cellX == 40 and pacer.cellY == 13, "Pacer walked 2 steps down to tile adjacent to player (40, 13)")
|
|
check(pacer.movement == "STAY", "Pacer movement changed to STAY")
|
|
check(pacer.movementType == 0x08, "Pacer movementType changed to MOVEMENT_TYPE_FACE_DOWN (0x08)")
|
|
check(pacer.homeX == 40 and pacer.homeY == 13, "Pacer home coordinates updated to post-approach location (40, 13)")
|
|
|
|
-- Verify that subsequent idle updates do not cause the trainer to resume pacing
|
|
pacer.idleTimer = 0
|
|
for _ = 1, 10 do
|
|
Objects.update(dummyGame)
|
|
end
|
|
check(pacer.moving == false and pacer.cellX == 40 and pacer.cellY == 13, "Trainer remains stationary and does not resume wander cycle")
|
|
|
|
resetField()
|
|
|
|
if failed > 0 then
|
|
print(string.format("\n%d FAILURE(S)", failed))
|
|
os.exit(1)
|
|
end
|
|
print("\nAll trainer sight checks passed successfully!")
|