A bit of testing for baserunning and fielding.

Required minor reworking to get those test harnesses in place.
This commit is contained in:
Sage Vaillancourt 2025-02-12 22:49:37 -05:00
parent a801b64f55
commit 027bb31bff
9 changed files with 287 additions and 22 deletions

View File

@ -19,7 +19,7 @@ Baserunning = {}
-- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked. -- TODO: Implement slides. Would require making fielders' gloves "real objects" whose state is tracked.
---@param announcer any ---@param announcer Announcer
---@return Baserunning ---@return Baserunning
function Baserunning.new(announcer, onThirdOut) function Baserunning.new(announcer, onThirdOut)
local o = setmetatable({ local o = setmetatable({
@ -34,7 +34,7 @@ function Baserunning.new(announcer, onThirdOut)
onThirdOut = onThirdOut, onThirdOut = onThirdOut,
}, { __index = Baserunning }) }, { __index = Baserunning })
o.batter = o:newRunner() o:pushNewBatter()
return o return o
end end
@ -79,6 +79,7 @@ end
function Baserunning:outEligibleRunners(fielder) function Baserunning:outEligibleRunners(fielder)
local touchedBase = utils.isTouchingBase(fielder.x, fielder.y) local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
local didOutRunner = false local didOutRunner = false
for i, runner in pairs(self.runners) do for i, runner in pairs(self.runners) do
local runnerOnBase = utils.isTouchingBase(runner.x, runner.y) local runnerOnBase = utils.isTouchingBase(runner.x, runner.y)
if -- Force out if -- Force out
@ -116,6 +117,14 @@ function Baserunning:updateForcedRunners()
end end
end end
function Baserunning:convertBatterToRunner()
self.batter.nextBase = C.Bases[C.First]
self.batter.prevBase = C.Bases[C.Home]
self:updateForcedRunners()
self.batter.forcedTo = C.Bases[C.First]
self.batter = nil -- Demote batter to a mere runner
end
---@param deltaSeconds number ---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds) function Baserunning:walkAwayOutRunners(deltaSeconds)
for i, runner in ipairs(self.outRunners) do for i, runner in ipairs(self.outRunners) do
@ -129,7 +138,7 @@ function Baserunning:walkAwayOutRunners(deltaSeconds)
end end
---@return Runner ---@return Runner
function Baserunning:newRunner() function Baserunning:pushNewBatter()
local new = { local new = {
-- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol. -- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
x = C.RightHandedBattersBox.x - 60, x = C.RightHandedBattersBox.x - 60,
@ -139,6 +148,7 @@ function Baserunning:newRunner()
forcedTo = C.Bases[C.First], forcedTo = C.Bases[C.First],
} }
self.runners[#self.runners + 1] = new self.runners[#self.runners + 1] = new
self.batter = new
return new return new
end end
@ -244,3 +254,7 @@ function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSecon
return someRunnerMoved, runnersScored return someRunnerMoved, runnersScored
end end
if not playdate or playdate.TEST_MODE then
return Baserunning
end

View File

@ -2,8 +2,8 @@
C = {} C = {}
C.Screen = { C.Screen = {
W = playdate.display.getWidth(), W = playdate and playdate.display.getWidth() or 400,
H = playdate.display.getHeight(), H = playdate and playdate.display.getHeight() or 240,
} }
C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2) C.Center = utils.xy(C.Screen.W / 2, C.Screen.H / 2)
@ -131,3 +131,7 @@ C.UserThrowPower = 0.3
C.WalkedRunnerSpeed = 10 C.WalkedRunnerSpeed = 10
C.ResetFieldersAfterSeconds = 2.5 C.ResetFieldersAfterSeconds = 2.5
if not playdate then
return C
end

View File

