-- 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)