Files
gen1recomp/tests/gen2_npc_interact_test.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

236 lines
8.6 KiB
Lua

-- Talking to overworld objects: the Route 30 roadblock's Rattata turn to face
-- the player (ObjectEvent is `jumptextfaceplayer`, home/map.asm, and
-- SPRITE_MONSTER is a WALKING_SPRITE so ApplyObjectFacing really turns it),
-- and the talked-to / engaged object holds still for the conversation the way
-- FreezeAllOtherObjects + EndScript's UnfreezeAllObjects bracket it on the
-- cart -- a SPINRANDOM trainer must not roll a new facing under his own
-- sighting text.
--
-- luajit tests/gen2_npc_interact_test.lua (ROM-free)
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 npc interact")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
local World = require("src.world.gen2.World")
local NPC = require("src.world.gen2.Npc")
local Vm = require("src.script.gen2.Vm")
local Permissions = require("src.world.gen2.Permissions")
local COLL_FLOOR = 0x00
-- The gen2_world_test rig, trimmed: a flat map, a fake player, and NPCs built
-- as NPC-shaped tables (a real NPC.new needs a sprite sheet and a graphics
-- device) so facePlayer / update / scriptFace are the shipped methods.
local MAP_W, MAP_H = 10, 10
local function fakeMap()
local map
map = {
id = "TEST_MAP",
width = MAP_W, height = MAP_H,
def = { bgEvents = {}, objects = {}, width = MAP_W, height = MAP_H },
cellCollision = function() return COLL_FLOOR end,
inBounds = function(_, x, y)
return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2
end,
isWalkable = function() return true end,
warpAt = function() return nil end,
}
return map
end
local function fakePlayer(x, y, facing)
return {
cellX = x, cellY = y, px = x * 16, py = y * 16,
facing = facing, moving = false, turnArmed = true,
update = function() return false end,
setSprite = function() end,
}
end
local function npcAt(def, x, y, facing, kind)
return setmetatable({
def = def, id = "npc_" .. tostring(def.index),
cellX = x, cellY = y, homeX = x, homeY = y,
px = x * 16, py = y * 16,
facing = facing, moving = false, progress = 0, stepFlip = false,
frozen = false, kind = kind or "stand",
roamDirs = { "up", "down", "left", "right" },
radiusX = 0, radiusY = 0, spinLo = 1, spinHi = 1, timer = 1,
}, NPC)
end
local function talkWorld(opts)
opts = opts or {}
local game = {
data = {},
save = { player = { name = "GOLD" }, party = opts.party or {},
inventory = {} },
}
local world = World.new(game)
game.world = world
world.map = fakeMap()
world.maps = { TEST_MAP = world.map.def }
world.player = fakePlayer(opts.px or 4, opts.py or 5, opts.facing or "right")
world.pollTimeOfDay = function() end
local log = {}
world.log = log
world.showText = function(self, body, onDone)
log[#log + 1] = body
self.textbox = true
self.pendingText = function()
self.textbox = nil
if onDone then onDone() end
end
end
world.vm = Vm.new(opts.scripts or {}, opts.texts or {}, world.events, {
showText = function(body, onDone) world:showText(body, onDone) end,
-- The shipped hook body from World:load, restated over this rig: the
-- talked-to NPC turns through the real NPC:facePlayer.
facePlayer = function()
if world.talkNpc and world.player then
world.talkNpc:facePlayer(world.player)
end
end,
showEmote = function() end,
encounterMusic = function() end,
trainerApproach = function(onDone) world:trainerApproach(onDone) end,
lookupTrainer = function()
return { name = "DON", class = 36, member = 1 }
end,
startBattle = function(_, _, onDone) onDone("win") end,
reloadMap = function() end,
})
return world
end
local function advanceText(world)
local fn = world.pendingText
world.pendingText = nil
if fn then fn() end
end
-- ---- the roadblock monsters turn to the player -----------------------------
-- maps/Route30.asm: both SPRITE_MONSTER rows point at ObjectEvent, one
-- STANDING_DOWN and one STANDING_UP -- they face each other until spoken to.
do
local OBJECT_EVENT = "00:2812"
local monster = { index = 7, sprite = "SPRITE_MONSTER", movement = 7,
scriptKey = OBJECT_EVENT }
local world = talkWorld({
scripts = { [OBJECT_EVENT] = {
{ op = "jumptextfaceplayer", text = "00:2815" },
} },
texts = { ["00:2815"] = "Object event." },
})
eq(NPC.new and select(1, "x"), "x", "NPC module loaded")
local rattata = npcAt(monster, 5, 5, "up")
world.npcs = { rattata }
world.entities = { world.player, rattata }
check(world:interact(), "A from the west starts the ObjectEvent script")
eq(rattata.facing, "left", "the spoken-to Rattata turns to face the player")
eq(world.log[1], "Object event.", "and its line prints")
check(rattata.frozen, "it holds still while its text is up")
advanceText(world)
for _ = 1, 3 do world:step() end
check(not rattata.frozen, "and is released when the interaction ends")
end
-- ---- a spinning trainer cannot spin under his own sighting text ------------
local BEAT_FLAG = 1000
local record = { event = BEAT_FLAG, class = 36, member = 1,
seenText = "t:seen", winText = "t:win", scriptKey = "s:after" }
local function trainerWorld(px, py, facing, npcFacing)
local world = talkWorld({
px = px, py = py, facing = facing,
party = { { species = "RATTATA", hp = 10 } },
scripts = { ["s:after"] = { { op = "end" } } },
texts = {
["t:seen"] = "Instead of a bug\nPOKéMON, I found\va trainer!",
["t:win"] = "Deary me.",
},
})
local don = { index = 4, sprite = "SPRITE_BUG_CATCHER", movement = 10,
trainer = record, sight = 3 }
local npc = npcAt(don, 4, 7, npcFacing, "spin")
world.npcs = { npc }
world.entities = { world.player, npc }
return world, npc
end
do
-- Player one cell below the trainer, trainer facing down: engagement.
local world, npc = trainerWorld(4, 8, "up", "down")
check(world:checkTrainerBattle(), "inside the cone the trainer engages")
check(npc.frozen, "and freezes for the whole engagement")
local before = npc.facing
npc.timer = 1
for _ = 1, 30 do world:updatePeople() end
eq(npc.facing, before, "his spin cannot roll a new facing mid-script")
-- Drain the engagement: the emote's 30-frame hold, the seen text, the
-- battle, the after script. updatePeople keeps running under it, which is
-- exactly when an unfrozen spinner would drift.
local drifted = false
for _ = 1, 60 do
world:step()
world:updatePeople()
if world.vm:running() and npc.facing ~= before then drifted = true end
advanceText(world)
end
eq(world.log[1], "Instead of a bug\nPOKéMON, I found\va trainer!",
"the sighting text is the struct's seen text, verbatim")
check(not drifted, "and the spin never rolled while the script ran")
check(not world.vm:running(), "the engagement script finished")
check(world.events:get(BEAT_FLAG), "and the trainer is marked beaten")
for _ = 1, 3 do world:step() end
check(not npc.frozen, "the freeze lifts once everything settles")
end
do
-- Player BEHIND the trainer (he faces down, player stands above): nothing.
local world = trainerWorld(4, 6, "down", "down")
check(not world:checkTrainerBattle(),
"standing behind his facing never engages")
eq(#world.log, 0, "and no sighting text prints")
end
do
-- Off his column entirely: nothing, whatever the range.
local world = trainerWorld(6, 8, "up", "down")
check(not world:checkTrainerBattle(), "off the sight line never engages")
end
do
-- Facing him but beyond sight 3: nothing.
local world, npc = trainerWorld(4, 11, "up", "down")
npc.cellY = 7
check(not world:checkTrainerBattle(), "past the sight range never engages")
end
do
-- CheckTrainerEvent is PlayerEvents' FIRST test (engine/overworld/events.asm:
-- 245) and, unlike every arm of CheckTileEvent, it is NOT behind
-- wEnabledPlayerEvents -- MapEvents clears that byte every pass
-- (events.asm:168) and CheckPlayerState only re-sets it on a step that
-- landed (events.asm:210-221). So the sight cone is polled on every
-- overworld frame, not only on the frame a step lands: a spinner that
-- rotates onto a STANDING player engages there and then. Nothing is called
-- directly here -- that is the point, the call SITE is what was wrong.
local world, npc = trainerWorld(4, 8, "up", "up")
world.updatePeople = function() end
world:step()
check(not world.vm:running(),
"a trainer facing away leaves the standing player alone")
npc.facing = "down"
for _ = 1, 3 do world:step() end
check(world.vm:running(),
"and the frame he turns onto the player's line, he engages -- no step needed")
check(npc.frozen, "the engagement freezes him the same way")
end
S.finish()