@ -21,9 +21,9 @@ end
-- Only works if called with the bases empty (i.e. the only runner should be the batter. -- Only works if called with the bases empty (i.e. the only runner should be the batter.
-- selene: allow(unused_variable) -- selene: allow(unused_variable)
function dbg.loadTheBases(br) function dbg.loadTheBases(br)
br:newRunner() br:pushNewBatter()
br:newRunner() br:pushNewBatter()
br:newRunner() br:pushNewBatter()
br.runners[2].x = C.Bases[C.First].x br.runners[2].x = C.Bases[C.First].x
br.runners[2].y = C.Bases[C.First].y br.runners[2].y = C.Bases[C.First].y

View File

@ -195,3 +195,7 @@ function Fielding:drawFielders(fielderSprites, ball)
end end
return ballIsHeld return ballIsHeld
end end
if not playdate or playdate.TEST_MODE then
return { Fielding, newFielder }
end

View File

@ -248,13 +248,14 @@ local function nextBatter()
playdate.timer.new(2000, function() playdate.timer.new(2000, function()
pitchTracker:reset() pitchTracker:reset()
if not baserunning.batter then if not baserunning.batter then
baserunning.batter = baserunning:newRunner() baserunning:pushNewBatter()
end end
end) end)
end end
local function walk() local function walk()
announcer:say("Walk!") announcer:say("Walk!")
-- TODO? Use baserunning:convertBatterToRunner()
baserunning.batter.nextBase = C.Bases[C.First] baserunning.batter.nextBase = C.Bases[C.First]
baserunning.batter.prevBase = C.Bases[C.Home] baserunning.batter.prevBase = C.Bases[C.Home]
offenseState = C.Offense.walking offenseState = C.Offense.walking
@ -310,11 +311,7 @@ local function updateBatting(batDeg, batSpeed)
return return
end end
baserunning.batter.nextBase = C.Bases[C.First] baserunning:convertBatterToRunner()
baserunning.batter.prevBase = C.Bases[C.Home]
baserunning:updateForcedRunners()
baserunning.batter.forcedTo = C.Bases[C.First]
baserunning.batter = nil -- Demote batter to a mere runner
fielding:haveSomeoneChase(ballDestX, ballDestY) fielding:haveSomeoneChase(ballDestX, ballDestY)
end end
@ -427,7 +424,7 @@ local function updateGameState()
offenseState = C.Offense.batting offenseState = C.Offense.batting
-- TODO: Remove, or replace with nextBatter() -- TODO: Remove, or replace with nextBatter()
if not baserunning.batter then if not baserunning.batter then
baserunning.batter = baserunning:newRunner() baserunning:pushNewBatter()
end end
end end
end end
@ -447,8 +444,8 @@ local function updateGameState()
end end
if fielderHoldingBall then if fielderHoldingBall then
local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall) local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
if not userOnDefense then if not userOnDefense and offenseState == C.Offense.running then
npc:fielderAction(offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall) npc:fielderAction(fielderHoldingBall, outedSomeRunner, ball, launchBall)
end end
end end
@ -456,6 +453,8 @@ local function updateGameState()
actionQueue:runWaiting(deltaSeconds) actionQueue:runWaiting(deltaSeconds)
end end
-- TODO: Swappable update() for main menu, etc.
function playdate.update() function playdate.update()
playdate.timer.updateTimers() playdate.timer.updateTimers()
gfx.animation.blinker.updateAll() gfx.animation.blinker.updateAll()

View File

@ -136,15 +136,11 @@ local function tryToMakeAPlay(fielders, fielder, runners, ball, launchBall)
end end
end end
---@param offenseState OffenseState
---@param fielder Fielder ---@param fielder Fielder
---@param outedSomeRunner boolean ---@param outedSomeRunner boolean
---@param ball { x: number, y: number, heldBy: Fielder | nil } ---@param ball { x: number, y: number, heldBy: Fielder | nil }
---@param launchBall LaunchBall ---@param launchBall LaunchBall
function Npc:fielderAction(offenseState, fielder, outedSomeRunner, ball, launchBall) function Npc:fielderAction(fielder, outedSomeRunner, ball, launchBall)
if offenseState ~= C.Offense.running then
return
end
if outedSomeRunner then if outedSomeRunner then
-- Delay a little before the next play -- Delay a little before the next play
playdate.timer.new(750, function() playdate.timer.new(750, function()

21
src/test/mocks.lua Normal file
View File

@ -0,0 +1,21 @@
utils = require("utils")
local mockPlaydate = {
TEST_MODE = true,
graphics = {
animator = {
new = function()
return utils.staticAnimator(0)
end,
},
},
}
---@type Announcer
local mockAnnouncer = {
say = function(self, message)
self.lastMessage = message
end,
}
return { mockPlaydate, mockAnnouncer }

View File

@ -0,0 +1,170 @@
import = function() end
luaunit = require("luaunit")
luaunit.ORDER_ACTUAL_EXPECTED = false
utils = require("utils")
C = require("constants")
mocks = require("test/mocks")
playdate, announcer = mocks[1], mocks[2]
Baserunning = require("baserunning")
local _f = require("fielding")
Fielding, newFielder = _f[1], _f[2]
---@return Baserunning, { called: boolean }
function buildBaserunning()
local thirdOutCallbackData = { called = false }
local baserunning = Baserunning.new(announcer, function()
thirdOutCallbackData.called = true
end)
return baserunning, thirdOutCallbackData
end
---@alias BaseIndexOrXyPair (integer | XyPair)
--- NOTE: in addition to the given runners, there is implicitly a batter running from first.
---@param runnerLocations BaseIndexOrXyPair[]
---@return Baserunning
function buildRunnersOn(runnerLocations)
local baserunning = buildBaserunning()
baserunning:convertBatterToRunner()
for _, location in ipairs(runnerLocations) do
baserunning:pushNewBatter()
local runner = baserunning.batter
baserunning:convertBatterToRunner()
if type(location) == "number" then
-- Is a base index
-- Push the runner *through* each base.
for b = 1, location do
runner.x = C.Bases[b].x
runner.y = C.Bases[b].y
baserunning:updateNonBatterRunners(0.001, false, 0.001)
end
else
-- Is a raw XyPair
runner.x = location.x
runner.y = location.y
end
end
return baserunning
end
---@alias Condition { fielderWithBallAt: XyPair, outWhen: BaseIndexOrXyPair[][], safeWhen: BaseIndexOrXyPair[][] }
---@param expected boolean
---@param fielderWithBallAt XyPair
---@param when integer[][]
function assertRunnerOutCondition(expected, when, fielderWithBallAt)
local msg = expected and "out" or "safe"
for _, runnersOn in ipairs(when) do
local baserunning = buildRunnersOn(runnersOn)
local outedSomeRunner = baserunning:outEligibleRunners(fielderWithBallAt)
luaunit.failIf(outedSomeRunner ~= expected, "Runner should have been " .. msg .. ", but was not!")
end
end
---@param condition Condition
function assertRunnerStatuses(condition)
assertRunnerOutCondition(true, condition.outWhen, condition.fielderWithBallAt)
assertRunnerOutCondition(false, condition.safeWhen, condition.fielderWithBallAt)
end
function testForceOutsAtFirst()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.First],
outWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
{ 1, 2, 3 },
},
safeWhen = {},
})
end
function testForceOutsAtSecond()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Second],
outWhen = {
{ 1 },
{ 1, 2 },
{ 1, 3 },
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 2 },
{ 3 },
{ 2, 3 },
},
})
end
function testForceOutsAtThird()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {
{ 1, 2 },
{ 1, 2, 3 },
},
safeWhen = {
{ 1 },
{ 2 },
{ 3 },
{ 2, 3 },
{ 1, 3 },
},
})
end
function testForceOutsAtHome()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Home],
outWhen = {
{ 1, 2, 3 },
},
safeWhen = {
{},
{ 1 },
{ 2 },
{ 3 },
{ 1, 2 },
{ 1, 3 },
{ 2, 3 },
},
})
end
function testTagOutsShouldHappenOffBase()
local fielderWithBallAt = utils.xy(10, 10) -- Some location not on a base.
local farFromFielder = utils.xy(100, 100)
assertRunnerStatuses({
fielderWithBallAt = fielderWithBallAt,
outWhen = {
{ fielderWithBallAt },
},
safeWhen = {
{ farFromFielder },
},
})
end
function testTagOutsShouldNotHappenOnBase()
assertRunnerStatuses({
fielderWithBallAt = C.Bases[C.Third],
outWhen = {},
safeWhen = {
{ 2 },
{ 3 },
{ 2, 3 },
{ 2, 3, 4 },
},
})
end
os.exit(luaunit.LuaUnit.run())

