Files
2026-07-21 16:28:15 +02:00

262 lines
11 KiB
Lua

-- Tests de logique pure, exécutables avec : lua tests/run_tests.lua (ou luajit)
-- Depuis la racine du projet.
package.path = "./?.lua;./?/init.lua;" .. package.path
local failures, passed = 0, 0
local function ok(cond, name)
if cond then passed = passed + 1
else failures = failures + 1; print("ÉCHEC : " .. name) end
end
local C = require("src.config")
local Noise = require("src.noise")
local World = require("src.world")
local Pathfind = require("src.pathfind")
local Weather = require("src.weather")
local Buildings = require("src.buildings")
local Robot = require("src.robot")
local Tasks = require("src.tasks")
local Game = require("src.game")
local Save = require("src.save")
-- 1. Génération déterministe
do
local w1, w2, w3 = World.new(42), World.new(42), World.new(43)
local same = true
for y = -30, 30 do for x = -30, 30 do
if w1:getTile(x, y) ~= w2:getTile(x, y) then same = false end
end end
ok(same, "monde déterministe : même graine = même terrain")
local diff = false
for y = -30, 30 do for x = -30, 30 do
if w1:getTile(x, y) ~= w3:getTile(x, y) then diff = true; break end
end if diff then break end end
ok(diff, "monde déterministe : graine différente = terrain différent")
-- chunks générés à la demande
local w = World.new(7)
ok(next(w.chunks) == nil, "aucun chunk pré-généré")
w:getTile(0, 0)
ok(next(w.chunks) ~= nil, "chunk généré à la demande")
end
-- 2. Météo reproductible et prévision pas arbitraire
do
ok(Weather.forDay(99, 5) == Weather.forDay(99, 5), "météo reproductible")
local exact, approx = 0, 0
for d = 1, 50 do
local f = Weather.forecast(99, d)
if f == Weather.forDay(99, d) then exact = exact + 1 end
if f == C.WEATHER.CLEAR or f == C.WEATHER.COLD or f == C.WEATHER.STORM then approx = approx + 1 end
end
ok(exact >= 35, "prévision majoritairement exacte (" .. exact .. "/50)")
ok(approx == 50, "prévision toujours une météo valide")
end
-- 3. Recherche de chemin : évite les obstacles, tient compte des coûts
do
-- grille 10x10 avec un mur vertical en x=5 (sauf passage en y=9)
local function cost(x, y)
if x < 0 or x > 9 or y < 0 or y > 9 then return math.huge end
if x == 5 and y ~= 9 then return math.huge end
return 1
end
local p = Pathfind.find(0, 0, 9, 0, cost)
ok(p ~= nil, "A* trouve un chemin autour du mur")
local throughGap = false
for _, n in ipairs(p) do if n.x == 5 and n.y == 9 then throughGap = true end end
ok(throughGap, "A* passe par l'unique ouverture")
local p2 = Pathfind.find(0, 0, 9, 0, function(x, y) return math.huge end)
ok(p2 == nil, "A* renvoie nil si tout est bloqué")
-- coûts : préfère contourner une zone chère si plus court en coût
local function costly(x, y)
if x < 0 or x > 9 or y < 0 or y > 9 then return math.huge end
if y == 0 and x > 0 and x < 9 then return 100 end -- ligne directe très chère
return 1
end
local p3 = Pathfind.find(0, 0, 9, 0, costly)
local expensive = 0
for _, n in ipairs(p3) do if n.y == 0 and n.x > 0 and n.x < 9 then expensive = expensive + 1 end end
ok(expensive == 0, "A* évite les cases à coût élevé")
end
-- 4. Placement de bâtiments : règles et coûts
do
local g = Game.new(1234)
local res = { energy = 0, ore = 100, data = 200 }
-- non exploré -> refus
local okk, reason = g.buildings:canPlace("solar", 50, 50, g.world, res)
ok(not okk and reason:match("explor"), "refus sur case inexplorée : " .. tostring(reason))
-- exploré plaine -> ok
local okk2 = g.buildings:canPlace("solar", 1, 1, g.world, res)
ok(okk2, "panneau solaire posable sur plaine explorée")
-- extracteur nécessite gisement : trouver un gisement exploré
g.world:exploreRadius(0, 0, 30)
local oreX, oreY
for y = -30, 30 do for x = -30, 30 do
if g.world:getTile(x, y) == C.T.ORE and not oreX and not g.buildings:at(x, y)
and g.world:isExplored(x, y) then oreX, oreY = x, y end
end end
ok(oreX ~= nil, "gisement trouvé à proximité")
local okk3, r3 = g.buildings:canPlace("extractor", 2, 2, g.world, res)
ok(not okk3, "extracteur refusé hors gisement")
local okk4, r4 = g.buildings:canPlace("extractor", oreX, oreY, g.world, res)
ok(okk4, "extracteur accepté sur gisement (" .. tostring(oreX) .. "," .. tostring(oreY) .. " : " .. tostring(r4) .. ")")
-- coût prélevé
local before = res.ore
g.buildings:place("solar", 1, 1, res)
ok(res.ore == before - C.BUILDINGS.solar.cost.ore, "coût de construction prélevé")
-- minerai insuffisant
local okk5 = g.buildings:canPlace("workshop", 2, 1, g.world, { ore = 0, data = 0 })
ok(not okk5, "refus si minerai insuffisant")
-- distance relais
g.buildings:place("relay", -5, -5, nil)
local okk6, r6 = g.buildings:canPlace("relay", -4, -5, g.world, res)
ok(not okk6, "relais refusé trop proche d'un autre relais")
-- balise unique
g.buildings:place("beacon", 3, 3, nil)
local okk7 = g.buildings:canPlace("beacon", 4, 4, g.world, res)
ok(not okk7, "balise unique : second plan refusé")
end
-- 5. Production des bâtiments
do
local g = Game.new(777)
local res = { energy = 10, ore = 0, data = 0 }
local s = g.buildings:place("solar", 1, 1, nil); s.built = true
g.buildings:simulate(60, res, C.WEATHER.CLEAR, g)
ok(res.energy > 10 + 30, "solaire produit de l'énergie par temps normal")
local eClear = res.energy
res.energy = 10
g.buildings:simulate(60, res, C.WEATHER.STORM, g)
ok(res.energy < 10 + (eClear - 10) * 0.5, "solaire réduit en tempête")
-- extracteur : produit du minerai, consomme de l'énergie
res.energy = 50
local ex = g.buildings:place("extractor", 2, 2, nil); ex.built = true
g.buildings:simulate(60, res, C.WEATHER.CLEAR, g)
ok(res.ore > 0, "extracteur produit du minerai")
-- batterie augmente le stockage
local cap0 = g.buildings:energyStorage()
local bt = g.buildings:place("battery", 3, 3, nil); bt.built = true
ok(g.buildings:energyStorage() == cap0 + C.BUILDINGS.battery.storageBonus,
"batterie augmente la capacité de stockage")
end
-- 6. Planification : ajout, suppression, réorganisation, validation, sérialisation
do
local g = Game.new(55)
local t = g.tasks
t:add({ type = "explore", x = 5, y = 5 })
t:add({ type = "recharge" })
t:add({ type = "explore", x = 6, y = 6 })
ok(#t.list == 3, "ajout de tâches")
t:move(3, -1)
ok(t.list[2].x == 6, "réorganisation des tâches")
t:remove(1)
ok(#t.list == 2 and t.list[1].x == 6, "suppression de tâche")
-- validation : tâche de construction sans plan -> impossible
t:add({ type = "build", buildingId = 999 })
t:validate(g)
ok(t.list[3].state == "impossible", "tâche manifestement impossible identifiée")
-- sérialisation aller-retour
local s = t:serialize()
local t2 = Tasks.new()
t2:deserialize(s)
ok(#t2.list == 3 and t2.list[1].x == 6 and t2.list[2].type == "recharge",
"sérialisation des tâches aller-retour")
-- estimation présente
ok(Tasks.estimate({ type = "explore", x = 10, y = 0 }, g.robot, g) > 0, "estimation de durée")
end
-- 7. Robot : déplacement, énergie, sérialisation
do
local g = Game.new(88)
local r = g.robot
local okSet = r:setDestination(3, 0, g:costFn())
ok(okSet, "destination atteignable acceptée")
local arrived = false
for i = 1, 5000 do
local res = r:updateMove(0.05, g.world, C.WEATHER.CLEAR, g:costFn())
if res == true then arrived = true; break end
if res == "stranded" then break end
end
ok(arrived, "le robot rejoint sa destination automatiquement")
ok(r.energy < C.ROBOT.energyMax, "le déplacement consomme de l'énergie")
ok(r.wear > 0, "le déplacement use le robot")
-- épuisement -> immobilisation
r.energy = 0.01
r:setDestination(30, 30, g:costFn())
local stranded = false
for i = 1, 5000 do
if r:updateMove(0.05, g.world, C.WEATHER.CLEAR, g:costFn()) == "stranded" then stranded = true; break end
end
ok(stranded, "robot à court d'énergie = immobilisé")
-- sérialisation
local s = r:serialize()
local r2 = Robot.deserialize(s)
ok(math.abs(r2.x - r.x) < 0.001 and math.abs(r2.wear - r.wear) < 0.01, "robot sérialisation aller-retour")
end
-- 8. Sauvegarde complète aller-retour (sans love.filesystem)
do
local g = Game.new(2024)
g.res.ore = 77
g.world:exploreRadius(10, 10, 4)
local b = g.buildings:place("solar", 1, 1, nil)
b.progress = 12
g.tasks:add({ type = "build", buildingId = b.id, x = 1, y = 1 })
g.objectivesDone["solar1"] = true
local text = Save.encode(g)
local g2 = Save.decode(text, Game)
ok(g2 ~= nil, "décodage de la sauvegarde")
ok(g2.seed == g.seed and g2.day == g.day, "graine et jour conservés")
ok(g2.res.ore == 77, "ressources conservées")
ok(g2.world.exploredCount == g.world.exploredCount, "zones explorées conservées")
local b2 = g2.buildings:get(b.id)
ok(b2 and b2.kind == "solar" and math.abs(b2.progress - 12) < 0.01, "plans de construction conservés")
ok(#g2.tasks.list == 1 and g2.tasks.list[1].type == "build", "tâches restantes conservées")
ok(g2.objectivesDone["solar1"] == true, "progression des objectifs conservée")
ok(g2.station ~= nil, "station présente après chargement")
end
-- 9. Boucle de journée : exécution d'une tâche de construction de bout en bout
do
local g = Game.new(31415)
g.res.ore = 100
local b = g.buildings:place("solar", 1, 0, g.res)
g.tasks:add({ type = "build", buildingId = b.id, x = 1, y = 0 })
g:startDay()
ok(g.phase == "execution", "passage en phase exécution")
for i = 1, 6000 do
g:update(1 / 30)
if b.built or g.phase == "planning" then break end
end
ok(b.built, "le robot construit le bâtiment progressivement")
ok(g.tasks.list[1].state == "done", "tâche marquée terminée")
-- fin de journée -> retour en planification, jour +1
local day0 = g.day
for i = 1, 30000 do
g:update(1 / 30)
if g.phase == "planning" then break end
end
ok(g.phase == "planning" and g.day == day0 + 1, "journée terminée, jour suivant planifiable")
end
-- 10. Météo impacte la stratégie (froid augmente la consommation du robot)
do
local g = Game.new(606)
local function drainFor(weather)
local r = Robot.new(0.5, 0.5)
r:setDestination(5, 0, g:costFn())
for i = 1, 4000 do
local res = r:updateMove(0.05, g.world, weather, g:costFn())
if res == true or res == "stranded" then break end
end
return C.ROBOT.energyMax - r.energy
end
local dClear, dCold = drainFor(C.WEATHER.CLEAR), drainFor(C.WEATHER.COLD)
ok(dCold > dClear * 1.5, ("froid intense : consommation accrue (%.1f vs %.1f)"):format(dCold, dClear))
end
print(("\n%d tests réussis, %d échecs."):format(passed, failures))
os.exit(failures == 0 and 0 or 1)