first commit
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
-- 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)
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Permet de lancer les tests avec LÖVE : love . --tests
|
||||
-- Ce fichier est chargé par main.lua quand l'argument --tests est présent.
|
||||
-- Il exécute tests/run_tests.lua dans l'environnement courant puis quitte.
|
||||
|
||||
local oldPrint = print
|
||||
local lines = {}
|
||||
print = function(...)
|
||||
local parts = {}
|
||||
for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end
|
||||
lines[#lines + 1] = table.concat(parts, "\t")
|
||||
oldPrint(...)
|
||||
end
|
||||
|
||||
-- os.exit ne fonctionne pas sous love : on intercepte
|
||||
local exitCode = 0
|
||||
os.exit = function(code) exitCode = code or 0; error({ __testDone = true }) end
|
||||
|
||||
local chunk, err = loadfile("tests/run_tests.lua")
|
||||
if not chunk then
|
||||
oldPrint("Impossible de charger les tests : " .. tostring(err))
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
local ok, e = pcall(chunk)
|
||||
if not ok and not (type(e) == "table" and e.__testDone) then
|
||||
oldPrint("Erreur pendant les tests : " .. tostring(e))
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
oldPrint(table.concat(lines, "\n"))
|
||||
love.event.quit(exitCode)
|
||||
Reference in New Issue
Block a user