57
src/test/testFielding.lua Normal file
View File

@ -0,0 +1,57 @@
import = function() end
luaunit = require("luaunit")
luaunit.ORDER_ACTUAL_EXPECTED = false
utils = require("utils")
C = require("constants")
mocks = require("test/mocks")
playdate = mocks[1]
_f = require("fielding")
Fielding = _f[1]
---@return Fielding, number fielderCount
local function fieldersAtDefaultPositions()
local fielding = Fielding.new()
fielding:resetFielderPositions()
local fielderCount = 0
for _, fielder in pairs(fielding.fielders) do
fielder.x = fielder.target.x
fielder.y = fielder.target.y
fielderCount = fielderCount + 1
end
return fielding, fielderCount
end
---@param x number
---@param y number
---@param z number | nil
function fakeBall(x, y, z)
return {
x = x,
y = y,
z = z or 0,
heldBy = nil,
}
end
function testBallPickedUpByNearbyFielders()
local fielding, fielderCount = fieldersAtDefaultPositions()
luaunit.assertIs("table", type(fielding))
luaunit.assertIs("table", type(fielding.fielders))
luaunit.assertEquals(9, fielderCount)
local ball = fakeBall(-100, -100, -100)
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIsNil(ball.heldBy, "Ball should not be held by a fielder yet")
local secondBaseman = fielding.fielders.second
ball.x = secondBaseman.x
ball.y = secondBaseman.y
fielding:updateFielderPositions(ball, 0.01)
luaunit.assertIs(secondBaseman, ball.heldBy, "Ball should be held by the nearest fielder")
end
os.exit(luaunit.LuaUnit.run())