This document shows intentional specifications of the components provided by Lively Epsilon.

The following are code examples that are run as tests. So they sometimes use mocks that you could see in the code examples. Statements starting with assert are assumptions on the code that follows. Their name should be self-explanatory.

The documentation also documents internal functions. Please check if a method is supposed to be used in the API reference before using it.

BrokerUpgrade

BrokerUpgrade:new()

BrokerUpgrade:new() config.description, getDescription() fails if no player is given

local upgrade = upgradeMock()

assert.has_error(function() upgrade:getDescription() end)

BrokerUpgrade:new() config.description, getDescription() returns nil if no description is set

local upgrade = upgradeMock({description = nil})

assert.is_nil(upgrade:getDescription(player))

BrokerUpgrade:new() config.description, getDescription() returns the description if it is a function

local description = "This is an upate"
local upgrade
upgrade = upgradeMock({description = function(arg1, arg2)
    assert.is_same(upgrade, arg1)
    assert.is_same(player, arg2)
    return description
end})

assert.is_same(description, upgrade:getDescription(player))

BrokerUpgrade:new() config.description, getDescription() returns the description if it is a string

local description = "This is an upgrade"
local upgrade = upgradeMock({description = description})

assert.is_same(description, upgrade:getDescription(player))

BrokerUpgrade:new() config.id, getId() allows to set an id

local upgrade = upgradeMock({
    id = "fake_upgrade",
})
assert.is_same("fake_upgrade", upgrade:getId())

BrokerUpgrade:new() config.id, getId() fails if id is a number

assert.has_error(function() upgradeMock({id = 42}) end)

BrokerUpgrade:new() config.id, getId() sets a unique id

local upgrade = upgradeMock({id = nil})

assert.is_string(upgrade:getId())
assert.not_same("", upgrade:getId())

local upgrade2 = upgradeMock({id = nil})
assert.not_same(upgrade:getId(), upgrade2:getId())

BrokerUpgrade:new() config.installMessage, getInstallMessage() fails if no player is given

local upgrade = upgradeMock({installMessage = "Foobar" })

assert.has_error(function() upgrade:getInstallMessage() end)

BrokerUpgrade:new() config.installMessage, getInstallMessage() returns nil if no installMessage is set

local upgrade = upgradeMock({installMessage = nil})

assert.is_nil(upgrade:getInstallMessage(player))

BrokerUpgrade:new() config.installMessage, getInstallMessage() returns the message if it is a function

local message = "Thanks for buying that upgrade"
local upgrade
upgrade = upgradeMock({installMessage = function(arg1, arg2)
    assert.is_same(upgrade, arg1)
    assert.is_same(player, arg2)
    return message
end})

assert.is_same(message, upgrade:getInstallMessage(player))

BrokerUpgrade:new() config.installMessage, getInstallMessage() returns the message if it is a string

local message = "Thanks for buying that upgrade"
local upgrade = upgradeMock({installMessage = message })

assert.is_same(message, upgrade:getInstallMessage(player))

BrokerUpgrade:new() config.name, getName() fails if name is a number

assert.has_error(function() upgradeMock({name = 42}) end)

BrokerUpgrade:new() config.name, getName() fails if no name is given

assert.has_error(function() BrokerUpgrade:new({
    onInstall = function() end,
}) end)

BrokerUpgrade:new() config.name, getName() returns the name if it is a string

local name = "Hello World"
local upgrade = upgradeMock({name = name})

assert.is_same(name, upgrade:getName())

BrokerUpgrade:new() config.price, getPrice() deduces the cost from the players reputation points when installed

local upgrade = upgradeMock({price = 42.0})
local player = PlayerSpaceship():setReputationPoints(100)

upgrade:install(player)

assert.is_same(58, player:getReputationPoints())

BrokerUpgrade:new() config.price, getPrice() fails if price is non-numeric

assert.has_error(function()
    upgradeMock({price = "foo"})
end)

BrokerUpgrade:new() config.price, getPrice() returns 0 if config.price is not set

local upgrade = upgradeMock()

assert.is_same(0, upgrade:getPrice())

BrokerUpgrade:new() config.price, getPrice() takes the price that was set

local upgrade = upgradeMock({price = 42.0})

assert.is_same(42.0, upgrade:getPrice())

BrokerUpgrade:new() config.requiredUpgrade allows to install an upgrade if the required is installed

local required = upgradeMock({id = "required"})
local upgrade = upgradeMock({requiredUpgrade = "required"})
local player = PlayerSpaceship()
Player:withUpgradeTracker(player)
player:addUpgrade(required)

assert.is_true(upgrade:canBeInstalled(player))

BrokerUpgrade:new() config.requiredUpgrade exposes the required upgrade

local required = upgradeMock({id = "required"})
local upgrade = upgradeMock({requiredUpgrade = "required"})
local player = PlayerSpaceship()
Player:withUpgradeTracker(player)
player:addUpgrade(required)

assert.is_same("required", upgrade:getRequiredUpgradeString())

BrokerUpgrade:new() config.requiredUpgrade prevents the upgrade from being installed on a ship without Upgrade Tracker

local upgrade = upgradeMock({requiredUpgrade = "foobar"})
local player = PlayerSpaceship()

assert.is_false(upgrade:canBeInstalled(player))

BrokerUpgrade:new() config.requiredUpgrade prevents the upgrade to be installed if the required is not installed

local upgrade = upgradeMock({requiredUpgrade = "foobar"})
local player = PlayerSpaceship()
Player:withUpgradeTracker(player)
player:addUpgrade(upgradeMock())

assert.is_false(upgrade:canBeInstalled(player))

BrokerUpgrade:new() config.unique prevents the upgrade from being installed more than once on a ship

local upgrade = upgradeMock({unique = true})
local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

assert.is_true(upgrade:canBeInstalled(player))
upgrade:install(player)

assert.is_false(upgrade:canBeInstalled(player))

BrokerUpgrade:new() config.unique prevents the upgrade from being installed on a ship without Upgrade Tracker

local upgrade = upgradeMock({unique = true})
local player = PlayerSpaceship()

assert.is_false(upgrade:canBeInstalled(player))

BrokerUpgrade:new() fails if first argument is a number

assert.has_error(function() BrokerUpgrade:new(42) end)

BrokerUpgrade:new() fails if there is no install function given

assert.has_error(function() BrokerUpgrade:new(nil, function() end) end)

BrokerUpgrade:new() fails if there is no name given

assert.has_error(function() BrokerUpgrade:new("Foobar") end)

BrokerUpgrade:new() returns a valid BrokerUpgrade

local upgrade = upgradeMock()
assert.is_true(BrokerUpgrade:isUpgrade(upgrade))

BrokerUpgrade:new():canBeInstalled()

BrokerUpgrade:new():canBeInstalled() calls the callback

canBeInstalledCalled = 0

local success, msg = upgrade:canBeInstalled(player)
assert.is_same(1, canBeInstalledCalled)

BrokerUpgrade:new():canBeInstalled() fails if no player is given

canBeInstalledCalled = 0

assert.has_error(function() upgrade:canBeInstalled(42) end)
assert.is_same(0, canBeInstalledCalled)

BrokerUpgrade:new():canBeInstalled() interprets as true, when callback returns nil

local player = PlayerSpaceship()
local upgrade
upgrade = upgradeMock({canBeInstalled = function()
    return nil
end})

local success, msg = upgrade:canBeInstalled(player)
assert.is_true(success)
assert.is_nil(msg)

BrokerUpgrade:new():canBeInstalled() passes through a string on failure

local player = PlayerSpaceship()
local upgrade = upgradeMock({canBeInstalled = function()
    return false, "foobar"
end})

local success, msg = upgrade:canBeInstalled(player)
assert.is_false(success)
assert.is_same("foobar", msg)

BrokerUpgrade:new():canBeInstalled() removes message if it is attached to a true response

local player = PlayerSpaceship()
local upgrade = upgradeMock({canBeInstalled = function()
    return true, "message"
end})

local success, msg = upgrade:canBeInstalled(player)
assert.is_true(success)
assert.is_nil(msg)

BrokerUpgrade:new():canBeInstalled() removes non-string message on failure

local player = PlayerSpaceship()
local upgrade = upgradeMock({canBeInstalled = function()
    return false, 42
end})

local success, msg = upgrade:canBeInstalled(player)
assert.is_false(success)
assert.is_nil(msg)

BrokerUpgrade:new():install()

BrokerUpgrade:new():install() calls the canBeInstalled callback and the install callback

canBeInstalledCalled = 0
installCalled = 0

upgrade:install(player)
assert.is_same(1, canBeInstalledCalled)
assert.is_same(1, installCalled)

upgrade:install(player)
assert.is_same(2, canBeInstalledCalled)
assert.is_same(2, installCalled)

BrokerUpgrade:new():install() fails if no player is given

canBeInstalledCalled = 0
installCalled = 0

assert.has_error(function() upgrade:install(42) end)
assert.is_same(0, canBeInstalledCalled)
assert.is_same(0, installCalled)

BrokerUpgrade:new():install() throws error if requirement is not met

local upgrade = upgradeMock({canBeInstalled = function()
    return false
end})

assert.has_error(function() upgrade:install(player) end)

Chatter

Chatter:new()

Chatter:new() config.maxRange allows to hear chatter within range

local player = PlayerSpaceship()
mockPlayers(player)
local chatter = Chatter:new({maxRange = 30000})
local ship = CpuShip():setCallSign("John Doe")
player:setPosition(0, 0)
ship:setPosition(2000, 0)

chatter:say(ship, "Hello World")
assert.is_same("John Doe: Hello World", player.shipLogs[1].message)

Chatter:new() config.maxRange can happen that players only hear half of a conversation

local player = PlayerSpaceship()
mockPlayers(player)
local chatter = Chatter:new({maxRange = 30000})
local ship1 = CpuShip():setCallSign("Alice")
local ship2 = CpuShip():setCallSign("Bob")

player:setPosition(0, 0)
ship1:setPosition(2000, 0)
ship2:setPosition(99999, 0)

chatter:converse({
    {ship1, "Hey Bob. What was your password again?"},
    {ship2, "12345"},
    {ship1, "That's the stupidest combination I have ever heard in my life!"},
})
for i=1,15 do Cron.tick(i) end

assert.is_same("Alice: Hey Bob. What was your password again?", player.shipLogs[1].message)
assert.is_same("Alice: That's the stupidest combination I have ever heard in my life!", player.shipLogs[2].message)

Chatter:new() config.maxRange does not allow to hear chatter outside range

local player = PlayerSpaceship()
mockPlayers(player)
local chatter = Chatter:new({maxRange = 30000})
local ship = CpuShip():setCallSign("John Doe")
player:setPosition(0, 0)
ship:setPosition(99999, 0)

chatter:say(ship, "Hello World")
assert.is_same({}, player.shipLogs)

Chatter:new() config.maxRange does not limit range of non-ships

local player = PlayerSpaceship()
mockPlayers(player)
local chatter = Chatter:new({maxRange = 30000})
player:setPosition(0, 0)

chatter:say("John Doe", "Hello World")
assert.is_same("John Doe: Hello World", player.shipLogs[1].message)

Chatter:new() config.maxRange fails if maxRange is not a number

assert.has_error(function()
    Chatter:new({maxRange = "foobar"})
end)
assert.has_error(function()
    Chatter:new({maxRange = function() end})
end)

Chatter:new() config.maxRange works with multiple players

local player1 = PlayerSpaceship()
local player2 = PlayerSpaceship()
mockPlayers(player1, player2)
local ship = CpuShip():setCallSign("John Doe")
local chatter = Chatter:new({maxRange = 30000})

player1:setPosition(0, 0)
ship:setPosition(2000, 0)
player2:setPosition(99999, 0)

chatter:say(ship, "Hello World")

assert.is_same("John Doe: Hello World", player1.shipLogs[1].message)
assert.is_same({}, player2.shipLogs)

Chatter:new() creates a valid chatter

local chatter = Chatter:new()
assert.is_true(Chatter:isChatter(chatter))

Chatter:new() fails when config is not a table

assert.has_error(function()
    Chatter:new(42)
end)
assert.has_error(function()
    Chatter:new("foo")
end)

Chatter:new():converse()

Chatter:new():converse() aborts conversation if one speaker got destroyed

local player = PlayerSpaceship()
mockPlayers(player)
local chatter = Chatter:new()
local ship1 = CpuShip():setCallSign("John Doe")
local ship2 = CpuShip():setCallSign("Jack the Ripper")

chatter:converse({
    {ship2, "I'm gonna destroy you"},
    {ship1, "Oh no"},
    {ship2, "Wait. How did you survive that?"},
})

Cron.tick(1)
assert.is_same("Jack the Ripper: I'm gonna destroy you", player.shipLogs[1].message)
ship1:destroy()

for i=1,10,1 do Cron.tick(1) end

assert.is_nil(player.shipLogs[2])

Chatter:new():converse() fails if one of items in the table is not a table

local chatter = Chatter:new()
assert.has_error(function()
    chatter:converse({
        {"John Doe", "Hello World"},
        "Ehm... this breaks",
    })
end)

Chatter:new():converse() fails if one of the messages is a number

local chatter = Chatter:new()
assert.has_error(function()
    chatter:converse({
        {"John Doe", "Hello World"},
        {"Arthur Dent", 42},
    })
end)

Chatter:new():converse() fails if one of the senders is a number

local chatter = Chatter:new()
assert.has_error(function()
    chatter:converse({
        {"John Doe", "Hello World"},
        {42, "I got the answer"},
    })
end)

Chatter:new():converse() fails when messages is nil or a string

local chatter = Chatter:new()
assert.has_error(function()
    chatter:converse(nil)
end)
assert.has_error(function()
    chatter:converse("This breaks")
end)

Chatter:new():converse() leaves breaks between the speakers

local player = PlayerSpaceship()
local ship = CpuShip():setCallSign("Two")
mockPlayers(player)
local chatter = Chatter:new()

chatter:converse({
    {"One", "Saying six words takes three seconds."},
    {ship, "Four words - two seconds."}
})
assert.is_same("One: Saying six words takes three seconds.", player.shipLogs[1].message)
Cron.tick(1.5)
assert.is_nil(player.shipLogs[2])
Cron.tick(2)
assert.is_same("Two: Four words - two seconds.", player.shipLogs[2].message)

Chatter:new():converse() sends to all player ships

local player1 = PlayerSpaceship()
local player2 = PlayerSpaceship()
local player3 = PlayerSpaceship()
mockPlayers(player1, player2, player3)

local chatter = Chatter:new()
chatter:converse({{"John Doe", "Hello World"}})
assert.is_same("John Doe: Hello World", player1.shipLogs[1].message)
assert.is_same("John Doe: Hello World", player2.shipLogs[1].message)
assert.is_same("John Doe: Hello World", player3.shipLogs[1].message)

Chatter:new():say()

Chatter:new():say() does not send the message if the ship is invalid

local player = PlayerSpaceship()
local ship = CpuShip():setCallSign("John Doe")
ship:destroy()
mockPlayers(player)

local chatter = Chatter:new()
chatter:say(ship, "Hello World")
assert.is_same({}, player.shipLogs)

Chatter:new():say() fails when message is nil or a number

local chatter = Chatter:new()
assert.has_error(function()
    chatter:say("John Doe", nil)
end)
assert.has_error(function()
    chatter:say("John Doe", 42)
end)

Chatter:new():say() fails when sender is nil or a number

local chatter = Chatter:new()
assert.has_error(function()
    chatter:say(nil, "Hello World")
end)
assert.has_error(function()
    chatter:say(42, "Hello World")
end)

Chatter:new():say() sends to all player ships

local player1 = PlayerSpaceship()
local player2 = PlayerSpaceship()
local player3 = PlayerSpaceship()
mockPlayers(player1, player2, player3)

local chatter = Chatter:new()
chatter:say("John Doe", "Hello World")
assert.is_same("John Doe: Hello World", player1.shipLogs[1].message)
assert.is_same("John Doe: Hello World", player2.shipLogs[1].message)
assert.is_same("John Doe: Hello World", player3.shipLogs[1].message)

Chatter:new():say() works with a ship as sender

local player = PlayerSpaceship()
local ship = CpuShip():setCallSign("John Doe")
mockPlayers(player)

local chatter = Chatter:new()
chatter:say(ship, "Hello World")
assert.is_same("John Doe: Hello World", player.shipLogs[1].message)

Chatter:new():say() works with a string as sender

local player = PlayerSpaceship()
mockPlayers(player)

local chatter = Chatter:new()
chatter:say("John Doe", "Hello World")
assert.is_same("John Doe: Hello World", player.shipLogs[1].message)

Chatter:newFactory()

Chatter:newFactory() fails if config.filters is not a numeric table

assert.has_error(function() Chatter:newFactory(1, function() end, {
    filters = 1,
}) end)
assert.has_error(function() Chatter:newFactory(1, function() end, {
    filters = "string",
}) end)
assert.has_error(function() Chatter:newFactory(1, function() end, {
    filters = {foo = "bar"},
}) end)

Chatter:newFactory() fails if first parameter is not a positive integer

assert.has_error(function() Chatter:newFactory(-1, function() end) end)
assert.has_error(function() Chatter:newFactory(0, function() end) end)
assert.has_error(function() Chatter:newFactory(nil, function() end) end)
assert.has_error(function() Chatter:newFactory("string", function() end) end)
assert.has_error(function() Chatter:newFactory(SpaceStation(), function() end) end)

Chatter:newFactory() fails if second parameter is not a function

assert.has_error(function() Chatter:newFactory(1, 1) end)
assert.has_error(function() Chatter:newFactory(1, "string") end)
assert.has_error(function() Chatter:newFactory(1, nil) end)
assert.has_error(function() Chatter:newFactory(1, SpaceStation()) end)

Chatter:newFactory() fails if third parameter is not a table

assert.has_error(function() Chatter:newFactory(1, function() end, 1) end)
assert.has_error(function() Chatter:newFactory(1, function() end, "string") end)

Chatter:newFactory() should create a valid chat

local chat = Chatter:newFactory(1, function(one)
    return {
        {one, "Hello World"}
    }
end)

assert.is_true(Chatter:isChatFactory(chat))
assert.is_same(1, chat:getCardinality())
assert.is_true(chat:areValidArguments(CpuShip()))

Chatter:newFactory():areValidArguments()

Chatter:newFactory():areValidArguments() always returns true if no filter is set

local chat = Chatter:newFactory(1, function() end)

assert.is_true(chat:areValidArguments(SpaceStation()))
assert.is_true(chat:areValidArguments(CpuShip()))

Chatter:newFactory():areValidArguments() returns false if no shipTemplateBased is given

local chat = Chatter:newFactory(1, function() end)

assert.is_false(chat:areValidArguments(1))
assert.is_false(chat:areValidArguments(Asteroid()))

Chatter:newFactory():areValidArguments() uses filter on all arguments and gives other partners to filter function

local oneCalled, twoCalled, threeCalled = 0, 0, 0
local oneArg1
local twoArg1, twoArg2
local threeArg1, threeArg2, threeArg3

local one, two, three = SpaceStation(), CpuShip(), CpuShip()

local chat = Chatter:newFactory(3, function() end, {
    filters = {
        function(one)
            oneCalled = oneCalled + 1
            oneArg1 = one
            return true
        end,
        function(two, one)
            twoCalled = twoCalled + 1
            twoArg1 = two
            twoArg2 = one
            return true
        end,
        function(three, two, one)
            threeCalled = threeCalled + 1
            threeArg1 = three
            threeArg2 = two
            threeArg3 = one
            return true
        end,
    }
})

assert.is_true(chat:areValidArguments(one, two, three))
assert.is_same(1, oneCalled)
assert.is_same(one, oneArg1)
assert.is_same(1, twoCalled)
assert.is_same(two, twoArg1)
assert.is_same(one, twoArg2)
assert.is_same(1, threeCalled)
assert.is_same(three, threeArg1)
assert.is_same(two, threeArg2)
assert.is_same(one, threeArg3)

Chatter:newFactory():areValidArguments() works with one partner

local chat = Chatter:newFactory(1, function() end, {
    filters = {
        function(one) return isEeStation(one) end
    }
})

assert.is_true(chat:areValidArguments(SpaceStation()))
assert.is_false(chat:areValidArguments(CpuShip()))

Chatter:newFactory():areValidArguments() works with two partners

local chat = Chatter:newFactory(2, function() end, {
    filters = {
        function(one) return isEeStation(one) end,
        function(two, _) return isEeShip(two) end,
    }
})

assert.is_true(chat:areValidArguments(SpaceStation()))
assert.is_false(chat:areValidArguments(CpuShip()))

assert.is_false(chat:areValidArguments(CpuShip(), SpaceStation()))
assert.is_true(chat:areValidArguments(SpaceStation(), CpuShip()))

Chatter:newFactory():createChat()

Chatter:newFactory():createChat() gives all arguments to the factory

local factoryCalled = 0
local factoryArg1, factoryArg2
local ship1, ship2 = CpuShip(), CpuShip()

local chat = Chatter:newFactory(1, function(arg1, arg2)
    factoryCalled = factoryCalled + 1
    factoryArg1, factoryArg2 = arg1, arg2

    return {
        {arg1, "Hello World"},
        {arg2, "Foobar"},
    }
end)

local result = chat:createChat(ship1, ship2)
assert.is_same(1, factoryCalled)
assert.is_same(ship1, factoryArg1)
assert.is_same(ship2, factoryArg2)

assert.is_same("table", type(result))

Chatter:newNoise()

Chatter:newNoise() allows to remove chat factories

withUniverse(function()
    local player = PlayerSpaceship():setCallSign("player"):setPosition(0, 0)
    local ship = CpuShip():setCallSign("ship"):setPosition(1000, 0)

    print(typeInspect(player:getObjectsInRange(10000)))

    local chat1 = Chatter:newFactory(1, function(thisShip)
        return {
            { thisShip, "Hello World"},
        }
    end)
    local chat2 = Chatter:newFactory(1, function(thisShip)
        return {
            { thisShip, "You should not see me"},
        }
    end)

    local chatter = mockChatter()
    local noise = Chatter:newNoise(chatter)
    noise:addChatFactory(chat2, "chat2")
    noise:addChatFactory(chat1, "chat1")
    noise:removeChatFactory("chat2")

    for _=1,90 do Cron.tick(1) end

    assert.is_same({
        {ship, "Hello World"},
    }, chatter:getLastMessages())
end)

Chatter:newNoise() creates a valid ChatNoise

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
assert.is_true(Chatter:isChatNoise(noise))

Chatter:newNoise() fails when chatter is not a chatter

local chatter = mockChatter()
assert.has_error(function()
    Chatter:newNoise(nil)
end)
assert.has_error(function()
    Chatter:newNoise("foo")
end)
assert.has_error(function()
    Chatter:newNoise(42)
end)
assert.has_error(function()
    Chatter:newNoise({})
end)

Chatter:newNoise() tries to prevent repetition of chats

withUniverse(function()
    local player = PlayerSpaceship():setCallSign("player"):setPosition(0, 0)
    local ship1 = CpuShip():setCallSign("ship"):setPosition(1000, 0)
    local ship2 = CpuShip():setCallSign("ship"):setPosition(2000, 0)

    -- mock chatter to easily track which chats we have seen
    local chat1 = { { ship1, "one"} }
    local chat2 = { { ship2, "two"} }

    local secondToLastChat
    local lastChat

    local chatter = mockChatter()
    chatter.converse = function(_, conversation)
        local pretty = require 'pl.pretty'

        secondToLastChat = lastChat
        if conversation == chat1 then
            lastChat = "norep_chat1"
        elseif conversation == chat2 then
            lastChat = "norep_chat2"
        end

        conversation = lastChat
    end
    assert(Chatter:isChatter(chatter))

    local noise = Chatter:newNoise(chatter, {preventRepetition = true})

    -- chat for first chat
    noise:addChatFactory(Chatter:newFactory(1, function() return chat1 end, {filters = { function() return true end }}), "norep_chat1")
    noise:addChatFactory(Chatter:newFactory(1, function() return chat2 end, {filters = { function() return true end }}), "norep_chat2")

    for _=1,120 do Cron.tick(1) end
    for _=1,60 * 6 * 10 do
        Cron.tick(1)
        assert.not_same(secondToLastChat, lastChat)
    end
end)

Chatter:newNoise() works in a complex scenario

withUniverse(function()
    local player = PlayerSpaceship():setCallSign("player"):setPosition(0, 0)
    local ship = CpuShip():setCallSign("ship"):setPosition(1000, 0)
    local station = SpaceStation():setCallSign("station"):setPosition(0, 1000)
    local filterStation = function(thing) return thing == station end
    local filterShip = function(thing) return thing == ship end
    local filterShipOrStation = function(thing) return filterStation(thing) or filterShip(thing) end

    -- mock chatter to easily track which chats we have seen
    local chat1 = { {ship, "one"} }
    local chat2 = { {station, "two"} }
    local chat3WithShip = { {ship, "three"} }
    local chat3WithStation = { {station, "three"} }
    local chat4 = { {station, "four"}, {ship, "four"} }
    local chat5 = { {ship, "five"}, {station, "five"} }

    local chat1Seen = 0
    local chat2Seen = 0
    local chat3SeenWithShip = 0
    local chat3SeenWithStation = 0
    local chat4Seen = 0
    local chat5Seen = 0
    local errorsSeen = 0
    local chatter = mockChatter()
    chatter.converse = function(_, conversation)
        local pretty = require 'pl.pretty'

        if conversation == chat1 then chat1Seen = chat1Seen + 1
        elseif conversation == chat2 then chat2Seen = chat2Seen + 1
        elseif conversation == chat3WithShip then chat3SeenWithShip = chat3SeenWithShip + 1
        elseif conversation == chat3WithStation then chat3SeenWithStation = chat3SeenWithStation + 1
        elseif conversation == chat4 then chat4Seen = chat4Seen + 1
        elseif conversation == chat5 then chat5Seen = chat5Seen + 1
        else
            errorsSeen = errorsSeen + 1
        end
    end
    assert(Chatter:isChatter(chatter))

    local noise = Chatter:newNoise(chatter)

    -- chat for ship only
    noise:addChatFactory(Chatter:newFactory(1,
        function(one) if one == ship then return chat1  end end, {
        filters = { filterShip }
    }), "chat1")

    -- chat for station only
    noise:addChatFactory(Chatter:newFactory(1,
        function(one) if one == station then return chat2 end end, {
        filters = { filterStation }
    }), "chat2")

    -- chat for station or ship
    noise:addChatFactory(Chatter:newFactory(1,
        function(thisThing)
            if thisThing == ship then
                return chat3WithShip
            elseif thisThing == station then
                return chat3WithStation
            end
        end, {
        filters = { filterShipOrStation }
    }), "chat3")

    -- chat for station and ship
    noise:addChatFactory(Chatter:newFactory(2,
        function(one, two) if one == station and two == ship then return chat4 end end, {
        filters = { filterStation, filterShip }
    }), "chat4")

    -- chat for ship and station
    noise:addChatFactory(Chatter:newFactory(2,
        function(one, two) if one == ship and two == station then return chat5 end end, {
        filters = { filterShip, filterStation }
    }), "chat5")

    for _=1,60 * 6 * 10 do Cron.tick(1) end

    -- a naive check to see if results are random
    assert.is_same(0, errorsSeen)
    assert(chat1Seen >= 2 and chat1Seen <= 25, "chat1 should occur between 1 and 25 times. Got " .. chat1Seen)
    assert(chat2Seen >= 2 and chat2Seen <= 25, "chat2 should occur between 1 and 25 times. Got " .. chat2Seen)
    assert(chat3SeenWithShip >= 1 and chat3SeenWithShip <= 12, "chat3 should occur between 1 and 12 times with ship. Got " .. chat3SeenWithShip)
    assert(chat3SeenWithStation >= 1 and chat3SeenWithStation <= 12, "chat3 should occur between 1 and 12 times with station. Got " .. chat3SeenWithStation)
    assert(chat4Seen >= 2 and chat4Seen <= 25, "chat4 should occur between 1 and 25 times. Got " .. chat4Seen)
    assert(chat5Seen >= 2 and chat5Seen <= 25, "chat5 should occur between 1 and 25 times. Got " .. chat5Seen)
end)

Chatter:newNoise() works with a chat with filters

withUniverse(function()
    local player = PlayerSpaceship():setCallSign("player"):setPosition(0, 0)
    local ship = CpuShip():setCallSign("ship"):setPosition(1000, 0)
    local station = SpaceStation():setCallSign("station"):setPosition(0, 1000)

    local chat = Chatter:newFactory(2, function(thisStation, thisShip)
        return {
            { thisStation, "Who are you?"},
            { thisShip, "John Doe"},
        }
    end, {
        filters = {
            function(thing) return isEeStation(thing) end,
            function(thing) return isEeShip(thing) end,
        },
    })

    local chatter = mockChatter()
    local noise = Chatter:newNoise(chatter)
    noise:addChatFactory(chat)

    for _=1,90 do Cron.tick(1) end

    assert.is_same({
        {station, "Who are you?"},
        {ship, "John Doe"},
    }, chatter:getLastMessages())
end)

Chatter:newNoise():addChatFactory()

Chatter:newNoise():addChatFactory() allows to add a chat and returns an id

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local factory = mockChatFactory()

local id = noise:addChatFactory(factory)
assert.is_same("string", type(id))
assert.not_same("", id)

Chatter:newNoise():addChatFactory() fails if no chat is given

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)

assert.has_error(function() noise:addChatFactory(nil) end)
assert.has_error(function() noise:addChatFactory("foo") end)
assert.has_error(function() noise:addChatFactory(42) end)
assert.has_error(function() noise:addChatFactory({}) end)

Chatter:newNoise():addChatFactory() fails if the given id is not a non-empty string

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local factory = mockChatFactory()

assert.has_error(function() noise:addChatFactory(factory, "") end)
assert.has_error(function() noise:addChatFactory(factory, 42) end)
assert.has_error(function() noise:addChatFactory(factory, {}) end)

Chatter:newNoise():addChatFactory() uses an id if it given

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local factory = mockChatFactory()

local id = noise:addChatFactory(factory, "foobar")
assert.is_same("foobar", id)

Chatter:newNoise():getChatFactories()

Chatter:newNoise():getChatFactories() allows to remove all ChatFactories

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local chat1 = mockChatFactory()
local chat2 = mockChatFactory()
local chat3 = mockChatFactory()

noise:addChatFactory(chat1, "one")
noise:addChatFactory(chat2, "two")
noise:addChatFactory(chat3, "three")

for id, _ in pairs(noise:getChatFactories()) do
    noise:removeChatFactory(id)
end

assert.is_same({}, noise:getChatFactories())

Chatter:newNoise():getChatFactories() returns all ChatFactories

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local chat1 = mockChatFactory()
local chat2 = mockChatFactory()
local chat3 = mockChatFactory()

noise:addChatFactory(chat1, "one")
noise:addChatFactory(chat2, "two")
noise:addChatFactory(chat3, "three")

assert.is_same({
    one = chat1,
    two = chat2,
    three = chat3,
}, noise:getChatFactories())

Chatter:newNoise():removeChatFactory()

Chatter:newNoise():removeChatFactory() allows to remove a chat

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)
local factory = mockChatFactory()

noise:addChatFactory(factory, "id")
noise:removeChatFactory("id")

Chatter:newNoise():removeChatFactory() allows to remove a chat when it has not been set before

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)

noise:removeChatFactory("id")

Chatter:newNoise():removeChatFactory() fails if the given id is not a non-empty string

local chatter = mockChatter()
local noise = Chatter:newNoise(chatter)

assert.has_error(function() noise:removeChatFactory("") end)
assert.has_error(function() noise:removeChatFactory(42) end)
assert.has_error(function() noise:removeChatFactory({}) end)

Comms

Comms:merchantFactory()

Comms:merchantFactory() fails if any of the required configs is missing

for k, _ in pairs(requiredConfig) do
    local config = Util.deepCopy(requiredConfig)
    config[k] = nil

    assert.has_error(function() Comms:merchantFactory(config) end)
end

Comms:merchantFactory() should create a valid Comms:newReply

local merchantComms = Comms:merchantFactory(requiredConfig)

assert.is_true(Comms:isReply(merchantComms))

Comms:missionBrokerFactory()

Comms:missionBrokerFactory() fails if any of the required configs is missing

for k, _ in pairs(requiredConfig) do
    local config = Util.deepCopy(requiredConfig)
    config[k] = nil

    assert.has_error(function() Comms:missionBrokerFactory(config) end)
end

Comms:missionBrokerFactory() should create a valid Comms:newReply

local missionComms = Comms:missionBrokerFactory(requiredConfig)

assert.is_true(Comms:isReply(missionComms))

Comms:newReply()

Comms:newReply() can create a reply

local reply = Comms:newReply("Foobar", function() end)

assert.is_true(Comms:isReply(reply))
assert.is_same("Foobar", reply:getWhatPlayerSays(station, player))

Comms:newReply() can create a reply condition check

local condition = function() return true end
local reply = Comms:newReply("Foobar", nil, condition)

assert.is_true(Comms:isReply(reply))
assert.is_true(reply:checkCondition(station, player))

Comms:newReply() can create a reply with comms screen as return

local screen = Comms:newScreen("Hello World")
local reply = Comms:newReply("Foobar", screen)

assert.is_true(Comms:isReply(reply))
assert.is_same(screen, reply:getNextScreen(station, player))

Comms:newReply() can create a reply with function for name instead of string

local name = function() return "Foobar" end
local reply = Comms:newReply(name, function() end)

assert.is_true(Comms:isReply(reply))
assert.is_same("Foobar", reply:getWhatPlayerSays(station, player))

Comms:newReply() fails if first argument is a number

assert.has_error(function() Comms:newReply(42, function() end) end)

Comms:newReply() fails if second argument is a number

assert.has_error(function() Comms:newReply("Foobar", 42) end)

Comms:newReply() fails if third argument is a number

assert.has_error(function() Comms:newReply("Foobar", nil, 42) end)

Comms:newScreen()

Comms:newScreen() allows to add replies

local screen = Comms:newScreen("Hello World")
:addReply(Comms:newReply("One", function() end))
:addReply(Comms:newReply("Two", function() end))

assert.is_true(Comms:isScreen(screen))
assert.is_same(2, Util.size(screen:getHowPlayerCanReact(station, player)))

Comms:newScreen() can create a comms screen that it validates

local screen = Comms:newScreen("Hello World", {
    Comms:newReply("One", function() end),
    Comms:newReply("Two", function () end),
})

assert.is_true(Comms:isScreen(screen))
assert.is_same(2, Util.size(screen:getHowPlayerCanReact(station, player)))

Cron

Cron:addDelay()

Cron:addDelay() allows to override the delay of a regular

Cron.regular("foobar", function() end, 3, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.addDelay("foobar", 1)
assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(3, Cron.getDelay("foobar"))
Cron.addDelay("foobar", 2)
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))

Cron:addDelay() allows to set the delay when using once()

Cron.once("foobar", function() end, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.addDelay("foobar", 1)
assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.tick(1)

Cron:addDelay() fails silently when an unknown Cron is set

Cron.addDelay("doesnotexist", 5)
assert.is_nil(Cron.getDelay("doesnotexist"))

Cron:getDelay()

Cron:getDelay() allows to get the delay until the call of function when using once()

Cron.once("foobar", function() end, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_nil(Cron.getDelay("foobar"))

Cron:getDelay() allows to get the delay until the next call of function when using regular()

Cron.regular("foobar", function() end, 3, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(3, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(2, Cron.getDelay("foobar"))

Cron:getDelay() returns nil for an undefined cron

assert.is_nil(Cron.getDelay("doesnotexist"))

Cron:now()

Cron:now() gives the current time

local now = Cron.now()
assert.is_true(isNumber(now))

Cron.tick(1)

assert.is_same(1, Cron.now() - now)

Cron:once()

Cron:once() allows to remove a callback by calling one callback

local ids = {
    "foo",
    "bar",
}
for _, id in pairs(ids) do
    Cron.once(id, function()
        for _, id in pairs(ids) do
            Cron.abort(id)
        end
    end, 1)
end
assert.not_has_error(function()
    Cron.tick(1)
end)

Cron:once() allows to replace a once with regular on call

local called = 0
Cron.once("foobar", function()
    called = called + 1
    Cron.regular("foobar", function()
        called = called + 1
    end, 1)
end, 1)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(3, called)

Cron:once() allows to set a new callback after removing the current one

local i = 0
local fun
fun = function()
    i = i+1
    if i < 10 then Cron.once(fun, 1) end
end
Cron.once(fun, 1)
assert.not_has_error(function()
    for i=1,10 do Cron.tick(1) end
end)

Cron:once() is possible to remove a function with a generated name

local called = false
local cronId = Cron.once(function() called = true end, 5)

assert.is_false(called)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(called)

Cron.abort(cronId)

Cron.tick(1)
assert.is_false(called)

Cron:once() is possible to remove a function with a name

local called = false
Cron.once("foobar", function() called = true end, 5)

assert.is_false(called)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(called)

Cron.abort("foobar")

Cron.tick(1)
assert.is_false(called)

Cron:once() will call a function after a certain time

local called = false
Cron.once(function() called = true end, 5)

assert.is_false(called)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(called)
Cron.tick(1)
assert.is_true(called)

Cron:once() will call a function with a name

local called = false
Cron.once("foobar", function() called = true end, 5)

assert.is_false(called)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(called)
Cron.tick(1)
assert.is_true(called)

Cron:once() will call the function only once

local called = 0
Cron.once(function() called = called + 1 end, 1)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)

Cron:once() will get the elapsed time as argument to the function

local calledArg = nil
Cron.once(function(_, arg)
    calledArg = arg
end, 5)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1.2)
assert.is_near(5.2, calledArg, 0.0001)

Cron:regular()

Cron:regular() a function can be stopped from outside

local called = 0
local cronId = Cron.regular(function() called = called + 1 end, 1)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.abort(cronId)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)

Cron:regular() a function can remove itself

local called = 0
Cron.regular("foobar", function()
    if called >= 3 then Cron.abort("foobar") else called = called + 1 end
end, 1)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(3, called)
Cron.tick(1)
assert.is_same(3, called)
Cron.tick(1)
assert.is_same(3, called)

Cron:regular() allows the callback function to return the interval for the next try

local called = 0

Cron.regular("foobar", function()
    called = called + 1
    if called == 2 then return 3 end
end, 1)

assert.is_same(0, called)

Cron.tick(0.5)
assert.is_same(1, called)

Cron.tick(1)
assert.is_same(2, called)

Cron.tick(1)
assert.is_same(2, called)

Cron.tick(1)
assert.is_same(2, called)

Cron.tick(1)
assert.is_same(3, called)

Cron.tick(1)
assert.is_same(4, called)

Cron:regular() the function gets its generated id as first parameter

local theFirstParameter
Cron.regular(function(cronId)
    theFirstParameter = cronId
end, 1)

Cron.tick(1)
assert.not_nil(theFirstParameter)
assert.is_string(theFirstParameter)
assert.not_same("", theFirstParameter)

Cron:regular() the function gets its id as first parameter

local theFirstParameter
Cron.regular("foobar", function(cronId)
    theFirstParameter = cronId
end, 1)

Cron.tick(1)
assert.is_same("foobar", theFirstParameter)

Cron:regular() will call a function at a regular interval

local called = 0
Cron.regular("foobar", function() called = called + 1 end, 2)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(3, called)
Cron.tick(1)
assert.is_same(3, called)

Cron:regular() will call a function at a regular interval with delay

local called = 0
Cron.regular("foobar", function() called = called + 1 end, 2, 2)

assert.is_same(0, called)
Cron.tick(1)
assert.is_same(0, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(1)
assert.is_same(3, called)

Cron:regular() will call a function at every tick

local called = 0
Cron.regular(function() called = called + 1 end)

assert.is_same(0, called)
Cron.tick(10)
assert.is_same(1, called)
Cron.tick(1)
assert.is_same(2, called)
Cron.tick(0.5)
assert.is_same(3, called)
Cron.tick(0.1)
assert.is_same(4, called)
Cron.tick(0.01)
assert.is_same(5, called)

Cron:regular() will get the elapsed time as argument to the function

local called, calledArg = 0, nil
Cron.regular(function(_, arg)
    called = called + 1
    calledArg = arg
end, 1, 1)

Cron.tick(1)
assert.is_same(1, called)
assert.is_same(1, calledArg)
Cron.tick(1.2)
assert.is_same(2, called)
assert.is_near(1.2, calledArg, 0.0001)
Cron.tick(0.7)
Cron.tick(0.7)
assert.is_same(3, called)
assert.is_near(1.4, calledArg, 0.0001)

Cron:setDelay()

Cron:setDelay() allows to override the delay of a regular

Cron.regular("foobar", function() end, 3, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.setDelay("foobar", 5)
assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(3, Cron.getDelay("foobar"))
Cron.setDelay("foobar", 5)
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))

Cron:setDelay() allows to set the delay when using once()

Cron.once("foobar", function() end, 5)

assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.setDelay("foobar", 5)
assert.is_same(5, Cron.getDelay("foobar"))
Cron.tick(1)
assert.is_same(4, Cron.getDelay("foobar"))
Cron.tick(1)

Cron:setDelay() fails silently when an unknown Cron is set

Cron.setDelay("doesnotexist", 5)
assert.is_nil(Cron.getDelay("doesnotexist"))

EventHandler

EventHandler:new()

EventHandler:new() config.allowedEvents allows to register and fire events from the whitelist

local eh = EventHandler:new({allowedEvents = {"foo"}})
eh:register("foo", function() end)
eh:fire("foo")

EventHandler:new() config.allowedEvents fails if a different event is registered

local eh = EventHandler:new({allowedEvents = {"foo"}})
assert.has_error(function()
    eh:register("bar", function() end)
end)

EventHandler:new() config.allowedEvents fails if the config does not contain a table

assert.has_error(function()
    EventHandler:new({allowedEvents = 42})
end)

EventHandler:new() config.allowedEvents fails if the config does not contain strings

assert.has_error(function()
    EventHandler:new({allowedEvents = {"foo", "bar", 42}})
end)

EventHandler:new() fails if config is not a table

assert.has_error(function()
    EventHandler:new(42)
end)

EventHandler:new():fire()

EventHandler:new():fire() allows subsequent listener to modify the argument

local secret = {name=""}

local eh = EventHandler:new()

eh:register("test", function(self, argument) argument.name = argument.name .. "a" end)
eh:register("test", function(self, argument) argument.name = argument.name .. "b" end)
eh:register("test", function(self, argument) argument.name = argument.name .. "c" end)

eh:fire("test", secret)

assert.is.equal("abc", secret.name)

EventHandler:new():fire() calls all listeners even if one raises an error

local called = false
local eh = EventHandler:new()

eh:register("test", function() error("boom") end)
eh:register("test", function() called = true end)

eh:fire("test")

assert.is_true(called)

EventHandler:new():fire() calls the listeners in the order they where registered

local result = ""
local eh = EventHandler:new()

eh:register("test", function() result = result .. "a" end)
eh:register("test", function() result = result .. "b" end)
eh:register("test", function() result = result .. "c" end)

eh:fire("test")
assert.is.equal("abc", result)

eh:register("test", function() result = result .. "d" end)
eh:register("test", function() result = result .. "e" end)
eh:register("test", function() result = result .. "f" end)

result = ""
eh:fire("test")
assert.is.equal("abcdef", result)

eh:register("test", function() result = result .. "g" end)
eh:register("test", function() result = result .. "h" end)
eh:register("test", function() result = result .. "i" end)

result = ""
eh:fire("test")
assert.is.equal("abcdefghi", result)

EventHandler:new():fire() does not call an event twice if "unique" is set

local called = 0
local eh = EventHandler:new({
    unique = true,
})

eh:register("test", function() called = called + 1 end)

eh:fire("test")
assert.is_same(1, called)
eh:fire("test")
assert.is_same(1, called)

EventHandler:new():fire() fails if eventName is not a string

assert.has_error(function()
    EventHandler:new():fire(42)
end)

EventHandler:new():fire() fails if no eventName is given

assert.has_error(function()
    EventHandler:new():fire()
end)

EventHandler:new():fire() gives an additional argument to the listeners

local secret = "Hello World"
local gottenArgument
local eh = EventHandler:new()

eh:register("test", function(self, argument) gottenArgument = argument end)
eh:fire("test", secret)

assert.is.equal(secret, gottenArgument)

EventHandler:new():fire() passes if no event is registered

EventHandler:new():fire("test")

EventHandler:new():register()

EventHandler:new():register() allow to register a listener

local called = 0
local eh = EventHandler:new()
eh:register("test", function() called = called + 1 end)

eh:fire("test")
assert.is.equal(1, called)

EventHandler:new():register() allow to register multiple listeners to the event

local called = 0
local eh = EventHandler:new()

eh:register("test", function() called = called + 1 end)
eh:register("test", function() called = called + 2 end)

eh:fire("test")
assert.is.equal(3, called)

EventHandler:new():register() allows to set priority of events

local result = ""
local eh = EventHandler:new()

eh:register("test", function() result = result .. "b" end, 20)
eh:register("test", function() result = result .. "c" end, 30)
eh:register("test", function() result = result .. "a" end, 10)

eh:fire("test")
assert.is.equal("abc", result)

EventHandler:new():register() can handle a mix of priorities and registration order

local result = ""
local eh = EventHandler:new()

eh:register("test", function() result = result .. "c" end)
eh:register("test", function() result = result .. "g" end, 50)
eh:register("test", function() result = result .. "a" end, -10)
eh:register("test", function() result = result .. "h" end, 50)
eh:register("test", function() result = result .. "d" end)
eh:register("test", function() result = result .. "b" end, -10)
eh:register("test", function() result = result .. "f" end, 20)
eh:register("test", function() result = result .. "e" end)

eh:fire("test")
assert.is.equal("abcdefgh", result)

EventHandler:new():register() default priority is 0

local result = ""
local eh = EventHandler:new()

eh:register("test", function() result = result .. "b" end)
eh:register("test", function() result = result .. "c" end, 10)
eh:register("test", function() result = result .. "a" end, -10)

eh:fire("test")
assert.is.equal("abc", result)

EventHandler:new():register() fails if eventName is not a string

local eh = EventHandler:new()
assert.has_error(function()
    eh:register(42, function() end)
end)

EventHandler:new():register() fails if eventName is not given

local eh = EventHandler:new()
assert.has_error(function()
    eh:register()
end)

EventHandler:new():register() fails if handler is not a function

local eh = EventHandler:new()
assert.has_error(function()
    eh:register("foo", "invalid")
end)

EventHandler:new():register() fails if handler is not given

local eh = EventHandler:new()
assert.has_error(function()
    eh:register("foo")
end)

EventHandler:new():register() fails if priority is not a number

local eh = EventHandler:new()
assert.has_error(function()
    eh:register("foo", function() end, "foo")
end)

EventHandler:new():register() issues a warning if an event listener is added for an event that has already been fired and config.unique is set

withLogCatcher(function(logs)
    local eh = EventHandler:new({
        unique = true,
    })

    eh:register("test", function() end)

    eh:fire("test")
    assert.is_nil(logs:popLastWarning())

    eh:register("test", function() end)
    assert.is_not_nil(logs:popLastWarning())
end)

Fleet

Fleet:new()

Fleet:new() GM can issue an order to a wingman that is not changed if the leader is killed

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

ship5:orderDefendLocation(42, 4200)

Cron.tick(1)

assert.is_same("Defend Location", ship5:getOrder())
ship1:destroy()

assert.is_same("Defend Location", ship5:getOrder())

Fleet:new() GM can issue new orders to fleet leader and are carried out if fleet leader is killed

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

Fleet:new({ship1, ship2, ship3})

ship1:orderDefendLocation(42, 4200)

Cron.tick(1)

ship1:destroy()

Cron.tick(1)

assert.is_same("Defend Location", ship2:getOrder())
assert.is_same(42, ship2:getOrderTargetLocationX())
assert.is_same(4200, ship2:getOrderTargetLocationY())
assert.is_same("Fly in formation", ship3:getOrder())

Fleet:new() GM can reintegrate a wingman by setting order to Idle or Roaming

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

ship4:orderDefendLocation(42, 4200)
ship5:orderDefendLocation(42, 4200)

Cron.tick(1)

ship4:orderIdle()
ship5:orderRoaming()

Cron.tick(1)

assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-1400, ship4:getOrderTargetLocationY())
assert.is_same(0, ship4:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(1400, ship5:getOrderTargetLocationY())
assert.is_same(0, ship5:getOrderTargetLocationX())

Fleet:new() allows to give config

local ship = CpuShip()
local fleet = Fleet:new({ship}, {id = "foobar"})

assert.is_true(Fleet:isFleet(fleet))

Fleet:new() circle formation fills gaps when the leader is killed and the highest ranking ship is left

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5}, {formation = "circle"})

ship1:destroy()

Cron.tick(1)
assert.is_same("Fly in formation", ship3:getOrder())
assert.is_same(354, Util.round(ship3:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship3:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(-354, Util.round(ship5:getOrderTargetLocationY()))
assert.is_same(354, Util.round(ship5:getOrderTargetLocationX()))

Fleet:new() circle formation keep position when a wingman is destroyed

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5}, {formation = "circle"})

ship3:destroy()

Cron.tick(1)

assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same(354, Util.round(ship2:getOrderTargetLocationY()))
assert.is_same(354, Util.round(ship2:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship3:getOrder())
assert.is_same(354, Util.round(ship3:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship3:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(-354, Util.round(ship5:getOrderTargetLocationY()))
assert.is_same(354, Util.round(ship5:getOrderTargetLocationX()))

Fleet:new() circle formation lets them fly around the leader

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5}, {formation = "circle"})

assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same(354, Util.round(ship2:getOrderTargetLocationY()))
assert.is_same(354, Util.round(ship2:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship3:getOrder())
assert.is_same(354, Util.round(ship3:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship3:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationY()))
assert.is_same(-354, Util.round(ship4:getOrderTargetLocationX()))
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(-354, Util.round(ship5:getOrderTargetLocationY()))
assert.is_same(354, Util.round(ship5:getOrderTargetLocationX()))

Fleet:new() creates a valid fleet

local fleet = Fleet:new({CpuShip(), CpuShip()})

assert.is_true(Fleet:isFleet(fleet))

Fleet:new() does not allow to add ships that are already in a fleet

local ship = CpuShip()
Fleet:new({ship})

assert.has_error(function() Fleet:new({ship}) end)

Fleet:new() fails if a station is given

assert.has_error(function() Fleet:new({CpuShip(), SpaceStation()}) end)

Fleet:new() fails if id is not a string

local ship = CpuShip()
assert.has_error(function() Fleet:new({ship}, {id = 42}) end)

Fleet:new() fails if the config is not a table

local ship = CpuShip()
assert.has_error(function() Fleet:new({ship}, "This breaks") end)

Fleet:new() fails silently when fleet is invalid

local ship1 = CpuShip()

local fleet = Fleet:new({ship1})

ship1:destroy()

fleet:orderRoaming()

Fleet:new() gives all ships the fleet trait

local ship1 = CpuShip()
local ship2 = CpuShip()
Fleet:new({ship1, ship2})

assert.is_true(Ship:hasFleet(ship1))
assert.is_true(Ship:hasFleet(ship2))

Fleet:new() issues complex orders to fleet leader

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local station = SpaceStation()

local fleet = Fleet:new({ship1, ship2, ship3})

fleet:orderDefendTarget(station)

assert.is_same("Defend Target", ship1:getOrder())
assert.is_same(station, ship1:getOrderTarget())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Fleet:new() issues orders to fleet leader

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

fleet:orderRoaming()

assert.is_same("Roaming", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Fleet:new() orders all ships to fly in formation

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

Fleet:new({ship1, ship2, ship3})

assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Fleet:new() row formation fills gaps when a wingman is killed

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

ship3:destroy()

Cron.tick(1)

assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same(-700, ship2:getOrderTargetLocationY())
assert.is_same(0, ship2:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-1400, ship4:getOrderTargetLocationY())
assert.is_same(0, ship4:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(700, ship5:getOrderTargetLocationY())
assert.is_same(0, ship5:getOrderTargetLocationX())

Fleet:new() row formation fills gaps when the leader is killed and the highest ranking ship is left

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

ship1:destroy()

Cron.tick(1)

assert.is_same("Fly in formation", ship3:getOrder())
assert.is_same(700, ship3:getOrderTargetLocationY())
assert.is_same(0, ship3:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-700, ship4:getOrderTargetLocationY())
assert.is_same(0, ship4:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(1400, ship5:getOrderTargetLocationY())
assert.is_same(0, ship5:getOrderTargetLocationX())

Fleet:new() row formation fills gaps when the leader is killed and the highest ranking ship is right

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

ship2:destroy()
ship1:destroy()

Cron.tick(1)

assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-700, ship4:getOrderTargetLocationY())
assert.is_same(0, ship4:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(700, ship5:getOrderTargetLocationY())
assert.is_same(0, ship5:getOrderTargetLocationX())

Fleet:new() row formation lets them fly in a nuke-friendly formation

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()
local ship4 = CpuShip()
local ship5 = CpuShip()

Fleet:new({ship1, ship2, ship3, ship4, ship5})

assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same(-700, ship2:getOrderTargetLocationY())
assert.is_same(0, ship2:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship3:getOrder())
assert.is_same(700, ship3:getOrderTargetLocationY())
assert.is_same(0, ship3:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship4:getOrder())
assert.is_same(-1400, ship4:getOrderTargetLocationY())
assert.is_same(0, ship4:getOrderTargetLocationX())
assert.is_same("Fly in formation", ship5:getOrder())
assert.is_same(1400, ship5:getOrderTargetLocationY())
assert.is_same(0, ship5:getOrderTargetLocationX())

Fleet:new() transfers the order if the leader is killed

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

fleet:orderRoaming()
ship1:destroy()

Cron.tick(1)

assert.is_same("Roaming", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Fleet:new():countShips()

Fleet:new():countShips() counts all valid ships

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

assert.is_same(3, fleet:countShips())

ship1:destroy()
assert.is_same(2, fleet:countShips())
ship3:destroy()
assert.is_same(1, fleet:countShips())
ship2:destroy()
assert.is_same(0, fleet:countShips())

Fleet:new():getId()

Fleet:new():getId() generates an id if none is given

local ship = CpuShip()
local fleet = Fleet:new({ship})

assert.is_true(isString(fleet:getId()))
assert.is_not_same("", fleet:getId())

Fleet:new():getId() returns the given id

local ship = CpuShip()
local fleet = Fleet:new({ship}, {id = "foobar"})

assert.is_same("foobar", fleet:getId())

Fleet:new():getLeader()

Fleet:new():getLeader() returns the highest ranking valid ship

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

assert.is_same(ship1, fleet:getLeader())

ship1:destroy()
assert.is_same(ship2, fleet:getLeader())

Fleet:new():getShips()

Fleet:new():getShips() does not allow to manipulate the result

local ship = CpuShip()
local fleet = Fleet:new({ship})

local ships = fleet:getShips()
table.insert(ships, CpuShip())

assert.is_same({ship}, fleet:getShips())

Fleet:new():getShips() returns all valid ships

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

assert.contains_value(ship1, fleet:getShips())
assert.contains_value(ship2, fleet:getShips())
assert.contains_value(ship3, fleet:getShips())

ship1:destroy()
assert.not_contains_value(ship1, fleet:getShips())
assert.contains_value(ship2, fleet:getShips())
assert.contains_value(ship3, fleet:getShips())

ship2:destroy()
assert.not_contains_value(ship1, fleet:getShips())
assert.not_contains_value(ship2, fleet:getShips())
assert.contains_value(ship3, fleet:getShips())

ship3:destroy()
assert.is_same({}, fleet:getShips())

Fleet:new():isValid()

Fleet:new():isValid() it returns true as long as there are valid ships

local ship1 = CpuShip()
local ship2 = CpuShip()
local ship3 = CpuShip()

local fleet = Fleet:new({ship1, ship2, ship3})

assert.is_true(fleet:isValid())
ship1:destroy()
assert.is_true(fleet:isValid())
ship2:destroy()
assert.is_true(fleet:isValid())
ship3:destroy()
assert.is_false(fleet:isValid())

Fleet:withOrderQueue()

Fleet:withOrderQueue() fails if parameter is not a fleet

assert.has_error(function()
    Fleet:withOrderQueue()
end)
assert.has_error(function()
    Fleet:withOrderQueue(42)
end)
assert.has_error(function()
    Fleet:withOrderQueue(SpaceStation())
end)
assert.has_error(function()
    Fleet:withOrderQueue(CpuShip())
end)

Fleet:withOrderQueue() should create a fleet with order queue

local fleet = Fleet:new({CpuShip(), CpuShip(), CpuShip()})
Fleet:withOrderQueue(fleet)

assert.is_true(Fleet:hasOrderQueue(fleet))

Fleet:withOrderQueue():abortCurrentOrder()

Fleet:withOrderQueue():abortCurrentOrder() fleet idles if there is no next order

local fleet = Fleet:new({CpuShip(), CpuShip(), CpuShip()})
Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil

local order = Order:flyTo(1000, 0, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end
})

fleet:addOrder(order)
assert.is_same("Fly towards", fleet:getLeader():getOrder())
assert.is_same(0, onAbortCalled)

fleet:abortCurrentOrder()
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("user", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Fleet:withOrderQueue():flushOrders()

Fleet:withOrderQueue():flushOrders() fleet does not carry out any new orders after the one is completed

local fleet = Fleet:new({CpuShip(), CpuShip(), CpuShip()})
Fleet:withOrderQueue(fleet)
fleet:getLeader():setPosition(0, 0)

fleet:addOrder(Order:flyTo(1000, 0))
fleet:addOrder(Order:flyTo(2000, 0))
assert.is_same("Fly towards", fleet:getLeader():getOrder())
assert.is_same({1000, 0}, {fleet:getLeader():getOrderTargetLocation()})

Cron.tick(1)
fleet:flushOrders()
fleet:getLeader():setPosition(1000, 0)

Cron.tick(1)
assert.is_same("Fly towards", fleet:getLeader():getOrder())
assert.is_same({1000, 0}, {fleet:getLeader():getOrderTargetLocation()})

Fleet:withOrderQueue():forceOrderNow()

Fleet:withOrderQueue():forceOrderNow() executes a fleet order immediately

local fleet = Fleet:new({CpuShip(), CpuShip(), CpuShip()})
Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil

local order = Order:flyTo(1000, 0, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end
})

fleet:addOrder(order)
fleet:addOrder(Order:flyTo(2000, 0))
fleet:addOrder(Order:flyTo(3000, 0))
fleet:addOrder(Order:flyTo(4000, 0))
assert.is_same("Fly towards", fleet:getLeader():getOrder())
assert.is_same(0, onAbortCalled)

fleet:forceOrderNow(Order:flyTo(0, 1000))
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("user", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Fly towards", fleet:getLeader():getOrder())
assert.is_same({0, 1000}, {fleet:getLeader():getOrderTargetLocation()})

Generic

Generic:withTags()

Generic:withTags() allows to set tags in the constructor

local station = SpaceStation()
Generic:withTags(station, "foo", "bar", "baz")

assert.is_true(Generic:hasTags(station))
assert.is_same(3, Util.size(station:getTags()))

Generic:withTags() creates a valid tagged object

local station = SpaceStation()
Generic:withTags(station)

assert.is_true(Generic:hasTags(station))

Generic:withTags() fails if a tag is not a string

assert.has_error(function() Generic:withTags(station, "foo", "bar", 42) end)

Generic:withTags() fails if the first argument is a number

assert.has_error(function() Generic:withTags(42) end)

Generic:withTags():addTag()

Generic:withTags():addTag() adds a tag

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

station:addTag("baz")

assert.is_true(station:hasTag("baz"))
assert.contains_value("baz", station:getTags())

Generic:withTags():addTag() fails if a number is given

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

assert.has_error(function()
    station:addTag(42)
end)

Generic:withTags():addTags()

Generic:withTags():addTags() allows to batch add tags

local station = SpaceStation()
Generic:withTags(station)

station:addTags("foo", "bar", "baz")

assert.is_true(station:hasTag("foo"))
assert.is_true(station:hasTag("bar"))
assert.is_true(station:hasTag("baz"))
assert.contains_value("foo", station:getTags())
assert.contains_value("baz", station:getTags())
assert.contains_value("bar", station:getTags())

Generic:withTags():getTags()

Generic:withTags():getTags() does not allow to manipulate the tags

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

local tags = station:getTags()
table.insert(tags, "fake")

tags = station:getTags()
assert.not_contains_value("fake", tags)

Generic:withTags():getTags() does not return the same tag twice

local station = SpaceStation()
Generic:withTags(station, "foo", "bar", "bar", "bar")

local tags = station:getTags()
assert.is_same(2, Util.size(tags))
assert.contains_value("foo", tags)
assert.contains_value("bar", tags)

Generic:withTags():getTags() returns all tags set in the constructor

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

local tags = station:getTags()
assert.contains_value("foo", tags)
assert.contains_value("bar", tags)

Generic:withTags():hasTag()

Generic:withTags():hasTag() fails if a non-string tag is given

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

assert.has_error(function()
    station:hasTag(42)
end)

Generic:withTags():hasTag() returns true if a tag was set in the constructor and false if not

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

assert.is_true(station:hasTag("foo"))
assert.is_true(station:hasTag("bar"))
assert.is_false(station:hasTag("fake"))

Generic:withTags():removeTag()

Generic:withTags():removeTag() fails if a number is given

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

assert.has_error(function()
    station:removeTag(42)
end)

Generic:withTags():removeTag() fails silently if the tag was not set

local station = SpaceStation()
Generic:withTags(station, "foo", "bar")

station:removeTag("baz")
local tags = station:getTags()

assert.is_false(station:hasTag("baz"))
assert.not_contains_value("baz", tags)
assert.is_same(2, Util.size(tags))

Generic:withTags():removeTag() removes a tag if it was set before

local station = SpaceStation()
Generic:withTags(station, "foo", "bar", "baz")

station:removeTag("baz")
local tags = station:getTags()

assert.is_false(station:hasTag("baz"))
assert.not_contains_value("baz", tags)
assert.is_same(2, Util.size(tags))

Generic:withTags():removeTags()

Generic:withTags():removeTags() allows to batch remove tags

local station = SpaceStation()
Generic:withTags(station, "foo", "bar", "baz")

station:removeTags("bar", "baz")

assert.is_true(station:hasTag("foo"))
assert.is_false(station:hasTag("bar"))
assert.is_false(station:hasTag("baz"))
assert.contains_value("foo", station:getTags())
assert.not_contains_value("baz", station:getTags())
assert.not_contains_value("bar", station:getTags())

Mission

Mission:allOf()

Mission:allOf() fails if any sub mission fails

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:allOf(subMission1, subMission2, subMission3)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("accepted", subMission2:getState())
assert.is_same("accepted", subMission3:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission1:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission2:fail()
assert.is_same("failed", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("failed", subMission2:getState())
assert.is_same("failed", subMission3:getState())

Mission:allOf() makes all sub missions fail if the wrapper mission is set to fail

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:allOf(subMission1, subMission2, subMission3)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:accept()
mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission2:success()
assert.is_same("successful", subMission2:getState())

mission:fail()
assert.is_same("failed", mission:getState())
assert.is_same("failed", subMission1:getState())
assert.is_same("successful", subMission2:getState())
assert.is_same("failed", subMission3:getState())

Mission:allOf() makes all sub missions successful if the wrapper mission is set to success

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:allOf(subMission1, subMission2, subMission3)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:accept()
mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission2:success()
assert.is_same("successful", subMission2:getState())

mission:success()
assert.is_same("successful", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("successful", subMission2:getState())
assert.is_same("successful", subMission3:getState())

Mission:allOf() makes every sub mission a PlayerMission if it is a PlayerMission itself

local subMission1 = Mission:new()
local subMission2 = Mission:new()
Mission:forPlayer(subMission2)
local subMission3 = Mission:new()
local player = PlayerSpaceship()

local mission = Mission:allOf(subMission1, subMission2, subMission3)
Mission:forPlayer(mission)
mission:setPlayer(player)

mission:accept()
mission:start()

assert.is_true(Mission:isPlayerMission(subMission1))
assert.is_same(player, subMission1:getPlayer())
-- subMission2 was already a player mission, but the player should be set anyways
assert.is_true(Mission:isPlayerMission(subMission2))
assert.is_same(player, subMission2:getPlayer())
assert.is_true(Mission:isPlayerMission(subMission3))
assert.is_same(player, subMission3:getPlayer())

Mission:allOf() starts all the missions and finishes if all are completed (happy case)

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:allOf(subMission1, subMission2, subMission3)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("accepted", subMission2:getState())
assert.is_same("accepted", subMission3:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission1:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission3:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("successful", subMission3:getState())

subMission2:success()
assert.is_same("successful", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("successful", subMission2:getState())
assert.is_same("successful", subMission3:getState())

Mission:allOf():allOf()

Mission:allOf():allOf() allows to set config

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local onAcceptCalled = false
local mission = Mission:allOf(subMission1, subMission2, {onAccept = function()
    onAcceptCalled = true
end})

mission:accept()
assert.is_true(onAcceptCalled)

Mission:allOf():allOf() creates a valid mission and sets the parent mission

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:allOf(subMission1, subMission2)

assert.is_true(Mission:isMission(mission))
assert.is_true(Mission:isSubMission(subMission1))
assert.is_same(mission, subMission1:getParentMission())
assert.is_true(Mission:isSubMission(subMission2))
assert.is_same(mission, subMission2:getParentMission())

Mission:allOf():allOf() fails if any sub mission is already part of another mission container

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()
local subMission4 = Mission:new()

Mission:allOf(subMission1, subMission4)

assert.has_error(function() Mission:allOf(subMission1, subMission2, subMission3) end)

Mission:allOf():allOf() fails if any sub mission is not "new"

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

subMission3:accept()
assert.has_error(function() Mission:allOf(subMission1, subMission2, subMission3) end)

subMission3:start()
assert.has_error(function() Mission:allOf(subMission1, subMission2, subMission3) end)

subMission3:success()
assert.has_error(function() Mission:allOf(subMission1, subMission2, subMission3) end)

Mission:allOf():allOf() fails if no sub missions are given

assert.has_error(function()
    Mission:allOf()
end)

Mission:allOf():allOf() fails if no sub missions are given

assert.has_error(function()
    Mission:allOf()
end)

Mission:allOf():allOf() fails on invalid parameters

assert.has_error(function() Mission:allOf(42) end)
assert.has_error(function() Mission:allOf({}) end)
assert.has_error(function() Mission:allOf("broken") end)
assert.has_error(function() Mission:allOf(CpuShip()) end)

Mission:chain()

Mission:chain() fails if the first sub mission fails

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:chain(subMission1, subMission2)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("new", subMission2:getState())

subMission1:fail()
assert.is_same("failed", mission:getState())
assert.is_same("failed", subMission1:getState())
-- subMission2 state is undefined

Mission:chain() fails if the second sub mission fails

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:chain(subMission1, subMission2)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("new", subMission2:getState())

subMission1:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("started", subMission2:getState())

subMission2:fail()
assert.is_same("failed", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("failed", subMission2:getState())

Mission:chain() makes every sub mission a PlayerMission if it is a PlayerMission itself

local subMission1 = Mission:new()
local subMission2 = Mission:new()
Mission:forPlayer(subMission2)
local player = PlayerSpaceship()

local mission = Mission:chain(subMission1, subMission2)
Mission:forPlayer(mission)
mission:setPlayer(player)

mission:accept()
mission:start()

assert.is_true(Mission:isPlayerMission(subMission1))
assert.is_same(player, subMission1:getPlayer())

subMission1:success()

-- subMission2 was already a player mission, but the player should be set anyways
assert.is_true(Mission:isPlayerMission(subMission2))
assert.is_same(player, subMission2:getPlayer())

Mission:chain() makes the current sub mission fail if the wrapper mission is set to failure

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:chain(subMission1, subMission2)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:fail()
assert.is_same("failed", mission:getState())
assert.is_same("failed", subMission1:getState())
assert.is_same("new", subMission2:getState())

-- behavior on any further sub mission state changes are undefined

Mission:chain() makes the current sub mission successful if the wrapper mission is set to success

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:chain(subMission1, subMission2)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("new", subMission2:getState())

mission:success()
assert.is_same("successful", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("new", subMission2:getState())

-- behavior on any further sub mission state changes are undefined

Mission:chain() starts the missions one after another (happy case)

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:chain(subMission1, subMission2, subMission3)

assert.is_same("new", mission:getState())
assert.is_same("new", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:accept()
assert.is_same("accepted", mission:getState())
assert.is_same("accepted", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

mission:start()
assert.is_same("started", mission:getState())
assert.is_same("started", subMission1:getState())
assert.is_same("new", subMission2:getState())
assert.is_same("new", subMission3:getState())

subMission1:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("started", subMission2:getState())
assert.is_same("new", subMission3:getState())

subMission2:success()
assert.is_same("started", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("successful", subMission2:getState())
assert.is_same("started", subMission3:getState())

subMission3:success()
assert.is_same("successful", mission:getState())
assert.is_same("successful", subMission1:getState())
assert.is_same("successful", subMission2:getState())
assert.is_same("successful", subMission3:getState())

Mission:chain():chain()

Mission:chain():chain() allows to set config

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local onAcceptCalled = false
local mission = Mission:chain(subMission1, subMission2, {onAccept = function()
    onAcceptCalled = true
end})

mission:accept()
assert.is_true(onAcceptCalled)

Mission:chain():chain() creates a valid mission and sets the parent mission

local subMission1 = Mission:new()
local subMission2 = Mission:new()

local mission = Mission:chain(subMission1, subMission2)

assert.is_true(Mission:isMission(mission))
assert.is_true(Mission:isSubMission(subMission1))
assert.is_same(mission, subMission1:getParentMission())
assert.is_true(Mission:isSubMission(subMission2))
assert.is_same(mission, subMission2:getParentMission())

Mission:chain():chain() fails if any sub mission is already part of another mission container

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()
local subMission4 = Mission:new()

Mission:chain(subMission1, subMission4)

assert.has_error(function() Mission:chain(subMission1, subMission2, subMission3) end)

Mission:chain():chain() fails if any sub mission is not "new"

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

subMission3:accept()
assert.has_error(function() Mission:chain(subMission1, subMission2, subMission3) end)

subMission3:start()
assert.has_error(function() Mission:chain(subMission1, subMission2, subMission3) end)

subMission3:success()
assert.has_error(function() Mission:chain(subMission1, subMission2, subMission3) end)

Mission:chain():chain() fails if no sub missions are given

assert.has_error(function()
    Mission:chain()
end)

Mission:chain():chain() fails if no sub missions are given

assert.has_error(function()
    Mission:chain()
end)

Mission:chain():chain() fails on invalid parameters

assert.has_error(function() Mission:chain(42) end)
assert.has_error(function() Mission:chain({}) end)
assert.has_error(function() Mission:chain("broken") end)
assert.has_error(function() Mission:chain(CpuShip()) end)

Mission:chain():getCurrentMission()

Mission:chain():getCurrentMission() returns the correct currently running mission

local subMission1 = Mission:new()
local subMission2 = Mission:new()
local subMission3 = Mission:new()

local mission = Mission:chain(subMission1, subMission2, subMission3)

assert.is_nil(mission:getCurrentMission())

mission:accept()
assert.is_nil(mission:getCurrentMission())

mission:start()
assert.is_same(subMission1, mission:getCurrentMission())

subMission1:success()
assert.is_same(subMission2, mission:getCurrentMission())

subMission2:success()
assert.is_same(subMission3, mission:getCurrentMission())

subMission3:success()
assert.is_nil(mission:getCurrentMission())

Mission:forPlayer()

Mission:forPlayer() fails if no mission is given

local mission = missionMock()

assert.has_error(function() Mission:forPlayer(nil, player) end)

Mission:forPlayer() fails if the mission has been accepted already

local mission = missionMock()
mission:accept()

assert.has_error(function() Mission:forPlayer(mission, player) end)

Mission:forPlayer() fails if the mission is already a player mission

local mission = missionMock()
Mission:forPlayer(mission, player)

assert.has_error(function() Mission:forPlayer(mission, player) end)

Mission:forPlayer() fails if the player is destroyed

local player = PlayerSpaceship()

local onStartCalled = 0
local mission = Mission:new({
    onStart = function(self)
        onStartCalled = onStartCalled + 1
    end,
})
Mission:forPlayer(mission, player)

mission:setPlayer(player)
mission:accept()
mission:start()
assert.is_same(1, onStartCalled)

Cron.tick(1)
assert.is_same("started", mission:getState())

player:destroy()
Cron.tick(1)
assert.is_same("failed", mission:getState())

Mission:forPlayer() should create a valid Mission with player

local mission = missionMock()
Mission:forPlayer(mission, player)

assert.is_true(Mission:isPlayerMission(mission))

Mission:forPlayer():accept()

Mission:forPlayer():accept() adds the mission to the mission tracker if the player has one

local player = PlayerSpaceship()
Player:withMissionTracker(player)

local mission = Mission:new()
Mission:forPlayer(mission, player)
mission:setPlayer(player)

mission:accept()
mission:start()

assert.is_same({mission}, player:getStartedMissions())

Mission:forPlayer():accept() also calls the original implementation

local originalCalled = false

local mission = missionMock()
mission.accept = function() originalCalled = true end
Mission:forPlayer(mission, player)
mission:setPlayer(PlayerSpaceship())

mission:accept()
assert.is_true(originalCalled)

Mission:forPlayer():accept() can be called if Player are set

local mission = missionWithPlayerMock()
local player = PlayerSpaceship()

mission:setPlayer(player)

mission:accept()

Mission:forPlayer():accept() can not be called if Player is not set

local mission = missionWithPlayerMock()

assert.has_error(function() mission:accept() end)

Mission:forPlayer():getPlayer()

Mission:forPlayer():getPlayer() returns the set Player

local mission = missionWithPlayerMock()
local player = PlayerSpaceship()

mission:setPlayer(player)

assert.is_same(player, mission:getPlayer())

Mission:forPlayer():setPlayer()

Mission:forPlayer():setPlayer() can not be changed on an accepted mission

local player = PlayerSpaceship()

local mission = Mission:new()
Mission:forPlayer(mission, player)
mission:setPlayer(player)

mission:accept()

assert.has_error(function()
    mission:setPlayer(player)
end)

Mission:forPlayer():setPlayer() fails if argument is not a player

local mission = missionWithPlayerMock()

assert.has_error(function()mission:setPlayer(42) end)

Mission:new()

Mission:new() allows to give config

local id = "foobar"
local mission = Mission:new({id = id})

assert.is_same(id, mission:getId())
assert.is_true(Mission:isMission(mission))

Mission:new() fails if acceptCondition is not a function

assert.has_error(function() Mission:new({acceptCondition = 42}) end)

Mission:new() fails if onAccept is not a function

assert.has_error(function() Mission:new({onAccept = 42}) end)

Mission:new() fails if onDecline is not a function

assert.has_error(function() Mission:new({onDecline = 42}) end)

Mission:new() fails if onEnd is not a function

assert.has_error(function() Mission:new({onEnd = 42}) end)

Mission:new() fails if onFailure is not a function

assert.has_error(function() Mission:new({onFailure = 42}) end)

Mission:new() fails if onStart is not a function

assert.has_error(function() Mission:new({onStart = 42}) end)

Mission:new() fails if onSuccess is not a function

assert.has_error(function() Mission:new({onSuccess = 42}) end)

Mission:new() fails if the config is not a table

assert.has_error(function() Mission:new("thisBreaks") end)

Mission:new() should create a valid Mission

local mission = Mission:new()

assert.is_true(Mission:isMission(mission))

Mission:new() state machine allows to call accept on a mission in state new

mission[funcName](mission) -- it should not error

Mission:new() state machine allows to call decline on a mission in state new

mission[funcName](mission) -- it should not error

Mission:new() state machine allows to call fail on a mission in state started

mission[funcName](mission) -- it should not error

Mission:new() state machine allows to call start on a mission in state accepted

mission[funcName](mission) -- it should not error

Mission:new() state machine allows to call success on a mission in state started

mission[funcName](mission) -- it should not error

Mission:new() state machine prevents calls to accept on a mission in state accepted

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to accept on a mission in state declined

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to accept on a mission in state failed

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to accept on a mission in state started

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to accept on a mission in state successful

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to decline on a mission in state accepted

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to decline on a mission in state declined

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to decline on a mission in state failed

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to decline on a mission in state started

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to decline on a mission in state successful

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to fail on a mission in state accepted

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to fail on a mission in state declined

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to fail on a mission in state failed

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to fail on a mission in state new

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to fail on a mission in state successful

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to start on a mission in state declined

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to start on a mission in state failed

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to start on a mission in state new

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to start on a mission in state started

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to start on a mission in state successful

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to success on a mission in state accepted

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to success on a mission in state declined

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to success on a mission in state failed

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to success on a mission in state new

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new() state machine prevents calls to success on a mission in state successful

assert.has_error(function()
    mission[funcName](mission)
end)

Mission:new():accept()

Mission:new():accept() calls the onAccept callback

local callbackCalled = false
local mission
mission = newMission({onAccept = function(self)
    assert.is_same(mission, self)
    callbackCalled = true
end})

mission:accept()
assert.is_true(callbackCalled)

Mission:new():accept() calls the onAccept eventListener

local listenerCalled = false
local calledArg1
local mission = newMission()
mission:addAcceptListener(function(_, arg1)
    calledArg1 = arg1
    listenerCalled = true
end)

assert.is_false(listenerCalled)
mission:accept()
assert.is_true(listenerCalled)
assert.is_same(mission, calledArg1)

Mission:new():accept() fails if onAccept callback fails

local mission = newMission({onAccept = function() error("boom") end})

assert.has_error(function() mission:accept() end)

Mission:new():accept() fails when acceptCondition callback returns a string

local mission = newMission({acceptCondition = function() return "Just... No" end})

assert.has_error(function() mission:accept() end)

Mission:new():accept() fails when acceptCondition callback returns false

local mission = newMission({acceptCondition = function() return false end})

assert.has_error(function() mission:accept() end)

Mission:new():accept() switches to "accepted" if no callback is set

local mission = newMission()

mission:accept()
assert.is_same("accepted", mission:getState())

Mission:new():canBeAccepted()

Mission:new():canBeAccepted() is true when no config is set

local mission = newMission()
assert.is_true(mission:canBeAccepted())

Mission:new():decline()

Mission:new():decline() calls the onDecline callback

local callbackCalled = false
local mission
mission = newMission({onDecline = function(self)
    assert.is_same(mission, self)
    callbackCalled = true
end})

mission:decline()
assert.is_true(callbackCalled)

Mission:new():decline() calls the onDecline eventListener

local listenerCalled = false
local calledArg1
local mission = newMission()
mission:addDeclineListener(function(_, arg1)
    calledArg1 = arg1
    listenerCalled = true
end)

assert.is_false(listenerCalled)
mission:decline()
assert.is_true(listenerCalled)
assert.is_same(mission, calledArg1)

Mission:new():decline() fails if onDecline callback fails

local mission = newMission({onDecline = function() error("boom") end})

assert.has_error(function() mission:decline() end)

Mission:new():fail()

Mission:new():fail() calls the onFailure callback and then the onEnd callback

local onFailure = false
local onEndCalled = false

local mission
mission = startedMission({
    onFailure = function(self)
        assert.is_same(mission, self)
        onFailure = true
    end,
    onEnd = function(self)
        assert.is_same(mission, self)
        assert.is_true(onFailure)
        onEndCalled = true
    end
})

mission:fail()
assert.is_true(onFailure)
assert.is_true(onEndCalled)

Mission:new():fail() calls the onFailure eventListener and then the onEnd eventListener

local onFailureCalled = false
local onFailureCalledArg1
local onEndCalled = false
local onEndCalledArg1
local wasOnFailureCalled = false
local mission = startedMission()
mission:addFailureListener(function(_, arg1)
    onFailureCalledArg1 = arg1
    onFailureCalled = true
end)
mission:addEndListener(function(_, arg1)
    wasOnFailureCalled = onFailureCalled
    onEndCalledArg1 = arg1
    onEndCalled = true
end)

assert.is_false(onFailureCalled)
assert.is_false(onEndCalled)
mission:fail()
assert.is_true(onFailureCalled)
assert.is_same(mission, onFailureCalledArg1)
assert.is_true(wasOnFailureCalled)
assert.is_true(onEndCalled)
assert.is_same(mission, onEndCalledArg1)

Mission:new():fail() fails if onEnd callback fails

local mission = startedMission({onEnd = function() error("boom") end})

assert.has_error(function() mission:fail() end)

Mission:new():fail() fails if onFailure callback fails

local mission = startedMission({onFailure = function() error("boom") end})

assert.has_error(function() mission:fail() end)

Mission:new():getId()

Mission:new():getId() is set at random if none is given

local mission = Mission:new()

assert.is_true(isString(mission:getId()))
assert.is_not_same("", mission:getId())

Mission:new():getId() uses the given id

local id = "foobar"
local mission = Mission:new({id = id})

assert.is_same(id, mission:getId())

Mission:new():getState()

Mission:new():getState() returns "accepted" for an accepted mission

assert.is_same(accepted, acceptedMission():getState())

Mission:new():getState() returns "declined" for a declined mission

assert.is_same(declined, declinedMission():getState())

Mission:new():getState() returns "failed" for a failed mission

assert.is_same(failed, failedMission():getState())

Mission:new():getState() returns "new" for a new mission

assert.is_same(new, newMission():getState())

Mission:new():getState() returns "started" for a started mission

assert.is_same(started, startedMission():getState())

Mission:new():getState() returns "successful" for a successful mission

assert.is_same(successful, successfulMission():getState())

Mission:new():start()

Mission:new():start() calls the onStart callback

local callbackCalled = false
local mission
mission = acceptedMission({onStart = function(self)
    assert.is_same(mission, self)
    callbackCalled = true
end})

mission:start()
assert.is_true(callbackCalled)

Mission:new():start() calls the onStart eventListener

local listenerCalled = false
local calledArg1
local mission = acceptedMission()
mission:addStartListener(function(_, arg1)
    calledArg1 = arg1
    listenerCalled = true
end)

assert.is_false(listenerCalled)
mission:start()
assert.is_true(listenerCalled)
assert.is_same(mission, calledArg1)

Mission:new():start() fails if onDecline callback fails

local mission = acceptedMission({onStart = function() error("boom") end})

assert.has_error(function() mission:start() end)

Mission:new():success()

Mission:new():success() calls the onFailure eventListener and then the onEnd eventListener

local onSuccessCalled = false
local onSuccessCalledArg1
local onEndCalled = false
local onEndCalledArg1
local wasOnSuccessCalled = false
local mission = startedMission()
mission:addSuccessListener(function(_, arg1)
    onSuccessCalledArg1 = arg1
    onSuccessCalled = true
end)
mission:addEndListener(function(_, arg1)
    wasOnSuccessCalled = onSuccessCalled
    onEndCalledArg1 = arg1
    onEndCalled = true
end)

assert.is_false(onSuccessCalled)
assert.is_false(onEndCalled)
mission:success()
assert.is_true(onSuccessCalled)
assert.is_same(mission, onSuccessCalledArg1)
assert.is_true(wasOnSuccessCalled)
assert.is_true(onEndCalled)
assert.is_same(mission, onEndCalledArg1)

Mission:new():success() calls the onSuccess callback and then the onEnd callback

local onSuccessCalled = false
local onEndCalled = false

local mission
mission = startedMission({
    onSuccess = function(self)
        assert.is_same(mission, self)
        onSuccessCalled = true
    end,
    onEnd = function(self)
        assert.is_same(mission, self)
        assert.is_true(onSuccessCalled)
        onEndCalled = true
    end
})

mission:success()
assert.is_true(onSuccessCalled)
assert.is_true(onEndCalled)

Mission:new():success() fails if onEnd callback fails

local mission = startedMission({onEnd = function() error("boom") end})

assert.has_error(function() mission:success() end)

Mission:new():success() fails if onSuccess callback fails

local mission = startedMission({onSuccess = function() error("boom") end})

assert.has_error(function() mission:success() end)

Mission:registerMissionAcceptListener()

Mission:registerMissionAcceptListener() fires when mission is accepted

local eventCalled = false
local calledArg1
Mission:registerMissionAcceptListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()

assert.is_false(eventCalled)

mission:accept()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionCreationListener()

Mission:registerMissionCreationListener() fires when mission is created

local eventCalled = false
local calledArg1
Mission:registerMissionCreationListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

assert.is_false(eventCalled)

local mission = Mission:new()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionDeclineListener()

Mission:registerMissionDeclineListener() fires when mission is declined

local eventCalled = false
local calledArg1
Mission:registerMissionDeclineListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()

assert.is_false(eventCalled)

mission:decline()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionEndListener()

Mission:registerMissionEndListener() fires when mission is fails

local eventCalled = false
local calledArg1
Mission:registerMissionEndListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()
mission:accept()
mission:start()

assert.is_false(eventCalled)

mission:fail()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionEndListener() fires when mission is successful

local eventCalled = false
local calledArg1
Mission:registerMissionEndListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()
mission:accept()
mission:start()

assert.is_false(eventCalled)

mission:success()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionFailureListener()

Mission:registerMissionFailureListener() fires when mission is fails

local eventCalled = false
local calledArg1
Mission:registerMissionFailureListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()
mission:accept()
mission:start()

assert.is_false(eventCalled)

mission:fail()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionStartListener()

Mission:registerMissionStartListener() fires when mission is started

local eventCalled = false
local calledArg1
Mission:registerMissionStartListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()
mission:accept()

assert.is_false(eventCalled)

mission:start()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:registerMissionSuccessListener()

Mission:registerMissionSuccessListener() fires when mission is successful

local eventCalled = false
local calledArg1
Mission:registerMissionSuccessListener(function(_, arg1)
    eventCalled = true
    calledArg1 = arg1
end)

local mission = Mission:new()
mission:accept()
mission:start()

assert.is_false(eventCalled)

mission:success()

assert.is_true(eventCalled)
assert.is_same(mission, calledArg1)

Mission:withBroker()

Mission:withBroker() fails automatically if the broker is destroyed

local onStartCalled = 0
local station = SpaceStation()
local mission = Mission:new({
    onStart = function(self)
        onStartCalled = onStartCalled + 1
    end,
})
Mission:withBroker(mission, "Hello World")

mission:setMissionBroker(station)
mission:accept()
mission:start()

assert.is_same(1, onStartCalled)

Cron.tick(1)
assert.is_same("started", mission:getState())

station:destroy()
Cron.tick(1)
assert.is_same("failed", mission:getState())

Mission:withBroker() fails if no mission is given

local mission = missionMock()

assert.has_error(function() Mission:withBroker(nil, "Hello World") end)

Mission:withBroker() fails if no title is given

local mission = missionMock()

assert.has_error(function() Mission:withBroker(mission) end)

Mission:withBroker() fails if the config is not a table

local mission = missionMock()

assert.has_error(function() Mission:withBroker(mission, "Hello World", "thisBreaks") end)

Mission:withBroker() fails if the description is a number

assert.has_error(function() missionWithBrokerMock({description = 42}) end)

Mission:withBroker() fails if the mission has been accepted already

local mission = missionMock()
mission:accept()

assert.has_error(function() Mission:withBroker(mission, "Hello World") end)

Mission:withBroker() fails if the mission is already a story mission

local mission = missionMock()
Mission:withBroker(mission, "Hello World")

assert.has_error(function() Mission:withBroker(mission, "Hello World") end)

Mission:withBroker() should create a valid Mission with story

local mission = missionMock()
Mission:withBroker(mission, "Hello World")

assert.is_true(Mission:isBrokerMission(mission))

Mission:withBroker():accept()

Mission:withBroker():accept() also calls the original implementation

local originalCalled = false

local mission = missionMock()
mission.accept = function() originalCalled = true end
Mission:withBroker(mission, "Hello World")
mission:setMissionBroker(SpaceStation())

mission:accept()
assert.is_true(originalCalled)

Mission:withBroker():accept() can be called if MissionBroker is set

local mission = missionWithBrokerMock()
local station = SpaceStation()

mission:setMissionBroker(station)

mission:accept()

Mission:withBroker():accept() can not be called if no MissionBroker is set

local mission = missionWithBrokerMock()

assert.has_error(function() mission:accept() end)

Mission:withBroker():getAcceptMessage()

Mission:withBroker():getAcceptMessage() returns the message if it is a function

local message = "Thanks for taking that mission"
local mission
mission = missionWithBrokerMock({acceptMessage = function(callMission)
    assert.is_same(mission, callMission)
    return message
end})

assert.is_same(message, mission:getAcceptMessage())

Mission:withBroker():getAcceptMessage() returns the message if it is a string

local message = "Thanks for taking that mission"
local mission = missionWithBrokerMock({acceptMessage = message })

assert.is_same(message, mission:getAcceptMessage())

Mission:withBroker():getDescription()

Mission:withBroker():getDescription() returns the description if it is a function

local description = "This is a mission"
local mission
mission = missionWithBrokerMock({description = function(callMission)
    assert.is_same(mission, callMission)
    return description
end})

assert.is_same(description, mission:getDescription())

Mission:withBroker():getDescription() returns the description if it is a string

local description = "This is a mission"
local mission = missionWithBrokerMock({description = description})

assert.is_same(description, mission:getDescription())

Mission:withBroker():getHint(),:setHint()

Mission:withBroker():getHint(),:setHint() allows to remove the hint

local mission = missionWithBrokerMock()
mission:setHint(nil)

assert.is_nil(mission:getHint())

Mission:withBroker():getHint(),:setHint() fails if number is to be set as hint

local mission = missionWithBrokerMock()
assert.has_error(function() mission:setHint(42) end)

Mission:withBroker():getHint(),:setHint() returns nil by default

local mission = missionWithBrokerMock()

assert.is_nil(mission:getHint())

Mission:withBroker():getHint(),:setHint() returns nil if the function returns invalid type

local mission = missionWithBrokerMock()
mission:setHint(function() return 42 end)

assert.is_nil(mission:getHint())

Mission:withBroker():getHint(),:setHint() returns the set hint

local mission = missionWithBrokerMock()
mission:setHint("Use force")

assert.is_same("Use force", mission:getHint())

Mission:withBroker():getHint(),:setHint() returns the set hint by function

local mission = missionWithBrokerMock()
mission:setHint(function(theMission)
    assert.is_same(mission, theMission)
    return "Use force"
end)

assert.is_same("Use force", mission:getHint())

Mission:withBroker():getMissionBroker()

Mission:withBroker():getMissionBroker() returns the set MissionBroker

local mission = missionWithBrokerMock()
local station = SpaceStation()

mission:setMissionBroker(station)

assert.is_same(station, mission:getMissionBroker())

Mission:withBroker():getTitle()

Mission:withBroker():getTitle() returns the title if it is a function

local title = "Hello World"
local mission = missionMock()
Mission:withBroker(mission, function(callMission)
    assert.is_same(mission, callMission)
    return title
end)

assert.is_same(title, mission:getTitle())

Mission:withBroker():getTitle() returns the title if it is a string

local title = "Hello World"
local mission = missionMock()
Mission:withBroker(mission, title)

assert.is_same(title, mission:getTitle())

Mission:withTimeLimit()

Mission:withTimeLimit() allows to manipulate the time limit on a running mission

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

mission:accept()
mission:start()

assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(1, mission:getElapsedTime())
assert.is_same(9, mission:getRemainingTime())

mission:setTimeLimit(42)
assert.is_same(1, mission:getElapsedTime())
assert.is_same(41, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(2, mission:getElapsedTime())
assert.is_same(40, mission:getRemainingTime())

mission:modifyTimeLimit(-5)
assert.is_same(2, mission:getElapsedTime())
assert.is_same(35, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(3, mission:getElapsedTime())
assert.is_same(34, mission:getRemainingTime())

mission:modifyTimeLimit(2)
assert.is_same(3, mission:getElapsedTime())
assert.is_same(36, mission:getRemainingTime())

Mission:withTimeLimit() does not start the timer before mission is started

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

mission:accept()
assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

mission:start()
assert.is_same(0, mission:getElapsedTime())
assert.is_same(10, mission:getRemainingTime())

Cron.tick(1)
assert.is_same(1, mission:getElapsedTime())
assert.is_same(9, mission:getRemainingTime())

Mission:withTimeLimit() fails if mission is invalid

assert.has_error(function() Mission:withTimeLimit(nil, 10) end)
assert.has_error(function() Mission:withTimeLimit(PlayerSpaceship(), 10) end)
assert.has_error(function() Mission:withTimeLimit(123, 10) end)

Mission:withTimeLimit() fails if the mission already has a time limit

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

assert.has_error(function() Mission:withTimeLimit(mission, 10) end)

Mission:withTimeLimit() fails if the mission has been started already

local mission = Mission:new()
mission:accept()
mission:start()

assert.has_error(function() Mission:withTimeLimit(mission, 10) end)

Mission:withTimeLimit() fails to start if no time limit is set

local mission = Mission:new()
Mission:withTimeLimit(mission)

mission:accept()

assert.has_error(function() mission:start() end)

mission:setTimeLimit(10)
mission:start()

Mission:withTimeLimit() makes the mission fail if the time is up

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

mission:accept()
mission:start()

assert.is_same("started", mission:getState())
for _ = 1,9 do
    Cron.tick(1)
    assert.is_same("started", mission:getState())
end

Cron.tick(1.5)
assert.is_same("failed", mission:getState())

Mission:withTimeLimit() should create a valid Mission with time limit

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

assert.is_true(Mission:isTimeLimitMission(mission))

Mission:withTimeLimit():getRemainingTime()

Mission:withTimeLimit():getRemainingTime() never gets negative

local mission = Mission:new()
Mission:withTimeLimit(mission, 0.5)

mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, mission:getRemainingTime())

Mission:withTimeLimit():modifyTimeLimit()

Mission:withTimeLimit():modifyTimeLimit() can decrease the time limit

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

mission:modifyTimeLimit(-5)

assert.is_same(5, mission:getRemainingTime())

Mission:withTimeLimit():modifyTimeLimit() can increase the time limit

local mission = Mission:new()
Mission:withTimeLimit(mission, 10)

mission:modifyTimeLimit(10)

assert.is_same(20, mission:getRemainingTime())

Mission:withTimeLimit():modifyTimeLimit() fails on anything but a number

local mission = Mission:new()
Mission:withTimeLimit(mission)

assert.has_error(function() mission:modifyTimeLimit(nil) end)
assert.has_error(function() mission:modifyTimeLimit(PlayerSpaceship()) end)

Mission:withTimeLimit():setTimeLimit()

Mission:withTimeLimit():setTimeLimit() fails on non-positive numbers

local mission = Mission:new()
Mission:withTimeLimit(mission)

assert.has_error(function() mission:setTimeLimit(nil) end)
assert.has_error(function() mission:setTimeLimit(-1) end)
assert.has_error(function() mission:setTimeLimit(0) end)
assert.has_error(function() mission:setTimeLimit(PlayerSpaceship()) end)

Mission:withTimeLimit():setTimeLimit() sets the time limit

local mission = Mission:new()
Mission:withTimeLimit(mission)

mission:setTimeLimit(10)

assert.is_same(10, mission:getRemainingTime())

Missions

Missions:answer()

Missions:answer() can have dynamic questions and answers

local station = SpaceStation():setCallSign("Station")
local player = PlayerSpaceship():setCallSign("Player")
Station:withComms(station)

local questionCallArg1, questionCallArg2
local correctAnswerCallArg1, correctAnswerCallArg2
local wrongAnswerCallArg1, wrongAnswerCallArg2

local mission = Missions:answer(
        station,
        function(arg1, arg2)
            questionCallArg1, questionCallArg2 = arg1, arg2
            return "What is your ships call sign?"
        end,
        "I want to answer your question.", {
            correctAnswer = function(arg1, arg2)
                correctAnswerCallArg1, correctAnswerCallArg2 = arg1, arg2
                return arg2:getCallSign()
            end,
            correctAnswerResponse = "You are right.",
            wrongAnswers = function(arg1, arg2)
                wrongAnswerCallArg1, wrongAnswerCallArg2 = arg1, arg2
                return {
                    "Not " .. arg2:getCallSign()
                }
            end,
            wrongAnswerResponse = "You are wrong.",
        }
)

assert.is_true(Mission:isMission(mission))
mission:accept()
mission:start()

player:commandOpenTextComm(station)
player:selectComms("I want to answer your question.")
assert.is_same(mission, questionCallArg1)
assert.is_same(player, questionCallArg2)
assert.is_same("What is your ships call sign?", player:getCurrentCommsText())
assert.is_same(mission, correctAnswerCallArg1)
assert.is_same(player, correctAnswerCallArg2)
assert.is_same(mission, wrongAnswerCallArg1)
assert.is_same(player, wrongAnswerCallArg2)
player:commandCloseTextComm(station)

Missions:answer() is possible to enable the right answer through a function

local station = SpaceStation()
Station:withComms(station)
local correctAnswer = nil

local mission = Missions:answer(
        station,
        "What is my name?",
        "I want to answer your question.", {
            correctAnswer = function() return correctAnswer end,
            correctAnswerResponse = "You are right.",
            wrongAnswers = { "Paul", "Klaus", "Hans" },
            wrongAnswerResponse = "You are wrong.",
        }
)

assert.is_true(Mission:isMission(mission))
mission:accept()
mission:start()

local player = PlayerSpaceship()
player:commandOpenTextComm(station)
player:selectComms("I want to answer your question.")
assert.is_same("What is my name?", player:getCurrentCommsText())
assert.is_true(player:hasComms("Paul"))
assert.is_true(player:hasComms("Klaus"))
assert.is_true(player:hasComms("Hans"))
assert.is_false(player:hasComms("Rumpelstiltskin"))
player:commandCloseTextComm(station)

correctAnswer = "Rumpelstiltskin"
player:commandOpenTextComm(station)
player:selectComms("I want to answer your question.")
assert.is_same("What is my name?", player:getCurrentCommsText())
assert.is_true(player:hasComms("Rumpelstiltskin"))
player:selectComms("Rumpelstiltskin")
player:commandCloseTextComm(station)

Missions:answer() mission fails if the wrong answer is given

local station = SpaceStation()
Station:withComms(station)

local mission = Missions:answer(
        station,
        "What is the answer to my question?",
        "I want to answer your question.", {
            correctAnswer = "42",
            correctAnswerResponse = "You are right.",
            wrongAnswers = { "I don't know", "Yes", "No" },
            wrongAnswerResponse = "You are wrong.",
        }
)

assert.is_true(Mission:isMission(mission))
mission:accept()
mission:start()

local player = PlayerSpaceship()
player:commandOpenTextComm(station)
assert.is_true(player:hasComms("I want to answer your question."))
player:selectComms("I want to answer your question.")
assert.is_same("What is the answer to my question?", player:getCurrentCommsText())
player:selectComms("Yes")
assert.is_same("You are wrong.", player:getCurrentCommsText())

assert.is_same("failed", mission:getState())
player:commandCloseTextComm(station)

Missions:answer() mission is successful if the correct answer is given

local station = SpaceStation()
Station:withComms(station)

local mission = Missions:answer(
        station,
        "What is the answer to my question?",
        "I want to answer your question.", {
            correctAnswer = "42",
            correctAnswerResponse = "You are right.",
            wrongAnswers = { "I don't know", "Yes", "No" },
            wrongAnswerResponse = "You are wrong.",
        }
)

assert.is_true(Mission:isMission(mission))
mission:accept()
mission:start()

local player = PlayerSpaceship()
player:commandOpenTextComm(station)
assert.is_true(player:hasComms("I want to answer your question."))
player:selectComms("I want to answer your question.")
assert.is_same("What is the answer to my question?", player:getCurrentCommsText())
player:selectComms("42")
assert.is_same("You are right.", player:getCurrentCommsText())

assert.is_same("successful", mission:getState())
player:commandCloseTextComm(station)

Missions:answer() should create a valid Mission with one station and a guessing game

local station = SpaceStation()
Station:withComms(station)

local mission = Missions:answer(
    station,
    "What is the answer to my question?",
    "I want to answer your question.", {
        correctAnswer = "42",
        correctAnswerResponse = "You are right.",
        wrongAnswers = { "I don't know", "Yes", "No" },
        wrongAnswerResponse = "You are wrong.",
    }
)

assert.is_true(Mission:isMission(mission))

local player = PlayerSpaceship()

player:commandOpenTextComm(station)
assert.is_false(player:hasComms("I want to answer your question."))
player:commandCloseTextComm(station)

mission:accept()
mission:start()

player:commandOpenTextComm(station)
assert.is_true(player:hasComms("I want to answer your question."))
player:selectComms("I want to answer your question.")

assert.is_same("What is the answer to my question?", player:getCurrentCommsText())
assert.is_true(player:hasComms("42"))
assert.is_true(player:hasComms("I don't know"))
assert.is_true(player:hasComms("Yes"))
assert.is_true(player:hasComms("No"))

player:commandCloseTextComm(station)

Missions:capture()

Missions:capture() can run a successful mission with drop off point

local mission = Missions:capture(SpaceStation(), {
    dropOffTarget = SpaceStation()
})

mission:getBearer():setPosition(0,0)
player:setPosition(0,0)
player.isDocked = function(thing) return false end
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
mission:getBearer():destroy()
Cron.tick(1)
mission:getItemObject():destroy()
Cron.tick(1)

assert.is_same("started", mission:getState())

player.isDocked = function(self, thing) return thing == mission:getDropOffTarget() end
Cron.tick(1)

assert.is_same("successful", mission:getState())

Missions:capture() can run a successful mission without drop off point

local mission = Missions:capture(SpaceStation())

mission:getBearer():setPosition(0,0)
player:setPosition(0,0)
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
mission:getBearer():destroy()
Cron.tick(1)
mission:getItemObject():destroy()
Cron.tick(1)

assert.is_same("successful", mission:getState())

Missions:capture() config.onApproach is called when the player first enters around the bearer

local onApproachCalled = 0
local bearer = SpaceStation()
local mission
mission = Missions:capture(bearer, {
    approachDistance = 10000,
    onApproach = function(callMission, callEnemy)
        onApproachCalled = onApproachCalled + 1
        assert.is_same(mission, callMission)
        assert.is_same(bearer, callEnemy)
    end,
})

player:setPosition(20000, 0)
bearer:setPosition(0, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(9999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
Cron.tick(1)
player:setPosition(9999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Missions:capture() config.onBearerDestruction allows to return a custom itemObject

local bearer = SpaceStation()
local itemObject = CpuShip()
local mission
mission = Missions:capture(bearer, {
    onBearerDestruction = function(callMission, lastX, lastY)
        assert.is_same(mission, callMission)
        assert.is_same(4200, lastX)
        assert.is_same(-4200, lastY)
        return itemObject
    end,
})

bearer:setPosition(4200, -4200)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
bearer:destroy()

Cron.tick(1)

assert.is_nil(mission:getBearer())
assert.is_same(itemObject, mission:getItemObject())

Missions:capture() config.onBearerDestruction is called when the bearer is destroyed

local onBearerDestructionCalled = 0
local bearer = SpaceStation()
local mission
mission = Missions:capture(bearer, {
    onBearerDestruction = function(callMission, lastX, lastY)
        onBearerDestructionCalled = onBearerDestructionCalled + 1
        assert.is_same(mission, callMission)
        assert.is_same(4200, lastX)
        assert.is_same(-4200, lastY)
    end,
})

bearer:setPosition(4200, -4200)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onBearerDestructionCalled)
assert.is_same(bearer, mission:getBearer())
assert.is_nil(mission:getItemObject())

bearer:destroy()
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onBearerDestructionCalled)

assert.is_nil(mission:getBearer())
assert.is_true(isEeObject(mission:getItemObject()))

Missions:capture() config.onDropOff is called when the player returns the collected item

local onDropOffCalled = 0
local mission
mission = Missions:capture(SpaceStation(), {
    dropOffTarget = SpaceStation(),
    onDropOff = function(callMission)
        onDropOffCalled = onDropOffCalled + 1
        assert.is_same(mission, callMission)
    end,
})

local dockedToTarget = function(self, thing) return thing == mission:getDropOffTarget() end
local dockedToNil = function() return false end

mission:getBearer():setPosition(0,0)
player:setPosition(0,0)
player.isDocked = dockedToNil
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onDropOffCalled)

mission:getBearer():destroy()
Cron.tick(1)
assert.is_same(0, onDropOffCalled)
player.isDocked = dockedToTarget
Cron.tick(1)
assert.is_same(0, onDropOffCalled)

player.isDocked = dockedToNil
mission:getItemObject():destroy()
Cron.tick(1)
assert.is_same(0, onDropOffCalled)
player.isDocked = dockedToTarget
Cron.tick(1)
assert.is_same(1, onDropOffCalled)

assert.is_same("successful", mission:getState())

Missions:capture() config.onDropOffTargetDestroyed is called

local onDropOffTargetDestroyedCalled = 0
local mission
mission = Missions:capture(SpaceStation(), {
    dropOffTarget = SpaceStation(),
    onDropOffTargetDestroyed = function(callMission)
        onDropOffTargetDestroyedCalled = onDropOffTargetDestroyedCalled + 1
        assert.is_same(mission, callMission)
    end,
})

mission:getBearer():setPosition(0,0)
player:setPosition(0,0)
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onDropOffTargetDestroyedCalled)

mission:getBearer():destroy()
Cron.tick(1)
assert.is_same(0, onDropOffTargetDestroyedCalled)

mission:getItemObject():destroy()
Cron.tick(1)
assert.is_same(0, onDropOffTargetDestroyedCalled)

mission:getDropOffTarget():destroy()
Cron.tick(1)
assert.is_same(1, onDropOffTargetDestroyedCalled)

assert.is_same("failed", mission:getState())

Missions:capture() config.onItemDestruction is called when the item is destroyed and the player is too far

local onItemDestructionCalled = 0
local mission
mission = Missions:capture(SpaceStation(), {
    onItemDestruction = function(callMission, lastX, lastY)
        onItemDestructionCalled = onItemDestructionCalled + 1
        assert.is_same(mission, callMission)
        assert.is_same(4200, lastX)
        assert.is_same(-4200, lastY)
    end,
})

mission:getBearer():setPosition(4200, -4200)
player:setPosition(0, 0)
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onItemDestructionCalled)
mission:getBearer():destroy()

Cron.tick(1)

local x, y = mission:getItemObject():getPosition()
assert.is_same({4200, -4200}, {x,y})

Cron.tick(1)
assert.is_same(0, onItemDestructionCalled)

mission:getItemObject():destroy()
Cron.tick(1)
assert.is_same(1, onItemDestructionCalled)

Missions:capture() config.onPickup is called when the item is destroyed and the player is close enough

local onPickupCalled = 0
local mission
mission = Missions:capture(SpaceStation(), {
    onPickup = function(callMission)
        onPickupCalled = onPickupCalled + 1
        assert.is_same(mission, callMission)
    end,
})

mission:getBearer():setPosition(4200, -4200)
player:setPosition(4100, -4200)
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onPickupCalled)
mission:getBearer():destroy()

Cron.tick(1)

local x, y = mission:getItemObject():getPosition()
assert.is_same({4200, -4200}, {x,y})

Cron.tick(1)
assert.is_same(0, onPickupCalled)

mission:getItemObject():destroy()
Cron.tick(1)
assert.is_same(1, onPickupCalled)

Missions:capture() fails if a call back function is given, but returns a person

local mission = Missions:capture(function() return nil end)
mission:setPlayer(player)
assert.has_error(function()
    mission:accept()
    mission:start()
end)

Missions:capture() fails if a call back function is given, but returns a person

local mission = Missions:capture(function() return personMock() end)
mission:setPlayer(player)
assert.has_error(function()
    mission:accept()
    mission:start()
end)

Missions:capture() fails if second parameter is a number

assert.has_error(function() Missions:capture(CpuShip(), 3) end)

Missions:capture() fails when itemObject is destroyed when the player is too far away

local mission = Missions:capture(SpaceStation())

mission:getBearer():setPosition(0,0)
player:setPosition(4200,4200)
mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
mission:getBearer():destroy()
Cron.tick(1)
mission:getItemObject():destroy()
Cron.tick(1)

assert.is_same("failed", mission:getState())

Missions:capture() should create a valid Mission if a callback function is given that returns one ship

local ship = CpuShip()
local mission = Missions:capture(function() return ship end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same(ship, mission:getBearer())
assert.is_nil(mission:getItemObject())

Missions:capture() should create a valid Mission with one ship

local ship = CpuShip()
local mission = Missions:capture(ship)

assert.is_true(Mission:isMission(mission))
assert.is_same(ship, mission:getBearer())
assert.is_nil(mission:getItemObject())

Missions:capture() should create a valid Mission with one station

local station = SpaceStation()
local mission = Missions:capture(station)

assert.is_true(Mission:isMission(mission))
assert.is_same(station, mission:getBearer())
assert.is_nil(mission:getItemObject())

Missions:capture() should fail if a person is given instead of a bearer

assert.has_error(function() Missions:capture(personMock()) end)

Missions:capture() should fail if nil is given instead of a bearer

assert.has_error(function() Missions:capture(nil) end)

Missions:crewForRent()

Missions:crewForRent() config.onCrewReady is called when the crew can be picked up again

local onCrewReadyCalled = 0
local player = PlayerSpaceship()
Player:withMenu(player)
local mission
mission = Missions:crewForRent(CpuShip(), {
    duration = 3,
    sendCrewLabel = "Hello World",
    onCrewReady = function(callMission)
        onCrewReadyCalled = onCrewReadyCalled + 1
        assert.is_same(mission, callMission)
    end,
})
mission:setPlayer(player)
player:setRepairCrewCount(4)
player:setPosition(0,0)
mission:getNeedy():setPosition(0,0)
mission:accept()
mission:start()

assert.is_nil(mission:getTimeToReady())

Cron.tick(1)
player:clickButton("engineering", "Hello World")
Cron.tick(1)
assert.is_same(2, mission:getTimeToReady())
Cron.tick(1)
assert.is_same(1, mission:getTimeToReady())
assert.is_same(0, onCrewReadyCalled)
Cron.tick(1)
assert.is_same(1, onCrewReadyCalled)
Cron.tick(1)
assert.is_same(1, onCrewReadyCalled)

assert.is_same(0, mission:getTimeToReady())

Missions:crewForRent() fails to be accepted if player ship does not have Menus

local ship = CpuShip()
local mission = Missions:crewForRent(ship)
local player = PlayerSpaceship()

mission:setPlayer(player)

assert.has_error(function()
    mission:accept()
end)

Missions:crewForRent() returning crew should only display the button when the player is close enough to the ship

local label = "Come Back"
local ship = CpuShip()
local player = PlayerSpaceship()
Player:withMenu(player)
local mission = Missions:crewForRent(ship, {
    distance = 1000,
    crewCount = 4,
    duration = 5,
    sendCrewLabel = "Hello World",
    returnCrewLabel = label,
})
mission:setPlayer(player)
player:setPosition(0, 0)
mission:getNeedy():setPosition(0, 0)
player:setRepairCrewCount(5)
mission:accept()
mission:start()

Cron.tick(1)
player:clickButton("engineering", "Hello World")

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)

player:setPosition(10000, 0)
Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

player:setPosition(1001, 0)
Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

player:setPosition(999, 0)
Cron.tick(1)
assert.is_true(player:hasButton("engineering", label))
assert.is_true(player:hasButton("engineering+", label))

player:setPosition(1001, 0)
Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

Missions:crewForRent() returning crew should return the crew

local onCrewReturnedCalled = 0
local ship = CpuShip()
local player = PlayerSpaceship()
Player:withMenu(player)
local mission
mission = Missions:crewForRent(ship, {
    distance = 1000,
    crewCount = 1,
    duration = 5,
    sendCrewLabel = "Hello World",
    returnCrewLabel = "Come Back",
    onCrewReturned = function(theMission)
        onCrewReturnedCalled = onCrewReturnedCalled + 1
        assert.is_same(mission, theMission)
    end,
})
mission:setPlayer(player)
player:setPosition(0, 0)
mission:getNeedy():setPosition(0, 0)
player:setRepairCrewCount(1)
mission:accept()
mission:start()

Cron.tick(1)
player:clickButton("engineering", "Hello World")

Cron.tick(5)
Cron.tick(1)
assert.is_same(0, onCrewReturnedCalled)
player:clickButton("engineering", "Come Back")
assert.is_same(1, onCrewReturnedCalled)

Missions:crewForRent() sending crew fails when crew count is too low

local onCrewArrivedCalled = 0
local sendCrewFailedCalled = 0
local ship = CpuShip()
local player = PlayerSpaceship()
Player:withMenu(player)
local mission
mission = Missions:crewForRent(ship, {
    distance = 1000,
    crewCount = 5,
    sendCrewLabel = "Hello World",
    onCrewArrived = function(callMission)
        onCrewArrivedCalled = onCrewArrivedCalled + 1
    end,
    sendCrewFailed = function(callMission)
        sendCrewFailedCalled = sendCrewFailedCalled + 1
        assert.is_same(mission, callMission)
    end,
})
mission:setPlayer(player)
player:setRepairCrewCount(4)
player:setPosition(0, 0)
mission:getNeedy():setPosition(500, 0)

mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onCrewArrivedCalled)
assert.is_same(0, sendCrewFailedCalled)
assert.is_same(4, player:getRepairCrewCount())
assert.is_same(0, mission:getRepairCrewCount())

player:clickButton("engineering", "Hello World")
Cron.tick(1)
assert.is_same(0, onCrewArrivedCalled)
assert.is_same(1, sendCrewFailedCalled)

assert.is_same(4, player:getRepairCrewCount())
assert.is_same(0, mission:getRepairCrewCount())

Missions:crewForRent() sending crew should only display the button when the player is close enough to the ship

local label = "Hello World"
local ship = CpuShip()
local player = PlayerSpaceship()
Player:withMenu(player)
local mission = Missions:crewForRent(ship, {
    distance = 1000,
    sendCrewLabel = label,
})
mission:setPlayer(player)
player:setPosition(0, 0)
mission:getNeedy():setPosition(10000, 0)

mission:accept()
mission:start()

Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

mission:getNeedy():setPosition(1001, 0)
Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

mission:getNeedy():setPosition(999, 0)
Cron.tick(1)
assert.is_true(player:hasButton("engineering", label))
assert.is_true(player:hasButton("engineering+", label))

mission:getNeedy():setPosition(1001, 0)
Cron.tick(1)
assert.is_false(player:hasButton("engineering", label))
assert.is_false(player:hasButton("engineering+", label))

Missions:crewForRent() sending crew succeeds when crew count is high enough

local onCrewArrivedCalled = 0
local sendCrewFailedCalled = 0
local ship = CpuShip()
local player = PlayerSpaceship()
Player:withMenu(player)
local mission
mission = Missions:crewForRent(ship, {
    distance = 1000,
    crewCount = 4,
    sendCrewLabel = "Hello World",
    onCrewArrived = function(callMission)
        onCrewArrivedCalled = onCrewArrivedCalled + 1
        assert.is_same(mission, callMission)
    end,
    sendCrewFailed = function(callMission)
        sendCrewFailedCalled = sendCrewFailedCalled + 1
    end,
})
mission:setPlayer(player)
player:setRepairCrewCount(4)
player:setPosition(0, 0)
mission:getNeedy():setPosition(500, 0)

mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onCrewArrivedCalled)
assert.is_same(0, sendCrewFailedCalled)
assert.is_same(4, player:getRepairCrewCount())
assert.is_same(0, mission:getRepairCrewCount())

player:clickButton("engineering", "Hello World")
Cron.tick(1)
assert.is_same(1, onCrewArrivedCalled)
assert.is_same(0, sendCrewFailedCalled)

assert.is_same(0, player:getRepairCrewCount())
assert.is_same(4, mission:getRepairCrewCount())

Missions:crewForRent() should create a valid Mission with one ship

local ship = CpuShip()
local mission = Missions:crewForRent(ship)

assert.is_true(Mission:isMission(mission))
assert.is_same(ship, mission:getNeedy())
assert.is_same(0, mission:getRepairCrewCount())

Missions:crewForRent() successful mission

local player = PlayerSpaceship()
Player:withMenu(player)
local mission
mission = Missions:crewForRent(CpuShip(), {
    distance = 1000,
    crewCount = 4,
    duration = 5,
    sendCrewLabel = "Hello World",
    returnCrewLabel = "Come Back",
})
mission:setPlayer(player)
player:setPosition(0, 0)
mission:getNeedy():setPosition(0, 0)
player:setRepairCrewCount(5)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(5, player:getRepairCrewCount())
player:clickButton("engineering", "Hello World")
assert.is_same(1, player:getRepairCrewCount())

Cron.tick(5)
Cron.tick(1)
assert.is_same(1, player:getRepairCrewCount())
player:clickButton("engineering", "Come Back")
Cron.tick(1)
assert.is_same(5, player:getRepairCrewCount())

assert.is_false(player:hasButton("engineering", "Come Back"))
assert.is_false(player:hasButton("engineering+", "Come Back"))
assert.is_same("successful", mission:getState())

Missions:destroy()

Missions:destroy() can run a successful mission

local enemy1 = SpaceStation()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission
mission = Missions:destroy({enemy1, enemy2, enemy3})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
enemy1:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
enemy2:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
enemy3:destroy()
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:destroy() config.onApproach is called when the player first enters 10u around the enemy

local onApproachCalled = 0
local enemy = SpaceStation()
local mission
mission = Missions:destroy(enemy, {onApproach = function(callMission, callEnemy)
    assert.is_same(mission, callMission)
    assert.is_same(enemy, callEnemy)
    onApproachCalled = onApproachCalled + 1
end})

player:setPosition(20000, 0)
enemy:setPosition(0, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(9999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
Cron.tick(1)
player:setPosition(9999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Missions:destroy() config.onApproach is called when the player first gets closer than 10u to any enemy

local onApproachCalled = 0
local enemy1 = SpaceStation()
local enemy2 = SpaceStation()
local mission
mission = Missions:destroy({enemy1, enemy2}, {onApproach = function(callMission, callEnemy)
    assert.is_same(mission, callMission)
    assert.is_same(enemy2, callEnemy)
    onApproachCalled = onApproachCalled + 1
end})

enemy1:setPosition(0, 0)
enemy2:setPosition(1000, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

player:setPosition(11001, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(10999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

player:setPosition(9999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Missions:destroy() config.onDestruction is called each time an enemy is destroyed

local enemy1 = SpaceStation()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local enemy4 = WarpJammer()
local enemy5 = ScanProbe()
local callback1Called = 0
local callback2Called = 0
local callback3Called = 0
local callback4Called = 0
local callback5Called = 0
local mission
mission = Missions:destroy({enemy1, enemy2, enemy3, enemy4, enemy5}, {onDestruction = function(callMission, callEnemy)
    assert.is_same(mission, callMission)
    if callEnemy == enemy1 then callback1Called = callback1Called + 1 end
    if callEnemy == enemy2 then callback2Called = callback2Called + 1 end
    if callEnemy == enemy3 then callback3Called = callback3Called + 1 end
    if callEnemy == enemy4 then callback4Called = callback4Called + 1 end
    if callEnemy == enemy5 then callback5Called = callback5Called + 1 end
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)
assert.is_same(0, callback4Called)
assert.is_same(0, callback5Called)

enemy1:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)
assert.is_same(0, callback4Called)
assert.is_same(0, callback5Called)

enemy2:destroy()
enemy3:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(1, callback2Called)
assert.is_same(1, callback3Called)
assert.is_same(0, callback4Called)
assert.is_same(0, callback5Called)

enemy4:destroy()
enemy5:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(1, callback2Called)
assert.is_same(1, callback3Called)
assert.is_same(1, callback4Called)
assert.is_same(1, callback5Called)

Missions:destroy() config.onDestruction is only called once if callback errors

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local onDestructionCalled = 0
local mission
mission = Missions:destroy({enemy1, enemy2}, {onDestruction = function(_)
    onDestructionCalled = onDestructionCalled + 1
    error("Intentional error")
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onDestructionCalled)

enemy1:destroy()
Cron.tick(1)
assert.is_same(1, onDestructionCalled)
-- it should not be called again
Cron.tick(1)
assert.is_same(1, onDestructionCalled)

Missions:destroy() fails if a call back function is given that returns nil

local mission = Missions:destroy(function() return nil end)
mission:setPlayer(player)
mission:accept()

assert.has_error(function()
    mission:start()
end)

Missions:destroy() fails if a call back function is given, but returns a person

local mission = Missions:destroy(function() return personMock() end)
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:destroy() fails if a call back function is given, but returns a table where one item is a person

local mission = Missions:destroy(function() return {CpuShip(), SpaceStation(), personMock()} end)
mission:setPlayer(player)
mission:accept()

assert.has_error(function()
    mission:start()
end)

Missions:destroy() fails if second parameter is a number

assert.has_error(function() Missions:destroy(CpuShip(), 3) end)

Missions:destroy() fails if the first parameter is a person

assert.has_error(function() Missions:destroy(personMock()) end)

Missions:destroy() fails if the first parameter is a table where one item is a person

assert.has_error(function() Missions:destroy({CpuShip(), SpaceStation(), personMock()}) end)

Missions:destroy() fails if the first parameter is not given

assert.has_error(function() Missions:destroy() end)

Missions:destroy() should create a valid Mission if a callback function is given that returns mixed space objects

local thing1 = CpuShip()
local thing2 = SpaceStation()
local thing3 = WarpJammer()
local thing4 = ScanProbe()
local mission = Missions:destroy(function() return {thing1, thing2, thing3, thing4} end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getValidEnemies())
assert.contains_value(thing2, mission:getValidEnemies())
assert.contains_value(thing3, mission:getValidEnemies())
assert.contains_value(thing4, mission:getValidEnemies())

Missions:destroy() should create a valid Mission if a callback function is given that returns one scan probe

local probe = ScanProbe()
local mission = Missions:destroy(function() return probe end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({ probe }, mission:getValidEnemies())

Missions:destroy() should create a valid Mission if a callback function is given that returns one ship

local ship = CpuShip()
local mission = Missions:destroy(function() return ship end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({ship}, mission:getValidEnemies())

Missions:destroy() should create a valid Mission if a callback function is given that returns one station

local station = SpaceStation()
local mission = Missions:destroy(function() return station end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({station}, mission:getValidEnemies())

Missions:destroy() should create a valid Mission if a callback function is given that returns one warp jammer

local jammer = WarpJammer()
local mission = Missions:destroy(function() return jammer end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({ jammer }, mission:getValidEnemies())

Missions:destroy() should create a valid Mission with mixed space objects

local mission = Missions:destroy({CpuShip(), SpaceStation(), CpuShip(), WarpJammer(), ScanProbe()})

assert.is_true(Mission:isMission(mission))

Missions:destroy() should create a valid Mission with one ScanProbe

local mission = Missions:destroy(ScanProbe())

assert.is_true(Mission:isMission(mission))

Missions:destroy() should create a valid Mission with one WarpJammer

local mission = Missions:destroy(WarpJammer())

assert.is_true(Mission:isMission(mission))

Missions:destroy() should create a valid Mission with one ship

local mission = Missions:destroy(CpuShip())

assert.is_true(Mission:isMission(mission))

Missions:destroy() should create a valid Mission with one station

local mission = Missions:destroy(SpaceStation())

assert.is_true(Mission:isMission(mission))

Missions:destroy():getValidEnemies(),

Missions:destroy():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() return correct values

local enemy1 = SpaceStation()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local enemy4 = WarpJammer()
local enemy5 = ScanProbe()
local mission = Missions:destroy({enemy1, enemy2, enemy3, enemy4, enemy5})

assert.is_same(5, mission:countEnemies())
assert.is_same(5, mission:countValidEnemies())
assert.is_same(0, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.contains_value(enemy4, mission:getEnemies())
assert.contains_value(enemy5, mission:getEnemies())
assert.contains_value(enemy1, mission:getValidEnemies())
assert.contains_value(enemy2, mission:getValidEnemies())
assert.contains_value(enemy3, mission:getValidEnemies())
assert.contains_value(enemy4, mission:getValidEnemies())
assert.contains_value(enemy5, mission:getValidEnemies())
assert.not_contains_value(enemy1, mission:getInvalidEnemies())
assert.not_contains_value(enemy2, mission:getInvalidEnemies())
assert.not_contains_value(enemy3, mission:getInvalidEnemies())
assert.not_contains_value(enemy4, mission:getInvalidEnemies())
assert.not_contains_value(enemy5, mission:getInvalidEnemies())

enemy1:destroy()

assert.is_same(5, mission:countEnemies())
assert.is_same(4, mission:countValidEnemies())
assert.is_same(1, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.contains_value(enemy4, mission:getEnemies())
assert.contains_value(enemy5, mission:getEnemies())
assert.not_contains_value(enemy1, mission:getValidEnemies())
assert.contains_value(enemy2, mission:getValidEnemies())
assert.contains_value(enemy3, mission:getValidEnemies())
assert.contains_value(enemy4, mission:getValidEnemies())
assert.contains_value(enemy5, mission:getValidEnemies())
assert.contains_value(enemy1, mission:getInvalidEnemies())
assert.not_contains_value(enemy2, mission:getInvalidEnemies())
assert.not_contains_value(enemy3, mission:getInvalidEnemies())
assert.not_contains_value(enemy4, mission:getInvalidEnemies())
assert.not_contains_value(enemy5, mission:getInvalidEnemies())

enemy2:destroy()
enemy3:destroy()

assert.is_same(5, mission:countEnemies())
assert.is_same(2, mission:countValidEnemies())
assert.is_same(3, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.contains_value(enemy4, mission:getEnemies())
assert.contains_value(enemy5, mission:getEnemies())
assert.not_contains_value(enemy1, mission:getValidEnemies())
assert.not_contains_value(enemy2, mission:getValidEnemies())
assert.not_contains_value(enemy3, mission:getValidEnemies())
assert.contains_value(enemy4, mission:getValidEnemies())
assert.contains_value(enemy5, mission:getValidEnemies())
assert.contains_value(enemy1, mission:getInvalidEnemies())
assert.contains_value(enemy2, mission:getInvalidEnemies())
assert.contains_value(enemy3, mission:getInvalidEnemies())
assert.not_contains_value(enemy4, mission:getInvalidEnemies())
assert.not_contains_value(enemy5, mission:getInvalidEnemies())

enemy4:destroy()
enemy5:destroy()

assert.is_same(5, mission:countEnemies())
assert.is_same(0, mission:countValidEnemies())
assert.is_same(5, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.contains_value(enemy4, mission:getEnemies())
assert.contains_value(enemy5, mission:getEnemies())
assert.not_contains_value(enemy1, mission:getValidEnemies())
assert.not_contains_value(enemy2, mission:getValidEnemies())
assert.not_contains_value(enemy3, mission:getValidEnemies())
assert.not_contains_value(enemy4, mission:getValidEnemies())
assert.not_contains_value(enemy5, mission:getValidEnemies())
assert.contains_value(enemy1, mission:getInvalidEnemies())
assert.contains_value(enemy2, mission:getInvalidEnemies())
assert.contains_value(enemy3, mission:getInvalidEnemies())
assert.contains_value(enemy4, mission:getInvalidEnemies())
assert.contains_value(enemy5, mission:getInvalidEnemies())

Missions:destroy():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() returns nil it it is called before the ships are created in the callback

local enemy1 = SpaceStation()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:destroy(function () return {enemy1, enemy2, enemy3} end)

assert.is_nil(mission:countEnemies())
assert.is_nil(mission:countValidEnemies())
assert.is_nil(mission:countInvalidEnemies())
assert.is_nil(mission:getEnemies())
assert.is_nil(mission:getValidEnemies())
assert.is_nil(mission:getInvalidEnemies())

Missions:destroy():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() should not allow to manipulate the tables

local enemy1 = SpaceStation()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:destroy({enemy1, enemy2})

local enemies = mission:getEnemies()
table.insert(enemies, enemy3)

assert.is_same(2, mission:countEnemies())
assert.not_contains_value(enemy3, mission:getEnemies())

Missions:destroyRagingMiner()

Missions:destroyRagingMiner() can run a successful mission

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission
mission = Missions:destroyRagingMiner({enemy1, enemy2, enemy3})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
enemy1:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
enemy2:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
enemy3:destroy()
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:destroyRagingMiner() config.onDestruction is called each time an enemy is destroyed

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local callback1Called = 0
local callback2Called = 0
local callback3Called = 0
local mission
mission = Missions:destroyRagingMiner({enemy1, enemy2, enemy3}, {onDestruction = function(callMission, callEnemy)
    assert.is_same(mission, callMission)
    if callEnemy == enemy1 then callback1Called = callback1Called + 1 end
    if callEnemy == enemy2 then callback2Called = callback2Called + 1 end
    if callEnemy == enemy3 then callback3Called = callback3Called + 1 end
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)

enemy1:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)

enemy2:destroy()
enemy3:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(1, callback2Called)
assert.is_same(1, callback3Called)

Missions:destroyRagingMiner() fails if a call back function is given that returns nil

assert.has_error(function()
    Missions:destroyRagingMiner(function() return nil end)
    mission:setPlayer(player)
    mission:accept()
    mission:start()
end)

Missions:destroyRagingMiner() fails if a call back function is given, but returns a person

assert.has_error(function()
    Missions:destroyRagingMiner(function() return personMock() end)
    mission:setPlayer(player)
    mission:accept()
    mission:start()
end)

Missions:destroyRagingMiner() fails if a call back function is given, but returns a table where one item is a person

assert.has_error(function()
    Missions:destroyRagingMiner(function() return {CpuShip(), CpuShip(), personMock()} end)
    mission:setPlayer(player)
    mission:accept()
    mission:start()
end)

Missions:destroyRagingMiner() fails if second parameter is a number

assert.has_error(function() Missions:destroyRagingMiner(CpuShip(), 3) end)

Missions:destroyRagingMiner() fails if the first parameter is a person

assert.has_error(function() Missions:destroyRagingMiner(personMock()) end)

Missions:destroyRagingMiner() fails if the first parameter is a table where one item is a person

assert.has_error(function() Missions:destroyRagingMiner({CpuShip(), CpuShip(), personMock()}) end)

Missions:destroyRagingMiner() fails if the first parameter is not given

assert.has_error(function() Missions:destroyRagingMiner() end)

Missions:destroyRagingMiner() should create a valid Mission if a callback function is given that returns multiple ships

local mission = Missions:destroyRagingMiner(function() return {CpuShip(), CpuShip(), CpuShip()} end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

Missions:destroyRagingMiner() should create a valid Mission if a callback function is given that returns one ship

local ship = CpuShip()
local mission = Missions:destroyRagingMiner(function() return ship end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({ship}, mission:getValidEnemies())

Missions:destroyRagingMiner() should create a valid Mission with one ship

local mission = Missions:destroyRagingMiner(CpuShip())

assert.is_true(Mission:isMission(mission))

Missions:destroyRagingMiner():getValidEnemies(),

Missions:destroyRagingMiner():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() return correct values

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:destroyRagingMiner({enemy1, enemy2, enemy3})

assert.is_same(3, mission:countEnemies())
assert.is_same(3, mission:countValidEnemies())
assert.is_same(0, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.contains_value(enemy1, mission:getValidEnemies())
assert.contains_value(enemy2, mission:getValidEnemies())
assert.contains_value(enemy3, mission:getValidEnemies())
assert.not_contains_value(enemy1, mission:getInvalidEnemies())
assert.not_contains_value(enemy2, mission:getInvalidEnemies())
assert.not_contains_value(enemy3, mission:getInvalidEnemies())

enemy1:destroy()

assert.is_same(3, mission:countEnemies())
assert.is_same(2, mission:countValidEnemies())
assert.is_same(1, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.not_contains_value(enemy1, mission:getValidEnemies())
assert.contains_value(enemy2, mission:getValidEnemies())
assert.contains_value(enemy3, mission:getValidEnemies())
assert.contains_value(enemy1, mission:getInvalidEnemies())
assert.not_contains_value(enemy2, mission:getInvalidEnemies())
assert.not_contains_value(enemy3, mission:getInvalidEnemies())

enemy2:destroy()
enemy3:destroy()

assert.is_same(3, mission:countEnemies())
assert.is_same(0, mission:countValidEnemies())
assert.is_same(3, mission:countInvalidEnemies())
assert.contains_value(enemy1, mission:getEnemies())
assert.contains_value(enemy2, mission:getEnemies())
assert.contains_value(enemy3, mission:getEnemies())
assert.not_contains_value(enemy1, mission:getValidEnemies())
assert.not_contains_value(enemy2, mission:getValidEnemies())
assert.not_contains_value(enemy3, mission:getValidEnemies())
assert.contains_value(enemy1, mission:getInvalidEnemies())
assert.contains_value(enemy2, mission:getInvalidEnemies())
assert.contains_value(enemy3, mission:getInvalidEnemies())

Missions:destroyRagingMiner():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() returns nil it it is called before the ships are created in the callback

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:destroyRagingMiner(function () return {enemy1, enemy2, enemy3} end)

assert.is_nil(mission:countEnemies())
assert.is_nil(mission:countValidEnemies())
assert.is_nil(mission:countInvalidEnemies())
assert.is_nil(mission:getEnemies())
assert.is_nil(mission:getValidEnemies())
assert.is_nil(mission:getInvalidEnemies())

Missions:destroyRagingMiner():getValidEnemies(), countValidEnemies(), getInvalidEnemies(), countInvalidEnemies(), getEnemies(), countEnemies() should not allow to manipulate the tables

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:destroyRagingMiner({enemy1, enemy2})

local enemies = mission:getEnemies()
table.insert(enemies, enemy3)

assert.is_same(2, mission:countEnemies())
assert.not_contains_value(enemy3, mission:getEnemies())

Missions:disable()

Missions:disable() can run a successful mission

local ship = CpuShip()
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, {
    damageThreshold = -0.5,
    distanceToFinish = 1000,
})

ship:setPosition(0, 0)
player:setPosition(9999, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same("started", mission:getState())

player:setPosition(500, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())

ship:setSystemHealth("impulse", -1)
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:disable() config.onApproach is called when the player first enters 10u around the target

local onApproachCalled = 0
local onApproachArg1 = nil
local ship = CpuShip()
local mission
mission = Missions:disable(ship, { approachDistance = 10000, onApproach = function(arg1)
    onApproachArg1 = arg1
    onApproachCalled = onApproachCalled + 1
end})

player:setPosition(20000, 0)
ship:setPosition(0, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
assert.is_same(0, onApproachCalled)

player:setPosition(9999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)
assert.is_same(mission, onApproachArg1)

Cron.tick(1)
assert.is_same(1, onApproachCalled)

player:setPosition(10001, 0)
Cron.tick(1)
player:setPosition(9999, 0)
Cron.tick(1)
assert.is_same(1, onApproachCalled)

Missions:disable() config.onDestruction is called when ship is completely destroyed

local onDestructionCalled = 0
local onDestructionArg1 = nil
local ship = CpuShip()
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, { onDestruction = function(arg1)
    onDestructionArg1 = arg1
    onDestructionCalled = onDestructionCalled + 1
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onDestructionCalled)

ship:destroy()
Cron.tick(1)
assert.is_same(1, onDestructionCalled)
assert.is_same(mission, onDestructionArg1)

assert.is_same("failed", mission:getState())

Missions:disable() config.onSurrender closeness is necessary for surrender

local onSurrenderCalled = 0
local onSurrenderArg1 = nil
local ship = CpuShip()
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, {
    damageThreshold = -0.5,
    distanceToFinish = 1000,
    onSurrender = function(arg1)
        onSurrenderArg1 = arg1
        onSurrenderCalled = onSurrenderCalled + 1
    end
})

ship:setPosition(0, 0)
player:setPosition(9999, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("impulse", -1)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

player:setPosition(1001, 0)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

player:setPosition(1000, 0)
Cron.tick(1)
assert.is_same(1, onSurrenderCalled)
assert.is_same(mission, onSurrenderArg1)

Missions:disable() config.onSurrender disabled impulse engine is necessary for surrender

local onSurrenderCalled = 0
local onSurrenderArg1 = nil
local ship = CpuShip()
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, {
    damageThreshold = -0.5,
    distanceToFinish = 1000,
    onSurrender = function(arg1)
        onSurrenderArg1 = arg1
        onSurrenderCalled = onSurrenderCalled + 1
    end
})

ship:setPosition(0, 0)
player:setPosition(100, 0)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("impulse", 0)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("impulse", -0.49)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("impulse", -0.5)
Cron.tick(1)

assert.is_same(1, onSurrenderCalled)
assert.is_same(mission, onSurrenderArg1)

Missions:disable() config.onSurrender jump drive needs to be destroyed too if a ship has it

local onSurrenderCalled = 0
local onSurrenderArg1 = nil
local ship = CpuShip()
ship:setJumpDrive(true)
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, {
    damageThreshold = -0.5,
    distanceToFinish = 1000,
    onSurrender = function(arg1)
        onSurrenderArg1 = arg1
        onSurrenderCalled = onSurrenderCalled + 1
    end
})

mission:setPlayer(player)
mission:accept()
mission:start()

ship:setSystemHealth("impulse", -1)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("jumpdrive", 0)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)


ship:setSystemHealth("jumpdrive", -0.49)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("jumpdrive", -0.5)
Cron.tick(1)
assert.is_same(1, onSurrenderCalled)
assert.is_same(mission, onSurrenderArg1)

Missions:disable() config.onSurrender warp drive needs to be destroyed too if a ship has it

local onSurrenderCalled = 0
local onSurrenderArg1 = nil
local ship = CpuShip()
ship:setWarpDrive(true)
local player = PlayerSpaceship()
local mission
mission = Missions:disable(ship, {
    damageThreshold = -0.5,
    distanceToFinish = 1000,
    onSurrender = function(arg1)
        onSurrenderArg1 = arg1
        onSurrenderCalled = onSurrenderCalled + 1
    end
})

mission:setPlayer(player)
mission:accept()
mission:start()

ship:setSystemHealth("impulse", -1)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("warp", 0)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)


ship:setSystemHealth("warp", -0.49)
Cron.tick(1)
assert.is_same(0, onSurrenderCalled)

ship:setSystemHealth("warp", -0.5)
Cron.tick(1)
assert.is_same(1, onSurrenderCalled)
assert.is_same(mission, onSurrenderArg1)

Missions:disable() fails if no valid ship is given

assert.has_error(function() Missions:disable(nil) end)
assert.has_error(function() Missions:disable(personMock()) end)
assert.has_error(function() Missions:disable(42) end)
assert.has_error(function() Missions:disable(SpaceStation()) end)

Missions:disable() should create a valid Mission with a function returning a ship

local mission = Missions:disable(function() return CpuShip() end)

assert.is_true(Mission:isMission(mission))

Missions:disable() should create a valid Mission with one ship

local mission = Missions:disable(CpuShip())

assert.is_true(Mission:isMission(mission))

Missions:disable():getTarget()

Missions:disable():getTarget() returns the target when no function is used

local ship = CpuShip()
local mission = Missions:disable(ship)

assert.is_same(ship, mission:getTarget())

Missions:disable():getTarget() returns the target when no function is used

local ship = CpuShip()
local player = PlayerSpaceship()
local mission = Missions:disable(function() return ship end)

assert.is_nil(mission:getTarget())
mission:setPlayer(player)
mission:accept()
mission:start()
assert.is_same(ship, mission:getTarget())

Missions:pickUp()

Missions:pickUp() can run a happy scenario with return to station

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local station = SpaceStation()
local player = PlayerSpaceship()

local onStartCalled, onSuccessCalled, onEndCalled = 0, 0, 0
local onPickUpCalled, onPickUpArg1, onPickUpArg2 = 0, nil, nil
local onAllPickedUpCalled, onAllPickedUpArg1 = 0, nil
local mission = Missions:pickUp({artifact, supplyDrop}, station, {
    onStart = function() onStartCalled = onStartCalled + 1 end,
    onPickUp = function(arg1, arg2)
        onPickUpCalled = onPickUpCalled + 1
        onPickUpArg1 = arg1
        onPickUpArg2 = arg2
    end,
    onAllPickedUp = function(arg1)
        onAllPickedUpCalled = onAllPickedUpCalled + 1
        onAllPickedUpArg1 = arg1
    end,
    onSuccess = function() onSuccessCalled = onSuccessCalled + 1 end,
    onEnd = function() onEndCalled = onEndCalled + 1 end,
})

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(artifact, mission:getPickUps())
assert.contains_value(supplyDrop, mission:getPickUps())
assert.is_same(2, mission:countPickUps())
assert.is_same(station, mission:getDeliveryStation())
assert.is_same(1, onStartCalled)
assert.is_same(0, onPickUpCalled)
assert.is_same(0, onAllPickedUpCalled)
assert.is_same(0, onSuccessCalled)
assert.is_same(0, onEndCalled)

artifact:pickUp(player)
assert.is_same(1, onPickUpCalled)
assert.is_same(mission, onPickUpArg1)
assert.is_same(artifact, onPickUpArg2)
assert.is_same(0, onAllPickedUpCalled)

supplyDrop:pickUp(player)
assert.is_same(2, onPickUpCalled)
assert.is_same(mission, onPickUpArg1)
assert.is_same(supplyDrop, onPickUpArg2)
assert.is_same(1, onAllPickedUpCalled)
assert.is_same(mission, onAllPickedUpArg1)

Cron.tick(1)

assert.is_same(0, onSuccessCalled)
assert.is_same(0, onEndCalled)

player:setDockedAt(station)
Cron.tick(1)
assert.is_same(1, onSuccessCalled)
assert.is_same(1, onEndCalled)

Missions:pickUp() can run a happy scenario without return to station

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local player = PlayerSpaceship()

local onStartCalled, onSuccessCalled, onEndCalled = 0, 0, 0
local onPickUpCalled, onPickUpArg1, onPickUpArg2 = 0, nil, nil
local onAllPickedUpCalled, onAllPickedUpArg1 = 0, nil
local mission = Missions:pickUp({artifact, supplyDrop}, {
    onStart = function() onStartCalled = onStartCalled + 1 end,
    onPickUp = function(arg1, arg2)
        onPickUpCalled = onPickUpCalled + 1
        onPickUpArg1 = arg1
        onPickUpArg2 = arg2
    end,
    onAllPickedUp = function(arg1)
        onAllPickedUpCalled = onAllPickedUpCalled + 1
        onAllPickedUpArg1 = arg1
    end,
    onSuccess = function() onSuccessCalled = onSuccessCalled + 1 end,
    onEnd = function() onEndCalled = onEndCalled + 1 end,
})

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(artifact, mission:getPickUps())
assert.contains_value(supplyDrop, mission:getPickUps())
assert.is_same(2, mission:countPickUps())
assert.is_nil(mission:getDeliveryStation())
assert.is_same(1, onStartCalled)
assert.is_same(0, onPickUpCalled)
assert.is_same(0, onAllPickedUpCalled)
assert.is_same(0, onSuccessCalled)
assert.is_same(0, onEndCalled)

artifact:pickUp(player)
assert.is_same(1, onPickUpCalled)
assert.is_same(mission, onPickUpArg1)
assert.is_same(artifact, onPickUpArg2)
assert.is_same(0, onAllPickedUpCalled)

supplyDrop:pickUp(player)
assert.is_same(2, onPickUpCalled)
assert.is_same(mission, onPickUpArg1)
assert.is_same(supplyDrop, onPickUpArg2)
assert.is_same(1, onAllPickedUpCalled)
assert.is_same(mission, onAllPickedUpArg1)
assert.is_same(1, onSuccessCalled)
assert.is_same(1, onEndCalled)

Missions:pickUp() fails if a pickup disappears

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local player = PlayerSpaceship()

local mission = Missions:pickUp({artifact, supplyDrop})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
artifact:destroy()
Cron.tick(1)
assert.is_same("failed", mission:getState())

Missions:pickUp() fails if a pickup is picked up by a different player

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local player = PlayerSpaceship()
local evilPlayer = PlayerSpaceship()

local onPickUpCalled, onFailureCalled, onEndCalled = 0, 0, 0
local mission = Missions:pickUp({artifact, supplyDrop}, {
    onPickUp = function() onPickUpCalled = onPickUpCalled + 1 end,
    onFailure = function() onFailureCalled = onFailureCalled + 1 end,
    onEnd = function() onEndCalled = onEndCalled + 1 end,
})

mission:setPlayer(player)
mission:accept()
mission:start()

artifact:pickUp(evilPlayer)
assert.is_same(0, onPickUpCalled)
assert.is_same(1, onFailureCalled)
assert.is_same(1, onEndCalled)

Missions:pickUp() fails if deliveryStation disappears

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local station = SpaceStation()
local player = PlayerSpaceship()

local mission = Missions:pickUp({artifact, supplyDrop}, station)

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

station:destroy()
Cron.tick(1)
assert.is_same("failed", mission:getState())

Missions:pickUp() fails if first argument is invalid

assert.has_error(function() Mission:pickUp(SpaceStation()) end)
assert.has_error(function() Mission:pickUp(nil) end)
assert.has_error(function() Mission:pickUp(42) end)
assert.has_error(function() Mission:pickUp({SpaceStation(), SupplyDrop()}) end)

assert.has_error(function() Mission:pickUp(function()
    return SpaceStation()
end) end)
assert.has_error(function() Mission:pickUp(function()
    return nil
end) end)
assert.has_error(function() Mission:pickUp(function()
    return 42
end) end)
assert.has_error(function() Mission:pickUp(function()
    return {SpaceStation(), SupplyDrop()}
end) end)

Missions:pickUp() should create a valid Mission with a function returning a supply drop

local supplyDrop = SupplyDrop()
local mission = Missions:pickUp(function() return supplyDrop end)

assert.is_true(Mission:isMission(mission))

assert.is_nil(mission:getPickUps())
assert.is_nil(mission:countPickUps())

mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({supplyDrop}, mission:getPickUps())
assert.is_same(1, mission:countPickUps())

Missions:pickUp() should create a valid Mission with a function returning a supply drop and artifact

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local mission = Missions:pickUp(function() return {artifact, supplyDrop} end)

assert.is_true(Mission:isMission(mission))

assert.is_nil(mission:getPickUps())
assert.is_nil(mission:countPickUps())

mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(artifact, mission:getPickUps())
assert.contains_value(supplyDrop, mission:getPickUps())
assert.is_same(2, mission:countPickUps())

Missions:pickUp() should create a valid Mission with a function returning an artifact

local artifact = Artifact()
local mission = Missions:pickUp(function() return artifact end)

assert.is_true(Mission:isMission(mission))

assert.is_nil(mission:getPickUps())
assert.is_nil(mission:countPickUps())

mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({artifact}, mission:getPickUps())
assert.is_same(1, mission:countPickUps())

Missions:pickUp() should create a valid Mission with a supply drop

local supplyDrop = SupplyDrop()
local mission = Missions:pickUp(supplyDrop)

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({supplyDrop}, mission:getPickUps())
assert.is_same(1, mission:countPickUps())

Missions:pickUp() should create a valid Mission with a supply drop and artifact

local artifact = Artifact()
local supplyDrop = SupplyDrop()
local mission = Missions:pickUp({artifact, supplyDrop})

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(artifact, mission:getPickUps())
assert.contains_value(supplyDrop, mission:getPickUps())
assert.is_same(2, mission:countPickUps())

Missions:pickUp() should create a valid Mission with an artifact

local artifact = Artifact()
local mission = Missions:pickUp(artifact)

assert.is_true(Mission:isMission(mission))

mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({artifact}, mission:getPickUps())
assert.is_same(1, mission:countPickUps())

Missions:scan()

Missions:scan() can run a successful mission when not all ships are destroyed

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission
mission = Missions:scan({ target1, target2, target3 })

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
target1:destroy()
target2:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target3:fullScannedByPlayer()
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:scan() can run a successful mission with asteroids

local target1 = Asteroid()
local target2 = Asteroid()
local target3 = Asteroid()
local mission
mission = Missions:scan({ target1, target2, target3 })

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
target1:scannedByPlayer()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target2:scannedByPlayer()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target3:scannedByPlayer()
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:scan() can run a successful mission with ships

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission
mission = Missions:scan({ target1, target2, target3 }, {scan = "full"})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
target1:fullScannedByPlayer()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target2:fullScannedByPlayer()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target3:fullScannedByPlayer()
Cron.tick(1)
assert.is_same("successful", mission:getState())

Missions:scan() config.onDestruction is called when a target is destroyed

local target1 = CpuShip()
local target2 = CpuShip()
local callback1Called = 0
local callback2Called = 0
local mission
mission = Missions:scan({ target1, target2 }, {onDestruction = function(callMission, callEnemy)
    assert.is_same(mission, callMission)
    if callEnemy == target1 then callback1Called = callback1Called + 1 end
    if callEnemy == target2 then callback2Called = callback2Called + 1 end
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)
assert.is_same(0, callback2Called)

target1:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(0, callback2Called)

target2:destroy()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(1, callback2Called)

Missions:scan() config.onDestruction the unscanned targets are already updated

local target1 = CpuShip()
local target2 = CpuShip()
local calledArg1, calledArg2, calledUnscannedTargets, calledUnscannedTargetsCount
local mission
mission = Missions:scan({ target1, target2 }, {onDestruction = function(arg1, arg2)
    calledArg1 = arg1
    calledArg2 = arg2
    calledUnscannedTargets = arg1:getUnscannedTargets()
    calledUnscannedTargetsCount = arg1:countUnscannedTargets()
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)

target1:destroy()
Cron.tick(1)
assert.is_same(mission, calledArg1)
assert.is_same(target1, calledArg2)
assert.is_same(1, calledUnscannedTargetsCount)
assert.is_same({target2}, calledUnscannedTargets)

Missions:scan() config.onScan is called by default simple scan was run

local target = CpuShip()
local callback1Called = 0
local mission
mission = Missions:scan(target, {onScan = function(callMission, callTarget)
    callback1Called = callback1Called + 1
    assert.is_same(mission, callMission)
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
target:friendOrFoeIdentifiedByPlayer()
Cron.tick(1)
assert.is_same(0, callback1Called)

Cron.tick(1)
target:scannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

target:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

Missions:scan() config.onScan is called each time a target is scanned

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local callback1Called = 0
local callback2Called = 0
local callback3Called = 0
local mission
mission = Missions:scan({ target1, target2, target3 }, {onScan = function(callMission, callTarget)
    if callTarget == target1 then callback1Called = callback1Called + 1 end
    if callTarget == target2 then callback2Called = callback2Called + 1 end
    if callTarget == target3 then callback3Called = callback3Called + 1 end
    assert.is_same(mission, callMission)
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)

target1:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(0, callback2Called)
assert.is_same(0, callback3Called)

target2:fullScannedByPlayer()
target3:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)
assert.is_same(1, callback2Called)
assert.is_same(1, callback3Called)

Missions:scan() config.onScan is called when friend or foe is identified

local target = CpuShip()
local callback1Called = 0
local mission
mission = Missions:scan(target, {scan = "fof", onScan = function(callMission, callTarget)
    callback1Called = callback1Called + 1
    assert.is_same(mission, callMission)
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)

target:friendOrFoeIdentifiedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

Cron.tick(1)
target:scannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

target:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

Missions:scan() config.onScan is only called after full scan when a full scan is required

local target = CpuShip()
local callback1Called = 0
local mission
mission = Missions:scan(target, {
    scan = "full",
    onScan = function(callMission, callTarget)
        callback1Called = callback1Called + 1
        assert.is_same(mission, callMission)
    end }
)

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)
assert.is_same(0, callback1Called)

target:friendOrFoeIdentifiedByPlayer()
Cron.tick(1)
assert.is_same(0, callback1Called)

Cron.tick(1)
target:scannedByPlayer()
Cron.tick(1)
assert.is_same(0, callback1Called)

target:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(1, callback1Called)

Missions:scan() config.onScan the unscanned targets are already updated

local target1 = CpuShip()
local target2 = CpuShip()
local calledArg1, calledArg2, calledUnscannedTargets, calledUnscannedTargetsCount
local mission
mission = Missions:scan({ target1, target2 }, {onScan = function(arg1, arg2)
    calledArg1 = arg1
    calledArg2 = arg2
    calledUnscannedTargets = arg1:getUnscannedTargets()
    calledUnscannedTargetsCount = arg1:countUnscannedTargets()
end})

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
Cron.tick(1)

target1:fullScannedByPlayer()
Cron.tick(1)
assert.is_same(mission, calledArg1)
assert.is_same(target1, calledArg2)
assert.is_same(1, calledUnscannedTargetsCount)
assert.is_same({target2}, calledUnscannedTargets)

Missions:scan() config.scan can be set to "fof", "simple" or "full" for ships

Missions:scan(CpuShip(), {scan = "fof"})
Missions:scan({CpuShip(), CpuShip()}, {scan = "fof"})
Missions:scan(CpuShip(), {scan = "simple"})
Missions:scan({CpuShip(), CpuShip()}, {scan = "simple"})
Missions:scan(CpuShip(), {scan = "full"})
Missions:scan({CpuShip(), CpuShip()}, {scan = "full"})
-- it does not error

Missions:scan() config.scan can be set to "simple" for asteroids

Missions:scan(Asteroid(), {scan = "simple"})
Missions:scan({Asteroid(), Asteroid()}, {scan = "simple"})
-- it does not error

Missions:scan() config.scan fails if it is an unknown string

assert.has_error(function()
    Missions:scan(CpuShip(), {scan = "foobar"})
end)

Missions:scan() config.scan fails if it is set to "fof" and function returns a list where one is not a ship

local mission = Missions:scan(function() return {Asteroid(), CpuShip()} end, {scan = "fof"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "fof" and function returns a non-ship

local mission = Missions:scan(function() return Asteroid() end, {scan = "fof"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "fof" and function returns multiple non-ships

local mission = Missions:scan(function() return {Asteroid(), Asteroid()} end, {scan = "fof"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "fof" for a list where one is not a ship

assert.has_error(function()
    Missions:scan({CpuShip(), Asteroid()}, {scan = "fof"})
end)

Missions:scan() config.scan fails if it is set to "fof" for a multiple non-ship

assert.has_error(function()
    Missions:scan({Asteroid(), Asteroid()}, {scan = "fof"})
end)

Missions:scan() config.scan fails if it is set to "fof" for a non-ship

assert.has_error(function()
    Missions:scan(Asteroid(), {scan = "fof"})
end)

Missions:scan() config.scan fails if it is set to "full" and function returns a list where one is not a ship

local mission = Missions:scan(function() return {Asteroid(), CpuShip()} end, {scan = "full"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "full" and function returns a non-ship

local mission = Missions:scan(function() return Asteroid() end, {scan = "full"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "full" and function returns multiple non-ships

local mission = Missions:scan(function() return {Asteroid(), Asteroid()} end, {scan = "full"})
mission:setPlayer(player)
mission:accept()
assert.has_error(function()
    mission:start()
end)

Missions:scan() config.scan fails if it is set to "full" for a list where one is not a ship

assert.has_error(function()
    Missions:scan({CpuShip(), Asteroid()}, {scan = "full"})
end)

Missions:scan() config.scan fails if it is set to "full" for a multiple non-ship

assert.has_error(function()
    Missions:scan({Asteroid(), Asteroid()}, {scan = "full"})
end)

Missions:scan() config.scan fails if it is set to "full" for a non-ship

assert.has_error(function()
    Missions:scan(Asteroid(), {scan = "full"})
end)

Missions:scan() fails if a call back function is given that returns nil

local mission = Missions:scan(function() return nil end)
mission:setPlayer(player)
mission:accept()

assert.has_error(function()
    mission:start()
end)

Missions:scan() fails if no player was set

local mission = Missions:scan(CpuShip())

assert.has_error(function()
    mission:accept()
    mission:start()
end)

Missions:scan() fails if second parameter is a number

assert.has_error(function() Missions:scan(CpuShip(), 3) end)

Missions:scan() fails if the first parameter is not given

assert.has_error(function() Missions:scan() end)

Missions:scan() fails when all ships are destroyed without having scanned one

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission
mission = Missions:scan({ target1, target2, target3 })

mission:setPlayer(player)
mission:accept()
mission:start()

Cron.tick(1)
target1:destroy()
target2:destroy()
Cron.tick(1)
assert.is_same("started", mission:getState())
Cron.tick(1)
target3:destroy()
Cron.tick(1)
assert.is_same("failed", mission:getState())

Missions:scan() should create a valid Mission if a callback function is given that returns multiple asteroids

local thing1 = Asteroid()
local thing2 = Asteroid()
local thing3 = Asteroid()
local mission = Missions:scan(function() return {thing1, thing2, thing3} end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission if a callback function is given that returns multiple mixed objects

local thing1 = SpaceStation()
local thing2 = CpuShip()
local thing3 = Asteroid()
local mission = Missions:scan(function() return {thing1, thing2, thing3} end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission if a callback function is given that returns multiple ships

local thing1 = CpuShip()
local thing2 = CpuShip()
local thing3 = CpuShip()
local mission = Missions:scan(function() return {thing1, thing2, thing3} end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission if a callback function is given that returns one ship

local ship = CpuShip()
local mission = Missions:scan(function() return ship end)
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same({ship}, mission:getTargets())

Missions:scan() should create a valid Mission with mixed objects

local thing1 = SpaceStation()
local thing2 = CpuShip()
local thing3 = Asteroid()
local mission = Missions:scan({thing1, thing2, thing3})
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission with multiple asteroids

local thing1 = Asteroid()
local thing2 = Asteroid()
local thing3 = Asteroid()
local mission = Missions:scan({thing1, thing2, thing3})
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission with multiple ships

local thing1 = CpuShip()
local thing2 = CpuShip()
local thing3 = CpuShip()
local mission = Missions:scan({thing1, thing2, thing3})
assert.is_true(Mission:isMission(mission))
mission:setPlayer(player)
mission:accept()
mission:start()

assert.contains_value(thing1, mission:getTargets())
assert.contains_value(thing2, mission:getTargets())
assert.contains_value(thing3, mission:getTargets())

Missions:scan() should create a valid Mission with one ship

local mission = Missions:scan(CpuShip())

assert.is_true(Mission:isMission(mission))

Missions:scan():getTargets(),

Missions:scan():getTargets(), countTargets(), getScannedTargets(), countScannedTargets(), getUnscannedTargets(), countUnscannedTargets() return correct values

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission = Missions:scan({ target1, target2, target3 })
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same(3, mission:countTargets())
assert.is_same(3, mission:countUnscannedTargets())
assert.is_same(0, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.contains_value(target1, mission:getUnscannedTargets())
assert.contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.not_contains_value(target1, mission:getScannedTargets())
assert.not_contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

target1:fullScannedByPlayer()
Cron.tick(1)

assert.is_same(3, mission:countTargets())
assert.is_same(2, mission:countUnscannedTargets())
assert.is_same(1, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.not_contains_value(target1, mission:getUnscannedTargets())
assert.contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.contains_value(target1, mission:getScannedTargets())
assert.not_contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

target2:fullScannedByPlayer()
target3:fullScannedByPlayer()
Cron.tick(1)

assert.is_same(3, mission:countTargets())
assert.is_same(0, mission:countUnscannedTargets())
assert.is_same(3, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.not_contains_value(target1, mission:getUnscannedTargets())
assert.not_contains_value(target2, mission:getUnscannedTargets())
assert.not_contains_value(target3, mission:getUnscannedTargets())
assert.contains_value(target1, mission:getScannedTargets())
assert.contains_value(target2, mission:getScannedTargets())
assert.contains_value(target3, mission:getScannedTargets())

Missions:scan():getTargets(), countTargets(), getScannedTargets(), countScannedTargets(), getUnscannedTargets(), countUnscannedTargets() return correct values when ships are destroyed

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission = Missions:scan({ target1, target2, target3 })
mission:setPlayer(player)
mission:accept()
mission:start()

assert.is_same(3, mission:countTargets())
assert.is_same(3, mission:countUnscannedTargets())
assert.is_same(0, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.contains_value(target1, mission:getUnscannedTargets())
assert.contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.not_contains_value(target1, mission:getScannedTargets())
assert.not_contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

target1:destroy()
Cron.tick(1)

assert.is_same(3, mission:countTargets())
assert.is_same(2, mission:countUnscannedTargets())
assert.is_same(0, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.not_contains_value(target1, mission:getUnscannedTargets())
assert.contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.not_contains_value(target1, mission:getScannedTargets())
assert.not_contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

target2:fullScannedByPlayer()
Cron.tick(1)

assert.is_same(3, mission:countTargets())
assert.is_same(1, mission:countUnscannedTargets())
assert.is_same(1, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.not_contains_value(target1, mission:getUnscannedTargets())
assert.not_contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.not_contains_value(target1, mission:getScannedTargets())
assert.contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

target2:destroy()
Cron.tick(1)

assert.is_same(3, mission:countTargets())
assert.is_same(1, mission:countUnscannedTargets())
assert.is_same(1, mission:countScannedTargets())
assert.contains_value(target1, mission:getTargets())
assert.contains_value(target2, mission:getTargets())
assert.contains_value(target3, mission:getTargets())
assert.not_contains_value(target1, mission:getUnscannedTargets())
assert.not_contains_value(target2, mission:getUnscannedTargets())
assert.contains_value(target3, mission:getUnscannedTargets())
assert.not_contains_value(target1, mission:getScannedTargets())
assert.contains_value(target2, mission:getScannedTargets())
assert.not_contains_value(target3, mission:getScannedTargets())

Missions:scan():getTargets(), countTargets(), getScannedTargets(), countScannedTargets(), getUnscannedTargets(), countUnscannedTargets() returns nil it it is called before the ships are created in the callback

local enemy1 = CpuShip()
local enemy2 = CpuShip()
local enemy3 = CpuShip()
local mission = Missions:scan(function () return {enemy1, enemy2, enemy3} end)

assert.is_nil(mission:countTargets())
assert.is_nil(mission:countScannedTargets())
assert.is_nil(mission:countUnscannedTargets())
assert.is_nil(mission:getTargets())
assert.is_nil(mission:getScannedTargets())
assert.is_nil(mission:getUnscannedTargets())

Missions:scan():getTargets(), countTargets(), getScannedTargets(), countScannedTargets(), getUnscannedTargets(), countUnscannedTargets() should not allow to manipulate the tables

local target1 = CpuShip()
local target2 = CpuShip()
local target3 = CpuShip()
local mission = Missions:scan({ target1, target2 })
mission:setPlayer(player)
mission:accept()
mission:start()


local targets = mission:getTargets()
table.insert(targets, target3)

assert.is_same(2, mission:countTargets())
assert.not_contains_value(target3, mission:getTargets())

Missions:transportProduct()

Missions:transportProduct() calls onInsufficientStorage if the player ship has no storage as soon as the player docks

local onInsufficientStorageCalled = 0
local player = PlayerSpaceship()

local mission
mission = Missions:transportProduct(from, to, product, {
    amount = 42,
    onInsufficientStorage = function(theMission)
        assert.is_same(mission, theMission)
        onInsufficientStorageCalled = onInsufficientStorageCalled + 1
    end,
})
Mission:withBroker(mission, "Dummy")
Player:withStorage(player, {maxStorage=100})

mission:setPlayer(player)
mission:setMissionBroker(from)
mission:accept()
mission:start()

player:modifyProductStorage(product, 60)

player.isDocked = function(self, thing)
    return thing == from
end

Cron.tick(1)
assert.is_same(1, onInsufficientStorageCalled)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, onInsufficientStorageCalled)

Missions:transportProduct() fails if first parameter is not a station

local from = CpuShip()
assert.has_error(function() Missions:transportProduct(from, to, product) end)

Missions:transportProduct() fails if fourth parameter is a number

assert.has_error(function() Missions:transportProduct(from, to, product, 3) end)

Missions:transportProduct() fails if second parameter is not a station

assert.has_error(function() Missions:transportProduct(from, CpuShip, product) end)

Missions:transportProduct() fails if third parameter is a number

assert.has_error(function() Missions:transportProduct(from, to, 3) end)

Missions:transportProduct() fails to accept if mission is not a broker mission

local mission = Missions:transportProduct(from, to, product)
assert.has_error(function() mission:accept() end)

Missions:transportProduct() fails to accept if the player ship has no storage at all

local acceptConditionCalled = false
local player = PlayerSpaceship()
local mission
mission = Missions:transportProduct(from, to, product, {
    acceptCondition = function(theMission, theError)
        assert.is_same(mission, theMission)
        assert.is_same("no_storage", theError)
        acceptConditionCalled = true
        return "You have no storage"
    end
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
mission:setMissionBroker(from)

local success, message = mission:canBeAccepted()
assert.is_true(acceptConditionCalled)
assert.is_false(success)
assert.is_same("You have no storage", message)

assert.has_error(function() mission:accept() end)

Missions:transportProduct() fails to accept if the player ship has too little storage even if they removed everything

local acceptConditionCalled = false
local player = PlayerSpaceship()
local mission
mission = Missions:transportProduct(from, to, product, {
    amount = 42,
    acceptCondition = function(theMission, theError)
        assert.is_same(mission, theMission)
        assert.is_same("small_storage", theError)
        acceptConditionCalled = true
        return "You have too little storage"
    end,
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
Player:withStorage(player, {maxStorage=40})
mission:setMissionBroker(from)

local success, message = mission:canBeAccepted()
assert.is_true(acceptConditionCalled)
assert.is_false(success)
assert.is_same("You have too little storage", message)

assert.has_error(function() mission:accept() end)

Missions:transportProduct() fails when product is sold or lost

local onProductLostCalled = false
local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage=100})

local mission
mission = Missions:transportProduct(from, to, product, {
    amount = 42,
    onProductLost = function(theMission)
        assert.is_same(mission, theMission)
        onProductLostCalled = true
    end,
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
mission:setMissionBroker(from)
mission:accept()
mission:start()

player.isDocked = function()
    return false
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isLoaded())
assert.is_false(onProductLostCalled)

player.isDocked = function(self, thing)
    return thing == from
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_true(mission:isLoaded())
assert.is_false(onProductLostCalled)

player:modifyProductStorage(product, -10)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isLoaded())
assert.is_true(onProductLostCalled)
assert.is_same(32, player:getProductStorage(product))

assert.is_same("failed", mission:getState())

Missions:transportProduct() should create a valid Mission

local mission = Missions:transportProduct(from, to, product)
assert.is_true(Mission:isMission(mission))

Missions:transportProduct() successful mission

local onLoadCalled = false
local onUnloadCalled = false
local player = PlayerSpaceship()
local mission
mission = Missions:transportProduct(from, to, product, {
    amount = 42,
    onLoad = function(theMission)
        assert.is_same(mission, theMission)
        onLoadCalled = true
    end,
    onUnload = function(theMission)
        assert.is_same(mission, theMission)
        onUnloadCalled = true
    end,
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
Player:withStorage(player, {maxStorage=100})
mission:setMissionBroker(from)
mission:accept()
mission:start()

player.isDocked = function()
    return false
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isLoaded())
assert.is_false(onLoadCalled)
assert.is_false(onUnloadCalled)
assert.is_same(0, player:getProductStorage(product))

player.isDocked = function(self, thing)
    return thing == from
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_true(mission:isLoaded())
assert.is_true(onLoadCalled)
assert.is_false(onUnloadCalled)
assert.is_same(42, player:getProductStorage(product))

player.isDocked = function()
    return false
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)

player.isDocked = function(self, thing)
    return thing == to
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isLoaded())
assert.is_true(onLoadCalled)
assert.is_true(onUnloadCalled)
assert.is_same(0, player:getProductStorage(product))

assert.is_same("successful", mission:getState())

Missions:transportToken()

Missions:transportToken() can run a successful mission

local onLoadCalled = false
local onUnloadCalled = false

local from = SpaceStation()
local to = SpaceStation()
local player = PlayerSpaceship()
local mission = Missions:transportToken(from, to, {
    onLoad = function() onLoadCalled = true end,
    onUnload = function() onUnloadCalled = true end,
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
mission:setMissionBroker(from)
mission:accept()
mission:start()

player.isDocked = function()
    return false
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isTokenLoaded())
assert.is_false(onLoadCalled)
assert.is_false(onUnloadCalled)

player.isDocked = function(self, thing)
    return thing == from
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_true(mission:isTokenLoaded())
assert.is_true(onLoadCalled)
assert.is_false(onUnloadCalled)

player.isDocked = function(self, thing)
    return thing == to
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(mission:isTokenLoaded())
assert.is_true(onLoadCalled)
assert.is_true(onUnloadCalled)

assert.is_same("successful", mission:getState())

Missions:transportToken() fails if first parameter is not a station

local from = CpuShip()
local to = SpaceStation()
assert.has_error(function() Missions:transportToken(from, to) end)

Missions:transportToken() fails if second parameter is not a station

local from = SpaceStation()
local to = CpuShip()
assert.has_error(function() Missions:transportToken(from, to) end)

Missions:transportToken() fails if third parameter is a number

local from = SpaceStation()
local to = SpaceStation()
assert.has_error(function() Missions:transportToken(from, to, 3) end)

Missions:transportToken() fails to start if mission is not a broker mission

local from = SpaceStation()
local to = SpaceStation()
local mission = Missions:transportToken(from, to)

assert.has_error(function() mission:accept() end)

Missions:transportToken() should create a valid Mission

local from = SpaceStation()
local to = SpaceStation()
local mission = Missions:transportToken(from, to)

assert.is_true(Mission:isMission(mission))

Missions:visit()

Missions:visit() can run a successful mission

local onVisitCalled = false

local station = SpaceStation()
local player = PlayerSpaceship()
local mission = Missions:visit(station, {
    onVisit = function() onVisitCalled = true end,
})
Mission:withBroker(mission, "Dummy")

mission:setPlayer(player)
mission:setMissionBroker(station)
mission:accept()
mission:start()

player.isDocked = function()
    return false
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_false(onVisitCalled)

player.isDocked = function(self, thing)
    return thing == station
end

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_true(onVisitCalled)

assert.is_same("successful", mission:getState())

Missions:visit() fails if first parameter is not a station

local station = CpuShip()
assert.has_error(function() Missions:visit(station) end)

Missions:visit() fails if second parameter is a number

local station = SpaceStation()
assert.has_error(function() Missions:visit(station, 3) end)

Missions:visit() fails to start if mission is not a broker mission

local station = SpaceStation()
local mission = Missions:visit(station)

assert.has_error(function() mission:accept() end)

Missions:visit() should create a valid Mission

local station = SpaceStation()
local mission = Missions:visit(station, to)

assert.is_true(Mission:isMission(mission))

Missions:wayPoints()

Missions:wayPoints() can run a successful mission while adding waypoints dynamically

local onWayPointCalled = 0

local player = PlayerSpaceship()
local mission
mission = Missions:wayPoints(nil, {
    minDistance = 1000,
    onStart = function(self)
        self:addWayPoint(10000, 0)
    end,
    onWayPoint = function(self, x, y)
        onWayPointCalled = onWayPointCalled + 1
        if isEeObject(x) then
            x, y = x:getPosition()
        end
        if x < 49999 then
            x = x + 10000
            if x > 25000 then
                -- make sure the current position is used and not the one when it was added
                local artifact = Artifact():setPosition(x / 2, y / 2)
                self:addWayPoint(artifact)
                artifact:setPosition(x, y)
            else
                self:addWayPoint(x, y)
            end
        end
    end,
})

mission:setPlayer(player)
mission:accept()
mission:start()

player:setPosition(0, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())
assert.is_same(0, mission:countVisitedWayPoints())
assert.is_same(0, onWayPointCalled)

player:setPosition(10000, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())
assert.is_same(1, mission:countVisitedWayPoints())
assert.is_same(1, onWayPointCalled)

player:setPosition(20000, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())
assert.is_same(2, mission:countVisitedWayPoints())
assert.is_same(2, onWayPointCalled)

player:setPosition(30000, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())
assert.is_same(3, mission:countVisitedWayPoints())
assert.is_same(3, onWayPointCalled)

player:setPosition(40000, 0)
Cron.tick(1)
assert.is_same("started", mission:getState())
assert.is_same(4, mission:countVisitedWayPoints())
assert.is_same(4, onWayPointCalled)

player:setPosition(50000, 0)
Cron.tick(1)
assert.is_same("successful", mission:getState())
assert.is_same(5, mission:countVisitedWayPoints())
assert.is_same(5, onWayPointCalled)

Missions:wayPoints() can run a successful mission with a static list of waypoints

local onWayPointCalled = 0
local callbackArg2 = nil
local callbackArg3 = nil

local artifact = Artifact():setPosition(10000, 0)

local player = PlayerSpaceship()
local mission
mission = Missions:wayPoints({
    {10000, 10000},
    artifact,
    {0, 0}
}, {
    minDistance = 1000,
    onWayPoint = function(callMission, arg2, arg3)
        onWayPointCalled = onWayPointCalled + 1
        callbackArg2, callbackArg3 = arg2, arg3
        assert.is_same(mission, callMission)
    end,
})

mission:setPlayer(player)
mission:accept()
mission:start()

player:setPosition(0, 0)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(0, mission:countVisitedWayPoints())
assert.is_same(0, onWayPointCalled)
assert.is_same("started", mission:getState())

-- move artifact to verify the current position of the object is used
artifact:setPosition(20000, 0)

player:setPosition(20000, 0)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
-- it should not count, because the waypoints should be visited sequentially
assert.is_same(0, mission:countVisitedWayPoints())
assert.is_same(0, onWayPointCalled)
assert.is_same("started", mission:getState())

player:setPosition(10000, 10000)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(1, mission:countVisitedWayPoints())
assert.is_same(1, onWayPointCalled)
assert.is_same(10000, callbackArg2)
assert.is_same(10000, callbackArg3)
assert.is_same("started", mission:getState())

player:setPosition(20000, 900)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
-- it should allow to be minDistance away
assert.is_same(2, mission:countVisitedWayPoints())
assert.is_same(2, onWayPointCalled)
assert.is_same(artifact, callbackArg2)
assert.is_same(nil, callbackArg3)
assert.is_same("started", mission:getState())

player:setPosition(200, -300)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(3, mission:countVisitedWayPoints())
assert.is_same(3, onWayPointCalled)
assert.is_same(0, callbackArg2)
assert.is_same(0, callbackArg3)
assert.is_same("successful", mission:getState())

Missions:wayPoints() fails if first parameter does not contain valid data

assert.has_error(function() Missions:wayPoints({ 42, "foobar"}) end)
assert.has_error(function() Missions:wayPoints({ { 42, 42}, { 0, 0}, { "this", "is", "invalid"}}) end)

Missions:wayPoints() fails if first parameter is a number

assert.has_error(function() Missions:wayPoints(42) end)

Missions:wayPoints() should create a valid Mission

local mission = Missions:wayPoints({ { 0, 0}})

assert.is_true(Mission:isMission(mission))

Missions:wayPoints():addWayPoint()

Missions:wayPoints():addWayPoint() allows to add an EE object

local mission = Missions:wayPoints()
mission:addWayPoint(Artifact():setPosition(2000, -2000))

Missions:wayPoints():addWayPoint() allows to add coordinates

local mission = Missions:wayPoints()
mission:addWayPoint(2000, -2000)

Missions:wayPoints():addWayPoint() fails if first parameter is not a number

local mission = Missions:wayPoints()

assert.has_error(function() mission:addWayPoint(nil, 0) end)
assert.has_error(function() mission:addWayPoint("foobar", 0) end)

Missions:wayPoints():addWayPoint() fails if second parameter is not a number

local mission = Missions:wayPoints()

assert.has_error(function() mission:addWayPoint(0) end)
assert.has_error(function() mission:addWayPoint(0, nil) end)
assert.has_error(function() mission:addWayPoint(0, "foobar") end)
assert.has_error(function() mission:addWayPoint(0, SpaceShip()) end)

Order

Order:attack()

Order:attack() fails if enemy is neutral for fleet

local fleet = Fleet:new({
    CpuShip():setFactionId(0),
    CpuShip():setFactionId(0),
    CpuShip():setFactionId(0),
})
local enemy = SpaceStation():setFactionId(1)

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:attack(enemy, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("no_enemy", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:attack() fails if enemy is neutral for ship

local ship = CpuShip():setFactionId(0)
local enemy = SpaceStation():setFactionId(1)

Ship:withOrderQueue(ship)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:attack(enemy, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("no_enemy", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:attack() fails if enemy turns into neutral for fleet

local fleet = Fleet:new({
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
})
local enemy = SpaceStation():setFactionId(2)

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:attack(enemy, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

Cron.tick(1)
assert.is_same("Attack", fleet:getLeader():getOrder())

enemy:setFactionId(1)
Cron.tick(1)
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("no_enemy", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:attack() fails if enemy turns into neutral for ship

local ship = CpuShip():setFactionId(1)
local enemy = SpaceStation():setFactionId(2)

Ship:withOrderQueue(ship)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:attack(enemy, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

Cron.tick(1)
assert.is_same("Attack", ship:getOrder())

enemy:setFactionId(1)
Cron.tick(1)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("no_enemy", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:attack() fails if no station or ship is given

assert.has_error(function()
    Order:attack(nil)
end)
assert.has_error(function()
    Order:attack("foo")
end)
assert.has_error(function()
    Order:attack(Asteroid())
end)

Order:defend()

Order:defend() target aborts if target is destroyed (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
local station = SpaceStation()
station.areEnemiesInRange = function() return false end

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:defend(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)
assert.is_same(0, onAbortCalled)
Cron.tick(1)

station:destroy()
Cron.tick(1)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("destroyed", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:defend() target aborts if target is destroyed (ship)

local ship = CpuShip()
local station = SpaceStation()
station.areEnemiesInRange = function() return false end

Ship:withOrderQueue(ship)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:defend(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)
assert.is_same(0, onAbortCalled)
Cron.tick(1)

station:destroy()
Cron.tick(1)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("destroyed", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:defend() target aborts if target is enemy of fleet

local fleet = Fleet:new({
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1)
})
local station = SpaceStation():setFactionId(2)
station.areEnemiesInRange = function() return false end

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:defend(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("is_enemy", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:defend() target aborts if target is enemy of ship

local ship = CpuShip():setFactionId(1)
local station = SpaceStation():setFactionId(2)
station.areEnemiesInRange = function() return false end

Ship:withOrderQueue(ship)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:defend(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("is_enemy", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:defend() target carries out the order for 30 seconds by default (ship)

local ship = CpuShip()
local station = SpaceStation()
station.areEnemiesInRange = function() return false end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend(station, {
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", ship:getOrder())
assert.is_same(station, ship:getOrderTarget())

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", ship:getOrder())
assert.is_same(station, ship:getOrderTarget())

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() target carries out the order for 60 seconds by default (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
local station = SpaceStation()
station.areEnemiesInRange = function() return false end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend(station, {
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", fleet:getLeader():getOrder())
assert.is_same(station, fleet:getLeader():getOrderTarget())

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", fleet:getLeader():getOrder())
assert.is_same(station, fleet:getLeader():getOrderTarget())

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() target carries out the order until there are no enemies in range for 15 seconds (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
local station = SpaceStation()
local rangeCalled = nil
station.areEnemiesInRange = function(_, range)
    rangeCalled = range
    return true
end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend(station, {
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", fleet:getLeader():getOrder())
assert.is_same(station, fleet:getLeader():getOrderTarget())
assert.is_same(20000, rangeCalled)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", fleet:getLeader():getOrder())
assert.is_same(station, fleet:getLeader():getOrderTarget())

-- no enemies for 5 seconds - is too short
station.areEnemiesInRange = function() return false end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
station.areEnemiesInRange = function() return true end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
station.areEnemiesInRange = function() return false end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() target carries out the order until there are no enemies in range for 15 seconds (ship)

local ship = CpuShip()
local station = SpaceStation()
local rangeCalled = nil
station.areEnemiesInRange = function(_, range)
    rangeCalled = range
    return true
end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend(station, {
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", ship:getOrder())
assert.is_same(station, ship:getOrderTarget())
assert.is_same(20000, rangeCalled)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Target", ship:getOrder())
assert.is_same(station, ship:getOrderTarget())

-- no enemies for 5 seconds - is too short
station.areEnemiesInRange = function() return false end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
station.areEnemiesInRange = function() return true end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
station.areEnemiesInRange = function() return false end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() target fails if minClearTime is not a positive number

assert.has_error(function()
    Order:defend(SpaceStation(), {
        minClearTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        minClearTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        minClearTime = -42,
    })
end)

Order:defend() target fails if minDefendTime is not a positive number

assert.has_error(function()
    Order:defend(SpaceStation(), {
        minDefendTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        minDefendTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        minDefendTime = -42,
    })
end)

Order:defend() target fails if parameter is an invalid target

assert.has_error(function()
    Order:defend(42)
end)
assert.has_error(function()
    Order:defend("foo")
end)
assert.has_error(function()
    Order:defend(Asteroid())
end)

Order:defend() target fails if range is not a positive number

assert.has_error(function()
    Order:defend(SpaceStation(), {
        range = "foo",
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        range = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(SpaceStation(), {
        range = -42,
    })
end)

Order:defend() with location carries out the order for 30 seconds (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
fleet:getLeader().areEnemiesInRange = function() return false end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend(5000, 0, {
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", fleet:getLeader():getOrder())
assert.is_same({5000, 0}, {fleet:getLeader():getOrderTargetLocation()})

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", fleet:getLeader():getOrder())
assert.is_same({5000, 0}, {fleet:getLeader():getOrderTargetLocation()})

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() with location carries out the order for 30 seconds (ship)

local ship = CpuShip()
_G.getObjectsInRadius = function() return {} end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend(5000, 0, {
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", ship:getOrder())
assert.is_same({5000, 0}, {ship:getOrderTargetLocation()})

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", ship:getOrder())
assert.is_same({5000, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() with location carries out the order until there are no enemies in range for 15 seconds (fleet)

local fleet = Fleet:new({
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
})
local calledRange = nil
_G.getObjectsInRadius = function(_, _, range)
    calledRange = range
    return {CpuShip():setFactionId(2)}
end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend(5000, 0, {
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", fleet:getLeader():getOrder())
assert.is_same({5000, 0}, {fleet:getLeader():getOrderTargetLocation()})
assert.is_same(20000, calledRange)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", fleet:getLeader():getOrder())
assert.is_same({5000, 0}, {fleet:getLeader():getOrderTargetLocation()})

-- no enemies for 5 seconds - is too short
_G.getObjectsInRadius = function() return {} end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
_G.getObjectsInRadius = function() return {CpuShip():setFactionId(2)} end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
_G.getObjectsInRadius = function() return {} end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() with location carries out the order until there are no enemies in range for 15 seconds (ship)

local ship = CpuShip():setFactionId(1)
local calledRange = nil
_G.getObjectsInRadius = function(_, _, range)
    calledRange = range
    return {CpuShip():setFactionId(2)}
end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend(5000, 0, {
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", ship:getOrder())
assert.is_same({5000, 0}, {ship:getOrderTargetLocation()})
assert.is_same(20000, calledRange)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Defend Location", ship:getOrder())
assert.is_same({5000, 0}, {ship:getOrderTargetLocation()})

-- no enemies for 5 seconds - is too short
_G.getObjectsInRadius = function() return {} end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
_G.getObjectsInRadius = function() return {CpuShip():setFactionId(2)} end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
_G.getObjectsInRadius = function() return {} end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() with location fails if any coordinate is not a number

assert.has_error(function()
    Order:defend(nil, 42)
end)
assert.has_error(function()
    Order:defend(5000, nil)
end)
assert.has_error(function()
    Order:defend("foo", 42)
end)
assert.has_error(function()
    Order:defend(5000, "foo")
end)
assert.has_error(function()
    Order:defend(SpaceStation(), 42)
end)
assert.has_error(function()
    Order:defend(5000, SpaceStation())
end)

Order:defend() with location fails if minClearTime is not a positive number

assert.has_error(function()
    Order:defend(0, 0, {
        minClearTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        minClearTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        minClearTime = -42,
    })
end)

Order:defend() with location fails if minDefendTime is not a positive number

assert.has_error(function()
    Order:defend(0, 0, {
        minDefendTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        minDefendTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        minDefendTime = -42,
    })
end)

Order:defend() with location fails if range is not a positive number

assert.has_error(function()
    Order:defend(0, 0, {
        range = "foo",
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        range = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend(0, 0, {
        range = -42,
    })
end)

Order:defend() without argument carries out the order for 30 seconds by default (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
fleet:getLeader().areEnemiesInRange = function() return false end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend({
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", fleet:getLeader():getOrder())

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", fleet:getLeader():getOrder())

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() without argument carries out the order for 30 seconds by default (ship)

local ship = CpuShip()
ship.areEnemiesInRange = function() return false end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend({
    minDefendTime = 30,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", ship:getOrder())

for _=1,29 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", ship:getOrder())

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() without argument carries out the order until there are no enemies in range for 15 seconds (fleet)

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
local calledRange = nil
fleet:getLeader().areEnemiesInRange = function(_, range)
    calledRange = range
    return true
end

Fleet:withOrderQueue(fleet)
local onCompletionCalled = 0
local order = Order:defend({
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

fleet:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", fleet:getLeader():getOrder())
assert.is_same(20000, calledRange)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", fleet:getLeader():getOrder())

-- no enemies for 5 seconds - is too short
fleet:getLeader().areEnemiesInRange = function() return false end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
fleet:getLeader().areEnemiesInRange = function() return true end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
fleet:getLeader().areEnemiesInRange = function() return false end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() without argument carries out the order until there are no enemies in range for 15 seconds (ship)

local ship = CpuShip()
local calledRange = nil
ship.areEnemiesInRange = function(_, range)
    calledRange = range
    return true
end

Ship:withOrderQueue(ship)
local onCompletionCalled = 0
local order = Order:defend({
    range = 20000,
    minClearTime = 15,
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
})

ship:addOrder(order)

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", ship:getOrder())
assert.is_same(20000, calledRange)

for _=1,60 do Cron.tick(1) end

assert.is_same(0, onCompletionCalled)
assert.is_same("Stand Ground", ship:getOrder())

-- no enemies for 5 seconds - is too short
ship.areEnemiesInRange = function() return false end
for _=1,5 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

-- ...an other enemy appears
ship.areEnemiesInRange = function() return true end
Cron.tick(1)
assert.is_same(0, onCompletionCalled)

-- ...and goes away
ship.areEnemiesInRange = function() return false end
for _=1,14 do Cron.tick(1) end
assert.is_same(0, onCompletionCalled)

Cron.tick(1)
Cron.tick(1)

assert.is_same(1, onCompletionCalled)

Order:defend() without argument fails if minClearTime is not a positive number

assert.has_error(function()
    Order:defend({
        minClearTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend({
        minClearTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend({
        minClearTime = -42,
    })
end)

Order:defend() without argument fails if minDefendTime is not a positive number

assert.has_error(function()
    Order:defend({
        minDefendTime = "foo",
    })
end)
assert.has_error(function()
    Order:defend({
        minDefendTime = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend({
        minDefendTime = -42,
    })
end)

Order:defend() without argument fails if range is not a positive number

assert.has_error(function()
    Order:defend({
        range = "foo",
    })
end)
assert.has_error(function()
    Order:defend({
        range = SpaceStation(),
    })
end)
assert.has_error(function()
    Order:defend({
        range = -42,
    })
end)

Order:dock()

Order:dock() does not wait for missiles, when waitForMissileRestock is false

local ship = CpuShip():setWeaponStorageMax("homing", 8):setWeaponStorage("homing", 0)

local station = SpaceStation()

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    waitForMissileRestock = false,
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked but no missiles
ship:setDockedAt(station)
Cron.tick(1)
assert.is_true(completed)

Order:dock() does not wait for repairs, when waitForRepair is false

local ship = CpuShip():setHullMax(100):setHull(50)

local station = SpaceStation()
station:setRepairDocked(true)

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    waitForRepair = false,
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked
ship:setDockedAt(station)
Cron.tick(1)
assert.is_true(completed)

Order:dock() does not wait for shields to recharge, when waitForShieldRecharge is false

local ship = CpuShip():setShieldsMax(100, 50, 10):setShields(20, 50, 0)

local station = SpaceStation()

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    waitForShieldRecharge = false,
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked but low shields
ship:setDockedAt(station)
Cron.tick(1)
assert.is_true(completed)

Order:dock() fails if no station is given

assert.has_error(function()
    Order:dock(nil)
end)
assert.has_error(function()
    Order:dock("foo")
end)
assert.has_error(function()
    Order:dock(CpuShip())
end)

Order:dock() fails if station is an enemy for fleet

local fleet = Fleet:new({
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
})
local station = SpaceStation():setFactionId(2)

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("enemy_station", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:dock() fails if station is an enemy for ship

local ship = CpuShip():setFactionId(1)
local station = SpaceStation():setFactionId(2)

Ship:withOrderQueue(ship)

local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("enemy_station", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:dock() fails if station is destroyed for fleet

local fleet = Fleet:new({CpuShip(), CpuShip(), CpuShip()})
local station = SpaceStation()

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

Cron.tick(1)
assert.is_same("Dock", fleet:getLeader():getOrder())

station:destroy()

Cron.tick(1)
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("invalid_station", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:dock() fails if station is destroyed for ship

local ship = CpuShip()
local station = SpaceStation()

Ship:withOrderQueue(ship)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

Cron.tick(1)
assert.is_same("Dock", ship:getOrder())

station:destroy()

Cron.tick(1)
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("invalid_station", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:dock() fails if station turns into an enemy for fleet

local fleet = Fleet:new({
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
    CpuShip():setFactionId(1),
})
local station = SpaceStation():setFactionId(0)

Fleet:withOrderQueue(fleet)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
fleet:addOrder(order)

Cron.tick(1)
assert.is_same("Dock", fleet:getLeader():getOrder())

station:setFactionId(2)
Cron.tick(1)
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("enemy_station", abortArg2)
assert.is_same(fleet, abortArg3)
assert.is_same("Idle", fleet:getLeader():getOrder())

Order:dock() fails if station turns into an enemy for ship

local ship = CpuShip():setFactionId(1)
local station = SpaceStation():setFactionId(0)

Ship:withOrderQueue(ship)

local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:dock(station, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

Cron.tick(1)
assert.is_same("Dock", ship:getOrder())

station:setFactionId(2)
Cron.tick(1)
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("enemy_station", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:dock() repairs a docked fleet if the station is friendly and supports it

local ship1 = CpuShip():setHullMax(100):setHull(50)
local ship2 = CpuShip():setHullMax(100):setHull(50)
local ship3 = CpuShip():setHullMax(100):setHull(50)
local fleet = Fleet:new({ship1, ship2, ship3})

local station = SpaceStation()
station:setRepairDocked(true)

Fleet:withOrderQueue(fleet)
local completed = false
fleet:addOrder(Order:dock(station, {
    onCompletion = function() completed = true end,
}))
fleet:addOrder(Order:flyTo(1000, 0))

-- fleet leader not docked
Cron.tick(1)
assert.is_false(completed)
assert.is_same("Dock", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

-- fleet leader docked, fleet leader unrepaired
ship1:setDockedAt(station)
Cron.tick(1)
assert.is_false(completed)
assert.is_same("Dock", ship1:getOrder())
assert.is_same("Dock", ship2:getOrder())
assert.is_same(station, ship2:getOrderTarget())
assert.is_same("Dock", ship3:getOrder())
assert.is_same(station, ship3:getOrderTarget())

-- fleet leader docked, fleet leader repaired, wingmen not docked
ship1:setHull(100)
Cron.tick(1)
assert.is_false(completed)
assert.is_same("Dock", ship1:getOrder())
assert.is_same("Dock", ship2:getOrder())
assert.is_same("Dock", ship3:getOrder())

-- fleet leader repaired, wingmen docked
ship2:setDockedAt(station)
ship3:setDockedAt(station)
Cron.tick(1)
assert.is_false(completed)

-- fleet leader repaired, wingmen repaired
ship2:setHull(100)
ship3:setHull(100)
Cron.tick(1)
assert.is_true(completed)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)

assert.is_same("Fly towards", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Order:dock() repairs a docked ship if the station is friendly and supports it

local ship = CpuShip():setHullMax(100):setHull(50)

local station = SpaceStation()
station:setRepairDocked(true)

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked but unrepaired
ship:setDockedAt(station)
Cron.tick(1)
assert.is_false(completed)

-- ship docked and repaired
ship:setHull(100)
Cron.tick(1)
assert.is_true(completed)

Order:dock() undocks all fleet ships if the order is aborted

local ship1 = CpuShip():setHullMax(100):setHull(50)
local ship2 = CpuShip():setHullMax(100):setHull(50)
local ship3 = CpuShip():setHullMax(100):setHull(50)
local fleet = Fleet:new({ship1, ship2, ship3})

local station = SpaceStation()
station:setRepairDocked(true)

Fleet:withOrderQueue(fleet)
fleet:addOrder(Order:dock(station))

-- fleet leader docked, fleet leader unrepaired
ship1:setDockedAt(station)
Cron.tick(1)
assert.is_same("Dock", ship1:getOrder())
assert.is_same("Dock", ship2:getOrder())
assert.is_same("Dock", ship3:getOrder())

fleet:abortCurrentOrder()
Cron.tick(1)

assert.is_same("Idle", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Order:dock() waits for all ships to be repaired, refilled and recharged

local ship1 = CpuShip()
local ship2 = CpuShip():setHullMax(100):setHull(50) -- damaged
local ship3 = CpuShip():setWeaponStorageMax("homing", 8):setWeaponStorage("homing", 0) -- weapons
local ship4 = CpuShip():setShieldsMax(100, 50, 10):setShields(20, 50, 0) -- broken shields
local fleet = Fleet:new({ship1, ship2, ship3, ship4})

local station = SpaceStation()
station:setRepairDocked(true)

Fleet:withOrderQueue(fleet)
local completed = false
fleet:addOrder(Order:dock(station, {
    onCompletion = function() completed = true end,
}))
fleet:addOrder(Order:flyTo(1000, 0))

ship1:setDockedAt(station)
ship2:setDockedAt(station)
ship3:setDockedAt(station)
ship4:setDockedAt(station)

assert.is_false(completed)

-- repair ship
ship2:setHull(100)
Cron.tick(1)
Cron.tick(1) --twice for Fleet's cron to catch up
assert.is_false(completed)
assert.is_same("Fly in formation", ship2:getOrder())

-- refill missiles
ship3:setWeaponStorage("homing", 8)
Cron.tick(1)
Cron.tick(1) --twice for Fleet's cron to catch up
assert.is_false(completed)
assert.is_same("Fly in formation", ship3:getOrder())

-- recharge shields
ship4:setShields(100, 50, 10)
Cron.tick(1)
Cron.tick(1) --twice for Fleet's cron to catch up
assert.is_true(completed)
assert.is_same("Fly in formation", ship4:getOrder())

Order:dock() waits for missiles to be refilled

local ship = CpuShip():setWeaponStorageMax("homing", 8):setWeaponStorage("homing", 0)

local station = SpaceStation()

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked but no missiles
ship:setDockedAt(station)
Cron.tick(1)
assert.is_false(completed)

-- ship docked and refilled
ship:setWeaponStorage("homing", 8)
Cron.tick(1)
assert.is_true(completed)

Order:dock() waits for shields to recharge

local ship = CpuShip():setShieldsMax(100, 50, 10):setShields(20, 50, 0)

local station = SpaceStation()

Ship:withOrderQueue(ship)
local completed = false
ship:addOrder(Order:dock(station, {
    onCompletion = function() completed = true end,
}))

-- ship not docked
Cron.tick(1)
assert.is_false(completed)

-- ship docked but low shields
ship:setDockedAt(station)
Cron.tick(1)
assert.is_false(completed)

-- ship docked and shields loaded
ship:setShields(100, 50, 10)
Cron.tick(1)
assert.is_true(completed)

Order:flyTo()

Order:flyTo() config.ignoreEnemies allows to set the distance to trigger for fleet

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:setPosition(1000, 0)

ship:addOrder(Order:flyTo(0, 0, {
    ignoreEnemies = true,
}))
assert.is_same("Fly towards (ignore all)", ship:getOrder())

Order:flyTo() config.ignoreEnemies allows to set the distance to trigger for ship

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
Fleet:withOrderQueue(fleet)

fleet:getLeader():setPosition(1000, 0)

fleet:addOrder(Order:flyTo(0, 0, {
    ignoreEnemies = true,
}))
assert.is_same("Fly towards (ignore all)", fleet:getLeader():getOrder())

Order:flyTo() config.ignoreEnemies fails if it is not a boolean

local ship = CpuShip()
Ship:withOrderQueue(ship)

assert.has_error(function()
    Order:flyTo(0, 0, {
        ignoreEnemies = "foobar",
    })
end)
assert.has_error(function()
    Order:flyTo(0, 0, {
        ignoreEnemies = 42,
    })
end)

Order:flyTo() config.minDistance allows to set the distance to trigger for fleet leader

local fleet = Fleet:new({
    CpuShip(),
    CpuShip(),
    CpuShip(),
})
Fleet:withOrderQueue(fleet)

fleet:getLeader():setPosition(1000, 0)

local completed = false
fleet:addOrder(Order:flyTo(0, 0, {
    minDistance = 100,
    onCompletion = function() completed = true end,
}))

assert.is_false(completed)

fleet:getLeader():setPosition(500, 0)
Cron.tick(1)
assert.is_false(completed)

fleet:getLeader():setPosition(101, 0)
Cron.tick(1)
assert.is_false(completed)

fleet:getLeader():setPosition(99, 0)
Cron.tick(1)
assert.is_true(completed)

Order:flyTo() config.minDistance allows to set the distance to trigger for ship

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:setPosition(1000, 0)

local completed = false
ship:addOrder(Order:flyTo(0, 0, {
    minDistance = 100,
    onCompletion = function() completed = true end,
}))

assert.is_false(completed)

ship:setPosition(500, 0)
Cron.tick(1)
assert.is_false(completed)

ship:setPosition(101, 0)
Cron.tick(1)
assert.is_false(completed)

ship:setPosition(99, 0)
Cron.tick(1)
assert.is_true(completed)

Order:flyTo() config.minDistance fails if it is not a number or negative

local ship = CpuShip()
Ship:withOrderQueue(ship)

assert.has_error(function()
    Order:flyTo(0, 0, {
        minDistance = "foobar",
    })
end)
assert.has_error(function()
    Order:flyTo(0, 0, {
        minDistance = -42,
    })
end)

Order:flyTo() fails if x or y are not numbers

assert.has_error(function()
    Order:flyTo(nil, 0)
end)
assert.has_error(function()
    Order:flyTo("foo", 0)
end)
assert.has_error(function()
    Order:flyTo({}, 0)
end)
assert.has_error(function()
    Order:flyTo(0, nil)
end)
assert.has_error(function()
    Order:flyTo(0, "foo")
end)
assert.has_error(function()
    Order:flyTo(0, {})
end)

Order:use()

Order:use() fails if wormHole is not a WormHole

assert.has_error(function()
    Order:use(nil)
end)
assert.has_error(function()
    Order:use("foo")
end)
assert.has_error(function()
    Order:use({})
end)
assert.has_error(function()
    Order:use(0)
end)

Order:use() fails if wormHole is not valid

assert.has_error(function()
    local ship = CpuShip()
    local wormhole = WormHole():destroy()

    ship:addOrder(Order:use(wormhole))
end)

Order:use() fails if wormhole disappears

local ship = CpuShip()
local wormhole = WormHole():setPosition(0,0):setTargetPosition(10000, 0)

Ship:withOrderQueue(ship)

local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil
local order = Order:use(wormhole, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end,
})
ship:addOrder(order)

Cron.tick(1)
wormhole:destroy()
Cron.tick(1)

assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("invalid_target", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Order:use() fleet breaks up in front of the wormhole and calls the config.onBreakUp callback

local leader = CpuShip()
local ship1 = CpuShip()
local ship2 = CpuShip()
local wormhole = WormHole():setPosition(5000,0):setTargetPosition(99999, 0)

local fleet = Fleet:new({leader, ship1, ship2})
Fleet:withOrderQueue(fleet)

local onBreakUpCalled, breakUpArg1, breakUpArg2 = 0, nil, nil
local order = Order:use(wormhole, {
    onBreakUp = function(arg1, arg2)
        onBreakUpCalled = onBreakUpCalled + 1
        breakUpArg1 = arg1
        breakUpArg2 = arg2
    end,
})
fleet:addOrder(order)
Cron.tick(1)

assert.is_same(0, onBreakUpCalled)
assert.is_same("Fly towards", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())

leader:setPosition(3000, 0)
Cron.tick(1)
assert.is_same("Fly towards (ignore all)", leader:getOrder())
assert.is_same(5000, leader:getOrderTargetLocationX())
assert.is_same(0, leader:getOrderTargetLocationY())
assert.is_same("Fly towards (ignore all)", ship1:getOrder())
assert.is_same(5000, ship1:getOrderTargetLocationX())
assert.is_same(0, ship1:getOrderTargetLocationY())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())
assert.is_same(5000, ship2:getOrderTargetLocationX())
assert.is_same(0, ship2:getOrderTargetLocationY())
assert.is_same(1, onBreakUpCalled)
assert.is_same(order, breakUpArg1)
assert.is_same(fleet, breakUpArg2)

Cron.tick(1)
assert.is_same(1, onBreakUpCalled) -- it is only called once

Order:use() fleet leader waits after jump and regroups

local leader = CpuShip():setCallSign("leader")
local ship1 = CpuShip():setCallSign("ship 1")
local ship2 = CpuShip():setCallSign("ship 2")
local wormhole = WormHole():setPosition(5000,0):setTargetPosition(99999, 0)

local fleet = Fleet:new({leader, ship1, ship2})
Fleet:withOrderQueue(fleet)

local order = Order:use(wormhole)
fleet:addOrder(order)

Cron.tick(1)

assert.is_same("Fly towards", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())

leader:setPosition(3000, 0)
Cron.tick(1)
assert.is_same("Fly towards (ignore all)", leader:getOrder())
assert.is_same("Fly towards (ignore all)", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())

-- leader jumps first
leader:setPosition(99999, 0)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly towards (ignore all)", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())

-- wingman jumps second
ship1:setPosition(99999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())

-- second wingman jumps
ship2:setPosition(99999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())

Order:use() fleet waits after jump and regroups

local leader = CpuShip():setCallSign("leader")
local ship1 = CpuShip():setCallSign("ship 1")
local ship2 = CpuShip():setCallSign("ship 2")
local ship3 = CpuShip():setCallSign("ship 3")
local wormhole = WormHole():setPosition(5000,0):setTargetPosition(99999, 0)

local fleet = Fleet:new({leader, ship1, ship2, ship3})
Fleet:withOrderQueue(fleet)

local order = Order:use(wormhole)
fleet:addOrder(order)

Cron.tick(1)

assert.is_same("Fly towards", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

leader:setPosition(3000, 0)
Cron.tick(1)
assert.is_same("Fly towards (ignore all)", leader:getOrder())
assert.is_same("Fly towards (ignore all)", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())
assert.is_same("Fly towards (ignore all)", ship3:getOrder())

-- wingman jumps first
ship1:setPosition(99999, 0)
Cron.tick(1)
assert.is_same("Fly towards (ignore all)", leader:getOrder())
assert.is_same("Stand Ground", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())
assert.is_same("Fly towards (ignore all)", ship3:getOrder())


-- leader jumps second
leader:setPosition(99999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly towards (ignore all)", ship2:getOrder())
assert.is_same("Fly towards (ignore all)", ship3:getOrder())

-- second wingman jumps
ship2:setPosition(99999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly towards (ignore all)", ship3:getOrder())

ship3:setPosition(99999, 0)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Stand Ground", leader:getOrder())
assert.is_same("Fly in formation", ship1:getOrder())
assert.is_same("Fly in formation", ship2:getOrder())
assert.is_same("Fly in formation", ship3:getOrder())

Person

Person:byName()

Person:byName() should create a valid Person object by name

local person = Person:byName("John Doe")

assert.is_same("John Doe", person.getFormalName())
assert.is_same("John Doe", person.getNickName())
assert.is_true(Person:isPerson(person))

Person:byName() should create a valid Person object by name with nickname

local person = Person:byName("John Doe", "John")

assert.is_same("John Doe", person.getFormalName())
assert.is_same("John", person.getNickName())
assert.is_true(Person:isPerson(person))

Player

Player:withMenu()

Player:withMenu() creates a valid menu

local player = PlayerSpaceship()
Player:withMenu(player)

assert.is_true(Player:hasMenu(player))

Player:withMenu() does not change menus on the 6/5 stations when buttons on the 4/3 stations are clicked

local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
})

local submenu = mockSubmenu("Submenu", function(menu)
    menu:addItem(mockMenuLabel("You are in a submenu"))
end)

player:addScienceMenuItem(submenu)

assert.is_true(player:hasButton("science", "Submenu"))
assert.is_true(player:hasButton("operations", "Submenu"))
assert.is_false(player:hasButton("science", "Back"))
assert.is_false(player:hasButton("operations", "Back"))

player:clickButton("operations", "Submenu")
assert.is_false(player:hasButton("operations", "Submenu"))
assert.is_true(player:hasButton("operations", "You are in a submenu"))
assert.is_true(player:hasButton("operations", "Back"))

assert.is_true(player:hasButton("science", "Submenu"))
assert.is_false(player:hasButton("science", "You are in a submenu"))
assert.is_false(player:hasButton("science", "Back"))

player:clickButton("science", "Submenu")
assert.is_false(player:hasButton("science", "Submenu"))
assert.is_true(player:hasButton("science", "You are in a submenu"))
assert.is_true(player:hasButton("science", "Back"))

player:clickButton("operations", "Back")
assert.is_true(player:hasButton("operations", "Submenu"))
assert.is_false(player:hasButton("operations", "Back"))

assert.is_false(player:hasButton("science", "Submenu"))
assert.is_true(player:hasButton("science", "You are in a submenu"))
assert.is_true(player:hasButton("science", "Back"))

Player:withMenu() draws submenus

-- test setup
local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
})

local submenu = mockSubmenu("Submenu", function(menu)
    menu:addItem(mockMenuLabel("You are in a submenu"))
end)

player:addScienceMenuItem(submenu)
player:addScienceMenuItem(mockMenuLabel("Original Item"))
assert.is_true(player:hasButton("science", "Submenu"))
assert.is_true(player:hasButton("science", "Original Item"))
assert.is_false(player:hasButton("science", "Back"))

player:clickButton("science", "Submenu")
assert.is_false(player:hasButton("science", "Submenu"))
assert.is_false(player:hasButton("science", "Original Item"))
assert.is_true(player:hasButton("science", "You are in a submenu"))

-- there should ALWAYS! be a back button so the player can go back to the main menu
assert.is_true(player:hasButton("science", "Back"))

player:clickButton("science", "Back")
assert.is_true(player:hasButton("science", "Submenu"))
assert.is_true(player:hasButton("science", "Original Item"))
assert.is_false(player:hasButton("science", "You are in a submenu"))
assert.is_false(player:hasButton("science", "Back"))

Player:withMenu() fails if the first argument already has menus

local player = PlayerSpaceship()
Player:withMenu(player)

assert.has_error(function() Player:withMenu(player) end)

Player:withMenu() fails if the first argument is not a player

assert.has_error(function() Player:withMenu(42) end)

Player:withMenu() lets menus fall back to the 4/3 station and single pilot

local player = PlayerSpaceship()
Player:withMenu(player)

local helmsCalled = 0
local weaponsCalled = 0
local relayCalled = 0
local scienceCalled = 0
local engineeringCalled = 0
player:addHelmsMenuItem("helms", Menu:newItem("Helms", function() helmsCalled = helmsCalled + 1 end))
player:addWeaponsMenuItem("weapons", Menu:newItem("Weapons", function() weaponsCalled = weaponsCalled + 1 end))
player:addRelayMenuItem("relay", Menu:newItem("Relay", function() relayCalled = relayCalled + 1 end))
player:addScienceMenuItem("science", Menu:newItem("Science", function() scienceCalled = scienceCalled + 1 end))
player:addEngineeringMenuItem("engineering", Menu:newItem("Engineering", function() engineeringCalled = engineeringCalled + 1 end))

assert.is_true(player:hasButton("tactical", "Helms"))
assert.is_true(player:hasButton("tactical", "Weapons"))
assert.is_true(player:hasButton("operations", "Relay"))
assert.is_true(player:hasButton("operations", "Science"))
assert.is_true(player:hasButton("engineering+", "Engineering"))

assert.is_true(player:hasButton("single", "Helms"))
assert.is_true(player:hasButton("single", "Weapons"))
assert.is_true(player:hasButton("single", "Relay"))
assert.is_true(player:hasButton("single", "Science"))
assert.is_true(player:hasButton("single", "Engineering"))

Player:withMenu() only shows a message on the station where the button was clicked

local player = PlayerSpaceship()
Player:withMenu(player)

player:addHelmsMenuItem("button", Menu:newItem("Click Me", function() return "Hey, it's me: your friendly pop up" end))

player:clickButton("tactical", "Click Me")
assert.is_same("Hey, it's me: your friendly pop up", player:getCustomMessage("tactical"))
assert.is_nil(player:getCustomMessage("helms"))

player:clickButton("helms", "Click Me")
assert.is_same("Hey, it's me: your friendly pop up", player:getCustomMessage("helms"))

Player:withMenu() should not have functions for tactical, operations, engineering+ and single pilot

local player = PlayerSpaceship()
Player:withMenu(player)

assert.is_nil(player.addTacticalMenuItem)
assert.is_nil(player.addOperationsMenuItem)
assert.is_nil(player.addSingleMenuItem)
assert.is_nil(player.removeTacticalMenuItem)
assert.is_nil(player.removeOperationsMenuItem)
assert.is_nil(player.removeSingleMenuItem)
assert.is_nil(player.drawTacticalMenu)
assert.is_nil(player.drawOperationsMenu)
assert.is_nil(player.drawSingleMenu)

assert.has_error(function()
    player:addMenuItem("operations", Menu:newItem("Boom", "This should not work"))
end)
assert.has_error(function()
    player:addMenuItem("tactical", Menu:newItem("Boom", "This should not work"))
end)
assert.has_error(function()
    player:addMenuItem("engineering+", Menu:newItem("Boom", "This should not work"))
end)
assert.has_error(function()
    player:addMenuItem("single", Menu:newItem("Boom", "This should not work"))
end)

Player:withMenu() triggers callbacks on click

local player = PlayerSpaceship()
Player:withMenu(player)

local called, callArg1, callArg2 = 0, nil, nil
player:addRelayMenuItem("function", Menu:newItem("Callback", function(arg1, arg2)
    called = called + 1
    callArg1 = arg1
    callArg2 = arg2
end))

assert.is_same(0, called)
assert.is_nil(callArg1)
assert.is_nil(callArg2)
player:clickButton("relay", "Callback")
assert.is_same(1, called)
assert.is_same(player, callArg1)
assert.is_same("relay", callArg2)

Player:withMenu():addHelmsMenuItem(),

Player:withMenu():addHelmsMenuItem(), removeHelmsMenuItem(), drawHelmsMenu() adds and removes menu items

local player = PlayerSpaceship()
Player:withMenu(player)

player:addHelmsMenuItem("submenu", mockSubmenu("Submenu 1"))
assert.is_true(player:hasButton("helms", "Submenu 1"))
player:addHelmsMenuItem(mockSubmenu("Submenu 2"))
assert.is_true(player:hasButton("helms", "Submenu 2"))

player:addHelmsMenuItem("label", mockMenuLabel("Label 1"))
assert.is_true(player:hasButton("helms", "Label 1"))
player:addHelmsMenuItem(mockMenuLabel("Label 2"))
assert.is_true(player:hasButton("helms", "Label 2"))

player:addHelmsMenuItem("sideeffects", mockMenuItemWithSideEffects("Effect 1"))
assert.is_true(player:hasButton("helms", "Effect 1"))
player:addHelmsMenuItem(mockMenuItemWithSideEffects("Effect 2"))
assert.is_true(player:hasButton("helms", "Effect 2"))

player:removeHelmsMenuItem("submenu")
assert.is_false(player:hasButton("helms", "Submenu 1"))

player:removeHelmsMenuItem("label")
assert.is_false(player:hasButton("helms", "Label 1"))

player:removeHelmsMenuItem("sideeffects")
assert.is_false(player:hasButton("helms", "Effect 1"))

-- you usually do not need to call that, but lets see if it throws an error
player:drawHelmsMenu()

Player:withMenu():addMenuItem()

Player:withMenu():addMenuItem() fails if an invalid position is given

local player = PlayerSpaceship()
Player:withMenu(player)

assert.has_error(function()
    player:addMenuItem("invalid", mockSubmenu())
end)
assert.has_error(function()
    player:addMenuItem(nil, mockSubmenu())
end)
assert.has_error(function()
    player:addMenuItem(42, mockSubmenu())
end)

Player:withMenu():addMenuItem(),

Player:withMenu():addMenuItem(), removeMenuItem(), drawMenu() adds and removes menu items

local player = PlayerSpaceship()
Player:withMenu(player)

player:addMenuItem("engineering", "submenu", mockSubmenu("Submenu 1"))
assert.is_true(player:hasButton("engineering", "Submenu 1"))
player:addMenuItem("engineering", mockSubmenu("Submenu 2"))
assert.is_true(player:hasButton("engineering", "Submenu 2"))

player:addMenuItem("engineering", "label", mockMenuLabel("Label 1"))
assert.is_true(player:hasButton("engineering", "Label 1"))
player:addMenuItem("engineering", mockMenuLabel("Label 2"))
assert.is_true(player:hasButton("engineering", "Label 2"))

player:addMenuItem("engineering", "sideeffects", mockMenuItemWithSideEffects("Effect 1"))
assert.is_true(player:hasButton("engineering", "Effect 1"))
player:addMenuItem("engineering", mockMenuItemWithSideEffects("Effect 2"))
assert.is_true(player:hasButton("engineering", "Effect 2"))

player:removeMenuItem("engineering", "submenu")
assert.is_false(player:hasButton("engineering", "Submenu 1"))

player:removeMenuItem("engineering", "label")
assert.is_false(player:hasButton("engineering", "Label 1"))

player:removeMenuItem("engineering", "sideeffects")
assert.is_false(player:hasButton("engineering", "Effect 1"))

-- you usually do not need to call that, but lets test in anyways
player:drawMenu("engineering")

Player:withMenu():addMenuItem(), removeMenuItem(), drawMenu() does not redraw the menu if some submenu is currently opened

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})

local menu = Menu:new()
menu:addItem("dummy", Menu:newItem("You are in the submenu"))

player:addMenuItem("engineering", "submenu", Menu:newItem("Submenu", menu))
player:clickButton("engineering", "Submenu")

assert.is_true(player:hasButton("engineering", "You are in the submenu"))
player:addMenuItem("engineering", "dummy", mockMenuLabel("Main Menu"))

-- assert you are not thrown back to the main menu
assert.is_true(player:hasButton("engineering", "You are in the submenu"))
player:clickButton("engineering", "Back")
assert.is_true(player:hasButton("engineering", "Main Menu"))
player:clickButton("engineering", "Submenu")
assert.is_true(player:hasButton("engineering", "You are in the submenu"))

player:removeMenuItem("engineering", "dummy")

-- assert you are not thrown back to the main menu
assert.is_true(player:hasButton("engineering", "You are in the submenu"))
player:clickButton("engineering", "Back")
assert.is_false(player:hasButton("engineering", "Main Menu"))

Player:withMenu():drawMenu()

Player:withMenu():drawMenu() can draw an arbitrary menu

-- test setup
local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
})

player:addMenuItem("weapons", mockSubmenu("Original Item 1"))
player:addMenuItem("weapons", mockMenuLabel("Original Item 2"))
assert.is_true(player:hasButton("weapons", "Original Item 1"))
assert.is_true(player:hasButton("weapons", "Original Item 2"))
assert.is_false(player:hasButton("weapons", "Back"))

local overrideMenu = Menu:new()
overrideMenu:addItem(mockMenuLabel("Override Item"))

player:drawMenu("weapons", overrideMenu)
assert.is_false(player:hasButton("weapons", "Original Item 1"))
assert.is_false(player:hasButton("weapons", "Original Item 2"))
assert.is_true(player:hasButton("weapons", "Override Item"))

-- there should ALWAYS! be a back button so the player can go back to the main menu
assert.is_true(player:hasButton("weapons", "Back"))
player:clickButton("weapons", "Back")

assert.is_true(player:hasButton("weapons", "Original Item 1"))
assert.is_true(player:hasButton("weapons", "Original Item 2"))
assert.is_false(player:hasButton("weapons", "Override Item"))
assert.is_false(player:hasButton("weapons", "Back"))

Player:withMenu():drawMenu() compensates for the back button on submenus

local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
    labelNext = "Next",
    labelPrevious = "Previous",
    itemsPerPage = 6,
})

local menu = Menu:new()
player:addMenuItem("helms", Menu:newItem("Click Me", menu))

for i=1,11 do
    menu:addItem(mockMenuLabel("Item " .. i, i))
end

player:clickButton("helms", "Click Me")

assert.is_true(player:hasButton("helms", "Item 1"))
assert.is_true(player:hasButton("helms", "Item 2"))
assert.is_true(player:hasButton("helms", "Item 3"))
assert.is_true(player:hasButton("helms", "Item 4"))
assert.is_false(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Back"))
assert.is_true(player:hasButton("helms", "Next"))
assert.is_false(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Next")
assert.is_false(player:hasButton("helms", "Item 4"))
assert.is_true(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Item 7"))
assert.is_false(player:hasButton("helms", "Item 8"))
assert.is_true(player:hasButton("helms", "Back"))
assert.is_true(player:hasButton("helms", "Next"))
assert.is_true(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Next")
assert.is_false(player:hasButton("helms", "Item 7"))
assert.is_true(player:hasButton("helms", "Item 8"))
assert.is_true(player:hasButton("helms", "Item 9"))
assert.is_true(player:hasButton("helms", "Item 10"))
assert.is_true(player:hasButton("helms", "Item 11"))
assert.is_true(player:hasButton("helms", "Back"))
assert.is_false(player:hasButton("helms", "Next"))
assert.is_true(player:hasButton("helms", "Previous"))

Player:withMenu():drawMenu() draws a main menu on one page if it fits

local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
    labelNext = "Next",
    labelPrevious = "Previous",
    itemsPerPage = 8,
})
for i=1,8 do
    player:addMenuItem("helms", mockMenuLabel("Item " .. i, i))
end
assert.is_true(player:hasButton("helms", "Item 1"))
assert.is_true(player:hasButton("helms", "Item 2"))
assert.is_true(player:hasButton("helms", "Item 3"))
assert.is_true(player:hasButton("helms", "Item 4"))
assert.is_true(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Item 7"))
assert.is_true(player:hasButton("helms", "Item 8"))
assert.is_false(player:hasButton("helms", "Next"))
assert.is_false(player:hasButton("helms", "Previous"))

Player:withMenu():drawMenu() draws the last page of the main menu on one page if it fits

local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
    labelNext = "Next",
    labelPrevious = "Previous",
    itemsPerPage = 6,
})
for i=1,14 do
    player:addMenuItem("helms", mockMenuLabel("Item " .. i, i))
end
assert.is_true(player:hasButton("helms", "Item 1"))
assert.is_true(player:hasButton("helms", "Item 2"))
assert.is_true(player:hasButton("helms", "Item 3"))
assert.is_true(player:hasButton("helms", "Item 4"))
assert.is_true(player:hasButton("helms", "Item 5"))
assert.is_false(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Next"))
assert.is_false(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Next")
assert.is_false(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Item 7"))
assert.is_true(player:hasButton("helms", "Item 8"))
assert.is_true(player:hasButton("helms", "Item 9"))
assert.is_false(player:hasButton("helms", "Item 10"))
assert.is_true(player:hasButton("helms", "Next"))
assert.is_true(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Next")
assert.is_false(player:hasButton("helms", "Item 9"))
assert.is_true(player:hasButton("helms", "Item 10"))
assert.is_true(player:hasButton("helms", "Item 11"))
assert.is_true(player:hasButton("helms", "Item 12"))
assert.is_true(player:hasButton("helms", "Item 13"))
assert.is_true(player:hasButton("helms", "Item 14"))
assert.is_false(player:hasButton("helms", "Next"))
assert.is_true(player:hasButton("helms", "Previous"))

Player:withMenu():drawMenu() fails if an invalid position is given

local player = PlayerSpaceship()
Player:withMenu(player)

assert.has_error(function()
    player:drawMenu("invalid")
end)
assert.has_error(function()
    player:drawMenu(nil)
end)
assert.has_error(function()
    player:drawMenu(42)
end)

Player:withMenu():drawMenu() paginates long main menus

local player = PlayerSpaceship()
Player:withMenu(player, {
    backLabel = "Back",
    labelNext = "Next",
    labelPrevious = "Previous",
    itemsPerPage = 8,
})
for i=1,10 do
    player:addMenuItem("helms", mockMenuLabel("Item " .. i, i))
end
assert.is_true(player:hasButton("helms", "Item 1"))
assert.is_true(player:hasButton("helms", "Item 2"))
assert.is_true(player:hasButton("helms", "Item 3"))
assert.is_true(player:hasButton("helms", "Item 4"))
assert.is_true(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Item 7"))
assert.is_false(player:hasButton("helms", "Item 8"))
assert.is_true(player:hasButton("helms", "Next"))
assert.is_false(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Next")
assert.is_false(player:hasButton("helms", "Item 7"))
assert.is_true(player:hasButton("helms", "Item 8"))
assert.is_true(player:hasButton("helms", "Item 9"))
assert.is_true(player:hasButton("helms", "Item 10"))
assert.is_false(player:hasButton("helms", "Next"))
assert.is_true(player:hasButton("helms", "Previous"))

player:clickButton("helms", "Previous")
assert.is_true(player:hasButton("helms", "Item 1"))
assert.is_true(player:hasButton("helms", "Item 2"))
assert.is_true(player:hasButton("helms", "Item 3"))
assert.is_true(player:hasButton("helms", "Item 4"))
assert.is_true(player:hasButton("helms", "Item 5"))
assert.is_true(player:hasButton("helms", "Item 6"))
assert.is_true(player:hasButton("helms", "Item 7"))
assert.is_false(player:hasButton("helms", "Item 8"))

Player:withMenu():removeMenuItem()

Player:withMenu():removeMenuItem() fails if an invalid position is given

local player = PlayerSpaceship()
Player:withMenu(player)

assert.has_error(function()
    player:removeMenuItem("invalid", "id")
end)
assert.has_error(function()
    player:removeMenuItem(nil, "id")
end)
assert.has_error(function()
    player:removeMenuItem(42, "id")
end)

Player:withMissionDisplay()

Player:withMissionDisplay() creates a valid mission display

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withMissionTracker(player)
Player:withMissionDisplay(player, defaultConfig)

assert.is_true(Player:hasMissionDisplay(player))

assert.is_true(player:hasButton("relay", "Missions"))
player:clickButton("relay", "Missions")
assert.is_true(player:hasCustomMessage("relay"))

Player:withMissionDisplay() fails if the first argument is a player without storage

assert.has_error(function() Player:withMissionDisplay(PlayerSpaceship(), defaultConfig) end)

Player:withMissionDisplay() fails if the first argument is already a mission display player

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withMissionTracker(player)
Player:withMissionDisplay(player, defaultConfig)

assert.has_error(function() Player:withMissionDisplay(player, defaultConfig) end)

Player:withMissionDisplay() fails if the first argument is not a player

assert.has_error(function() Player:withMissionDisplay(42, defaultConfig) end)

Player:withMissionTracker()

Player:withMissionTracker() creates a valid mission tracker

local player = PlayerSpaceship()
Player:withMissionTracker(player)

assert.is_true(Player:hasMissionTracker(player))

Player:withMissionTracker() fails if the first argument is already a mission tracker player

local player = PlayerSpaceship()
Player:withMissionTracker(player)

assert.has_error(function() Player:withMissionTracker(player) end)

Player:withMissionTracker() fails if the first argument is not a player

assert.has_error(function() Player:withMissionTracker(42) end)

Player:withMissionTracker():addMission()

Player:withMissionTracker():addMission() adds a mission

local player = PlayerSpaceship()
Player:withMissionTracker(player)

local mission = startedMissionWithBrokerMock()

player:addMission(mission)

assert.is_same(1, Util.size(player:getStartedMissions()))

Player:withMissionTracker():addMission() fails if the first parameter is not a mission

local player = PlayerSpaceship()
Player:withMissionTracker(player)

assert.has_error(function() player:addMission(42) end)

Player:withMissionTracker():getStartedMissions()

Player:withMissionTracker():getStartedMissions() does not return missions that do not have the state started

local player = PlayerSpaceship()
Player:withMissionTracker(player)

player:addMission(missionWithBrokerMock())
player:addMission(acceptedMissionWithBrokerMock())
player:addMission(declinedMissionWithBrokerMock())
player:addMission(failedMissionWithBrokerMock())
player:addMission(successfulMissionWithBrokerMock())

assert.is_same(0, Util.size(player:getStartedMissions()))

Player:withMissionTracker():getStartedMissions() manipulating the result set does not add missions

local player = PlayerSpaceship()
Player:withMissionTracker(player)

player:addMission(startedMissionWithBrokerMock())

local missions = player:getStartedMissions()
table.insert(missions, startedMissionWithBrokerMock())

assert.is_same(1, Util.size(player:getStartedMissions()))

Player:withMissionTracker():getStartedMissions() returns all started missions

local player = PlayerSpaceship()
Player:withMissionTracker(player)

player:addMission(startedMissionWithBrokerMock())
player:addMission(startedMissionWithBrokerMock())
player:addMission(startedMissionWithBrokerMock())

assert.is_same(3, Util.size(player:getStartedMissions()))

Player:withPowerPresets()

Player:withPowerPresets() can add a reset button

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})
Player:withPowerPresets(player, Util.mergeTables(defaultConfig, { labelReset = "Reset" }))
assert.is_true(Player:hasPowerPresets(player))

player:clickButton("engineering", "Presets")
assert.is_true(player:hasButton("engineering", "Reset"))

player:setSystemPower("impulse", 2)
player:setSystemCoolant("impulse", 1)
player:clickButton("engineering", "Reset")
assert.is_same(1, player:getSystemPower("impulse"))
assert.is_same(0, player:getSystemCoolant("impulse"))

Player:withPowerPresets() can add an info button

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})
Player:withPowerPresets(player, Util.mergeTables(defaultConfig, { labelInfo = "Info", infoText = "Hello World" }))
assert.is_true(Player:hasPowerPresets(player))

player:clickButton("engineering", "Presets")
assert.is_true(player:hasButton("engineering", "Info"))
player:clickButton("engineering", "Info")
assert.is_same("Hello World", player:getCustomMessage("engineering"))

Player:withPowerPresets() works with default parameters

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})
Player:withPowerPresets(player, Util.mergeTables(defaultConfig, { slots = 4 }))
assert.is_true(Player:hasPowerPresets(player))

assert.is_true(player:hasButton("engineering", "Presets"))
player:clickButton("engineering", "Presets")
assert.is_true(player:hasButton("engineering", "Load"))
assert.is_true(player:hasButton("engineering", "Store"))
player:clickButton("engineering", "Store")
assert.is_true(player:hasButton("engineering", "Store 1"))
assert.is_true(player:hasButton("engineering", "Store 2"))
assert.is_true(player:hasButton("engineering", "Store 3"))
assert.is_true(player:hasButton("engineering", "Store 4"))
assert.is_false(player:hasButton("engineering", "Store 5"))

player:setSystemPower("impulse", 0.5)
player:setSystemCoolant("impulse", 0.42)
player:clickButton("engineering", "Store 1")
player:clickButton("engineering", "Back")

player:setSystemPower("impulse", 1)
player:setSystemCoolant("impulse", 0)
player:clickButton("engineering", "Presets")
player:clickButton("engineering", "Load")
assert.is_true(player:hasButton("engineering", "Load 1"))
assert.is_true(player:hasButton("engineering", "Load 2"))
assert.is_true(player:hasButton("engineering", "Load 3"))
assert.is_true(player:hasButton("engineering", "Load 4"))
assert.is_false(player:hasButton("engineering", "Load 5"))

player:clickButton("engineering", "Load 1")

assert.is_same(0.5, player:getSystemPower("impulse"))
assert.is_same(0.42, player:getSystemCoolant("impulse"))

Player:withQuickDial()

Player:withQuickDial() ignores invalid stations

local player = PlayerSpaceship()
local station = SpaceStation():setCallSign("Outpost 42")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(station)

station:destroy()

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
assert.is_false(player:hasButton("relay", "Outpost 42"))

Player:withQuickDial() works with default parameters

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
player:clickButton("relay", "Back")

Player:withQuickDial():addQuickDial()

Player:withQuickDial():addQuickDial() allows to add fleets

local player = PlayerSpaceship()
local fleet = Fleet:new({
    CpuShip():setCallSign("Fleet Leader"),
    CpuShip():setCallSign("Wingman")
})
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(fleet)

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
assert.is_true(player:hasButton("relay", "Fleet Leader"))

Player:withQuickDial():addQuickDial() allows to add ships

local player = PlayerSpaceship()
local ship = CpuShip():setCallSign("Nostromo")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(ship)

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
assert.is_true(player:hasButton("relay", "Nostromo"))

Player:withQuickDial():addQuickDial() allows to add stations

local player = PlayerSpaceship()
local station = SpaceStation():setCallSign("Outpost 42")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(station)

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
assert.is_true(player:hasButton("relay", "Outpost 42"))

Player:withQuickDial():addQuickDial() fails when adding an invalid thing

local player = PlayerSpaceship()
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

assert.has_error(function()
    player:addQuickDial()
end)
assert.has_error(function()
    player:addQuickDial(42)
end)
assert.has_error(function()
    player:addQuickDial(Asteroid())
end)

Player:withQuickDial():getQuickDials()

Player:withQuickDial():getQuickDials() does not return invalid entities

local player = PlayerSpaceship()
local station = SpaceStation():setCallSign("Outpost 42")
local fleet = Fleet:new({
    CpuShip():setCallSign("Fleet Leader"),
    CpuShip():setCallSign("Wingman")
})
local ship = CpuShip():setCallSign("Nostromo")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(station)
player:addQuickDial(fleet)
player:addQuickDial(ship)

assert.contains_value(station, player:getQuickDials())
assert.contains_value(fleet, player:getQuickDials())
assert.contains_value(ship, player:getQuickDials())

ship:destroy()

assert.contains_value(station, player:getQuickDials())
assert.contains_value(fleet, player:getQuickDials())
assert.not_contains_value(ship, player:getQuickDials())

station:destroy()

assert.not_contains_value(station, player:getQuickDials())
assert.contains_value(fleet, player:getQuickDials())
assert.not_contains_value(ship, player:getQuickDials())

Player:withQuickDial():getQuickDials() returns a table with all added quick dials

local player = PlayerSpaceship()
local station = SpaceStation():setCallSign("Outpost 42")
local fleet = Fleet:new({
    CpuShip():setCallSign("Fleet Leader"),
    CpuShip():setCallSign("Wingman")
})
local ship = CpuShip():setCallSign("Nostromo")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

assert.not_contains_value(station, player:getQuickDials())
assert.not_contains_value(fleet, player:getQuickDials())
assert.not_contains_value(ship, player:getQuickDials())

player:addQuickDial(station)

assert.contains_value(station, player:getQuickDials())

player:addQuickDial(fleet)
player:addQuickDial(ship)

assert.contains_value(fleet, player:getQuickDials())
assert.contains_value(ship, player:getQuickDials())

player:removeQuickDial(station)

assert.not_contains_value(station, player:getQuickDials())
assert.contains_value(fleet, player:getQuickDials())
assert.contains_value(ship, player:getQuickDials())

Player:withQuickDial():removeQuickDial()

Player:withQuickDial():removeQuickDial() allows to remove a quick dial

local player = PlayerSpaceship()
local station = SpaceStation():setCallSign("Outpost 42")
Player:withMenu(player, {backLabel = "Back"})
Player:withQuickDial(player, defaultConfig)
assert.is_true(Player:hasQuickDial(player))

player:addQuickDial(CpuShip())
player:addQuickDial(CpuShip())
player:addQuickDial(station)
player:addQuickDial(CpuShip())

assert.is_true(player:hasButton("relay", "Quick Dial"))
player:clickButton("relay", "Quick Dial")
assert.is_true(player:hasButton("relay", "Outpost 42"))
player:clickButton("relay", "Back")

player:removeQuickDial(station)
player:clickButton("relay", "Quick Dial")
assert.is_false(player:hasButton("relay", "Outpost 42"))

Player:withStorage()

Player:withStorage() allows to configure the maxStorage

local player = PlayerSpaceship()

Player:withStorage(player, {maxStorage = 1000})

assert.is_true(Player:hasStorage(player))
assert.is_same(1000, player:getMaxStorageSpace())

Player:withStorage() creates a valid storage

local player = PlayerSpaceship()

Player:withStorage(player)
assert.is_true(Player:hasStorage(player))
assert.is_number(player:getMaxStorageSpace())

Player:withStorage() fails if first argument is a number

assert.has_error(function() Player:withStorage(42) end)

Player:withStorage() fails if second argument is a number

assert.has_error(function() Player:withStorage(PlayerSpaceship(), 42) end)

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage()

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage() returns the correct value

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(100, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(100, player:getMaxProductStorage(product2))
assert.is_same(100, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(100, player:getMaxProductStorage(product3))
assert.is_same(100, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product1, 10)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(90, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(90, player:getMaxProductStorage(product2))
assert.is_same(90, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(90, player:getMaxProductStorage(product3))
assert.is_same(90, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product2, 10)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(90, player:getMaxProductStorage(product1))
assert.is_same(80, player:getEmptyProductStorage(product1))
assert.is_same(10, player:getProductStorage(product2))
assert.is_same(90, player:getMaxProductStorage(product2))
assert.is_same(80, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(80, player:getMaxProductStorage(product3))
assert.is_same(80, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product2, -5)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(95, player:getMaxProductStorage(product1))
assert.is_same(85, player:getEmptyProductStorage(product1))
assert.is_same(5, player:getProductStorage(product2))
assert.is_same(90, player:getMaxProductStorage(product2))
assert.is_same(85, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(85, player:getMaxProductStorage(product3))
assert.is_same(85, player:getEmptyProductStorage(product3))

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage() they fail when called without argument

local player = PlayerSpaceship()
Player:withStorage(player)

assert.has_error(function() player:getProductStorage() end)
assert.has_error(function() player:getMaxProductStorage() end)
assert.has_error(function() player:getEmptyProductStorage() end)

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage() works correctly with sized products

local product1 = productMock()
local product2 = productMock()
local product3 = productMock()
product1.getSize = function() return 1 end
product2.getSize = function() return 2 end
product3.getSize = function() return 4 end

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(100, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(50, player:getMaxProductStorage(product2))
assert.is_same(50, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(25, player:getMaxProductStorage(product3))
assert.is_same(25, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product1, 10)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(90, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(45, player:getMaxProductStorage(product2))
assert.is_same(45, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(22, player:getMaxProductStorage(product3))
assert.is_same(22, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product2, 10)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(80, player:getMaxProductStorage(product1))
assert.is_same(70, player:getEmptyProductStorage(product1))
assert.is_same(10, player:getProductStorage(product2))
assert.is_same(45, player:getMaxProductStorage(product2))
assert.is_same(35, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(17, player:getMaxProductStorage(product3))
assert.is_same(17, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product2, -5)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(90, player:getMaxProductStorage(product1))
assert.is_same(80, player:getEmptyProductStorage(product1))
assert.is_same(5, player:getProductStorage(product2))
assert.is_same(45, player:getMaxProductStorage(product2))
assert.is_same(40, player:getEmptyProductStorage(product2))
assert.is_same(0, player:getProductStorage(product3))
assert.is_same(20, player:getMaxProductStorage(product3))
assert.is_same(20, player:getEmptyProductStorage(product3))

player:modifyProductStorage(product3, 5)

assert.is_same(10, player:getProductStorage(product1))
assert.is_same(70, player:getMaxProductStorage(product1))
assert.is_same(60, player:getEmptyProductStorage(product1))
assert.is_same(5, player:getProductStorage(product2))
assert.is_same(35, player:getMaxProductStorage(product2))
assert.is_same(30, player:getEmptyProductStorage(product2))
assert.is_same(5, player:getProductStorage(product3))
assert.is_same(20, player:getMaxProductStorage(product3))
assert.is_same(15, player:getEmptyProductStorage(product3))

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage() works with rockets

local hvli = Product:new("HVLI", {id = "hvli"})
local player = PlayerSpaceship()
Player:withStorage(player)
player:setWeaponStorageMax("hvli", 8)
player:setWeaponStorage("hvli", 6)

assert.is_same(6, player:getProductStorage(hvli))
assert.is_same(8, player:getMaxProductStorage(hvli))
assert.is_same(2, player:getEmptyProductStorage(hvli))

for _, weapon in pairs({"hvli", "homing", "mine", "nuke", "emp"}) do
    local rocket = Product:new(weapon, {id = weapon})
    player:setWeaponStorageMax(weapon, 0)
    player:setWeaponStorage(weapon, 0)

    assert.is_same(0, player:getProductStorage(rocket))
    assert.is_same(0, player:getMaxProductStorage(rocket))
    assert.is_same(0, player:getEmptyProductStorage(rocket))
end

Player:withStorage():getProductStorage(),:getEmptyProductStorage(),:getMaxProductStorage() works with scan probes

local probe = Product:new("Scan Probe", {id = "scanProbe"})
local player = PlayerSpaceship()
Player:withStorage(player)
player:setMaxScanProbeCount(8)
player:setScanProbeCount(6)

assert.is_same(6, player:getProductStorage(probe))
assert.is_same(8, player:getMaxProductStorage(probe))
assert.is_same(2, player:getEmptyProductStorage(probe))

player:setMaxScanProbeCount(0)
player:setScanProbeCount(0)

assert.is_same(0, player:getProductStorage(probe))
assert.is_same(0, player:getMaxProductStorage(probe))
assert.is_same(0, player:getEmptyProductStorage(probe))

Player:withStorage():getStorageSpace(),

Player:withStorage():getStorageSpace(), getEmptyStorageSpace(), getMaxStorageSpace() returns the correct value

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())

player:modifyProductStorage(product1, 10)

assert.is_same(10, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(90, player:getEmptyStorageSpace())

player:modifyProductStorage(product2, 10)

assert.is_same(20, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(80, player:getEmptyStorageSpace())

player:modifyProductStorage(product2, -5)

assert.is_same(15, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(85, player:getEmptyStorageSpace())

Player:withStorage():getStorageSpace(), getEmptyStorageSpace(), getMaxStorageSpace() returns the correct values for sized products

local product1 = productMock()
local product2 = productMock()
local product3 = productMock()
product1.getSize = function() return 1 end
product2.getSize = function() return 2 end
product3.getSize = function() return 4 end

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())

player:modifyProductStorage(product1, 10)

assert.is_same(10, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(90, player:getEmptyStorageSpace())

player:modifyProductStorage(product2, 10)

assert.is_same(30, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(70, player:getEmptyStorageSpace())

player:modifyProductStorage(product2, -5)

assert.is_same(20, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(80, player:getEmptyStorageSpace())

player:modifyProductStorage(product3, 5)

assert.is_same(40, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(60, player:getEmptyStorageSpace())

Player:withStorage():getStoredProducts()

Player:withStorage():getStoredProducts() does not return rockets and probes, because we might not know the correct object to return

local player = PlayerSpaceship()
Player:withStorage(player)

player:setWeaponStorageMax("hvli", 5)
player:setWeaponStorage("hvli", 5)
player:setWeaponStorageMax("homing", 4)
player:setWeaponStorage("homing", 4)
player:setWeaponStorageMax("mine", 3)
player:setWeaponStorage("mine", 3)
player:setWeaponStorageMax("emp", 2)
player:setWeaponStorage("emp", 2)
player:setWeaponStorageMax("nuke", 1)
player:setWeaponStorage("nuke", 1)
player:setMaxScanProbeCount(4)
player:setScanProbeCount(4)

assert.is_same({}, player:getStoredProducts())

Player:withStorage():getStoredProducts() returns all the products that are currently stored

local player = PlayerSpaceship()
Player:withStorage(player)

assert.is_same({}, player:getStoredProducts())

player:modifyProductStorage(product1, 1)
assert.is_same(1, Util.size(player:getStoredProducts()))
assert.contains_value(product1, player:getStoredProducts())

player:modifyProductStorage(product1, 1)
assert.is_same(1, Util.size(player:getStoredProducts()))
assert.contains_value(product1, player:getStoredProducts())

player:modifyProductStorage(product2, 1)
assert.is_same(2, Util.size(player:getStoredProducts()))
assert.contains_value(product2, player:getStoredProducts())

player:modifyProductStorage(product2, -1)
assert.is_same(1, Util.size(player:getStoredProducts()))
assert.contains_value(product1, player:getStoredProducts())
assert.not_contains_value(product2, player:getStoredProducts())

Player:withStorage():modifyProductStorage()

Player:withStorage():modifyProductStorage() allows to handle rockets

local player = PlayerSpaceship()
Player:withStorage(player)
for _, weapon in pairs({"hvli", "homing", "mine", "nuke", "emp"}) do
    local rocket = Product:new(weapon, {id = weapon})
    player:setWeaponStorageMax(weapon, 4)
    player:setWeaponStorage(weapon, 0)

    player:modifyProductStorage(rocket, 2)
    assert.is_same(2, player:getWeaponStorage(weapon))
end

Player:withStorage():modifyProductStorage() allows to handle scan probes

local probe = Product:new("Scan Probe", {id = "scanProbe"})
local player = PlayerSpaceship()
Player:withStorage(player)

player:setMaxScanProbeCount(4)
player:setScanProbeCount(0)

player:modifyProductStorage(probe, 2)
assert.is_same(2, player:getScanProbeCount())

Player:withStorage():modifyProductStorage() fails if no amount is given

local player = PlayerSpaceship()
Player:withStorage(player)

assert.has_error(function() player:modifyProductStorage(product1, nil) end)

Player:withStorage():modifyProductStorage() fails if no product is given

local player = PlayerSpaceship()
Player:withStorage(player)

assert.has_error(function() player:modifyProductStorage(nil, 10) end)

Player:withStorage():modifyProductStorage() it allows to overload the storage so that important mission items are not lost

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())
assert.is_same(0, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(100, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(100, player:getMaxProductStorage(product2))
assert.is_same(100, player:getEmptyProductStorage(product2))

player:modifyProductStorage(product1, 999)

assert.is_same(999, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(0, player:getEmptyStorageSpace())
assert.is_same(999, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(0, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(0, player:getMaxProductStorage(product2))
assert.is_same(0, player:getEmptyProductStorage(product2))

Player:withStorage():modifyProductStorage() it keeps sure the storage level will not be negative

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())
assert.is_same(0, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(100, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(100, player:getMaxProductStorage(product2))
assert.is_same(100, player:getEmptyProductStorage(product2))

player:modifyProductStorage(product1, -10)

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())
assert.is_same(0, player:getProductStorage(product1))
assert.is_same(100, player:getMaxProductStorage(product1))
assert.is_same(100, player:getEmptyProductStorage(product1))
assert.is_same(0, player:getProductStorage(product2))
assert.is_same(100, player:getMaxProductStorage(product2))
assert.is_same(100, player:getEmptyProductStorage(product2))

Player:withStorage():setMaxStorageSpace()

Player:withStorage():setMaxStorageSpace() allows to set the maximum storage space

local player = PlayerSpaceship()
Player:withStorage(player, {maxStorage = 100})

assert.is_same(0, player:getStorageSpace())
assert.is_same(100, player:getMaxStorageSpace())
assert.is_same(100, player:getEmptyStorageSpace())

player:setMaxStorageSpace(120)

assert.is_same(0, player:getStorageSpace())
assert.is_same(120, player:getMaxStorageSpace())
assert.is_same(120, player:getEmptyStorageSpace())

Player:withStorageDisplay()

Player:withStorageDisplay() creates a valid storage display

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withStorage(player)
Player:withStorageDisplay(player, defaultConfig)

assert.is_true(Player:hasStorageDisplay(player))

assert.is_true(player:hasButton("engineering", "Storage"))
player:clickButton("engineering", "Storage")
assert.is_true(player:hasCustomMessage("engineering"))

Player:withStorageDisplay() fails if the first argument is a player without storage

local player = PlayerSpaceship()

assert.has_error(function() Player:withStorageDisplay(player, defaultConfig) end)

Player:withStorageDisplay() fails if the first argument is already a storage display player

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withStorage(player)
Player:withStorageDisplay(player, defaultConfig)

assert.has_error(function() Player:withStorageDisplay(player, defaultConfig) end)

Player:withStorageDisplay() fails if the first argument is not a player

assert.has_error(function() Player:withStorageDisplay(42, defaultConfig) end)

Player:withUpgradeDisplay()

Player:withUpgradeDisplay() creates a valid upgrade display

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withUpgradeTracker(player)
Player:withUpgradeDisplay(player, defaultConfig)

assert.is_true(Player:hasUpgradeDisplay(player))

assert.is_true(player:hasButton("engineering", "Upgrades"))
player:clickButton("engineering", "Upgrades")
assert.is_true(player:hasCustomMessage("engineering"))

Player:withUpgradeDisplay() fails if the first argument is a player without storage

assert.has_error(function() Player:withUpgradeDisplay(PlayerSpaceship(), defaultConfig) end)

Player:withUpgradeDisplay() fails if the first argument is already an upgrade display player

local player = PlayerSpaceship()
Player:withMenu(player)
Player:withUpgradeTracker(player)
Player:withUpgradeDisplay(player, defaultConfig)

assert.has_error(function() Player:withUpgradeDisplay(player, defaultConfig) end)

Player:withUpgradeDisplay() fails if the first argument is not a player

assert.has_error(function() Player:withUpgradeDisplay(42, defaultConfig) end)

Player:withUpgradeTracker()

Player:withUpgradeTracker() creates a valid upgrade tracker

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

assert.is_true(Player:hasUpgradeTracker(player))

Player:withUpgradeTracker() fails if the first argument is already an upgrade tracker player

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

assert.has_error(function() Player:withUpgradeTracker(player) end)

Player:withUpgradeTracker() fails if the first argument is not a player

assert.has_error(function() Player:withUpgradeTracker(42) end)

Player:withUpgradeTracker():addUpgrade()

Player:withUpgradeTracker():addUpgrade() adds a upgrade

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

local upgrade = upgradeMock()

player:addUpgrade(upgrade)

assert.is_same(1, Util.size(player:getUpgrades()))

Player:withUpgradeTracker():addUpgrade() fails if the first parameter is not a upgrade

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

assert.has_error(function() player:addUpgrade(42) end)

Player:withUpgradeTracker():getUpgrades()

Player:withUpgradeTracker():getUpgrades() manipulating the result set does not add upgrades

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())

local upgrades = player:getUpgrades()
table.insert(upgrades, upgradeMock())

assert.is_same(1, Util.size(player:getUpgrades()))

Player:withUpgradeTracker():getUpgrades() returns all upgrades

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())

assert.is_same(3, Util.size(player:getUpgrades()))

Player:withUpgradeTracker():hasUpgrade()

Player:withUpgradeTracker():hasUpgrade() fails if the given argument is a number

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())

assert.has_error(function() player:hasUpgrade(42) end)

Player:withUpgradeTracker():hasUpgrade() returns false if the upgrade is not installed by name

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())

assert.is_false(player:hasUpgrade("fake"))

Player:withUpgradeTracker():hasUpgrade() returns false if the upgrade is not installed by name

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())

local upgrade = upgradeMock()
player:addUpgrade(upgrade)

assert.is_true(player:hasUpgrade(upgrade:getId()))

Player:withUpgradeTracker():hasUpgrade() returns false if the upgrade is not installed by object

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())

assert.is_false(player:hasUpgrade(upgradeMock()))

Player:withUpgradeTracker():hasUpgrade() returns true if the upgrade is installed by object

local player = PlayerSpaceship()
Player:withUpgradeTracker(player)

player:addUpgrade(upgradeMock())
player:addUpgrade(upgradeMock())

local upgrade = upgradeMock()
player:addUpgrade(upgrade)

assert.is_true(player:hasUpgrade(upgrade))

Product

Product:new()

Product:new() allows to set an id

local product = Product:new("Fake", {id = "unobtainium"})
assert.is_same("unobtainium", product:getId())

Product:new() allows to set size

local product = Product:new("Fake", {size = 42})
assert.is_same(42, product:getSize())

Product:new() auto generates an id

local product = Product:new("Fake")

assert.is_string(product:getId())
assert.not_same("", product:getId())

Product:new() fails if first argument is a number

assert.has_error(function() Product:new(42) end)

Product:new() fails if second argument is numeric

assert.has_error(function() Product:new("Fake", 42) end)

Product:new() fails if size is non-numeric

assert.has_error(function() Product:new("Fake", {size = "foo"}) end)

Product:new() returns a valid Product

local product = Product:new("Fake")

assert.is_true(Product:isProduct(product))

assert.is_same("Fake", product:getName())
assert.is_string(product:getId())
assert.not_same("", product:getId())
assert.is_same(1, product:getSize())

Product:new():toId()

Product:new():toId() returns a string if product is given

local product = Product:new("Product", {id = "theId"})

assert.is_same("theId", Product:toId(product))

Product:new():toId() returns string if a string was given

assert.is_same("foobar", Product:toId("foobar"))

Other

Ship

Ship GM interaction GM can carry out orders on behalf of the ship

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
ship:addOrder(Order:flyTo(0, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

-- GM interferes
ship:orderRoaming()
Cron.tick(1)
assert.is_same("Roaming", ship:getOrder())

-- ship passes by accident :)
ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

Ship GM interaction recovers after GM interaction

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

-- GM interferes
ship:orderRoaming()
Cron.tick(1)
assert.is_same("Roaming", ship:getOrder())

-- GM resets
ship:orderIdle()
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship delay delays the execution of the next command

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

local onCompletionCalled = 0
ship:addOrder(Order:flyTo(1000, 0, {
    onCompletion = function() onCompletionCalled = onCompletionCalled + 1 end,
    delayAfter = 10,
}))
ship:addOrder(Order:flyTo(0, 1000))

assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

assert.is_same(0, onCompletionCalled)
ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same(1, onCompletionCalled)
-- but the command should not have changed yet
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})
for _=1,9 do Cron.tick(1) end
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 1000}, {ship:getOrderTargetLocation()})

Ship delay will be respected if addCommand is called during delay

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(0, 0, {
    delayAfter = 10,
}))

for _=1,5 do Cron.tick(1) end
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

ship:addOrder(Order:flyTo(1000, 0))
-- it should not change yet
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

for _=1,4 do Cron.tick(1) end
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship loop allows to loop orders

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0, {
    onCompletion = function(self, ship) ship:addOrder(self) end,
}))
ship:addOrder(Order:flyTo(0, 0, {
    onCompletion = function(self, ship) ship:addOrder(self) end,
}))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(0, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

Ship

Ship:Order:tick()

Ship:Order:tick() executes the next order if Order:tick() returns false

local order = mockOrder()

local called = 0
order.getShipExecutor = function()
    return {
        go = function(_, ship) ship:orderRoaming() end,
        tick = function()
            called = called + 1
            if called > 3 then
                return false, "boom"
            end
        end,
    }
end

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:addOrder(order)
ship:addOrder(Order:flyTo(1000, 0))
assert.is_same("Roaming", ship:getOrder())

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())

Ship:Order:tick() makes the ship idle if Order:tick() returns false and there is no next order

local order = mockOrder()

local called = 0
order.getShipExecutor = function()
    return {
        go = function(_, ship) ship:orderRoaming() end,
        tick = function()
            called = called + 1
            if called > 3 then
                return false, "boom"
            end
        end,
    }
end

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:addOrder(order)
assert.is_same("Roaming", ship:getOrder())

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same("Idle", ship:getOrder())

ship:addOrder(Order:flyTo(1000, 0))
assert.is_same("Fly towards", ship:getOrder())

Ship:Ship:withOrderQueue()

Ship:Ship:withOrderQueue() fails if parameter is not a ship

assert.has_error(function()
    Ship:withOrderQueue()
end)
assert.has_error(function()
    Ship:withOrderQueue(42)
end)
assert.has_error(function()
    Ship:withOrderQueue(SpaceStation())
end)
assert.has_error(function()
    Ship:withOrderQueue(Fleet:new({CpuShip(), CpuShip(), CpuShip()}))
end)

Ship:Ship:withOrderQueue() should create a ship with order queue

local ship = CpuShip()
Ship:withOrderQueue(ship)

assert.is_true(Ship:hasOrderQueue(ship))

Ship:abortCurrentOrder()

Ship:abortCurrentOrder() carries out next order if there is one in the cue

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
ship:addOrder(Order:flyTo(2000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

ship:abortCurrentOrder()
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({2000, 0}, {ship:getOrderTargetLocation()})

Ship:abortCurrentOrder() carries out next order without delay if there is one in the cue

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0, {delayAfter = 10}))
ship:addOrder(Order:flyTo(2000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)

ship:abortCurrentOrder()
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({2000, 0}, {ship:getOrderTargetLocation()})

Ship:abortCurrentOrder() does nothing if there is no current order

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:abortCurrentOrder()

Ship:abortCurrentOrder() ship idles if there is no next order

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil

local order = Order:flyTo(1000, 0, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end
})
ship:addOrder(order)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same(0, onAbortCalled)

ship:abortCurrentOrder()
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("user", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Idle", ship:getOrder())

Ship:addOrder()

Ship:addOrder() carries out an order until the next one is given

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(0, 0))

Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 0}, {ship:getOrderTargetLocation()})

ship:addOrder(Order:flyTo(0, 1000))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 1000}, {ship:getOrderTargetLocation()})

Ship:addOrder() fails if parameter is not an order

local ship = CpuShip()
Ship:withOrderQueue(ship)

assert.has_error(function()
    ship:addOrder()
end)
assert.has_error(function()
    ship:addOrder(42)
end)
assert.has_error(function()
    ship:addOrder(CpuShip())
end)

Ship:addOrder() immediately carries out an order if there are no other order queued

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship:addOrder() queues orders and carries them out consecutively

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
ship:addOrder(Order:flyTo(0, 1000))
ship:addOrder(Order:flyTo(-1000, 0))

assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(1000, 0)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 1000}, {ship:getOrderTargetLocation()})

ship:setPosition(0, 1000)
Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({-1000, 0}, {ship:getOrderTargetLocation()})

Ship:addOrder() waits until the first order is issued

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

assert.is_same("Idle", ship:getOrder())
Cron.tick(1)
assert.is_same("Idle", ship:getOrder())

ship:addOrder(Order:flyTo(1000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship:behaveAsMiner()

Ship:behaveAsMiner() GM can change the mined asteroid

withUniverse(function()

    local station = mockValidStation()
    local miner = mockValidMiner()
    local asteroid1 = Asteroid()
    local asteroid2 = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    asteroid1:setPosition(1000, 0)
    asteroid2:setPosition(99999, 0)

    local whenMinedCalled = 0
    Ship:behaveAsMiner(miner, station, function()
        whenMinedCalled = whenMinedCalled + 1
        return {
            [product] = 42,
        }
    end)

    -- find a close asteroid
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid1, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())

    -- now it is close and should start mining
    miner:setPosition(800, 0)
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid1, miner:getOrderTarget())
    assert.is_same("mining", miner:getMinerState())

    -- it should spend some time mining, but not finish
    for i=1,10 do Cron.tick(1) end
    assert.is_same(0, whenMinedCalled)
    assert.is_same("mining", miner:getMinerState())

    -- now the GM interjects
    miner:orderFlyTowardsBlind(asteroid2:getPosition()) -- GM interface does not allow to issue Attack orders on asteroids
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid2, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())

    -- now it is close and should start mining
    miner:setPosition(asteroid2:getPosition())
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid2, miner:getOrderTarget())
    assert.is_same("mining", miner:getMinerState())

    -- it should spend the whole time mining
    for i=1,14 do Cron.tick(1) end
    assert.is_same(0, whenMinedCalled)
    assert.is_same("mining", miner:getMinerState())

    -- now the mining should have finished
    Cron.tick(1)
    assert.is_same(1, whenMinedCalled)
    assert.is_same(42, miner:getProductStorage(product))
    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
    assert.is_same("home", miner:getMinerState())

end)

Ship:behaveAsMiner() GM can force a miner to go home

withUniverse(function()
    local station = mockValidStation()
    local miner = mockValidMiner()
    local asteroid = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(1000, 0)
    asteroid:setPosition(1000, 0)

    local headingHomeCalled = 0
    Ship:behaveAsMiner(miner, station, function()
        return { [product] = 42, }
    end, {
        onHeadingHome = function()
            headingHomeCalled = headingHomeCalled + 1
        end
    })

    local asteroid2 = Asteroid()
    asteroid2:setPosition(2000, 0)

    for i=1,20 do Cron.tick(1) end
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid2, miner:getOrderTarget())

    miner:orderDock(station)
    Cron.tick(1)
    assert.is_same(1, headingHomeCalled)

    -- ...GM rethinks...
    miner:orderIdle()
    Cron.tick(1)
    assert.is_same("asteroid", miner:getMinerState())

    -- ...but then orders miner home again...
    miner:orderDock(station)
    Cron.tick(1)
    assert.is_same(2, headingHomeCalled)

    miner:setPosition(station:getPosition())
    miner:setDockedAt(station)
    for i=1,20 do Cron.tick(1) end

    assert.is_same(0, miner:getProductStorage(product))
    assert.is_same(42, station:getProductStorage(product))
    -- ...miner goes on as usual after unloading
    assert.is_same("Attack", miner:getOrder())
end)

Ship:behaveAsMiner() GM can issue custom orders and reset them using the Idle order

withUniverse(function()
    local station = mockValidStation()
    local otherStation = SpaceStation()
    local miner = mockValidMiner()
    local asteroid = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    asteroid:setPosition(1000, 0)

    local whenMinedCalled = 0
    Ship:behaveAsMiner(miner, station, function()
        whenMinedCalled = whenMinedCalled + 1
        return {
            [product] = 42,
        }
    end)


    -- find a close asteroid
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())

    -- GM interjects
    miner:orderRoaming()
    for i=1,15 do
        Cron.tick(1)
        assert.is_same("unknown", miner:getMinerState())
    end

    -- GM resets the order
    miner:orderIdle()
    for i=1,15 do
        Cron.tick(1)
        assert.is_same("Attack", miner:getOrder())
        assert.is_same(asteroid, miner:getOrderTarget())
        assert.is_same("asteroid", miner:getMinerState())
    end

    -- GM interjects
    miner:orderDock(otherStation)
    for i=1,15 do
        Cron.tick(1)
        assert.is_same("unknown", miner:getMinerState())
    end

    -- GM resets the order
    miner:orderIdle()
    for i=1,15 do
        Cron.tick(1)
        assert.is_same("Attack", miner:getOrder())
        assert.is_same(asteroid, miner:getOrderTarget())
        assert.is_same("asteroid", miner:getMinerState())
    end
end)

Ship:behaveAsMiner() does basically work:)

withUniverse(function()
    local station = mockValidStation()
    local miner = mockValidMiner()
    local asteroid = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    asteroid:setPosition(1000, 0)

    local whenMinedCalled = 0
    local asteroidMinedCalled = 0
    local asteroidMinedArg1, asteroidMinedArg2, asteroidMinedArg3
    local headingAsteroidCalled = 0
    local headingAsteroidArg1, headingAsteroidArg2
    local unloadedCalled = 0
    local unloadedArg1, unloadedArg2, unloadedArg3
    local headingHomeCalled = 0
    local headingHomeArg1, headingHomeArg2, headingHomeArg3
    Ship:behaveAsMiner(miner, station, function()
        whenMinedCalled = whenMinedCalled + 1
        return {
            [product] = 42,
        }
    end, {
        onHeadingAsteroid = function(arg1, arg2)
            headingAsteroidCalled = headingAsteroidCalled + 1
            headingAsteroidArg1, headingAsteroidArg2 = arg1, arg2
        end,
        onAsteroidMined = function(arg1, arg2, arg3)
            asteroidMinedCalled = asteroidMinedCalled + 1
            asteroidMinedArg1, asteroidMinedArg2, asteroidMinedArg3 = arg1, arg2, arg3
        end,
        onHeadingHome = function(arg1, arg2, arg3)
            headingHomeCalled = headingHomeCalled + 1
            headingHomeArg1, headingHomeArg2, headingHomeArg3 = arg1, arg2, arg3
        end,
        onUnloaded = function(arg1, arg2, arg3)
            unloadedCalled = unloadedCalled + 1
            unloadedArg1, unloadedArg2, unloadedArg3 = arg1, arg2, arg3
        end,
    })

    -- find a close asteroid
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())
    assert.is_same(1, headingAsteroidCalled)
    assert.is_same(miner, headingAsteroidArg1)
    assert.is_same(asteroid, headingAsteroidArg2)

    miner:setPosition(100, 0)
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())

    miner:setPosition(200, 0)
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())

    -- now it is close and should start mining
    miner:setPosition(800, 0)
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())
    assert.is_same("mining", miner:getMinerState())

    -- ...let it mine a little...
    for i=1,14 do Cron.tick(1) end
    assert.is_same(0, whenMinedCalled)
    assert.is_same(0, asteroidMinedCalled)
    assert.is_same(0, headingHomeCalled)
    assert.is_same("mining", miner:getMinerState())

    -- now the mining should have finished
    Cron.tick(1)
    assert.is_same(1, whenMinedCalled)
    assert.is_same(42, miner:getProductStorage(product))
    assert.is_same(1, asteroidMinedCalled)
    assert.is_same(miner, asteroidMinedArg1)
    assert.is_same(asteroid, asteroidMinedArg2)
    assert.is_same("table", type(asteroidMinedArg3))
    assert.is_same(42, asteroidMinedArg3[product])

    assert.is_same(1, headingHomeCalled)
    assert.is_same(miner, headingHomeArg1)
    assert.is_same(station, headingHomeArg2)
    assert.is_same("table", type(headingHomeArg3))
    assert.is_same(42, headingHomeArg3[product])

    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
    assert.is_same("home", miner:getMinerState())

    miner:setPosition(200, 0)
    Cron.tick(1)
    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
    assert.is_same("home", miner:getMinerState())

    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    Cron.tick(1)
    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
    assert.is_same("unloading", miner:getMinerState())

    -- ...let it unload a little...
    for i=1,14 do Cron.tick(1) end
    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
    assert.is_same(42, miner:getProductStorage(product))
    assert.is_same(0, station:getProductStorage(product))
    assert.is_same("unloading", miner:getMinerState())
    assert.is_same(0, unloadedCalled)

    -- ...now delivery should be complete
    Cron.tick(1)
    assert.is_same(0, miner:getProductStorage(product))
    assert.is_same(42, station:getProductStorage(product))
    assert.is_same(1, unloadedCalled)
    assert.is_same(miner, unloadedArg1)
    assert.is_same(station, unloadedArg2)
    assert.is_same("table", type(unloadedArg3))
    assert.is_same(42, unloadedArg3[product])

    -- cycle starts again
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())
end)

Ship:behaveAsMiner() does not mine more asteroids when a maximum time has run out

withUniverse(function()
    local station = mockValidStation()
    local miner = mockValidMiner()
    local asteroid1 = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    asteroid1:setPosition(1000, 0)

    local whenMinedCalled = 0
    Ship:behaveAsMiner(miner, station, function()
        whenMinedCalled = whenMinedCalled + 1
        return {
            [product] = 42,
        }
    end)

    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid1, miner:getOrderTarget())

    for i=1,999 do Cron.tick(1) end

    local asteroid2 = Asteroid()
    asteroid2:setPosition(2000, 0)

    -- it cares out its order first
    miner:setPosition(asteroid1:getPosition())
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid1, miner:getOrderTarget())
    for i=1,15 do Cron.tick(1) end
    assert.is_same("Dock", miner:getOrder())
    assert.is_same(station, miner:getOrderTarget())
end)

Ship:behaveAsMiner() fails if homeStation is not a Station

local miner = mockValidMiner()

assert.has_error(function()
    Ship:behaveAsMiner(miner, nil, function() end)
end, "Expected homeStation to be a Station, but got <nil>")
assert.has_error(function()
    Ship:behaveAsMiner(miner, 42, function() end)
end, "Expected homeStation to be a Station, but got <number>42")
assert.has_error(function()
    Ship:behaveAsMiner(miner, SpaceShip():setCallSign("Test"), function() end)
end, "Expected homeStation to be a Station, but got <SpaceShip>\"Test\"")

Ship:behaveAsMiner() fails if ship does not have storage

local station = mockValidStation()
local miner = CpuShip():setCallSign("Dummy")

assert.has_error(function()
    Ship:behaveAsMiner(miner, station, function() end)
end, "Ship Dummy needs to have storage configured")

Ship:behaveAsMiner() fails if ship is destroyed

local station = mockValidStation()
local miner = mockValidMiner()
miner:destroy()

assert.has_error(function()
    Ship:behaveAsMiner(miner, station, function() end)
end, "Expected ship to be a valid CpuShip, but got a destroyed one")

Ship:behaveAsMiner() fails if ship is not a ship

local station = mockValidStation()
assert.has_error(function()
    Ship:behaveAsMiner(nil, station, function() end)
end, "Expected ship to be a CpuShip, but got <nil>")
assert.has_error(function()
    Ship:behaveAsMiner(42, station, function() end)
end, "Expected ship to be a CpuShip, but got <number>42")
assert.has_error(function()
    Ship:behaveAsMiner(SpaceStation():setCallSign("Test"), station, function() end)
end, "Expected ship to be a CpuShip, but got <SpaceStation>\"Test\"")
assert.has_error(function()
    Ship:behaveAsMiner(SpaceShip():setCallSign("Test"), station, function() end)
end, "Expected ship to be a CpuShip, but got <SpaceShip>\"Test\"")

Ship:behaveAsMiner() fails if station does not have storage

local station = SpaceStation():setCallSign("Home")
local miner = mockValidMiner()

assert.has_error(function()
    Ship:behaveAsMiner(miner, station, function() end)
end, "Station Home needs to have storage configured")

Ship:behaveAsMiner() idles when home base is destroyed

withUniverse(function() withLogCatcher(function(logs)
    local station = mockValidStation():setCallSign("Home")
    local miner = mockValidMiner():setCallSign("Dummy")
    local asteroid = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    asteroid:setPosition(1000, 0)

    Ship:behaveAsMiner(miner, station, function()
        return {
            [product] = 42,
        }
    end)

    Cron.tick(1)
    assert.is_same("asteroid", miner:getMinerState())

    station:destroy()
    Cron.tick(1)
    assert.is_same(1, logs:countWarnings())
    assert.is_same("unknown", miner:getMinerState())
    assert.is_same("Dummy has lost its home base. :(", logs:popLastWarning())
    assert.is_same(nil, logs:popLastWarning()) -- no further errors

    Cron.tick(1)
    assert.is_same(nil, logs:popLastWarning()) -- it does not spam

end) end)

Ship:behaveAsMiner() selects a new asteroid if the current target is destroyed

withUniverse(function(logs)
    local station = mockValidStation():setCallSign("Home")
    local miner = mockValidMiner():setCallSign("Dummy")
    local asteroid1 = Asteroid():setCallSign("one")
    local asteroid2 = Asteroid():setCallSign("two")

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    asteroid1:setPosition(1000, 0)
    asteroid2:setPosition(2000, 0)

    Ship:behaveAsMiner(miner, station, function()
        return {
            [product] = 42,
        }
    end)

    Cron.tick(1)
    assert.is_same("asteroid", miner:getMinerState())
    assert.is_same("Attack", miner:getOrder())

    local target = miner:getOrderTarget()

    target:destroy()
    Cron.tick(1)
    assert.is_same("asteroid", miner:getMinerState())
    assert.is_same("Attack", miner:getOrder())
    assert.not_is_same(target, miner:getOrderTarget())
end)

Ship:behaveAsMiner() selects a new asteroid if the current target is destroyed while mining

withUniverse(function(logs)
    local station = mockValidStation():setCallSign("Home")
    local miner = mockValidMiner():setCallSign("Dummy")
    local asteroid1 = Asteroid():setCallSign("one")
    local asteroid2 = Asteroid():setCallSign("two")

    station:setPosition(100, 0)
    miner:setPosition(0, 100)
    miner:setDockedAt(station)
    asteroid1:setPosition(0, 0)
    asteroid2:setPosition(0, 500)

    Ship:behaveAsMiner(miner, station, function()
        return {
            [product] = 42,
        }
    end)

    Cron.tick(1)
    assert.is_same("mining", miner:getMinerState())
    assert.is_same("Attack", miner:getOrder())

    local target = miner:getOrderTarget()

    target:destroy()
    Cron.tick(1)
    assert.is_same("asteroid", miner:getMinerState())
    assert.is_same("Attack", miner:getOrder())
    assert.not_is_same(target, miner:getOrderTarget())
end)

Ship:behaveAsMiner() warns when there are no mineable asteroids around the station

withUniverse(function() withLogCatcher(function(logs)
    local station = mockValidStation():setCallSign("Home")
    local miner = mockValidMiner():setCallSign("Dummy")
    local asteroid = Asteroid()

    station:setPosition(0, 0)
    miner:setPosition(0, 0)
    miner:setDockedAt(station)
    asteroid:setPosition(99999, 0)

    local whenMinedCalled = 0
    Ship:behaveAsMiner(miner, station, function()
        whenMinedCalled = whenMinedCalled + 1
        return {
            [product] = 42,
        }
    end)

    assert.is_same(1, logs:countWarnings())
    assert.is_same("unknown", miner:getMinerState())
    assert.is_same("Dummy did not find any mineable asteroids around Home", logs:popLastWarning())
    assert.is_same(nil, logs:popLastWarning()) -- no further errors

    Cron.tick(1)
    assert.is_same(nil, logs:popLastWarning()) -- it does not spam

    -- but it starts as soon as an asteroid is close enough
    asteroid:setPosition(1000, 0)
    Cron.tick(1)
    assert.is_same("Attack", miner:getOrder())
    assert.is_same(asteroid, miner:getOrderTarget())
    assert.is_same("asteroid", miner:getMinerState())
end) end)

Ship:flushOrders()

Ship:flushOrders() does nothing if there are no further orders

local ship = CpuShip()
Ship:withOrderQueue(ship)

ship:abortCurrentOrder()

Ship:flushOrders() ship does not carry out any new orders after the one is completed

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

ship:addOrder(Order:flyTo(1000, 0))
ship:addOrder(Order:flyTo(2000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)
ship:flushOrders()
ship:setPosition(1000, 0)

Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship:forceOrderNow()

Ship:forceOrderNow() excutes the order if none was executed before

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)

Cron.tick(1)

ship:forceOrderNow(Order:flyTo(1000, 0))
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Cron.tick(1)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

Ship:forceOrderNow() executes a ship order immediately

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)
local onAbortCalled, abortArg1, abortArg2, abortArg3 = 0, nil, nil, nil

local order = Order:flyTo(1000, 0, {
    onAbort = function(arg1, arg2, arg3)
        onAbortCalled = onAbortCalled + 1
        abortArg1 = arg1
        abortArg2 = arg2
        abortArg3 = arg3
    end
})
ship:addOrder(order)
ship:addOrder(Order:flyTo(2000, 0))
ship:addOrder(Order:flyTo(3000, 0))
ship:addOrder(Order:flyTo(4000, 0))

assert.is_same("Fly towards", ship:getOrder())
assert.is_same(0, onAbortCalled)

ship:forceOrderNow(Order:flyTo(0, 1000))
assert.is_same(1, onAbortCalled)
assert.is_same(order, abortArg1)
assert.is_same("user", abortArg2)
assert.is_same(ship, abortArg3)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({0, 1000}, {ship:getOrderTargetLocation()})

Ship:forceOrderNow() fails if parameter is not an order

local ship = CpuShip()
Ship:withOrderQueue(ship)
ship:setPosition(0, 0)
ship:addOrder(Order:flyTo(1000, 0))
ship:addOrder(Order:flyTo(2000, 0))
ship:addOrder(Order:flyTo(3000, 0))
ship:addOrder(Order:flyTo(4000, 0))

assert.has_error(function()
    ship:forceOrderNow()
end)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({1000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(1000, 0)
Cron.tick(1)

assert.has_error(function()
    ship:forceOrderNow(42)
end)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({2000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(2000, 0)
Cron.tick(1)

assert.has_error(function()
    ship:forceOrderNow(CpuShip())
end)
assert.is_same("Fly towards", ship:getOrder())
assert.is_same({3000, 0}, {ship:getOrderTargetLocation()})

ship:setPosition(3000, 0)
Cron.tick(1)

assert.is_same("Fly towards", ship:getOrder())
assert.is_same({4000, 0}, {ship:getOrderTargetLocation()})

Ship:withCaptain(),:getCaptain()

Ship:withCaptain(),:getCaptain() sets the captain

local person = personMock()
local ship = CpuShip()

Ship:withCaptain(ship, person)
assert.is_true(ship:hasCrewAtPosition("captain"))
assert.is_true(ship:hasCaptain())
assert.is_same(person, ship:getCrewAtPosition("captain"))
assert.is_same(person, ship:getCaptain())

Ship:withCrew()

Ship:withCrew() can be called multiple times to add different persons

local person1 = personMock()
local person2 = personMock()
local ship = CpuShip()

Ship:withCrew(ship, {captain = person1})
Ship:withCrew(ship, {science = person2})

assert.is_same(person1, ship:getCrewAtPosition("captain"))
assert.is_same(person2, ship:getCrewAtPosition("science"))

Ship:withCrew() can be called multiple times to override a position

local person1 = personMock()
local person2 = personMock()
local ship = CpuShip()

Ship:withCrew(ship, {captain = person1})
Ship:withCrew(ship, {captain = person2})

assert.is_same(person2, ship:getCrewAtPosition("captain"))

Ship:withCrew() should create a crew

local ship = CpuShip()

Ship:withCrew(ship)

assert.is_true(Ship:hasCrew(ship))

Ship:withCrew() should create a crew with a certain position

local person = personMock()
local ship = CpuShip()

Ship:withCrew(ship, {captain = person})

assert.is_true(ship:hasCrewAtPosition("captain"))
assert.is_same(person, ship:getCrewAtPosition("captain"))
assert.is_false(ship:hasCrewAtPosition("science"))

Ship:withCrew() should fail when the position is not a person

local person = {}
local ship = CpuShip()

assert.has_error(function () Ship:withCrew(ship, {captain = person}) end)

Ship:withCrew() should fail when the position is not a string

local person = personMock()
local ship = CpuShip()

assert.has_error(function () Ship:withCrew(ship, {person}) end)

Ship:withEngineeringOfficer()

Ship:withEngineeringOfficer() sets the engineering officer

local person = personMock()
local ship = CpuShip()

Ship:withEngineeringOfficer(ship, person)
assert.is_true(ship:hasCrewAtPosition("engineering"))
assert.is_true(ship:hasEngineeringOfficer())
assert.is_same(person, ship:getCrewAtPosition("engineering"))
assert.is_same(person, ship:getEngineeringOfficer())

Ship:withEvents()

Ship:withEvents() config.onDockInitiation does not fail if the callback errors

local station = SpaceStation()
local ship = CpuShip()
Ship:withEvents(ship, {
    onDockInitiation = function()
        error("Boom")
    end,
})
station:setPosition(0, 0)
ship:setPosition(2000, 0)
ship:orderDock(station)

assert.not_has_error(function()
    Cron.tick(1)
end)

Ship:withEvents() config.onDockInitiation fails if onDockInitiation is not a callback

local ship = CpuShip()

assert.has_error(function()
    Ship:withEvents(ship, { onDockInitiation = 42})
end)

Ship:withEvents() config.onDockInitiation is called when ship can not decide between two stations

local called = 0
local station1 = SpaceStation()
local station2 = SpaceStation()
local ship = CpuShip()
local calledArg1, calledArg2
Ship:withEvents(ship, {
    onDockInitiation = function(arg1, arg2)
        called = called + 1
        calledArg1 = arg1
        calledArg2 = arg2
    end,
})
station1:setPosition(0, 0)
ship:setPosition(2000, 0)
station2:setPosition(4000, 0)

ship:orderDock(station1)
Cron.tick(1)
assert.is_same(1, called)
assert.is_same(ship, calledArg1)
assert.is_same(station1, calledArg2)

ship:orderDock(station2)
Cron.tick(1)
assert.is_same(2, called)
assert.is_same(ship, calledArg1)
assert.is_same(station2, calledArg2)

ship:orderDock(station1)
Cron.tick(1)
assert.is_same(3, called)
assert.is_same(ship, calledArg1)
assert.is_same(station1, calledArg2)

Ship:withEvents() config.onDockInitiation is called when the ship approaches a station with the intention of docking

local called = 0
local station = SpaceStation()
local ship = CpuShip()
local calledArg1, calledArg2
Ship:withEvents(ship, {
    onDockInitiation = function(arg1, arg2)
        called = called + 1
        calledArg1 = arg1
        calledArg2 = arg2
    end,
})
station:setPosition(0, 0)
ship:setPosition(10000, 0)
ship:orderDock(station)

Cron.tick(1)
assert.is_same(0, called)

ship:setPosition(2000, 0)
Cron.tick(1)
assert.is_same(1, called)
assert.is_same(ship, calledArg1)
assert.is_same(station, calledArg2)

-- it is not called multiple times
Cron.tick(1)
assert.is_same(1, called)

-- it resets after a ship was docked at a station
ship:setDockedAt(station)
Cron.tick(1)
assert.is_same(1, called)

ship:orderIdle()
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(1, called)

ship:orderDock(station)
Cron.tick(1)
assert.is_same(2, called)

Ship:withEvents() config.onDockInitiation resets when ship changes orders

local called = 0
local station = SpaceStation()
local ship = CpuShip()
local calledArg1, calledArg2
Ship:withEvents(ship, {
    onDockInitiation = function(arg1, arg2)
        called = called + 1
        calledArg1 = arg1
        calledArg2 = arg2
    end,
})
station:setPosition(0, 0)
ship:setPosition(10000, 0)
ship:orderDock(station)

Cron.tick(1)
assert.is_same(0, called)

ship:setPosition(2000, 0)
Cron.tick(1)
assert.is_same(1, called)

ship:orderIdle()
Cron.tick(1)
assert.is_same(1, called)

ship:orderDock(station)
Cron.tick(1)
assert.is_same(2, called)

Ship:withEvents() config.onDocking does not fail if the callback errors

local station = SpaceStation()
local ship = CpuShip()
Ship:withEvents(ship, {
    onDocking = function()
        error("Boom")
    end,
})

ship:orderDock(station)
ship:setDockedAt(station)

assert.not_has_error(function()
    Cron.tick(1)
end)

Ship:withEvents() config.onDocking fails if onDocking is not a callback

local ship = CpuShip()

assert.has_error(function()
    Ship:withEvents(ship, { onDocking = 42})
end)

Ship:withEvents() config.onDocking is called when ship docks multiple stations

local called = 0
local station1 = SpaceStation()
local station2 = SpaceStation()
local ship = CpuShip()
local calledArg1, calledArg2
Ship:withEvents(ship, {
    onDocking = function(arg1, arg2)
        called = called + 1
        calledArg1, calledArg2 = arg1, arg2
    end,
})

Cron.tick(1)
assert.is_same(0, called)

ship:orderDock(station1)
Cron.tick(1)
assert.is_same(0, called)

ship:setDockedAt(station1)
Cron.tick(1)
assert.is_same(1, called)
assert.is_same(calledArg1, ship)
assert.is_same(calledArg2, station1)

ship:orderDock(station2)
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(1, called)

ship:setDockedAt(station2)
Cron.tick(1)
assert.is_same(2, called)
assert.is_same(calledArg1, ship)
assert.is_same(calledArg2, station2)

ship:orderDock(station1)
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(2, called)

ship:setDockedAt(station1)
Cron.tick(1)
assert.is_same(3, called)

Ship:withEvents() config.onDocking is called when the ship docks a station

local called = 0
local station = SpaceStation()
local ship = CpuShip()
Ship:withEvents(ship, {
    onDocking = function()
        called = called + 1
    end,
})

Cron.tick(1)
assert.is_same(0, called)

ship:orderDock(station)
Cron.tick(1)
assert.is_same(0, called)

ship:setDockedAt(station)
Cron.tick(1)
assert.is_same(1, called)

-- it is only called once
Cron.tick(1)
assert.is_same(1, called)

ship:orderIdle()
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(1, called)

ship:orderDock(station)
Cron.tick(1)
assert.is_same(1, called)

-- it triggers again when undocked in between
ship:setDockedAt(station)
Cron.tick(1)
assert.is_same(2, called)

Ship:withEvents() config.onUndocking does not fail if the callback errors

local station = SpaceStation()
local ship = CpuShip()
Ship:withEvents(ship, {
    onUndocking = function()
        error("Boom")
    end,
})

ship:orderDock(station)
ship:setDockedAt(station)
Cron.tick(1)
ship:orderIdle()
ship:setDockedAt(nil)

assert.not_has_error(function()
    Cron.tick(1)
end)

Ship:withEvents() config.onUndocking fails if onUndocking is not a callback

local ship = CpuShip()

assert.has_error(function()
    Ship:withEvents(ship, { onUndocking = 42})
end)

Ship:withEvents() config.onUndocking is called when ship undocks multiple stations

local called = 0
local station1 = SpaceStation()
local station2 = SpaceStation()
local ship = CpuShip()
local calledArg1, calledArg2
Ship:withEvents(ship, {
    onUndocking = function(arg1, arg2)
        called = called + 1
        calledArg1, calledArg2 = arg1, arg2
    end,
})

Cron.tick(1)
assert.is_same(0, called)

ship:orderDock(station1)
Cron.tick(1)
assert.is_same(0, called)

ship:setDockedAt(station1)
Cron.tick(1)
assert.is_same(0, called)

ship:orderDock(station2)
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(1, called)
assert.is_same(calledArg1, ship)
assert.is_same(calledArg2, station1)

ship:setDockedAt(station2)
Cron.tick(1)
assert.is_same(1, called)

ship:orderDock(station1)
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(2, called)
assert.is_same(calledArg1, ship)
assert.is_same(calledArg2, station2)

ship:setDockedAt(station1)
Cron.tick(1)
assert.is_same(2, called)

ship:orderDock(station2)
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(3, called)
assert.is_same(calledArg1, ship)
assert.is_same(calledArg2, station1)

Ship:withEvents() config.onUndocking is called when the ship undocks a station

local called = 0
local station = SpaceStation()
local ship = CpuShip()
Ship:withEvents(ship, {
    onUndocking = function()
        called = called + 1
    end,
})

Cron.tick(1)
assert.is_same(0, called)

ship:orderDock(station)
ship:setDockedAt(station)
Cron.tick(1)
assert.is_same(0, called)

ship:orderIdle()
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(1, called)

-- it is only called once
Cron.tick(1)
assert.is_same(1, called)

ship:orderDock(station)
ship:setDockedAt(station)
Cron.tick(1)
assert.is_same(1, called)

-- it triggers again
ship:orderIdle()
ship:setDockedAt(nil)
Cron.tick(1)
assert.is_same(2, called)

Ship:withEvents() fails if a number is given instead of ship

assert.has_error(function()
    Ship:withEvents(42)
end)

Ship:withEvents() includes events from ShipTemplateBased

-- just test onDestruction
local called = 0
local ship = CpuShip()
Ship:withEvents(ship, {
    onDestruction = function()
        called = called + 1
    end,
})

Cron.tick(1)
assert.is_same(0, called)

ship:destroy()
Cron.tick(1)
assert.is_same(1, called)

Ship:withFleet()

Ship:withFleet() fails if ship already has a fleet

local ship = CpuShip()
local fleet = fleetMock({ship})
Ship:withFleet(ship, fleet)

assert.has_error(function() Ship:withFleet(ship, fleet) end)

Ship:withFleet() fails when no fleet is given

local ship = CpuShip()

assert.has_error(function() Ship:withFleet(ship, 42) end)

Ship:withFleet() fails when no ship is given

local ship = CpuShip()
local fleet = fleetMock({ship})

assert.has_error(function() Ship:withFleet(42, fleet) end)

Ship:withFleet() should create a ship with fleet

local ship = CpuShip()
local fleet = fleetMock({ship})
Ship:withFleet(ship, fleet)

assert.is_true(Ship:hasFleet(ship))

Ship:withFleet():getFleet()

Ship:withFleet():getFleet() returns the fleet

local ship = CpuShip()
local fleet = fleetMock({ship})
Ship:withFleet(ship, fleet)

assert.is_same(fleet, ship:getFleet())

Ship:withFleet():getFleetLeader()

Ship:withFleet():getFleetLeader() returns the leader of the fleet

local ship = CpuShip()
local fleet = fleetMock({ship})
fleet.getLeader = function(self) return ship end

Ship:withFleet(ship, fleet)

assert.is_same(ship, ship:getFleetLeader())

Ship:withFleet():isFleetLeader()

Ship:withFleet():isFleetLeader() returns true if ship is the fleet leader

local ship1 = CpuShip()
local ship2 = CpuShip()
local fleet = fleetMock({ship1, ship2})
fleet.getLeader = function(self) return ship1 end

Ship:withFleet(ship1, fleet)
Ship:withFleet(ship2, fleet)

assert.is_true(ship1:isFleetLeader())
assert.is_false(ship2:isFleetLeader())

Ship:withHelmsOfficer()

Ship:withHelmsOfficer() sets the helms officer

local person = personMock()
local ship = CpuShip()

Ship:withHelmsOfficer(ship, person)
assert.is_true(ship:hasCrewAtPosition("helms"))
assert.is_true(ship:hasHelmsOfficer())
assert.is_same(person, ship:getCrewAtPosition("helms"))
assert.is_same(person, ship:getHelmsOfficer())

Ship:withRelayOfficer()

Ship:withRelayOfficer() sets the relay officer

local person = personMock()
local ship = CpuShip()

Ship:withRelayOfficer(ship, person)
assert.is_true(ship:hasCrewAtPosition("relay"))
assert.is_true(ship:hasRelayOfficer())
assert.is_same(person, ship:getCrewAtPosition("relay"))
assert.is_same(person, ship:getRelayOfficer())

Ship:withScienceOfficer()

Ship:withScienceOfficer() sets the science officer

local person = personMock()
local ship = CpuShip()

Ship:withScienceOfficer(ship, person)
assert.is_true(ship:hasCrewAtPosition("science"))
assert.is_true(ship:hasScienceOfficer())
assert.is_same(person, ship:getCrewAtPosition("science"))
assert.is_same(person, ship:getScienceOfficer())

Ship:withWeaponsOfficer()

Ship:withWeaponsOfficer() sets the weapons officer

local person = personMock()
local ship = CpuShip()

Ship:withWeaponsOfficer(ship, person)
assert.is_true(ship:hasCrewAtPosition("weapons"))
assert.is_true(ship:hasWeaponsOfficer())
assert.is_same(person, ship:getCrewAtPosition("weapons"))
assert.is_same(person, ship:getWeaponsOfficer())

ShipTemplateBased

ShipTemplateBased:withComms()

ShipTemplateBased:withComms() can set a hail text

local hail = "Hello World"
local station = SpaceStation()

ShipTemplateBased:withComms(station, {hailText = hail})
assert.is_same(hail, station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withComms() can set comms

local station = SpaceStation()

ShipTemplateBased:withComms(station, {comms = { commsScreenReplyMock(), commsScreenReplyMock(), commsScreenReplyMock()}})
assert.is_same(3, Util.size(station:getComms(player):getHowPlayerCanReact()))

ShipTemplateBased:withComms() causes hasComms() to be true

local station = SpaceStation()

ShipTemplateBased:withComms(station)
assert.is_true(ShipTemplateBased:hasComms(station))

ShipTemplateBased:withComms() fails if comms is a number

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withComms(station, {comms = 42}) end)

ShipTemplateBased:withComms() fails if first argument is already a SpaceObject with comms

local station = SpaceStation()

ShipTemplateBased:withComms(station)
assert.has_error(function() ShipTemplateBased:withComms(station) end)

ShipTemplateBased:withComms() fails if first parameter is a number

assert.has_error(function() ShipTemplateBased:withComms(4) end)

ShipTemplateBased:withComms() fails if one of the comms is not a comms

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withComms(station, {comms = { commsScreenReplyMock(), commsScreenReplyMock(), 42}}) end)

ShipTemplateBased:withComms() fails if second argument is not a table

local station = SpaceStation()
assert.has_error(function() ShipTemplateBased:withComms(station, 42) end)

ShipTemplateBased:withComms() fails if the hailText is a number

local station = SpaceStation()
assert.has_error(function() ShipTemplateBased:withComms(station, {hailText = 42})end)

ShipTemplateBased:withComms() sets a comms script

local station = SpaceStation()
local called = false
station.setCommsScript = function() called = true end

ShipTemplateBased:withComms(station)
assert.is_true(called)

ShipTemplateBased:withComms():addComms()

ShipTemplateBased:withComms():addComms() allows to be called with a reply

local station = SpaceStation()
ShipTemplateBased:withComms(station)
station:addComms(commsScreenReplyMock())

assert.is_same(1, Util.size(station:getComms(player):getHowPlayerCanReact()))

ShipTemplateBased:withComms():addComms() fails if a number is given as id

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:addComms(commsScreenReplyMock(), 42) end)

ShipTemplateBased:withComms():addComms() fails if no reply is given

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:addComms() end)

ShipTemplateBased:withComms():addComms() fails if reply is a number

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:addComms(42) end)

ShipTemplateBased:withComms():addComms() generates an id if none is set

local station = SpaceStation()
ShipTemplateBased:withComms(station)
local id = station:addComms(commsScreenReplyMock())

assert.not_nil(id)
assert.is_true(isString(id))
assert.not_same("", id)

ShipTemplateBased:withComms():addComms() uses a given id

local id = "foobar"
local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.is_same(id, station:addComms(commsScreenReplyMock(), id))

ShipTemplateBased:withComms():getComms()

ShipTemplateBased:withComms():getComms() does not allow to manipulate internal state

local station = SpaceStation()
ShipTemplateBased:withComms(station, {comms = { commsScreenReplyMock() }})
local comms = station:getComms(player)

table.insert(comms:getHowPlayerCanReact(), commsScreenReplyMock())

assert.is_same(1, Util.size(station:getComms(player):getHowPlayerCanReact()))

ShipTemplateBased:withComms():getComms() fails if it is called with a number

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:getComms(42) end)

ShipTemplateBased:withComms():getComms() fails if it is called without argument

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:getComms() end)

ShipTemplateBased:withComms():getComms() returns all replies from the constructor and addComms()

local reply1 = commsScreenReplyMock()
local reply2 = commsScreenReplyMock()
local reply3 = commsScreenReplyMock()

local station = SpaceStation()
ShipTemplateBased:withComms(station, {comms = { reply1 }})
station:addComms(reply2)
station:addComms(reply3)

local comms = station:getComms(player)
assert.is_true(Comms:isScreen(comms))
assert.contains_value(reply1, comms:getHowPlayerCanReact())
assert.contains_value(reply2, comms:getHowPlayerCanReact())
assert.contains_value(reply3, comms:getHowPlayerCanReact())

ShipTemplateBased:withComms():overrideComms()

ShipTemplateBased:withComms():overrideComms() allows to override comms once

local station = SpaceStation()

ShipTemplateBased:withComms(station)
local screen = commsScreenMock()

station:overrideComms(screen, true)
assert.is_same(screen, station:getComms(player))
assert.not_same(screen, station:getComms(player))

ShipTemplateBased:withComms():overrideComms() allows to permanently override comms

local station = SpaceStation()

ShipTemplateBased:withComms(station)
local screen = commsScreenMock()

station:overrideComms(screen)
assert.is_same(screen:getWhatNpcSays(station, player), station:getComms(player):getWhatNpcSays(station, player))
assert.is_same(screen:getHowPlayerCanReact(), station:getComms(player):getHowPlayerCanReact())

ShipTemplateBased:withComms():overrideComms() allows to remove override

local station = SpaceStation()

ShipTemplateBased:withComms(station)
local screen = commsScreenMock()

station:overrideComms(screen)
assert.is_same(screen, station:getComms(player))
station:overrideComms(nil)
assert.not_same(screen, station:getComms(player))

ShipTemplateBased:withComms():overrideComms() fails if first argument is not a screen

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:overrideComms(42) end)

ShipTemplateBased:withComms():overrideComms() fails if second argument is not boolean

local station = SpaceStation()
ShipTemplateBased:withComms(station)
local screen = commsScreenMock()

assert.has_error(function() station:overrideComms(screen, 42) end)

ShipTemplateBased:withComms():removeComms()

ShipTemplateBased:withComms():removeComms() allows to remove a comms that has been added before

local station = SpaceStation()
local reply = commsScreenReplyMock()
ShipTemplateBased:withComms(station)
local id = station:addComms(reply)
station:addComms(commsScreenReplyMock())
station:addComms(commsScreenReplyMock())

station:removeComms(id)
assert.not_contains_value(reply, station:getComms(player):getHowPlayerCanReact())

ShipTemplateBased:withComms():removeComms() fails if a number is given instead of an id

local station = SpaceStation()
ShipTemplateBased:withComms(station)

assert.has_error(function() station:removeComms(42) end)

ShipTemplateBased:withComms():removeComms() fails silently if an invalid id is given

local station = SpaceStation()
ShipTemplateBased:withComms(station)

station:removeComms("does not exist")
assert.is_same({}, station:getComms(player):getHowPlayerCanReact())

ShipTemplateBased:withComms():setHailText()

ShipTemplateBased:withComms():setHailText() calls a set function and returns a string

local hail = "Hello World"
station:setHailText(function(callStation, callPlayer)
    assert.is_same(station, callStation)
    assert.is_same(player, callPlayer)
    return hail
end)
assert.is_same(hail, station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withComms():setHailText() calls a set function and returns nil

station:setHailText(function() end)
assert.is_same("", station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withComms():setHailText() calls a set function and returns nil if the return value is a number

station:setHailText(function() return 42 end)
assert.is_same("", station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withComms():setHailText() return nil if nil was set

station:setHailText(nil)
assert.is_same("", station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withComms():setHailText() returns a set string

local hail = "Hello World"
station:setHailText(hail)
assert.is_same(hail, station:getComms(player):getWhatNpcSays(station, player))

ShipTemplateBased:withCrew()

ShipTemplateBased:withCrew() can be called multiple times to add different persons

local person1 = personMock()
local person2 = personMock()
local station = SpaceStation()

ShipTemplateBased:withCrew(station, {commander = person1})
ShipTemplateBased:withCrew(station, {relay = person2})

assert.is_same(person1, station:getCrewAtPosition("commander"))
assert.is_same(person2, station:getCrewAtPosition("relay"))

ShipTemplateBased:withCrew() can be called multiple times to override a position

local person1 = personMock()
local person2 = personMock()
local station = SpaceStation()

ShipTemplateBased:withCrew(station, {commander = person1})
ShipTemplateBased:withCrew(station, {commander = person2})

assert.is_same(person2, station:getCrewAtPosition("commander"))

ShipTemplateBased:withCrew() should create a crew

local station = SpaceStation()

ShipTemplateBased:withCrew(station)

assert.is_true(ShipTemplateBased:hasCrew(station))

ShipTemplateBased:withCrew() should fail when the position is not a person

local station = SpaceStation()

assert.has_error(function () ShipTemplateBased:withCrew(station, {captain = {}}) end)

ShipTemplateBased:withCrew() should fail when the position is not a string

local person = personMock()
local station = SpaceStation()

assert.has_error(function () ShipTemplateBased:withCrew(station, {person}) end)

ShipTemplateBased:withEvents()

ShipTemplateBased:withEvents() config.onBeingAttacked does not fail if the callback errors

local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function()
        error("Boom")
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(2000, 0)

Cron.tick(1)
station:setShields(90)

assert.not_has_error(function()
    Cron.tick(1)
end)

ShipTemplateBased:withEvents() config.onBeingAttacked fails if onBeingAttacked is not a callback

local station = SpaceStation()

assert.has_error(function()
    ShipTemplateBased:withEvents(station, { onBeingAttacked = 42})
end)

ShipTemplateBased:withEvents() config.onBeingAttacked is called after it has not received damage for 2 minutes

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function()
        called = called + 1
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(2000, 0)

called = 0
Cron.tick(1)
assert.is_same(0, called)

station:setShields(90)
Cron.tick(1)
assert.is_same(1, called)

for i=1,120 do Cron.tick(1) end

station:setShields(80)
Cron.tick(1)
assert.is_same(2, called)

ShipTemplateBased:withEvents() config.onBeingAttacked is called when the shipTemplateBased looses hull and enemy is close

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function()
        called = called + 1
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(2000, 0)

called = 0
Cron.tick(1)
assert.is_same(0, called)

station:setHull(90)
Cron.tick(1)
assert.is_same(1, called)

station:setHull(80)
Cron.tick(1)
assert.is_same(1, called)

ShipTemplateBased:withEvents() config.onBeingAttacked is called when the shipTemplateBased looses shield and enemy is close

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function()
        called = called + 1
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(2000, 0)

called = 0
Cron.tick(1)
assert.is_same(0, called)

station:setShields(90)
Cron.tick(1)
assert.is_same(1, called)

station:setShields(80)
Cron.tick(1)
assert.is_same(1, called)

ShipTemplateBased:withEvents() config.onBeingAttacked is called with the shipTemplateBased

local station = SpaceStation()
local enemy = CpuShip()
local calledArg
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function(arg1)
        calledArg = arg1
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(2000, 0)

Cron.tick(1)
station:setShields(90)
Cron.tick(1)

assert.is_same(station, calledArg)

ShipTemplateBased:withEvents() config.onBeingAttacked is not called when hull or shield are damaged, but there is no enemy close

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onBeingAttacked = function()
        called = called + 1
    end,
})
station:setHullMax(100)
station:setHull(100)
station:setShieldsMax(100)
station:setShields(100)
station:setPosition(0, 0)
enemy:setPosition(99999, 0)

called = 0
Cron.tick(1)
assert.is_same(0, called)

station:setHull(90)
Cron.tick(1)
assert.is_same(0, called)

station:setShields(90)
Cron.tick(1)
assert.is_same(0, called)

ShipTemplateBased:withEvents() config.onDestruction does not fail if the callback errors

local station = SpaceStation()
ShipTemplateBased:withEvents(station, {
    onDestruction = function()
        error("Boom")
    end,
})

station:destroy()
assert.not_has_error(function()
    Cron.tick(1)
end)

ShipTemplateBased:withEvents() config.onDestruction fails if onDestruction is not a callback

local station = SpaceStation()

assert.has_error(function()
    ShipTemplateBased:withEvents(station, { onDestruction = 42})
end)

ShipTemplateBased:withEvents() config.onDestruction is called when the shipTemplateBased is destroyed

local called = 0
local station = SpaceStation()
ShipTemplateBased:withEvents(station, {
    onDestruction = function()
        called = called + 1
    end,
})

Cron.tick(1)
assert.is_same(0, called)

station:destroy()
Cron.tick(1)
assert.is_same(1, called)

-- it is only called once
Cron.tick(1)
assert.is_same(1, called)

ShipTemplateBased:withEvents() config.onDestruction is called with the destroyed shipTemplateBased

local station = SpaceStation()
local calledArg

ShipTemplateBased:withEvents(station, {
    onDestruction = function(arg)
        calledArg = arg
    end,
})

station:destroy()
Cron.tick(1)
assert.is_same(station, calledArg)

ShipTemplateBased:withEvents() config.onEnemyClear does not fail if the callback errors

local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyClear = function()
        error("Boom")
    end,
})
station:setPosition(0, 0)
enemy:setPosition(1000, 0)

assert.not_has_error(function()
    Cron.tick(1)
end)

ShipTemplateBased:withEvents() config.onEnemyClear does not trigger multiple times when multiple enemies enter and leave

local called = 0
local station = SpaceStation()
local enemy1 = CpuShip()
local enemy2 = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy1) < range or distance(self, enemy2) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyClear = function()
        called = called + 1
    end,
})

-- don't trigger when enemies are outside scanner range
station:setPosition(0, 0)
enemy1:setPosition(99999, 0)
enemy2:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(0, called)

enemy1:setPosition(20000, 0)
enemy2:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(0, called)

-- do not trigger when one enemy leaves range
enemy2:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(0, called)

-- but trigger if both leave the range
enemy1:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(1, called)

ShipTemplateBased:withEvents() config.onEnemyClear does only trigger when an enemy moves out of range

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyClear = function()
        called = called + 1
    end,
})

-- don't trigger when enemy is outside scanner range
station:setPosition(0, 0)
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(0, called)

-- do not call when enemy moves in range
enemy:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(0, called)

-- does not call as long as enemy stays in range
Cron.tick(1)
assert.is_same(0, called)

-- calls when enemy leaves range
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(1, called)

-- does not trigger when enemy stays out of range
Cron.tick(1)
assert.is_same(1, called)

-- calls when enemy leaves range again
enemy:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(1, called)
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(2, called)

ShipTemplateBased:withEvents() config.onEnemyClear fails if onDestruction is not a callback

local station = SpaceStation()

assert.has_error(function()
    ShipTemplateBased:withEvents(station, { onEnemyClear = 42})
end)

ShipTemplateBased:withEvents() config.onEnemyClear is called with the shipTemplateBased

local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
local calledArg

ShipTemplateBased:withEvents(station, {
    onEnemyClear = function(arg)
        calledArg = arg
    end,
})

station:setPosition(0, 0)
enemy:setPosition(20000, 0)
Cron.tick(1)
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(station, calledArg)

ShipTemplateBased:withEvents() config.onEnemyDetection does not fail if the callback errors

local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyDetection = function()
        error("Boom")
    end,
})
station:setPosition(0, 0)
enemy:setPosition(1000, 0)

assert.not_has_error(function()
    Cron.tick(1)
end)

ShipTemplateBased:withEvents() config.onEnemyDetection does not trigger multiple times when multiple enemies enter and leave

local called = 0
local station = SpaceStation()
local enemy1 = CpuShip()
local enemy2 = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy1) < range or distance(self, enemy2) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyDetection = function()
        called = called + 1
    end,
})

-- don't trigger when enemies are outside scanner range
station:setPosition(0, 0)
enemy1:setPosition(99999, 0)
enemy2:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(0, called)

enemy1:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(1, called)

-- do not trigger when second enemy enters range
enemy2:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(1, called)

-- do not trigger when one enemy leaves and enters again
enemy1:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(1, called)
enemy1:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(1, called)

-- trigger again when ships reenter
enemy1:setPosition(99999, 0)
enemy2:setPosition(99999, 0)
Cron.tick(1)

enemy1:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(2, called)

ShipTemplateBased:withEvents() config.onEnemyDetection does only trigger when an enemy moves into range

local called = 0
local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
ShipTemplateBased:withEvents(station, {
    onEnemyDetection = function()
        called = called + 1
    end,
})

-- don't trigger when enemy is outside scanner range
station:setPosition(0, 0)
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(0, called)

-- call when enemy moves in range
enemy:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(1, called)

-- does not call as long as enemy stays in range
Cron.tick(1)
assert.is_same(1, called)

-- does not trigger when enemy leaves range
enemy:setPosition(99999, 0)
Cron.tick(1)
assert.is_same(1, called)

-- does trigger again when enemy reenters scanner range
enemy:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(2, called)

ShipTemplateBased:withEvents() config.onEnemyDetection fails if onDestruction is not a callback

local station = SpaceStation()

assert.has_error(function()
    ShipTemplateBased:withEvents(station, { onEnemyDetection = 42})
end)

ShipTemplateBased:withEvents() config.onEnemyDetection is called with the shipTemplateBased

local station = SpaceStation()
local enemy = CpuShip()
station.areEnemiesInRange = function(self, range)
    return distance(self, enemy) < range
end
local calledArg

ShipTemplateBased:withEvents(station, {
    onEnemyDetection = function(arg)
        calledArg = arg
    end,
})

station:setPosition(0, 0)
enemy:setPosition(20000, 0)
Cron.tick(1)
assert.is_same(station, calledArg)

ShipTemplateBased:withEvents() fails if a number is given instead of shipTemplateBased

assert.has_error(function()
    ShipTemplateBased:withEvents(42)
end)

ShipTemplateBased:withMissionBroker()

ShipTemplateBased:withMissionBroker() allows to set missions

local station = SpaceStation()

ShipTemplateBased:withMissionBroker(station, {missions = {missionWithBrokerMock(), missionWithBrokerMock(), missionWithBrokerMock()}})
assert.is_same(3, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker() causes hasMissionBroker() to be true

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.is_true(ShipTemplateBased:hasMissionBroker(station))

ShipTemplateBased:withMissionBroker() fails if any of the missions is not a mission with broker

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withMissionBroker(station, {missions = {missionMock}}) end)

ShipTemplateBased:withMissionBroker() fails if first argument is already a SpaceObject with broker

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.has_error(function() ShipTemplateBased:withMissionBroker(station) end)

ShipTemplateBased:withMissionBroker() fails if first argument is not a SpaceObject

assert.has_error(function() ShipTemplateBased:withMissionBroker(42) end)

ShipTemplateBased:withMissionBroker() fails if missions is a number

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withMissionBroker(station, {missions = 42}) end)

ShipTemplateBased:withMissionBroker() fails if second argument is not a table

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withMissionBroker(station, 42) end)

ShipTemplateBased:withMissionBroker():addMission()

ShipTemplateBased:withMissionBroker():addMission() allows to add missions

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

station:addMission(missionWithBrokerMock())
assert.is_same(1, Util.size(station:getMissions()))
station:addMission(missionWithBrokerMock())
assert.is_same(2, Util.size(station:getMissions()))
station:addMission(missionWithBrokerMock())
assert.is_same(3, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker():addMission() fails if the argument is a number

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.has_error(function() station:addMission(42) end)

ShipTemplateBased:withMissionBroker():addMission() fails if the mission is not a brokerMission

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.has_error(function() station:addMission(missionMock()) end)

ShipTemplateBased:withMissionBroker():getMissions()

ShipTemplateBased:withMissionBroker():getMissions() returns an empty table if no missions where added

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.is_same(0, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker():getMissions() returns any missions added via withMissionBroker() and addMission()

local station = SpaceStation()
local mission1 = missionWithBrokerMock()
local mission2 = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions = {mission1}})
station:addMission(mission2)

local mission1Found = false
local mission2Found = false

for _, mission in pairs(station:getMissions()) do
    if mission == mission1 then mission1Found = true end
    if mission == mission2 then mission2Found = true end
end

assert.is_true(mission1Found)
assert.is_true(mission2Found)

ShipTemplateBased:withMissionBroker():getMissions() should not allow to manipulate the mission table

local station = SpaceStation()
local mission1 = missionWithBrokerMock()
local mission2 = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions = {mission1}})

table.insert(station:getMissions(), mission2)

assert.is_same(1, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker():hasMissions()

ShipTemplateBased:withMissionBroker():hasMissions() returns false if no missions where added

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.is_false(station:hasMissions())

ShipTemplateBased:withMissionBroker():hasMissions() returns true if a mission has been added via addMission()

local station = SpaceStation()
local mission = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station)
station:addMission(mission)

assert.is_true(station:hasMissions())

ShipTemplateBased:withMissionBroker():hasMissions() returns true if a mission has been added via withMissionBroker()

local station = SpaceStation()
local mission = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions = {mission}})

assert.is_true(station:hasMissions())

ShipTemplateBased:withMissionBroker():removeMission()

ShipTemplateBased:withMissionBroker():removeMission() allows to remove a mission by its id

local station = SpaceStation()
local mission = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions = {mission}})

assert.is_same(1, Util.size(station:getMissions()))
station:removeMission(mission:getId())
assert.is_same(0, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker():removeMission() allows to remove a mission object

local station = SpaceStation()
local mission = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions = {mission}})

assert.is_same(1, Util.size(station:getMissions()))
station:removeMission(mission)
assert.is_same(0, Util.size(station:getMissions()))

ShipTemplateBased:withMissionBroker():removeMission() fails if the argument is a number

local station = SpaceStation()
ShipTemplateBased:withMissionBroker(station)

assert.has_error(function() station:removeMission(42) end)

ShipTemplateBased:withMissionBroker():removeMission() fails silently if the mission is unknown

local station = SpaceStation()
local mission1 = missionWithBrokerMock()
local mission2 = missionWithBrokerMock()
ShipTemplateBased:withMissionBroker(station, {missions={mission1}})

station:removeMission(mission2)
assert.is_same(1, Util.size(station:getMissions()))

ShipTemplateBased:withStorageRooms()

ShipTemplateBased:withStorageRooms() canStoreProduct should return false if the product was not configured

assert.is_false(station:canStoreProduct(Product:new("Test Mock", {id="foo"})))

ShipTemplateBased:withStorageRooms() canStoreProduct should return true if the product was configured

assert.is_true(station:canStoreProduct(product))

ShipTemplateBased:withStorageRooms() causes hasStorage() to be true

assert.is_true(Ship:hasStorage(station))

ShipTemplateBased:withStorageRooms() it keeps constraints on the storage capacity without raising an error

station:modifyProductStorage(product, -9999)
assert.is_same(0, station:getProductStorage(product))

station:modifyProductStorage(product, 9999)
assert.is_same(1000, station:getProductStorage(product))

ShipTemplateBased:withStorageRooms() remembers which products where stored

station:modifyProductStorage(product, 100)
assert.is_same(100, station:getProductStorage(product))

station:modifyProductStorage(product, 42)
assert.is_same(142, station:getProductStorage(product))

station:modifyProductStorage(product, -100)
assert.is_same(42, station:getProductStorage(product))

ShipTemplateBased:withUpgradeBroker()

ShipTemplateBased:withUpgradeBroker() allows to set upgrades

local station = SpaceStation()

ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgradeMock(), upgradeMock(), upgradeMock()}})
assert.is_same(3, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker() causes hasUpgradeBroker() to be true

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.is_true(ShipTemplateBased:hasUpgradeBroker(station))

ShipTemplateBased:withUpgradeBroker() fails if any of the upgrades is not a upgrade with broker

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgradeMock}}) end)

ShipTemplateBased:withUpgradeBroker() fails if first argument is already a SpaceObject with broker

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.has_error(function() ShipTemplateBased:withUpgradeBroker(station) end)

ShipTemplateBased:withUpgradeBroker() fails if first argument is not a SpaceObject

assert.has_error(function() ShipTemplateBased:withUpgradeBroker(42) end)

ShipTemplateBased:withUpgradeBroker() fails if second argument is not a table

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withUpgradeBroker(station, 42) end)

ShipTemplateBased:withUpgradeBroker() fails if upgrades is a number

local station = SpaceStation()

assert.has_error(function() ShipTemplateBased:withUpgradeBroker(station, {upgrades = 42}) end)

ShipTemplateBased:withUpgradeBroker():addUpgrade()

ShipTemplateBased:withUpgradeBroker():addUpgrade() allows to add upgrades

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

station:addUpgrade(upgradeMock())
assert.is_same(1, Util.size(station:getUpgrades()))
station:addUpgrade(upgradeMock())
assert.is_same(2, Util.size(station:getUpgrades()))
station:addUpgrade(upgradeMock())
assert.is_same(3, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker():addUpgrade() fails if the argument is a number

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.has_error(function() station:addUpgrade(42) end)

ShipTemplateBased:withUpgradeBroker():getUpgrades()

ShipTemplateBased:withUpgradeBroker():getUpgrades() returns an empty table if no upgrades where added

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.is_same(0, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker():getUpgrades() returns any upgrades added via withUpgradeBroker() and addUpgrade()

local station = SpaceStation()
local upgrade1 = upgradeMock()
local upgrade2 = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade1}})
station:addUpgrade(upgrade2)

local upgrade1Found = false
local upgrade2Found = false

for _, upgrade in pairs(station:getUpgrades()) do
    if upgrade == upgrade1 then upgrade1Found = true end
    if upgrade == upgrade2 then upgrade2Found = true end
end

assert.is_true(upgrade1Found)
assert.is_true(upgrade2Found)

ShipTemplateBased:withUpgradeBroker():getUpgrades() should not allow to manipulate the upgrade table

local station = SpaceStation()
local upgrade1 = upgradeMock()
local upgrade2 = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade1}})

table.insert(station:getUpgrades(), upgrade2)

assert.is_same(1, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker():hasUpgrade()

ShipTemplateBased:withUpgradeBroker():hasUpgrade() raises an error on invalid arguments

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade}})

assert.has_error(function() station:hasUpgrade(42) end)
assert.has_error(function() station:hasUpgrade(SpaceStation()) end)
assert.has_error(function() station:hasUpgrade(nil) end)

ShipTemplateBased:withUpgradeBroker():hasUpgrade() returns false if no upgrades where added

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)
local upgrade = upgradeMock()

assert.is_false(station:hasUpgrade(upgrade))

ShipTemplateBased:withUpgradeBroker():hasUpgrade() returns true if a upgrade has been added via addUpgrade()

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station)
station:addUpgrade(upgrade)

assert.is_true(station:hasUpgrade(upgrade))
assert.is_true(station:hasUpgrade(upgrade:getId()))

ShipTemplateBased:withUpgradeBroker():hasUpgrade() returns true if a upgrade has been added via withUpgradeBroker()

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade}})

assert.is_true(station:hasUpgrade(upgrade))
assert.is_true(station:hasUpgrade(upgrade:getId()))

ShipTemplateBased:withUpgradeBroker():hasUpgrades()

ShipTemplateBased:withUpgradeBroker():hasUpgrades() returns false if no upgrades where added

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.is_false(station:hasUpgrades())

ShipTemplateBased:withUpgradeBroker():hasUpgrades() returns true if a upgrade has been added via addUpgrade()

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station)
station:addUpgrade(upgrade)

assert.is_true(station:hasUpgrades())

ShipTemplateBased:withUpgradeBroker():hasUpgrades() returns true if a upgrade has been added via withUpgradeBroker()

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade}})

assert.is_true(station:hasUpgrades())

ShipTemplateBased:withUpgradeBroker():removeUpgrade()

ShipTemplateBased:withUpgradeBroker():removeUpgrade() allows to remove a upgrade by its id

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade}})

assert.is_same(1, Util.size(station:getUpgrades()))
station:removeUpgrade(upgrade:getId())
assert.is_same(0, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker():removeUpgrade() allows to remove a upgrade object

local station = SpaceStation()
local upgrade = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades = {upgrade}})

assert.is_same(1, Util.size(station:getUpgrades()))
station:removeUpgrade(upgrade)
assert.is_same(0, Util.size(station:getUpgrades()))

ShipTemplateBased:withUpgradeBroker():removeUpgrade() fails if the argument is a number

local station = SpaceStation()
ShipTemplateBased:withUpgradeBroker(station)

assert.has_error(function() station:removeUpgrade(42) end)

ShipTemplateBased:withUpgradeBroker():removeUpgrade() fails silently if the upgrade is unknown

local station = SpaceStation()
local upgrade1 = upgradeMock()
local upgrade2 = upgradeMock()
ShipTemplateBased:withUpgradeBroker(station, {upgrades={upgrade1}})

station:removeUpgrade(upgrade2)
assert.is_same(1, Util.size(station:getUpgrades()))

Station

Station:withMerchant()

Station:withMerchant() can use a function for the buying price

local station = SpaceStation()
Station:withStorageRooms(station, {
    [product] = 1000
})
Station:withMerchant(station, {
    [product] = { buyingPrice = function(self) return 42 end }
})

assert.is_same(42, station:getProductBuyingPrice(product))

Station:withMerchant() can use a function for the selling price

local station = SpaceStation()
Station:withStorageRooms(station, {
    [product] = 1000
})
Station:withMerchant(station, {
    [product] = { sellingPrice = function(self) return 42 end }
})

assert.is_same(42, station:getProductSellingPrice(product))

Station:withMerchant() does not buy above the buyingLimit

local station = SpaceStation()
Station:withStorageRooms(station, {
    [product] = 1000
})
Station:withMerchant(station, {
    [product] = { buyingPrice = 42, buyingLimit = 100 }
})

station:modifyProductStorage(product, -9999)
assert.is_same(100, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 30)
assert.is_same(70, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 670)
assert.is_same(0, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 300)
assert.is_same(0, station:getMaxProductBuying(product))

Station:withMerchant() does not sell below the sellingLimit

local station = SpaceStation()
Station:withStorageRooms(station, {
    [product] = 1000
})
Station:withMerchant(station, {
    [product] = { sellingPrice = 42, sellingLimit = 100 }
})

station:modifyProductStorage(product, -9999)
assert.is_same(0, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 30)
assert.is_same(0, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 670)
assert.is_same(600, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 300)
assert.is_same(900, station:getMaxProductSelling(product))

Station:withMerchant() player-dependent offers getMaxProductBuying() filters products

assert.not_nil(stationBuying:getMaxProductBuying(productForFriends, friendlyPlayer))
assert.is_nil(stationBuying:getMaxProductBuying(productForFriends, neutralPlayer))

Station:withMerchant() player-dependent offers getMaxProductSelling() filters products

assert.not_nil(stationSelling:getMaxProductSelling(productForFriends, friendlyPlayer))
assert.is_nil(stationSelling:getMaxProductSelling(productForFriends, neutralPlayer))

Station:withMerchant() player-dependent offers getProductBuyingPrice() filters products

assert.is_same(42, stationBuying:getProductBuyingPrice(productForFriends, friendlyPlayer))
assert.is_nil(stationBuying:getProductBuyingPrice(productForFriends, neutralPlayer))

Station:withMerchant() player-dependent offers getProductSellingPrice() filters products

assert.is_same(42, stationSelling:getProductSellingPrice(productForFriends, friendlyPlayer))
assert.is_nil(stationSelling:getProductSellingPrice(productForFriends, neutralPlayer))

Station:withMerchant() player-dependent offers getProductsBought() filters products

assert.contains_value(productForFriends, stationBuying:getProductsBought(friendlyPlayer))
assert.not_contains_value(productForFriends, stationBuying:getProductsBought(neutralPlayer))

Station:withMerchant() player-dependent offers getProductsSold() filters products

assert.contains_value(productForFriends, stationSelling:getProductsSold(friendlyPlayer))
assert.not_contains_value(productForFriends, stationSelling:getProductsSold(neutralPlayer))

Station:withMerchant() player-dependent offers isBuyingProduct() filters products

assert.is_true(stationBuying:isBuyingProduct(productForFriends, friendlyPlayer))
assert.is_false(stationBuying:isBuyingProduct(productForFriends, neutralPlayer))

Station:withMerchant() player-dependent offers isSellingProduct() filters products

assert.is_true(stationSelling:isSellingProduct(productForFriends, friendlyPlayer))
assert.is_false(stationSelling:isSellingProduct(productForFriends, neutralPlayer))

Station:withMerchant() throws an error if neither buying nor selling price is set

local station = SpaceStation()
Station:withStorageRooms(station, {
    [product] = 1000
})

assert.has_error(function()
    Station:withMerchant(station, {
        [product] = {}
    })
end)

Station:withMerchant() when configuring a bought and sold product causes hasMerchant() to be true

assert.is_true(Station:hasMerchant(station))

Station:withMerchant() when configuring a bought and sold product causes isBuyingProduct() to be true

assert.is_true(station:isBuyingProduct(product))

Station:withMerchant() when configuring a bought and sold product causes isSellingProduct() to be true

assert.is_true(station:isSellingProduct(product))

Station:withMerchant() when configuring a bought and sold product does not buy above the maxStorage

station:modifyProductStorage(product, -9999)
assert.is_same(1000, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 300)
assert.is_same(700, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 700)
assert.is_same(0, station:getMaxProductBuying(product))

Station:withMerchant() when configuring a bought and sold product does not sell below 0

station:modifyProductStorage(product, -9999)
assert.is_same(0, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 300)
assert.is_same(300, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 700)
assert.is_same(1000, station:getMaxProductSelling(product))

Station:withMerchant() when configuring a bought and sold product returns a list of bought products

assert.is_same({[product:getId()] = product}, station:getProductsBought(product))

Station:withMerchant() when configuring a bought and sold product returns a list of sold products

assert.is_same({[product:getId()] = product}, station:getProductsSold(product))

Station:withMerchant() when configuring a bought and sold product returns the correct buying price

assert.is_same(21, station:getProductBuyingPrice(product))

Station:withMerchant() when configuring a bought and sold product returns the correct selling price

assert.is_same(42, station:getProductSellingPrice(product))

Station:withMerchant() when configuring a bought product causes hasMerchant() to be true

assert.is_true(Station:hasMerchant(station))

Station:withMerchant() when configuring a bought product causes isBuyingProduct() to be true

assert.is_true(station:isBuyingProduct(product))

Station:withMerchant() when configuring a bought product causes isSellingProduct() to be false

assert.is_false(station:isSellingProduct(product))

Station:withMerchant() when configuring a bought product does not buy above the maxStorage

station:modifyProductStorage(product, -9999)
assert.is_same(1000, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 300)
assert.is_same(700, station:getMaxProductBuying(product))

station:modifyProductStorage(product, 700)
assert.is_same(0, station:getMaxProductBuying(product))

Station:withMerchant() when configuring a bought product returns a list of bought products

assert.is_same({[product:getId()] = product}, station:getProductsBought(product))

Station:withMerchant() when configuring a bought product returns an empty list of sold products

assert.is_same({}, station:getProductsSold(product))

Station:withMerchant() when configuring a bought product returns nil for getMaxProductSelling

assert.is_nil(station:getMaxProductSelling(product))

Station:withMerchant() when configuring a bought product returns no sellingPrice

assert.is_nil(station:getProductSellingPrice(product))

Station:withMerchant() when configuring a bought product returns the correct buying price

assert.is_same(42, station:getProductBuyingPrice(product))

Station:withMerchant() when configuring a sold product causes hasMerchant() to be true

assert.is_true(Station:hasMerchant(station))

Station:withMerchant() when configuring a sold product causes isBuyingProduct() to be false

assert.is_false(station:isBuyingProduct(product))

Station:withMerchant() when configuring a sold product causes isSellingProduct() to be true

assert.is_true(station:isSellingProduct(product))

Station:withMerchant() when configuring a sold product does not sell below 0

station:modifyProductStorage(product, -9999)
assert.is_same(0, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 300)
assert.is_same(300, station:getMaxProductSelling(product))

station:modifyProductStorage(product, 700)
assert.is_same(1000, station:getMaxProductSelling(product))

Station:withMerchant() when configuring a sold product returns a list of sold products

assert.is_same({[product:getId()] = product}, station:getProductsSold(product))

Station:withMerchant() when configuring a sold product returns an empty list of bought products

assert.is_same({}, station:getProductsBought(product))

Station:withMerchant() when configuring a sold product returns nil for getMaxProductBuying

assert.is_nil(station:getMaxProductBuying(product))

Station:withMerchant() when configuring a sold product returns no buyingPrice

assert.is_nil(station:getProductBuyingPrice(product))

Station:withMerchant() when configuring a sold product returns the correct selling price

assert.is_same(42, station:getProductSellingPrice(product))

Station:withProduction()

Station:withProduction() does not produce if any of the consumed products is not available

local station = SpaceStation()
Station:withStorageRooms(station, {
    [power] = 1000,
    [ore] = 1000,
    [glue] = 1000,
    [herring] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 1,
        produces = {
            { product = herring, amount = 5 },
        },
        consumes = {
            { product = power, amount = 10 },
            { product = ore, amount = 10 },
            { product = glue, amount = 10 },
        }
    }
})
station:modifyProductStorage(power, 100)
station:modifyProductStorage(ore, 5)
station:modifyProductStorage(glue, 100)

Cron.tick(5)
Cron.tick(5)
Cron.tick(5)
assert.is_same(100, station:getProductStorage(power))
assert.is_same(5, station:getProductStorage(ore))
assert.is_same(100, station:getProductStorage(glue))
assert.is_same(0, station:getProductStorage(herring))

Station:withProduction() does not produce if there is no storage space for products left

local station = SpaceStation()
Station:withStorageRooms(station, {
    [power] = 1000,
    [herring] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 1,
        produces = {
            { product = herring, amount = 5 },
        },
        consumes = {
            { product = power, amount = 10 },
        }
    }
})
station:modifyProductStorage(power, 1000)
station:modifyProductStorage(herring, 1000)

Cron.tick(5)
Cron.tick(5)
Cron.tick(5)
assert.is_same(1000, station:getProductStorage(power))
assert.is_same(1000, station:getProductStorage(herring))

Station:withProduction() does produce as long as there is any space left

local station = SpaceStation()
Station:withStorageRooms(station, {
    [power] = 1000,
    [herring] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 1,
        produces = {
            { product = herring, amount = 5 },
        },
        consumes = {
            { product = power, amount = 10 },
        }
    }
})
station:modifyProductStorage(power, 1000)
station:modifyProductStorage(herring, 999)

Cron.tick(5)
Cron.tick(5)
Cron.tick(5)
assert.is_same(990, station:getProductStorage(power))
assert.is_same(1000, station:getProductStorage(herring))

Station:withProduction() produces products in a interval

local station = SpaceStation()
Station:withStorageRooms(station, {
    [power] = 1000,
    [herring] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 5,
        produces = {
            { product = herring, amount = 5 },
        },
        consumes = {
            { product = power, amount = 5 },
        }
    }
})
station:modifyProductStorage(power, 100)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(100, station:getProductStorage(power))
assert.is_same(0, station:getProductStorage(herring))
Cron.tick(1)
assert.is_same(95, station:getProductStorage(power))
assert.is_same(5, station:getProductStorage(herring))
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
Cron.tick(1)
assert.is_same(95, station:getProductStorage(power))
assert.is_same(5, station:getProductStorage(herring))
Cron.tick(1)
assert.is_same(90, station:getProductStorage(power))
assert.is_same(10, station:getProductStorage(herring))

Station:withProduction() produces with callbacks

local station = SpaceStation()
local wasProduced = false
Station:withStorageRooms(station, {
    [power] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 5,
        produces = function() wasProduced = true end,
        consumes = {
            { product = power, amount = 10 },
        }
    }
})
station:modifyProductStorage(power, 1000)

Cron.tick(5)
assert.is_true(wasProduced)
assert.is_same(990, station:getProductStorage(power))

Station:withProduction() stops producing if station is destroyed

local station = SpaceStation()
local wasProduced = false
Station:withStorageRooms(station, {
    [herring] = 1000,
})
Station:withProduction(station, {
    {
        productionTime = 1,
        produces = function() wasProduced = true end,
        consumes = {
            { product = herring, amount = 1 },
        }
    }
})
station:modifyProductStorage(herring, 1000)

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)

station:destroy()
wasProduced = false

Cron.tick(1)
Cron.tick(1)
Cron.tick(1)

assert.is_false(wasProduced)

Tools

Tools:ensureComms()

Tools:ensureComms() aborts when player is destroyed

local station = SpaceStation()
Station:withComms(station)
station:setHailText("Hello World")
local blocker = SpaceStation()
Station:withComms(blocker)
blocker:setHailText("Blocking the connection")
local player = PlayerSpaceship()

player:commandOpenTextComm(blocker)

Tools:ensureComms(station, player)
Cron.tick(1)
player:destroy()
player:commandCloseTextComm()
Cron.tick(1)
assert.is_true(player:isCommsInactive())

Tools:ensureComms() aborts when sender is destroyed

local station = SpaceStation()
Station:withComms(station)
station:setHailText("Hello World")
local blocker = SpaceStation()
Station:withComms(blocker)
blocker:setHailText("Blocking the connection")
local player = PlayerSpaceship()

player:commandOpenTextComm(blocker)

Tools:ensureComms(station, player)
Cron.tick(1)
station:destroy()
player:commandCloseTextComm()
Cron.tick(1)
assert.is_true(player:isCommsInactive())

Tools:ensureComms() can open a communication channel

local station = SpaceStation()
Station:withComms(station)
station:setHailText("Hello World")
local player = PlayerSpaceship()

Tools:ensureComms(station, player)
assert.same("Hello World", player:getCurrentCommsText())

Tools:ensureComms() can send a plain message

local station = SpaceStation()
Station:withComms(station)
local player = PlayerSpaceship()

Tools:ensureComms(station, player, "Hello World")
assert.same("Hello World", player:getCurrentCommsText())

Tools:ensureComms() fails if no valid player is given

local station = SpaceStation()
Station:withComms(station)
station:setHailText("Hello World")

assert.has_error(function()
    Tools:ensureComms(station, nil)
end)
assert.has_error(function()
    Tools:ensureComms(station, 42)
end)
assert.has_error(function()
    Tools:ensureComms(station, CpuShip())
end)

Tools:ensureComms() fails if no valid station is given

local player = PlayerSpaceship()

assert.has_error(function()
    Tools:ensureComms(nil, player)
end)
assert.has_error(function()
    Tools:ensureComms(42, player)
end)
assert.has_error(function()
    -- does not have comms
    Tools:ensureComms(SpaceStation(), player)
end)

Tools:ensureComms() fails if third parameter is invalid

local station = SpaceStation()
Station:withComms(station)
local player = PlayerSpaceship()

assert.has_error(function()
    Tools:ensureComms(station, player, 42)
end)
assert.has_error(function()
    Tools:ensureComms(station, player, function() end)
end)

Tools:ensureComms() is send as soon as the communication channel opens

local station = SpaceStation()
Station:withComms(station)
station:setHailText("Hello World")
local blocker = SpaceStation()
Station:withComms(blocker)
blocker:setHailText("Blocking the connection")
local player = PlayerSpaceship()

player:commandOpenTextComm(blocker)

Tools:ensureComms(station, player)
assert.same("Blocking the connection", player:getCurrentCommsText())
Cron.tick(1)
assert.same("Blocking the connection", player:getCurrentCommsText())
player:commandCloseTextComm()
Cron.tick(1)
assert.same("Hello World", player:getCurrentCommsText())
player:commandCloseTextComm()
Cron.tick(1)
assert.is_true(player:isCommsInactive())

Tools:storyComms()

Tools:storyComms() ends when player is destroyed

finally(function() Tools:endStoryComms() end)

local station = SpaceStation()
Station:withComms(station)
local player = PlayerSpaceship()
local comms = Comms:newScreen("screen one")
comms:addReply(Comms:newReply("reply one", function()
    Tools:endStoryComms()

    return Comms:newScreen("screen two")
end))

Tools:storyComms(station, player, comms)
assert.same("screen one", player:getCurrentCommsText())

-- screen one pops up again if comms is closed
player:commandCloseTextComm()
player:destroy()
Cron.tick(1)

-- comms stays closed
assert.is_true(player:isCommsInactive())

Tools:storyComms() ends when station is destroyed

finally(function() Tools:endStoryComms() end)

local station = SpaceStation()
Station:withComms(station)
local player = PlayerSpaceship()
local comms = Comms:newScreen("screen one")
comms:addReply(Comms:newReply("reply one", function()
    Tools:endStoryComms()

    return Comms:newScreen("screen two")
end))

Tools:storyComms(station, player, comms)
assert.same("screen one", player:getCurrentCommsText())

-- screen one pops up again if comms is closed
player:commandCloseTextComm()
station:destroy()
Cron.tick(1)

-- comms stays closed
assert.is_true(player:isCommsInactive())

Tools:storyComms() invalid calls fails when an other storyComms() is currently running

finally(function() Tools:endStoryComms() end)

Tools:storyComms(station, player, screen)
assert.has_error(function() Tools:storyComms(station, player, screen) end)

Tools:storyComms() invalid calls fails when called with station without comms

assert.has_error(function() Tools:storyComms(SpaceStation(), player, screen) end)

Tools:storyComms() invalid calls fails when called without player

assert.has_error(function() Tools:storyComms(station, nil, screen) end)

Tools:storyComms() invalid calls fails when called without screen

assert.has_error(function() Tools:storyComms(station, player, nil) end)

Tools:storyComms() invalid calls fails when called without station

assert.has_error(function() Tools:storyComms(nil, player, screen) end)

Tools:storyComms() pops up if it is closed before dialog finished

finally(function() Tools:endStoryComms() end)

local station = SpaceStation()
Station:withComms(station)
local player = PlayerSpaceship()
local comms = Comms:newScreen("screen one")
comms:addReply(Comms:newReply("reply one", function()
    local screen2 = Comms:newScreen("screen two")
    screen2:addReply(Comms:newReply("reply two", function()
        Tools:endStoryComms()

        return Comms:newScreen("screen three")
    end))

    return screen2
end))

Tools:storyComms(station, player, comms)
assert.same("screen one", player:getCurrentCommsText())

-- screen one pops up again if comms is closed
player:commandCloseTextComm()
Cron.tick(1)
assert.same("screen one", player:getCurrentCommsText())

player:selectComms("reply one")
assert.same("screen two", player:getCurrentCommsText())
player:commandCloseTextComm()

-- conversation starts at screen one again
player:commandCloseTextComm()
Cron.tick(1)
assert.same("screen one", player:getCurrentCommsText())

player:selectComms("reply one")
assert.same("screen two", player:getCurrentCommsText())
player:selectComms("reply two")
assert.same("screen three", player:getCurrentCommsText())
player:commandCloseTextComm()
Cron.tick(1)

-- comms stays closed
assert.is_true(player:isCommsInactive())

Tools:storyComms() valid call can be created

finally(function() Tools:endStoryComms() end)

Tools:storyComms(station, player, screen)

Tools:storyComms() valid call can be stopped and started again

finally(function() Tools:endStoryComms() end)

Tools:endStoryComms()
Tools:storyComms(station, player, screen)

Translator

Translator:inspect()

Translator:inspect() fails if translator is not a Translator

assert.has_error(function()
    Translator:inspect(nil)
end)
assert.has_error(function()
    Translator:inspect(42)
end)
assert.has_error(function()
    Translator:inspect({})
end)

Translator:inspect() finds excessive translations

local translator = Translator:new()
translator:register("en", "say_hello", "Hello World")
translator:register("de", "say_hello", "Hallo Welt")
translator:register("de", "say_bye", "Tschau")
translator:useLocale("de")

local missingTranslations, excessiveTranslations = Translator:inspect(translator)

assert.is_same({}, missingTranslations)
assert.is_same({"say_bye"}, excessiveTranslations)

Translator:inspect() finds missing translations

local translator = Translator:new("en")
translator:register("en", "say_hello", "Hello World")
translator:register("en", "say_bye", "Good Bye")
translator:register("de", "say_bye", "Tschau")
translator:useLocale("de")

local missingTranslations, excessiveTranslations = Translator:inspect(translator)

assert.is_same({"say_hello"}, missingTranslations)
assert.is_same({}, excessiveTranslations)

Translator:inspect() sorts the result alphabethically by key

local translator = Translator:new()
translator:register("en", "say_a", "A")
translator:register("en", "say_b", "B")
translator:register("en", "say_c", "C")
translator:register("de", "say_d", "D")
translator:register("de", "say_e", "E")
translator:register("de", "say_f", "F")
translator:useLocale("de")

local missingTranslations, excessiveTranslations = Translator:inspect(translator)

assert.is_same({"say_a", "say_b", "say_c"}, missingTranslations)
assert.is_same({"say_d", "say_e", "say_f"}, excessiveTranslations)

Translator:new()

Translator:new() allows to set the default locale

local translator = Translator:new("de")
translator:register("en", "say_hello", "Hello World")
translator:register("de", "say_hello", "Hallo Welt")

assert.is_same("Hallo Welt", translator:translate("say_hello"))
translator:useLocale("en")
assert.is_same("Hello World", translator:translate("say_hello"))
translator:useLocale("de")
assert.is_same("Hallo Welt", translator:translate("say_hello"))

Translator:new() fails if defaultLocale is not a string

assert.has_error(function()
    Translator:new(42)
end)

Translator:new() should basically work with a different locale

local translator = Translator:new()

assert.is_true(Translator:isTranslator(translator))

translator:register("say_hello", "Hello World")
translator:register("de", "say_hello", "Hallo Welt")

assert.is_same("Hello World", translator:translate("say_hello"))
translator:useLocale("de")
assert.is_same("Hallo Welt", translator:translate("say_hello"))

Translator:new() should work when only using one locale

local translator = Translator:new()
assert.is_true(Translator:isTranslator(translator))

translator:register("say_hello", "Hello World")

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():register()

Translator:new():register() can take a table of translations should work with a different locale

local translator = Translator:new()

translator:register({
    say_hello = "Hello World",
    say_bye = "Goodbye",
})
translator:register("de", {
    say_hello = "Hallo Welt",
    say_bye = "Tschau",
})

translator:useLocale("de")
assert.is_same("Hallo Welt", translator:translate("say_hello"))

Translator:new():register() can take a table of translations should work with the default locale

local translator = Translator:new()

translator:register({
    say_hello = "Hello World",
    say_bye = "Goodbye",
})

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():register() fails if key is missing

local translator = Translator:new()

assert.has_error(function()
    translator:register()
end)

assert.has_error(function()
    translator:register("say_hello", nil)
end)

Translator:new():register() fails if label is not a string or function

local translator = Translator:new()

assert.has_error(function()
    translator:register("say_hello", 42)
end)

assert.has_error(function()
    translator:register("say_hello", nil)
end)

Translator:new():register() fails if locale is not a string

local translator = Translator:new()

assert.has_error(function()
    translator:register(42, "say_hello", "Hello World")
end)

assert.has_error(function()
    translator:register(nil, "say_hello", "Hello World")
end)

Translator:new():translate()

Translator:new():translate() can use a function as translation

local translator = Translator:new()
translator:register("say_hello", function() return "Hello World" end)

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():translate() gives the translator and all given arguments to translation function

local translator = Translator:new()
local t = translator.translate

local arg1 = Util.randomUuid()
local arg2 = Util.randomUuid()
local arg3 = Util.randomUuid()
local givenArg1, givenArg2, givenArg3

translator:register("say_hello", function(arg1, arg2, arg3)
    givenArg1 = arg1
    givenArg2 = arg2
    givenArg3 = arg3
    return "Hello World"
end)

assert.is_same("Hello World", t("say_hello", arg1, arg2, arg3))
assert.is_same(arg1, givenArg1)
assert.is_same(arg2, givenArg2)
assert.is_same(arg3, givenArg3)

Translator:new():translate() gives the translator and all given arguments to translation function

local translator = Translator:new()
local arg1 = Util.randomUuid()
local arg2 = Util.randomUuid()
local arg3 = Util.randomUuid()
local givenArg1, givenArg2, givenArg3

translator:register("say_hello", function(arg1, arg2, arg3)
    givenArg1 = arg1
    givenArg2 = arg2
    givenArg3 = arg3
    return "Hello World"
end)

assert.is_same("Hello World", translator:translate("say_hello", arg1, arg2, arg3))
assert.is_same(arg1, givenArg1)
assert.is_same(arg2, givenArg2)
assert.is_same(arg3, givenArg3)

Translator:new():translate() returns an empty string if a translation errors and all fallbacks error too

local translator = Translator:new()

translator:register("say_hello", function() error("Boom") end)
assert.is_same("", translator:translate("say_hello"))

Translator:new():translate() should allow to use short-hand

local translator = Translator:new()
local t = translator.translate

translator:register("say_hello", "Hello World")

assert.is_same("Hello World", t("say_hello"))

Translator:new():translate() should fail if key does not exist in translation or defaultLocale

local translator = Translator:new()

translator:register("say_hello", "Hello World")

assert.has_error(function()
    translator:translate("mistyped")
end)

Translator:new():translate() should fail if key is not a string

local translator = Translator:new()

translator:register("say_hello", "Hello World")

assert.has_error(function()
    translator:translate(42)
end, "Expected key to be a string, but got <number>42")
assert.has_error(function()
    translator:translate({})
end, "Expected key to be a string, but got <table>(size: 0)")

Translator:new():translate() transliterates german characters into ASCII

local translator = Translator:new()
translator:register("umlaut", "Falsches Üben von Xylophonmusik quält jeden größeren Zwerg")
translator:register("umlaut_func", function()
    return "Falsches Üben von Xylophonmusik quält jeden größeren Zwerg"
end)

assert.is_same("Falsches Ueben von Xylophonmusik quaelt jeden groesseren Zwerg", translator.translate("umlaut"))
assert.is_same("Falsches Ueben von Xylophonmusik quaelt jeden groesseren Zwerg", translator.translate("umlaut_func"))

Translator:new():translate() tries fallback locales if function does not return a string

local translator = Translator:new("en")
translator:useLocale("de")
translator:register("en", "say_hello", function() return "Hello World" end)
translator:register("de", "say_hello", function() return 42 end)

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():translate() tries fallback locales if function errors

-- this behavior should not be problematic because translations should not have side-effects
local translator = Translator:new("en")
translator:useLocale("de")
translator:register("en", "say_hello", function() return "Hello World" end)
translator:register("de", "say_hello", function() return error("Boom") end)

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():translate() uses a fallback when translation is not available

local translator = Translator:new()

assert.is_true(Translator:isTranslator(translator))

translator:register("say_hello", "Hello World")

translator:useLocale("de")
assert.is_same("Hello World", translator:translate("say_hello"))

Translator:new():translate() uses multiple fallbacks when translation is not available

local translator = Translator:new()
translator:useLocale("sp", "de")

translator:register("en", "test", "Hello World")
assert.is_same("Hello World", translator:translate("test"))
translator:register("de", "test", "Hallo Welt")
assert.is_same("Hallo Welt", translator:translate("test"))
translator:register("sp", "test", "Hola Mundo")
assert.is_same("Hola Mundo", translator:translate("test"))

Translator:new():useLocale()

Translator:new():useLocale() fails if any of the arguments is not a string

local translator = Translator:new()

assert.has_error(function()
    translator:useLocale(42)
end)

assert.has_error(function()
    translator:useLocale("en", "de", 42)
end)

Translator:new():useLocale() uses default locale if no argument is given

local translator = Translator:new()
translator:register("say_hello", "Hello World")

translator:useLocale()

assert.is_same("Hello World", translator:translate("say_hello"))

Translator:printInspection()

Translator:printInspection() look that there is no smoke

-- all good
local translator = Translator:new("en")
translator:register("en", "say_hello", "Hello World")
translator:register("de", "say_hello", "Hallo Welt")
translator:useLocale("de")

Translator:printInspection(translator)

-- only missing
local translator = Translator:new("en")
translator:register("en", "say_hello", "Hello World")
translator:register("en", "say_bye", "Good Bye")
translator:register("de", "say_bye", "Tschau")
translator:useLocale("de")

Translator:printInspection(translator)

-- only excessive
local translator = Translator:new()
translator:register("en", "say_hello", "Hello World")
translator:register("de", "say_hello", "Hallo Welt")
translator:register("de", "say_bye", "Tschau")
translator:useLocale("de")

Translator:printInspection(translator)

-- mixed
local translator = Translator:new()
translator:register("en", "say_a", "A")
translator:register("en", "say_b", "B")
translator:register("en", "say_c", "C")
translator:register("de", "say_d", "D")
translator:register("de", "say_e", "E")
translator:register("de", "say_f", "F")
translator:useLocale("de")

Translator:printInspection(translator)

Util

Util:addVector()

Util:addVector() has the x axis for 0 degree

local x, y = Util.addVector(1000, 0, 0, 1000)

assert.is_same(2000, math.floor(x))
assert.is_same(0, math.floor(y))

Util:addVector() it works counter clockwise

local x, y = Util.addVector(1000, 0, 90, 1000)

assert.is_same(1000, math.floor(x))
assert.is_same(1000, math.floor(y))

Util:addVector() it works with SpaceObject

local ship = CpuShip():setPosition(1337, 42)
local x, y = Util.addVector(ship, 90, 1000)

assert.is_same(1337, math.floor(x))
assert.is_same(1042, math.floor(y))

Util:addVector() it works with degrees

local x, y = Util.addVector(1000, 0, 180, 1000)

assert.is_same(0, math.floor(x))
assert.is_same(0, math.floor(y))

Util:addVector() returns the point when adding a vector of zero length

local x, y = Util.addVector(0, 0, 180, 0)
assert.is_same({0, 0}, {x, y})

Util:angleDiff()

Util:angleDiff() returns correct results

assert.is_same(20, Util.angleDiff(10, 30))
assert.is_same(-20, Util.angleDiff(30, 10))
assert.is_same(20, Util.angleDiff(350, 10))
assert.is_same(-20, Util.angleDiff(10, 350))

Util:angleFromVector()

Util:angleFromVector() has the x axis for 0 degree

local angle, distance = Util.angleFromVector(1000, 0)

assert.is_same(0, math.floor(angle))
assert.is_same(1000, math.floor(distance))

Util:angleFromVector() it works counter clockwise

local angle, distance = Util.angleFromVector(0, 1000)

assert.is_same(90, math.floor(angle))
assert.is_same(1000, math.floor(distance))

Util:angleFromVector() it works with degrees

local angle, distance = Util.angleFromVector(-1000, 0)

assert.is_same(180, math.floor(angle))
assert.is_same(1000, math.floor(distance))

Util:appendTables()

Util:appendTables() can merge three tables

local a = {1, 2}
local b = {3, 4}
local c = {5, 6}

local merged = Util.appendTables(a, b, c)
assert.is_same({1, 2, 3, 4, 5, 6}, merged)
-- ensure the original tables are not overridden
assert.not_same(a, merged)
assert.not_same(b, merged)
assert.not_same(c, merged)

Util:appendTables() does not remove duplicates

local a = {1, 3}
local b = {3, 7}

local merged = Util.appendTables(a, b)
assert.is_same({1, 3, 3, 7}, merged)

-- ensure the original tables are not overridden
assert.not_same(a, merged)
assert.not_same(b, merged)

Util:appendTables() fails if the first argument is not a numeric table

assert.has_error(function() Util.appendTables(42, {1}) end)
assert.has_error(function() Util.appendTables(nil, {1}) end)

Util:appendTables() fails if the second argument is not a table

assert.has_error(function() Util.appendTables({1}, 42) end)

Util:appendTables() returns a new table where all the items of all tables are present

local a = {1, 2}
local b = {3, 4}

local merged = Util.appendTables(a, b)
assert.is_same({1, 2, 3, 4}, merged)

-- ensure the original tables are not overridden
assert.not_same(a, merged)
assert.not_same(b, merged)

Util:deepCopy()

Util:deepCopy() should copy primitive types

local thing = {
    foo = "bar",
    baz = 42
}
local copied = Util.deepCopy(thing)

thing.foo = "fake"
thing.baz = 12
thing.blu = "some"

assert.equal("bar", copied.foo)
assert.equal(42, copied.baz)
assert.is_nil(copied.blu)

Util:deepCopy() should not copy objects from Empty Epsilon

-- Copying them would cause the object to exists twice in memory.
-- This would cause an inconsistent state and might cause the game to crash
-- because of access to invalid memory segments.

require "spec.mocks"

local thing = {
    foo = "bar",
    station = SpaceStation()
}
local copied = Util.deepCopy(thing)

thing.station.foo = "bar"

assert.same("bar", copied.station.foo)

Util:distanceToLineSegment()

Util:distanceToLineSegment() can use objects instead of positions

local start = CpuShip():setPosition(300, 0)
local stop = CpuShip():setPosition(1300, 0)
local point = CpuShip():setPosition(1300, 200)

assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, 1300, 200))
assert.is_same(200, Util.distanceToLineSegment(start, 1300, 0, 1300, 200))
assert.is_same(200, Util.distanceToLineSegment(300, 0, stop, 1300, 200))
assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, point))
assert.is_same(200, Util.distanceToLineSegment(start, stop, 1300, 200))
assert.is_same(200, Util.distanceToLineSegment(start, 1300, 0, point))
assert.is_same(200, Util.distanceToLineSegment(300, 0, stop, point))
assert.is_same(200, Util.distanceToLineSegment(start, stop, point))

Util:distanceToLineSegment() fails if any argument is not a number

assert.has_error(function() Util.distanceToLineSegment(0, 0, 1000, 0, 0, "") end)
assert.has_error(function() Util.distanceToLineSegment(0, 0, 1000, 0, "", 0) end)
assert.has_error(function() Util.distanceToLineSegment(0, 0, 1000, "", 0, 0) end)
assert.has_error(function() Util.distanceToLineSegment(0, 0, "fo", 0, 0, 0) end)
assert.has_error(function() Util.distanceToLineSegment(0, "", 1000, 0, 0, 0) end)
assert.has_error(function() Util.distanceToLineSegment("", 0, 1000, 0, 0, 0) end)

Util:distanceToLineSegment() fails if start and end are identical

assert.has_error(function() Util.distanceToLineSegment(0, 0, 0, 0, 0, 0) end)
assert.has_error(function() Util.distanceToLineSegment(100, 0, 100, 0, 0, 0) end)

Util:distanceToLineSegment() returns 0 if point is on the line segment

assert.is_same(0, Util.distanceToLineSegment(0, 0, 1000, 0, 0, 0))
assert.is_same(0, Util.distanceToLineSegment(0, 0, 1000, 0, 1000, 0))
assert.is_same(0, Util.distanceToLineSegment(0, 0, 1000, 0, 500, 0))

Util:distanceToLineSegment() returns distance if point is on the line, but outside the segment

assert.is_same(1000, Util.distanceToLineSegment(0, 0, 1000, 0, 2000, 0))
assert.is_same(500, Util.distanceToLineSegment(0, 0, 1000, 0, -500, 0))

Util:distanceToLineSegment() returns distance of a point from a line segment (when closest point is on the line)

assert.is_same(200, Util.distanceToLineSegment(
    0, 0,
    1000, 0,
    500, 200
))
assert.is_same(200, Util.distanceToLineSegment(
    0, 0,
    1000, 0,
    500, -200
))

-- the same shifted to the right
assert.is_same(200, Util.distanceToLineSegment(
    300, 0,
    1300, 0,
    800, 200
))
assert.is_same(200, Util.distanceToLineSegment(
    300, 0,
    1300, 0,
    800, -200
))

-- and now rotate the whole thing
for _, deg in pairs({30, 45, 60, 90, 120, 150}) do
    deg = deg / 180 * math.pi
    local rotate = function(x, y)
        return math.cos(deg) * x - math.sin(deg) * y, math.sin(deg) * x + math.cos(deg) * y
    end

    local startX, startY = rotate(300, 0)
    local endX, endY = rotate(1300, 0)

    local x1, y1 = rotate(800, 200)
    assert.is_same(200, Util.round(Util.distanceToLineSegment(startX, startY, endX, endY, x1, y1)))

    local x2, y2 = rotate(800, -200)
    assert.is_same(200, Util.round(Util.distanceToLineSegment(startX, startY, endX, endY, x2, y2)))
end

Util:distanceToLineSegment() returns distance of a point from a line segment (when closest point is the end)

assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, 1300, 200))
assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, 1300, -200))

Util:distanceToLineSegment() returns distance of a point from a line segment (when closest point is the start)

assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, 300, 200))
assert.is_same(200, Util.distanceToLineSegment(300, 0, 1300, 0, 300, -200))

Util:heading()

Util:heading() goes clockwise

local one, two = CpuShip(), CpuShip()
one:setPosition(0, 0)

two:setPosition(-1000, 0)
assert.is_same(270, Util.heading(one, two))

two:setPosition(0, -1000)
assert.is_same(0, Util.heading(one, two))

two:setPosition(1000, 0)
assert.is_same(90, Util.heading(one, two))

Util:heading() raises an error if used incorrectly

assert.has_error(function()
    Util.heading()
end)
assert.has_error(function()
    Util.heading("foo")
end)
assert.has_error(function()
    Util.heading(12, 13, 14)
end)
assert.has_error(function()
    Util.heading(12, CpuShip())
end)

Util:heading() takes positive y axis as 180°

local one, two = CpuShip(), CpuShip()
one:setPosition(0, 0)
two:setPosition(0, 1000)

assert.is_same(180, Util.heading(one, two))

Util:heading() works with coordinates as first argument

local ship = CpuShip()
ship:setPosition(-1000, 0)
assert.is_same(270, Util.heading(0, 0, ship))

Util:heading() works with coordinates as second argument

local ship = CpuShip()
ship:setPosition(0, 0)
assert.is_same(270, Util.heading(ship, -1000, 0))

Util:heading() works with coordinates only

assert.is_same(270, Util.heading(0, 0, -1000, 0))

Util:isNumericTable()

Util:isNumericTable() returns false on table that contains mixed indices

assert.is_false(Util.isNumericTable({42, foo = "bar", 2, 3, "bar", baz = 42}))

Util:isNumericTable() returns false on table that only contains string indices

assert.is_false(Util.isNumericTable({one = 1, two = 2, three = 3}))

Util:isNumericTable() returns true on empty table

assert.is_true(Util.isNumericTable({}))

Util:isNumericTable() returns true on table that only contains numerical indices

assert.is_true(Util.isNumericTable({1, 2, 3, 4}))

Util:keys()

Util:keys() fails if no table is given

assert.has_error(function() Util.keys() end)
assert.has_error(function() Util.keys(42) end)

Util:keys() returns the keys of a table in an arbitrary order

local input = {
    foo = "bar",
    baz = "blubb",
    number = 42,
}
local output = Util.keys(input)

assert.is_table(output)
assert.is_same(3, Util.size(output))
assert.contains_value("foo", output)
assert.contains_value("baz", output)
assert.contains_value("number", output)
assert.is_true(Util.isNumericTable(output))

Util:map()

Util:map() fails when first argument is not a table

assert.has_error(function()
    Util.map(42, function() end)
end)

Util:map() fails when second argument is not a function

assert.has_error(function()
    Util.map({}, 42)
end)

Util:map() makes the keys available in the function

local input = {a=1, b=2, c=3}
local output = Util.map(input, function(value, key) return key .. value end)

assert.is_same({a="a1", b="b2", c="c3"}, output)
assert.not_same(input, output) -- it should not change in-place

Util:map() maps a numberic table

local input = {1, 2, 3, 4}
local output = Util.map(input, function(value) return value+1 end)

assert.is_same({2, 3, 4, 5}, output)
assert.not_same(input, output) -- it should not change in-place

Util:map() maps values and retains keys

local input = {a=1, b=2, c=3}
local output = Util.map(input, function(value) return value+1 end)

assert.is_same({a=2, b=3, c=4}, output)
assert.not_same(input, output) -- it should not change in-place

Util:mergeTables()

Util:mergeTables() can merge three tables

local a = {a = 1, b = 2}
local b = {b = 3, c = 4}
local c = {c = 5, d = 6}

local merged = Util.mergeTables(a, b, c)
assert.is_same({a = 1, b = 3, c = 5, d = 6}, merged)
-- ensure the original tables are not overridden
assert.not_same(a, merged)
assert.not_same(b, merged)
assert.not_same(c, merged)

Util:mergeTables() fails if the first argument is not a table

assert.has_error(function() Util.mergeTables(42, {a = 1}) end)

Util:mergeTables() fails if the second argument is not a table

assert.has_error(function() Util.mergeTables({a = 1}, 42) end)

Util:mergeTables() returns a new table where all items and from the second are present

local a = {a = 1, b = 2}
local b = {c = 3, d = 4}

local merged = Util.mergeTables(a, b)
assert.is_same({a = 1, b = 2, c = 3, d = 4}, merged)
-- ensure the original tables are not overridden
assert.not_same(a, merged)
assert.not_same(b, merged)

Util:mergeTables() the second table overrides the first one

local a = {a = 1, b = 2}
local b = {b = 3, c = 4}

local merged = Util.mergeTables(a, b)
assert.is_same({a = 1, b = 3, c = 4}, merged)

Util:mkString()

Util:mkString() should return a string if lastSeparator is left out

local table = { "one", "two", "three" }

assert.equal(Util.mkString(table, ", "), "one, two, three")

Util:mkString() with lastSeparator parameter should fail when using an associative table

local table = { a = "one", c = "two", b = "three" }

assert.has_error(function()
    Util.mkString(table, ", ", " and ")
end)

Util:mkString() with lastSeparator parameter should return a string for a single value

local table = { "one" }

assert.equal(Util.mkString(table, ", ", " and "), "one")

Util:mkString() with lastSeparator parameter should return a string for three values

local table = { "one", "two", "three" }

assert.equal(Util.mkString(table, ", ", " and "), "one, two and three")

Util:mkString() with lastSeparator parameter should return a string for two values

local table = { "one", "two" }

assert.equal(Util.mkString(table, ", ", " and "), "one and two")

Util:mkString() with lastSeparator parameter should return an empty string if table is empty

local table = {}

assert.equal(Util.mkString(table, ", ", " and "), "")

Util:onVector()

Util:onVector() returns a point between point 1 and point 2

local x, y = Util.onVector(1000, 2000, 3000, 4000, 0.5)
assert.is_same({2000, 3000}, {x, y})

Util:onVector() returns point 1 when ratio is 0

local x, y = Util.onVector(1000, 2000, 3000, 4000, 0)
assert.is_same({1000, 2000}, {x, y})

Util:onVector() returns point 2 when ratio is 1

local x, y = Util.onVector(1000, 2000, 3000, 4000, 1)
assert.is_same({3000, 4000}, {x, y})

Util:onVector() works with an object and coordinates

local ship = CpuShip():setPosition(1000, 2000)
local x, y = Util.onVector(ship, 3000, 4000, 0.5)
assert.is_same({2000, 3000}, {x, y})

Util:onVector() works with coordinate and an object

local ship = CpuShip():setPosition(3000, 4000)
local x, y = Util.onVector(1000, 2000, ship, 0.5)
assert.is_same({2000, 3000}, {x, y})

Util:onVector() works with two objects

local ship1 = CpuShip():setPosition(1000, 2000)
local ship2 = CpuShip():setPosition(3000, 4000)
local x, y = Util.onVector(ship1, ship2, 0.5)
assert.is_same({2000, 3000}, {x, y})

Util:random()

Util:random() allows to filter elements

local thing1 = { foo = "bar" }
local thing2 = { baz = "bar" }
local thing3 = { blu = "bla" }

local testDummy = {thing1, thing2, thing3 }

local thing1Seen = false
local thing2Seen = false
local thing3Seen = false

for i=1,16,1 do
    local result = Util.random(testDummy, function(k, v)
        return k ~= 3
    end)
    if result == thing1 then thing1Seen = true elseif result == thing2 then thing2Seen = true elseif result == thing3 then thing3Seen = true end
end

assert.is_true(thing1Seen)
assert.is_true(thing2Seen)
assert.is_false(thing3Seen)

Util:random() returns all items from the list at random

local thing1 = { foo = "bar" }
local thing2 = { baz = "bar" }

local testDummy = {thing1, thing2 }

local thing1Seen = false
local thing2Seen = false

for i=1,16,1 do
    local result = Util.random(testDummy)
    if result == thing1 then thing1Seen = true elseif result == thing2 then thing2Seen = true end
end

assert.is_true(thing1Seen)
assert.is_true(thing2Seen)

Util:random() returns an element from a non-empty list with index

local thing = { foo = "bar" }

assert.is.equal(Util.random({foo = thing}), thing)

Util:random() returns an element from a non-empty list with numerical index

local thing = { foo = "bar" }

assert.is.equal(Util.random({thing}), thing)

Util:random() returns nil if list is empty

assert.is_nil(Util.random({}))

Util:random() returns nil if the filter does not leave any item

assert.is_nil(Util.random({1, 2, 3, 4}, function() return false end))

Util:randomSort()

Util:randomSort() fails if no table is given

assert.has_error(function() Util.randomSort() end)

Util:randomSort() randomly sorts a named list

local input = {a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10, k=11, l=12, m=13, n=14, o=15, p=16}
local output = Util.randomSort(input)

assert.is_table(output)
assert.is_same(16, Util.size(output))
assert.contains_value(8, output)
assert.is_true(Util.isNumericTable(output))
assert.not_same(input, output)

Util:randomSort() randomly sorts a numeric list

local input = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
local output = Util.randomSort(input)

assert.is_table(output)
assert.is_same(16, Util.size(output))
assert.contains_value(8, output)
assert.is_true(Util.isNumericTable(output))
assert.not_same(input, output)

Util:randomSort() returns different results each time in a numeric list

local input = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
local output1 = Util.randomSort(input)
local output2 = Util.randomSort(input)

assert.not_same(output1, output2)

Util:randomSort() returns different results each time in a numeric list

local input = {a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10, k=11, l=12, m=13, n=14, o=15, p=16}
local output1 = Util.randomSort(input)
local output2 = Util.randomSort(input)

assert.not_same(output1, output2)

Util:randomUuid()

Util:randomUuid() should not return the same uuid twice

local uuid = Util.randomUuid()
local uuid2 = Util.randomUuid()
assert.not_equal(uuid, uuid2)

Util:randomUuid() should return a 16 digit hex

local uuid = Util.randomUuid()
assert.not_nil(uuid:match("^([0-9a-f]+)$"))
assert.equal(uuid:len(), 16)

Util:round()

Util:round() can correctly round to a base of 10

assert.is_same(0, Util.round(0, 10))
assert.is_same(0, Util.round(1, 10))
assert.is_same(0, Util.round(4, 10))
assert.is_same(0, Util.round(4.9, 10))
assert.is_same(10, Util.round(5.1, 10))
assert.is_same(10, Util.round(6, 10))
assert.is_same(10, Util.round(9, 10))
assert.is_same(10, Util.round(10, 10))

Util:round() can round to different bases

assert.is_same(40, Util.round(42, 5))
assert.is_same(42, Util.round(42, 7))
assert.is_same(40, Util.round(42, 10))

Util:round() rounds mathematically correct for negative numbers

assert.is_same(-42, Util.round(-42))
assert.is_same(-42, Util.round(-42.1))
assert.is_same(-42, Util.round(-42.4))
assert.is_same(-42, Util.round(-42.49))
-- because of float magic do not test -42.5 directly
assert.is_same(-43, Util.round(-42.51))
assert.is_same(-43, Util.round(-42.6))
assert.is_same(-43, Util.round(-42.9))

Util:round() rounds mathematically correct for positive numbers

assert.is_same(42, Util.round(42))
assert.is_same(42, Util.round(42.1))
assert.is_same(42, Util.round(42.4))
assert.is_same(42, Util.round(42.49))
-- because of float magic do not test 42.5 directly
assert.is_same(43, Util.round(42.51))
assert.is_same(43, Util.round(42.6))
assert.is_same(43, Util.round(42.9))

Util:size()

Util:size() correctly determines size of a table with mixed indices

assert.is.same(Util.size({
    foo = 42,
    bar = "baz",
    baz = {},
    42,
}), 4)

Util:size() correctly determines size of a table with numerical index

assert.is.same(Util.size({42, "foobar", {}}), 3)

Util:size() correctly determines size of a table with object index

assert.is.same(Util.size({
    foo = 42,
    bar = "baz",
    baz = {}
}), 3)

Util:size() correctly determines size of an empty table

assert.is.same(Util.size({}), 0)

Util:vectorFromAngle()

Util:vectorFromAngle() has the x axis for 0 degree

local x, y = Util.vectorFromAngle(0, 1000)

assert.is_same(1000, math.floor(x))
assert.is_same(0, math.floor(y))

Util:vectorFromAngle() it works counter clockwise

local x, y = Util.vectorFromAngle(90, 1000)

assert.is_same(0, math.floor(x))
assert.is_same(1000, math.floor(y))

Util:vectorFromAngle() it works with degrees

local x, y = Util.vectorFromAngle(180, 1000)

assert.is_same(-1000, math.floor(x))
assert.is_same(0, math.floor(y))

Other

typeInspect()

typeInspect() prints PlayerSpaceShip

local player = PlayerSpaceship():setCallSign("Artemis")
assert.is_same("<PlayerSpaceship>\"Artemis\"", typeInspect(player))

player:destroy()
assert.is_same("<PlayerSpaceship>", typeInspect(player))

typeInspect() prints boolean

assert.is_same("<bool>true", typeInspect(true))
assert.is_same("<bool>false", typeInspect(false))

typeInspect() prints function

assert.is_same("<function>", typeInspect(function() end))

typeInspect() prints nil

assert.is_same("<nil>", typeInspect(nil))

typeInspect() prints number

assert.is_same("<number>42", typeInspect(42))
assert.is_same("<number>-123", typeInspect(-123))
assert.is_same("<number>12.3456", typeInspect(12.3456))

typeInspect() prints strings

assert.is_same("<string>\"foobar\"", typeInspect("foobar"))
assert.is_same("<string>\"This is a very long sting that...\"", typeInspect("This is a very long sting that should be cut off in order to not be too long."))
assert.is_same("<string>\"\"", typeInspect(""))

typeInspect() prints tables with numeric key

assert.is_same("<table>(size: 2)", typeInspect({"foo", "bar"}))

typeInspect() prints tables with string key

assert.is_same("<table>(size: 2)", typeInspect({foo = "bar", baz = 42}))

userCallback()

userCallback() calls the callback and returns the args if no error occurs

local a,b,c = userCallback(function(arg1, arg2, arg3) return arg1 * 7, arg2 .. "bar", not arg3 end, 6, "foo", true)

assert.is_same(42, a)
assert.is_same("foobar", b)
assert.is_false(c)

userCallback() logs an error and returns nil if an error occurs

withLogCatcher(function(logs)
    local result = userCallback(function() error("Fail") end)
    assert.is_nil(result)
    assert.is_not_nil(logs:popLastError())
end)

userCallback() logs an error if an invalid function is given

withLogCatcher(function(logs)
    local result = userCallback("foobar")
    assert.is_nil(result)
    assert.is_not_nil(logs:popLastError())
end)

userCallback() returns if no function is given

withLogCatcher(function(logs)
    local result = userCallback(nil)
    assert.is_nil(result)
    assert.is_nil(logs:popLastError())
end)