From 73e74a2859a4964dcd5f73a9a74f5b870f789497 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Mon, 13 Jul 2026 22:09:31 +0200 Subject: [PATCH] Implement NetWorker network puzzle game --- .../mindflux/config/NetworkerSeeder.java | 16 +++++ .../mindflux/config/SecurityConfig.java | 2 +- .../controller/NetworkerApiController.java | 8 +++ .../controller/NetworkerController.java | 4 ++ .../mindflux/dto/NetworkCheckRequest.java | 3 + .../mindflux/dto/NetworkFinishRequest.java | 3 + .../example/mindflux/dto/NetworkResult.java | 2 + .../example/mindflux/model/NetworkLevel.java | 17 +++++ .../example/mindflux/model/NetworkWorld.java | 21 ++++++ .../repository/NetworkLevelRepository.java | 4 ++ .../repository/NetworkWorldRepository.java | 5 ++ .../mindflux/service/AchievementService.java | 1 + .../service/AchievementServiceImpl.java | 6 ++ .../mindflux/service/NetSimulator.java | 71 +++++++++++++++++++ .../mindflux/service/NetworkerService.java | 28 ++++++++ .../resources/db/migration/V14__networker.sql | 23 ++++++ src/main/resources/networker/content.json | 23 ++++++ .../static/css/accessibility-placement.css | 1 + src/main/resources/static/css/networker.css | 1 + src/main/resources/static/js/accessibility.js | 2 +- src/main/resources/static/js/networker.js | 12 ++++ src/main/resources/static/js/theme.js | 4 +- src/main/resources/templates/dashboard.html | 2 +- src/main/resources/templates/networker.html | 1 + .../mindflux/service/NetSimulatorTest.java | 37 ++++++++++ 25 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/mindflux/config/NetworkerSeeder.java create mode 100644 src/main/java/com/example/mindflux/controller/NetworkerApiController.java create mode 100644 src/main/java/com/example/mindflux/controller/NetworkerController.java create mode 100644 src/main/java/com/example/mindflux/dto/NetworkCheckRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/NetworkFinishRequest.java create mode 100644 src/main/java/com/example/mindflux/dto/NetworkResult.java create mode 100644 src/main/java/com/example/mindflux/model/NetworkLevel.java create mode 100644 src/main/java/com/example/mindflux/model/NetworkWorld.java create mode 100644 src/main/java/com/example/mindflux/repository/NetworkLevelRepository.java create mode 100644 src/main/java/com/example/mindflux/repository/NetworkWorldRepository.java create mode 100644 src/main/java/com/example/mindflux/service/NetSimulator.java create mode 100644 src/main/java/com/example/mindflux/service/NetworkerService.java create mode 100644 src/main/resources/db/migration/V14__networker.sql create mode 100644 src/main/resources/networker/content.json create mode 100644 src/main/resources/static/css/networker.css create mode 100644 src/main/resources/static/js/networker.js create mode 100644 src/main/resources/templates/networker.html create mode 100644 src/test/java/com/example/mindflux/service/NetSimulatorTest.java diff --git a/src/main/java/com/example/mindflux/config/NetworkerSeeder.java b/src/main/java/com/example/mindflux/config/NetworkerSeeder.java new file mode 100644 index 0000000..c4daf55 --- /dev/null +++ b/src/main/java/com/example/mindflux/config/NetworkerSeeder.java @@ -0,0 +1,16 @@ +package com.example.mindflux.config; + +import com.example.mindflux.model.*; +import com.example.mindflux.repository.NetworkWorldRepository; +import com.fasterxml.jackson.databind.*; +import org.slf4j.*; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component public class NetworkerSeeder implements CommandLineRunner { + private static final Logger log=LoggerFactory.getLogger(NetworkerSeeder.class); private final NetworkWorldRepository worlds; private final ObjectMapper mapper; + public NetworkerSeeder(NetworkWorldRepository worlds,ObjectMapper mapper){this.worlds=worlds;this.mapper=mapper;} + @Override @Transactional public void run(String...args){if(worlds.count()>0)return;try{JsonNode root=mapper.readTree(new ClassPathResource("networker/content.json").getInputStream());int wi=0,li=0;for(JsonNode world:root.path("worlds")){NetworkWorld item=new NetworkWorld(world.path("slug").asText(),world.path("title").asText(),world.path("description").asText(),world.path("difficulty").asText("junior"),world.path("examScope").asText("AP1"),wi++);int order=0;for(JsonNode level:world.path("levels")){String payload=mapper.writeValueAsString(level.path("payload"));if(payload.length()<=8000){item.addLevel(new NetworkLevel(level.path("title").asText(),level.path("type").asText("assign"),level.path("points").asInt(100),order++,payload));li++;}}worlds.save(item);}log.info("NetWorker: {} Welten / {} Level geladen.",wi,li);}catch(Exception e){log.error("NetWorker-Seed fehlgeschlagen",e);}} +} diff --git a/src/main/java/com/example/mindflux/config/SecurityConfig.java b/src/main/java/com/example/mindflux/config/SecurityConfig.java index 7d70c61..7d02e13 100644 --- a/src/main/java/com/example/mindflux/config/SecurityConfig.java +++ b/src/main/java/com/example/mindflux/config/SecurityConfig.java @@ -42,7 +42,7 @@ public class SecurityConfig { .csrf(csrf -> csrf // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. - .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**") + .ignoringRequestMatchers("/api/games/**", "/api/techtacular/**", "/api/mindmatch/**", "/api/syntaxsaga/**", "/api/devlab/**", "/api/codeescape/**", "/api/bughunter/**", "/api/networker/**") ); return http.build(); diff --git a/src/main/java/com/example/mindflux/controller/NetworkerApiController.java b/src/main/java/com/example/mindflux/controller/NetworkerApiController.java new file mode 100644 index 0000000..5b03a89 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/NetworkerApiController.java @@ -0,0 +1,8 @@ +package com.example.mindflux.controller; +import com.example.mindflux.dto.*; +import com.example.mindflux.service.NetworkerService; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.servlet.http.HttpSession; +import org.springframework.web.bind.annotation.*; +import java.security.Principal; +@RestController @RequestMapping("/api/networker") public class NetworkerApiController { private final NetworkerService service; public NetworkerApiController(NetworkerService service){this.service=service;} @GetMapping("/catalog") public JsonNode catalog(){return service.catalog();} @GetMapping("/session/{slug}") public JsonNode session(@PathVariable String slug,HttpSession session){return service.session(slug,session);} @PostMapping("/check") public JsonNode check(@RequestBody NetworkCheckRequest request,HttpSession session){return service.check(request,session);} @PostMapping("/finish") public NetworkResult finish(@RequestBody NetworkFinishRequest request,Principal principal,HttpSession session){return service.finish(principal.getName(),request,session);} } diff --git a/src/main/java/com/example/mindflux/controller/NetworkerController.java b/src/main/java/com/example/mindflux/controller/NetworkerController.java new file mode 100644 index 0000000..893c681 --- /dev/null +++ b/src/main/java/com/example/mindflux/controller/NetworkerController.java @@ -0,0 +1,4 @@ +package com.example.mindflux.controller; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +@Controller public class NetworkerController { @GetMapping("/networker") public String page(){return "networker";} } diff --git a/src/main/java/com/example/mindflux/dto/NetworkCheckRequest.java b/src/main/java/com/example/mindflux/dto/NetworkCheckRequest.java new file mode 100644 index 0000000..09fccf2 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/NetworkCheckRequest.java @@ -0,0 +1,3 @@ +package com.example.mindflux.dto; +import com.fasterxml.jackson.databind.JsonNode; +public class NetworkCheckRequest { private Long levelId; private boolean reveal; private JsonNode config; public Long getLevelId(){return levelId;} public void setLevelId(Long value){levelId=value;} public boolean isReveal(){return reveal;} public void setReveal(boolean value){reveal=value;} public JsonNode getConfig(){return config;} public void setConfig(JsonNode value){config=value;} } diff --git a/src/main/java/com/example/mindflux/dto/NetworkFinishRequest.java b/src/main/java/com/example/mindflux/dto/NetworkFinishRequest.java new file mode 100644 index 0000000..efe3f8c --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/NetworkFinishRequest.java @@ -0,0 +1,3 @@ +package com.example.mindflux.dto; +import java.util.*; +public class NetworkFinishRequest { private String slug; private int durationSeconds; private List levelIds; public String getSlug(){return slug;} public void setSlug(String value){slug=value;} public int getDurationSeconds(){return durationSeconds;} public void setDurationSeconds(int value){durationSeconds=value;} public List getLevelIds(){return levelIds;} public void setLevelIds(List value){levelIds=value;} } diff --git a/src/main/java/com/example/mindflux/dto/NetworkResult.java b/src/main/java/com/example/mindflux/dto/NetworkResult.java new file mode 100644 index 0000000..9f847e0 --- /dev/null +++ b/src/main/java/com/example/mindflux/dto/NetworkResult.java @@ -0,0 +1,2 @@ +package com.example.mindflux.dto; +public record NetworkResult(int score, int maxScore, int solved, int total, int stars, int highscore, boolean newHighscore, long plays, boolean perfect) { } diff --git a/src/main/java/com/example/mindflux/model/NetworkLevel.java b/src/main/java/com/example/mindflux/model/NetworkLevel.java new file mode 100644 index 0000000..aafe4b8 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/NetworkLevel.java @@ -0,0 +1,17 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; + +@Entity @Table(name = "networker_levels") +public class NetworkLevel { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "world_id", nullable = false) private NetworkWorld world; + @Column(nullable = false) private String title; + @Column(name = "level_type") private String levelType; + private int points; + @Column(name = "sort_order") private int sortOrder; + @Column(length = 8000, nullable = false) private String payload; + public NetworkLevel() { } + public NetworkLevel(String title, String levelType, int points, int sortOrder, String payload) { this.title=title; this.levelType=levelType; this.points=points; this.sortOrder=sortOrder; this.payload=payload; } + public Long getId(){return id;} public void setId(Long value){id=value;} public NetworkWorld getWorld(){return world;} public void setWorld(NetworkWorld value){world=value;} public String getTitle(){return title;} public String getLevelType(){return levelType;} public int getPoints(){return points;} public int getSortOrder(){return sortOrder;} public String getPayload(){return payload;} +} diff --git a/src/main/java/com/example/mindflux/model/NetworkWorld.java b/src/main/java/com/example/mindflux/model/NetworkWorld.java new file mode 100644 index 0000000..57d1407 --- /dev/null +++ b/src/main/java/com/example/mindflux/model/NetworkWorld.java @@ -0,0 +1,21 @@ +package com.example.mindflux.model; + +import jakarta.persistence.*; +import java.util.*; + +@Entity @Table(name = "networker_worlds") +public class NetworkWorld { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(nullable = false, unique = true) private String slug; + @Column(nullable = false) private String title; + private String description; + private String difficulty; + @Column(name = "exam_scope") private String examScope; + @Column(name = "sort_order") private int sortOrder; + private boolean active = true; + @OneToMany(mappedBy = "world", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("sortOrder ASC, id ASC") private List levels = new ArrayList<>(); + public NetworkWorld() { } + public NetworkWorld(String slug, String title, String description, String difficulty, String examScope, int sortOrder) { this.slug=slug; this.title=title; this.description=description; this.difficulty=difficulty; this.examScope=examScope; this.sortOrder=sortOrder; } + public void addLevel(NetworkLevel level) { level.setWorld(this); levels.add(level); } + public Long getId(){return id;} public void setId(Long value){id=value;} public String getSlug(){return slug;} public String getTitle(){return title;} public String getDescription(){return description;} public String getDifficulty(){return difficulty;} public String getExamScope(){return examScope;} public int getSortOrder(){return sortOrder;} public boolean isActive(){return active;} public void setActive(boolean value){active=value;} public List getLevels(){return levels;} +} diff --git a/src/main/java/com/example/mindflux/repository/NetworkLevelRepository.java b/src/main/java/com/example/mindflux/repository/NetworkLevelRepository.java new file mode 100644 index 0000000..59003d0 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/NetworkLevelRepository.java @@ -0,0 +1,4 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.NetworkLevel; +import org.springframework.data.jpa.repository.JpaRepository; +public interface NetworkLevelRepository extends JpaRepository { } diff --git a/src/main/java/com/example/mindflux/repository/NetworkWorldRepository.java b/src/main/java/com/example/mindflux/repository/NetworkWorldRepository.java new file mode 100644 index 0000000..a4d8649 --- /dev/null +++ b/src/main/java/com/example/mindflux/repository/NetworkWorldRepository.java @@ -0,0 +1,5 @@ +package com.example.mindflux.repository; +import com.example.mindflux.model.NetworkWorld; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.*; +public interface NetworkWorldRepository extends JpaRepository { List findAllByActiveTrueOrderBySortOrderAscIdAsc(); Optional findBySlug(String slug); } diff --git a/src/main/java/com/example/mindflux/service/AchievementService.java b/src/main/java/com/example/mindflux/service/AchievementService.java index 9050f2c..e4ee07f 100644 --- a/src/main/java/com/example/mindflux/service/AchievementService.java +++ b/src/main/java/com/example/mindflux/service/AchievementService.java @@ -14,4 +14,5 @@ public interface AchievementService { * Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen. */ void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame); + void awardSpecial(String username, String title, String description); } diff --git a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java index 281daab..c852314 100644 --- a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java +++ b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java @@ -72,6 +72,12 @@ public class AchievementServiceImpl implements AchievementService { gameScoreRepository.countDistinctGamesByUsername(username) >= 4); } + @Override @Transactional + public void awardSpecial(String username, String title, String description) { + Set existing = achievementRepository.findByUsername(username).stream().map(Achievement::getTitle).collect(Collectors.toSet()); + if (!existing.contains(title)) { Achievement achievement = new Achievement(title, description, null, true); achievement.setUsername(username); achievementRepository.save(achievement); } + } + private void award(Set existing, String username, String title, boolean condition) { if (!condition || existing.contains(title)) { return; diff --git a/src/main/java/com/example/mindflux/service/NetSimulator.java b/src/main/java/com/example/mindflux/service/NetSimulator.java new file mode 100644 index 0000000..3f67ed6 --- /dev/null +++ b/src/main/java/com/example/mindflux/service/NetSimulator.java @@ -0,0 +1,71 @@ +package com.example.mindflux.service; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.*; + +/** Kleine, deterministische IPv4-Simulation für NetWorker. Sie bewertet Erreichbarkeit statt Mustertexte. */ +public final class NetSimulator { + private NetSimulator() { } + public record Goal(String id, boolean passed, boolean reachable, List trace, String reason, String message) { } + public record Simulation(List goals) { public boolean allPassed() { return goals.stream().allMatch(Goal::passed); } } + + public static Simulation simulate(JsonNode net) { + Map nodes = nodes(net); List result = new ArrayList<>(); + String validation = validation(nodes); + for (JsonNode goal : net.path("goals")) { + String id = goal.path("id").asText("ziel"), from = goal.path("from").asText(), to = goal.path("to").asText(); boolean expected = goal.path("shouldReach").asBoolean(true); + if (validation != null) { result.add(new Goal(id, false, false, List.of(from), "invalid_config", validation)); continue; } + Node source = nodes.get(from), target = nodes.get(to); + if (source == null || target == null) { result.add(new Goal(id, false, false, List.of(from), "missing_node", "Ein Zielgerät fehlt in der Topologie.")); continue; } + Route route = route(nodes, source, target, new LinkedHashSet<>(), 8); + boolean passed = route.reachable == expected; + String message = passed ? (expected ? "Paket zugestellt." : "Der Sperrpfad bleibt korrekt geschlossen.") : route.message; + result.add(new Goal(id, passed, route.reachable, route.trace, route.reason, message)); + } + return new Simulation(result); + } + + private static Route route(Map nodes, Node from, Node target, LinkedHashSet seen, int ttl) { + if (ttl <= 0) return Route.fail(List.copyOf(seen), "routing_loop", "Die Route endet in einer Schleife."); + if (sameSubnet(from.ip, from.prefix, target.ip)) return Route.ok(List.of(from.id, target.id)); + if (from.gateway == null) return Route.fail(List.of(from.id), "missing_gateway", "Das Paket verlässt das lokale Netz nicht: Es fehlt ein Gateway."); + Node router = routerForGateway(nodes, from.gateway); + if (router == null || !sameSubnet(from.ip, from.prefix, from.gateway)) return Route.fail(List.of(from.id), "gateway_unreachable", "Das eingetragene Gateway liegt nicht im lokalen Subnetz."); + List trace = new ArrayList<>(); trace.add(from.id); trace.add(router.id); + Route next = routerRoute(nodes, router, target.ip, seen, ttl - 1); + trace.addAll(next.trace); + return next.reachable ? Route.ok(trace) : Route.fail(trace, next.reason, next.message); + } + + private static Route routerRoute(Map nodes, Node router, long destination, LinkedHashSet seen, int ttl) { + if (!seen.add(router.id)) return Route.fail(List.of(router.id), "routing_loop", "Die Router verweisen im Kreis aufeinander."); + for (long gateway : router.gateways) if (sameSubnet(gateway, router.gatewayPrefix, destination)) return Route.ok(List.of()); + JsonNode best = null; int bestPrefix = -1; + for (JsonNode route : router.routes) { int prefix = route.path("prefix").asInt(-1); Long network = ip(route.path("network").asText()); if (network != null && prefix >= 0 && sameSubnet(network, prefix, destination) && prefix > bestPrefix) { best = route; bestPrefix = prefix; } } + if (best == null) return Route.fail(List.of(), "no_route", "Der Router kennt keine Route zum Zielnetz."); + Node next = nodes.get(best.path("nextRouter").asText()); + if (next == null || !"router".equals(next.type)) return Route.fail(List.of(), "invalid_route", "Die statische Route zeigt auf keinen erreichbaren Router."); + Route onward = routerRoute(nodes, next, destination, seen, ttl - 1); + List trace = new ArrayList<>(); trace.add(next.id); trace.addAll(onward.trace); return onward.reachable ? Route.ok(trace) : Route.fail(trace, onward.reason, onward.message); + } + + private static String validation(Map nodes) { + Map ips = new HashMap<>(); + for (Node node : nodes.values()) { + if (!"router".equals(node.type) && node.ip == null) return node.id + " enthält keine gültige IPv4-Adresse."; + if (node.ip == null || node.prefix < 0 || node.prefix > 30) continue; + if (hostPart(node.ip, node.prefix) == 0) return node.id + " nutzt eine Netzadresse statt einer Hostadresse."; + if (hostPart(node.ip, node.prefix) == hostMask(node.prefix)) return node.id + " nutzt eine Broadcast-Adresse statt einer Hostadresse."; + String previous = ips.putIfAbsent(node.ip + "/" + node.prefix, node.id); if (previous != null) return previous + " und " + node.id + " besitzen dieselbe IP-Adresse."; + } + return null; + } + private static Map nodes(JsonNode net) { Map result = new LinkedHashMap<>(); for (JsonNode raw : net.path("nodes")) { String id=raw.path("id").asText(); if(id.isBlank()) continue; List gateways=new ArrayList<>(); for(JsonNode value:raw.path("gateways")){Long parsed=ip(value.asText());if(parsed!=null)gateways.add(parsed);} List routes=new ArrayList<>(); raw.path("routes").forEach(routes::add); Long address=ip(raw.path("ip").asText()); Long gateway=ip(raw.path("gateway").asText()); result.put(id,new Node(id,raw.path("type").asText("host"),address,raw.path("prefix").asInt(-1),gateway,gateways,raw.path("gatewayPrefix").asInt(24),routes)); } return result; } + private static Node routerForGateway(Map nodes, long gateway) { return nodes.values().stream().filter(n -> n.gateways.contains(gateway)).findFirst().orElse(null); } + private static Long ip(String raw) { try { String[] p=raw.trim().split("\\."); if(p.length!=4)return null; long n=0; for(String part:p){int v=Integer.parseInt(part);if(v<0||v>255)return null;n=(n<<8)|v;} return n; } catch(Exception ignored){return null;} } + private static boolean sameSubnet(long first,int prefix,long second){ if(prefix<0||prefix>32)return false; long mask=prefix==0?0:(0xffffffffL<<(32-prefix))&0xffffffffL; return (first&mask)==(second&mask); } + private static long hostPart(long address,int prefix){long mask=prefix==0?0:(0xffffffffL<<(32-prefix))&0xffffffffL;return address&~mask&0xffffffffL;} + private static long hostMask(int prefix){return prefix==32?0:(1L<<(32-prefix))-1;} + private record Node(String id,String type,Long ip,int prefix,Long gateway,List gateways,int gatewayPrefix,List routes) { } + private record Route(boolean reachable,List trace,String reason,String message) { static Route ok(List trace){return new Route(true,trace,"","Paket zugestellt.");} static Route fail(List trace,String reason,String message){return new Route(false,trace,reason,message);} } +} diff --git a/src/main/java/com/example/mindflux/service/NetworkerService.java b/src/main/java/com/example/mindflux/service/NetworkerService.java new file mode 100644 index 0000000..92bdb9a --- /dev/null +++ b/src/main/java/com/example/mindflux/service/NetworkerService.java @@ -0,0 +1,28 @@ +package com.example.mindflux.service; + +import com.example.mindflux.dto.*; +import com.example.mindflux.model.*; +import com.example.mindflux.repository.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.node.*; +import jakarta.servlet.http.HttpSession; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +@Service +public class NetworkerService { + private static final String GAME="networker"; private static final int MAX_DURATION=14_400; + private final NetworkWorldRepository worlds; private final NetworkLevelRepository levels; private final GameScoreService scores; private final AchievementService achievements; private final ObjectMapper mapper; + public NetworkerService(NetworkWorldRepository worlds, NetworkLevelRepository levels, GameScoreService scores, AchievementService achievements, ObjectMapper mapper){this.worlds=worlds;this.levels=levels;this.scores=scores;this.achievements=achievements;this.mapper=mapper;} + @Transactional(readOnly=true) public JsonNode catalog(){ObjectNode root=mapper.createObjectNode();ArrayNode rows=root.putArray("worlds");for(NetworkWorld w:worlds.findAllByActiveTrueOrderBySortOrderAscIdAsc()){ObjectNode row=rows.addObject();row.put("slug",w.getSlug());row.put("title",w.getTitle());row.put("description",w.getDescription());row.put("difficulty",w.getDifficulty());row.put("examScope",w.getExamScope());row.put("levelCount",w.getLevels().size());}return root;} + @Transactional(readOnly=true) public JsonNode session(String slug,HttpSession http){NetworkWorld world=worlds.findBySlug(slug).filter(NetworkWorld::isActive).orElseThrow(()->new IllegalArgumentException("Netzwelt nicht gefunden."));ObjectNode root=mapper.createObjectNode();root.put("slug",world.getSlug());root.put("title",world.getTitle());root.put("description",world.getDescription());root.put("difficulty",world.getDifficulty());root.put("examScope",world.getExamScope());ArrayNode rows=root.putArray("levels");Map payloads=new HashMap<>();int max=0;for(NetworkLevel level:world.getLevels()){JsonNode raw=parse(level.getPayload());if(!(raw instanceof ObjectNode node))continue;payloads.put(level.getId(),node);ObjectNode row=rows.addObject();row.put("levelId",level.getId());row.put("title",level.getTitle());row.put("type",level.getLevelType());row.put("points",level.getPoints());row.set("network",sanitize(node));max+=level.getPoints();}root.put("maxScore",max);http.setAttribute("networker.run",new Run(world.getSlug(),payloads));return root;} + @Transactional(readOnly=true) public JsonNode check(NetworkCheckRequest request,HttpSession http){if(request==null||request.getLevelId()==null)throw new IllegalArgumentException("Level fehlt.");Run run=run(http);JsonNode raw=run.payloads.get(request.getLevelId());if(raw==null)throw new IllegalArgumentException("Level gehört nicht zu dieser Welt.");State state=run.states.computeIfAbsent(request.getLevelId(),ignored->new State());if(state.done)throw new IllegalArgumentException("Dieses Level ist bereits abgeschlossen.");if(!request.isReveal()&&state.attempts>=3)throw new IllegalArgumentException("Nach drei Versuchen kannst du die Lösung anzeigen.");ObjectNode merged=request.isReveal()?solution(raw):merge(raw,request.getConfig());NetSimulator.Simulation simulation=NetSimulator.simulate(merged);boolean passed=simulation.allPassed();if(request.isReveal()){state.revealed=true;state.done=true;}else{state.attempts++;if(passed)state.done=true;}state.passed=passed;ObjectNode result=mapper.createObjectNode();result.put("allPassed",passed);result.put("resolved",state.done);result.put("attempts",state.attempts);result.put("revealed",state.revealed);ArrayNode goals=result.putArray("goals");for(NetSimulator.Goal goal:simulation.goals()){ObjectNode row=goals.addObject();row.put("id",goal.id());row.put("passed",goal.passed());row.put("reachable",goal.reachable());row.put("reason",goal.reason());row.put("message",goal.message());ArrayNode trace=row.putArray("trace");goal.trace().forEach(trace::add);}if(request.isReveal())result.set("solution",sanitize(solution(raw)));return result;} + @Transactional public NetworkResult finish(String username,NetworkFinishRequest request,HttpSession http){if(request==null||blank(request.getSlug())||request.getDurationSeconds()<1||request.getDurationSeconds()>MAX_DURATION)throw new IllegalArgumentException("Unplausibler Abschluss.");Run run=run(http);if(!run.slug.equals(request.getSlug()))throw new IllegalArgumentException("Diese Welt wurde nicht angefordert.");NetworkWorld world=worlds.findBySlug(request.getSlug()).filter(NetworkWorld::isActive).orElseThrow(()->new IllegalArgumentException("Netzwelt nicht gefunden."));int score=0,max=0,solved=0;boolean perfect=true;for(NetworkLevel level:world.getLevels()){max+=level.getPoints();State state=run.states.get(level.getId());if(state!=null&&state.done&&state.passed&&!state.revealed){solved++;score+=(int)Math.round(level.getPoints()*(state.attempts==1?1:state.attempts==2?.6:.3));if(state.attempts!=1)perfect=false;}else perfect=false;}int stars=stars(score,max);ScoreResult saved=scores.recordScore(username,GAME,world.getSlug(),score,solved,request.getDurationSeconds());if(solved>0)achievements.awardSpecial(username,"Paketbote","Schließe deine erste NetWorker-Welt ab.");if(perfect&&solved==world.getLevels().size())achievements.awardSpecial(username,"Blueprint-Profi","Löse eine NetWorker-Welt fehlerfrei.");http.removeAttribute("networker.run");return new NetworkResult(score,max,solved,world.getLevels().size(),stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays(),perfect);} + private ObjectNode sanitize(ObjectNode raw){ObjectNode safe=raw.deepCopy();safe.remove("solution");return safe;} + private ObjectNode solution(JsonNode raw){ObjectNode result=((ObjectNode)raw).deepCopy();JsonNode solution=result.path("solution");if(solution.isObject())apply(result,(ObjectNode)solution);result.remove("solution");return result;} + private ObjectNode merge(JsonNode raw,JsonNode submitted){ObjectNode result=((ObjectNode)raw).deepCopy();if(submitted instanceof ObjectNode node)apply(result,node);result.remove("solution");return result;} + private void apply(ObjectNode target,ObjectNode submitted){Map incoming=new HashMap<>();submitted.path("nodes").forEach(node->{if(node instanceof ObjectNode value&&!value.path("id").asText().isBlank())incoming.put(value.path("id").asText(),value);});for(JsonNode node:target.path("nodes")){if(!(node instanceof ObjectNode editable))continue;ObjectNode value=incoming.get(editable.path("id").asText());if(value==null)continue;Set fields=new HashSet<>();editable.path("editable").forEach(field->fields.add(field.asText()));for(String field:fields){if(!value.has(field))continue;if(field.equals("routes")&&value.path(field).isArray())editable.set(field,value.get(field));else if(value.get(field).isValueNode())editable.set(field,value.get(field));}}} + private JsonNode parse(String raw){try{return mapper.readTree(raw);}catch(Exception e){return null;}} private int stars(int score,int max){if(max<=0||score<=0)return 0;double ratio=(double)score/max;return ratio>=.8?3:ratio>=.5?2:1;}private boolean blank(String value){return value==null||value.isBlank();}private Run run(HttpSession session){Object value=session.getAttribute("networker.run");if(value instanceof Run run)return run;throw new IllegalArgumentException("Fordere zuerst eine Netzwelt an.");} + static class Run {final String slug;final Map payloads;final Map states=new HashMap<>();Run(String slug,Map payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State{int attempts;boolean done,revealed,passed;} +} diff --git a/src/main/resources/db/migration/V14__networker.sql b/src/main/resources/db/migration/V14__networker.sql new file mode 100644 index 0000000..7e23534 --- /dev/null +++ b/src/main/resources/db/migration/V14__networker.sql @@ -0,0 +1,23 @@ +CREATE TABLE networker_worlds ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(150) NOT NULL UNIQUE, + title VARCHAR(200) NOT NULL, + description VARCHAR(1200), + difficulty VARCHAR(30) NOT NULL DEFAULT 'junior', + exam_scope VARCHAR(20) NOT NULL DEFAULT 'AP1', + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT chk_networker_exam_scope CHECK (exam_scope IN ('AP1','AP2_FIAE','AP2_FISI','CROSS')) +); + +CREATE TABLE networker_levels ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + world_id BIGINT NOT NULL REFERENCES networker_worlds(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + level_type VARCHAR(30) NOT NULL, + points INT NOT NULL DEFAULT 100, + sort_order INT NOT NULL DEFAULT 0, + payload VARCHAR(8000) NOT NULL +); + +CREATE INDEX idx_networker_levels_world ON networker_levels(world_id); diff --git a/src/main/resources/networker/content.json b/src/main/resources/networker/content.json new file mode 100644 index 0000000..f6aeb56 --- /dev/null +++ b/src/main/resources/networker/content.json @@ -0,0 +1,23 @@ +{"worlds":[ +{"slug":"ein-netz-fuer-alle","title":"Welt 1: Ein Netz für alle","description":"Grundlagen der IPv4-Adressierung: Hostadressen, Masken und ein gemeinsames LAN.","difficulty":"junior","examScope":"AP1","levels":[ +{"title":"Erste Verbindung","type":"assign","points":100,"payload":{"nodes":[{"id":"PC1","label":"PC-01","type":"pc","x":150,"y":270,"ip":"192.168.10.0","prefix":24,"editable":["ip","prefix"]},{"id":"SW1","label":"SW-01","type":"switch","x":450,"y":270},{"id":"SRV1","label":"SRV-01","type":"server","x":760,"y":270,"ip":"192.168.10.20","prefix":24}],"links":[["PC1","SW1"],["SW1","SRV1"]],"goals":[{"id":"pc-zu-server","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","ip":"192.168.10.10","prefix":24}]}}}, +{"title":"Die richtige Maske","type":"fix","points":100,"payload":{"nodes":[{"id":"PC1","label":"PC-02","type":"pc","x":150,"y":270,"ip":"192.168.20.10","prefix":25,"editable":["prefix"]},{"id":"SW1","label":"SW-02","type":"switch","x":450,"y":270},{"id":"SRV1","label":"SRV-02","type":"server","x":760,"y":270,"ip":"192.168.20.200","prefix":24}],"links":[["PC1","SW1"],["SW1","SRV1"]],"goals":[{"id":"maske","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","prefix":24}]}}}, +{"title":"Host statt Broadcast","type":"fix","points":100,"payload":{"nodes":[{"id":"PC1","label":"PC-03","type":"pc","x":150,"y":270,"ip":"192.168.30.255","prefix":24,"editable":["ip"]},{"id":"SW1","label":"SW-03","type":"switch","x":450,"y":270},{"id":"SRV1","label":"SRV-03","type":"server","x":760,"y":270,"ip":"192.168.30.50","prefix":24}],"links":[["PC1","SW1"],["SW1","SRV1"]],"goals":[{"id":"broadcast","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","ip":"192.168.30.30"}]}}}, +{"title":"Doppelte Adresse","type":"fix","points":120,"payload":{"nodes":[{"id":"PC1","label":"PC-04","type":"pc","x":150,"y":270,"ip":"192.168.40.20","prefix":24,"editable":["ip"]},{"id":"SW1","label":"SW-04","type":"switch","x":450,"y":270},{"id":"SRV1","label":"SRV-04","type":"server","x":760,"y":270,"ip":"192.168.40.20","prefix":24}],"links":[["PC1","SW1"],["SW1","SRV1"]],"goals":[{"id":"duplikat","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","ip":"192.168.40.10"}]}}}, +{"title":"Das /26-Labor","type":"assign","points":120,"payload":{"nodes":[{"id":"PC1","label":"PC-05","type":"pc","x":150,"y":270,"ip":"10.0.5.70","prefix":24,"editable":["prefix"]},{"id":"SW1","label":"SW-05","type":"switch","x":450,"y":270},{"id":"SRV1","label":"SRV-05","type":"server","x":760,"y":270,"ip":"10.0.5.100","prefix":26}],"links":[["PC1","SW1"],["SW1","SRV1"]],"goals":[{"id":"cidr","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","prefix":26}]}}} +]}, +{"slug":"ueber-den-router","title":"Welt 2: Über den Router","description":"Gateways verbinden getrennte Netze. Prüfe Adresse, Maske und den Ausgang aus dem LAN.","difficulty":"senior","examScope":"AP1","levels":[ +{"title":"Tor zum Serverraum","type":"assign","points":130,"payload":{"nodes":[{"id":"PC1","label":"PC-A","type":"pc","x":120,"y":270,"ip":"192.168.1.10","prefix":24,"gateway":"192.168.1.254","editable":["gateway"]},{"id":"R1","label":"RTR-01","type":"router","x":450,"y":270,"gateways":["192.168.1.1","192.168.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-A","type":"server","x":780,"y":270,"ip":"192.168.2.20","prefix":24}],"links":[["PC1","R1"],["R1","SRV1"]],"goals":[{"id":"gateway","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","gateway":"192.168.1.1"}]}}}, +{"title":"Gateway im falschen Netz","type":"fix","points":130,"payload":{"nodes":[{"id":"PC1","label":"PC-B","type":"pc","x":120,"y":270,"ip":"192.168.11.10","prefix":24,"gateway":"192.168.12.1","editable":["gateway"]},{"id":"R1","label":"RTR-02","type":"router","x":450,"y":270,"gateways":["192.168.11.1","192.168.12.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-B","type":"server","x":780,"y":270,"ip":"192.168.12.60","prefix":24}],"links":[["PC1","R1"],["R1","SRV1"]],"goals":[{"id":"local-gateway","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","gateway":"192.168.11.1"}]}}}, +{"title":"Netzadresse am Ausgang","type":"fix","points":140,"payload":{"nodes":[{"id":"PC1","label":"PC-C","type":"pc","x":120,"y":270,"ip":"172.20.1.0","prefix":24,"gateway":"172.20.1.1","editable":["ip"]},{"id":"R1","label":"RTR-03","type":"router","x":450,"y":270,"gateways":["172.20.1.1","172.20.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-C","type":"server","x":780,"y":270,"ip":"172.20.2.30","prefix":24}],"links":[["PC1","R1"],["R1","SRV1"]],"goals":[{"id":"hostadresse","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","ip":"172.20.1.10"}]}}}, +{"title":"Abteilung Nord","type":"assign","points":140,"payload":{"nodes":[{"id":"PC1","label":"PC-N","type":"pc","x":120,"y":270,"ip":"10.20.1.25","prefix":24,"gateway":"10.20.1.1","editable":["prefix"]},{"id":"R1","label":"RTR-04","type":"router","x":450,"y":270,"gateways":["10.20.1.1","10.20.2.1"],"gatewayPrefix":25},{"id":"SRV1","label":"SRV-N","type":"server","x":780,"y":270,"ip":"10.20.2.44","prefix":25}],"links":[["PC1","R1"],["R1","SRV1"]],"goals":[{"id":"praefix","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","prefix":25}]}}}, +{"title":"Freier Host","type":"assign","points":150,"payload":{"nodes":[{"id":"PC1","label":"PC-E","type":"pc","x":120,"y":270,"ip":"192.168.50.1","prefix":24,"gateway":"192.168.50.1","editable":["ip"]},{"id":"R1","label":"RTR-05","type":"router","x":450,"y":270,"gateways":["192.168.50.1","192.168.60.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-E","type":"server","x":780,"y":270,"ip":"192.168.60.15","prefix":24}],"links":[["PC1","R1"],["R1","SRV1"]],"goals":[{"id":"freier-host","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"PC1","ip":"192.168.50.10"}]}}} +]}, +{"slug":"routen-und-fehlersuche","title":"Welt 3: Routen & Fehlersuche","description":"Zwei Router, statische Routen und echte Diagnose auf AP2-FISI-Niveau.","difficulty":"principal","examScope":"AP2_FISI","levels":[ +{"title":"Der fehlende Weg","type":"route","points":180,"payload":{"nodes":[{"id":"PC1","label":"PC-OPS","type":"pc","x":100,"y":270,"ip":"10.10.1.10","prefix":24,"gateway":"10.10.1.1"},{"id":"R1","label":"RTR-EDGE","type":"router","x":350,"y":270,"gateways":["10.10.1.1","172.16.0.1"],"gatewayPrefix":24,"routes":[],"editable":["routes"]},{"id":"R2","label":"RTR-CORE","type":"router","x":600,"y":270,"gateways":["172.16.0.2","10.10.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-ERP","type":"server","x":850,"y":270,"ip":"10.10.2.20","prefix":24}],"links":[["PC1","R1"],["R1","R2"],["R2","SRV1"]],"goals":[{"id":"route-erp","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"R1","routes":[{"network":"10.10.2.0","prefix":24,"nextRouter":"R2"}]}]}}}, +{"title":"Route im Kreis","type":"route","points":180,"payload":{"nodes":[{"id":"PC1","label":"PC-LOG","type":"pc","x":100,"y":270,"ip":"10.20.1.10","prefix":24,"gateway":"10.20.1.1"},{"id":"R1","label":"RTR-01","type":"router","x":350,"y":270,"gateways":["10.20.1.1","172.20.0.1"],"gatewayPrefix":24,"routes":[{"network":"10.20.2.0","prefix":24,"nextRouter":"R1"}],"editable":["routes"]},{"id":"R2","label":"RTR-02","type":"router","x":600,"y":270,"gateways":["172.20.0.2","10.20.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-LOG","type":"server","x":850,"y":270,"ip":"10.20.2.50","prefix":24}],"links":[["PC1","R1"],["R1","R2"],["R2","SRV1"]],"goals":[{"id":"route-loop","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"R1","routes":[{"network":"10.20.2.0","prefix":24,"nextRouter":"R2"}]}]}}}, +{"title":"Das ferne Backup","type":"route","points":190,"payload":{"nodes":[{"id":"PC1","label":"PC-BACKUP","type":"pc","x":100,"y":270,"ip":"10.30.1.10","prefix":24,"gateway":"10.30.1.1"},{"id":"R1","label":"RTR-WEST","type":"router","x":350,"y":270,"gateways":["10.30.1.1","172.30.0.1"],"gatewayPrefix":24,"routes":[],"editable":["routes"]},{"id":"R2","label":"RTR-OST","type":"router","x":600,"y":270,"gateways":["172.30.0.2","10.30.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-BACKUP","type":"server","x":850,"y":270,"ip":"10.30.2.100","prefix":24}],"links":[["PC1","R1"],["R1","R2"],["R2","SRV1"]],"goals":[{"id":"route-backup","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"R1","routes":[{"network":"10.30.2.0","prefix":24,"nextRouter":"R2"}]}]}}}, +{"title":"Monitoring-Netz","type":"route","points":190,"payload":{"nodes":[{"id":"PC1","label":"PC-NOC","type":"pc","x":100,"y":270,"ip":"10.40.1.10","prefix":24,"gateway":"10.40.1.1"},{"id":"R1","label":"RTR-NOC","type":"router","x":350,"y":270,"gateways":["10.40.1.1","172.40.0.1"],"gatewayPrefix":24,"routes":[],"editable":["routes"]},{"id":"R2","label":"RTR-MON","type":"router","x":600,"y":270,"gateways":["172.40.0.2","10.40.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-MON","type":"server","x":850,"y":270,"ip":"10.40.2.8","prefix":24}],"links":[["PC1","R1"],["R1","R2"],["R2","SRV1"]],"goals":[{"id":"route-monitor","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"R1","routes":[{"network":"10.40.2.0","prefix":24,"nextRouter":"R2"}]}]}}}, +{"title":"Letzter Hop","type":"route","points":200,"payload":{"nodes":[{"id":"PC1","label":"PC-SOC","type":"pc","x":100,"y":270,"ip":"10.50.1.10","prefix":24,"gateway":"10.50.1.1"},{"id":"R1","label":"RTR-SOC","type":"router","x":350,"y":270,"gateways":["10.50.1.1","172.50.0.1"],"gatewayPrefix":24,"routes":[],"editable":["routes"]},{"id":"R2","label":"RTR-DC","type":"router","x":600,"y":270,"gateways":["172.50.0.2","10.50.2.1"],"gatewayPrefix":24},{"id":"SRV1","label":"SRV-SIEM","type":"server","x":850,"y":270,"ip":"10.50.2.200","prefix":24}],"links":[["PC1","R1"],["R1","R2"],["R2","SRV1"]],"goals":[{"id":"route-siem","from":"PC1","to":"SRV1","shouldReach":true}],"solution":{"nodes":[{"id":"R1","routes":[{"network":"10.50.2.0","prefix":24,"nextRouter":"R2"}]}]}}} +]} +]} diff --git a/src/main/resources/static/css/accessibility-placement.css b/src/main/resources/static/css/accessibility-placement.css index baf33d8..e24f6a6 100644 --- a/src/main/resources/static/css/accessibility-placement.css +++ b/src/main/resources/static/css/accessibility-placement.css @@ -6,6 +6,7 @@ body{padding-left:0!important;padding-bottom:0!important} .mf-theme-float{display:flex;align-items:center;gap:.55rem} body.mf-a11y-skin-codeescape{--mf-a11y-accent:var(--esc-accent);--mf-a11y-glow:var(--esc-glow);--mf-a11y-ink:#1c1100} body.mf-a11y-skin-bughunter{--mf-a11y-accent:var(--bh-accent);--mf-a11y-glow:var(--bh-glow);--mf-a11y-ink:#210c12} +body.mf-a11y-skin-networker{--mf-a11y-accent:var(--nw-accent);--mf-a11y-glow:var(--nw-glow);--mf-a11y-ink:#061321} body[class*="mf-a11y-skin-"] .mf-a11y-trigger{border-color:color-mix(in srgb,var(--mf-a11y-accent) 65%,var(--border));background:linear-gradient(135deg,var(--mf-a11y-accent),color-mix(in srgb,var(--mf-a11y-accent) 72%,#fff));color:var(--mf-a11y-ink);box-shadow:0 0 16px var(--mf-a11y-glow)} body[class*="mf-a11y-skin-"] .mf-a11y-panel{border-color:color-mix(in srgb,var(--mf-a11y-accent) 56%,var(--border));box-shadow:0 12px 45px rgba(0,0,0,.55),0 0 22px var(--mf-a11y-glow)} body[class*="mf-a11y-skin-"] .mf-a11y-head{background:color-mix(in srgb,var(--mf-a11y-accent) 28%,#07121f)} diff --git a/src/main/resources/static/css/networker.css b/src/main/resources/static/css/networker.css new file mode 100644 index 0000000..cc50291 --- /dev/null +++ b/src/main/resources/static/css/networker.css @@ -0,0 +1 @@ +.nw-app{--accent:var(--nw-accent);min-height:100vh;background:linear-gradient(rgba(12,30,55,.92),rgba(8,18,34,.96)),repeating-linear-gradient(0deg,transparent 0 29px,rgba(103,187,255,.08) 30px),repeating-linear-gradient(90deg,transparent 0 29px,rgba(103,187,255,.08) 30px);color:var(--text);font-family:"Courier New",monospace}.nw-top{min-height:72px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:12px 24px;border-bottom:1px solid color-mix(in srgb,var(--nw-accent) 52%,var(--border));background:color-mix(in srgb,var(--panel) 94%,transparent)}.nw-back{color:var(--muted);text-decoration:none}.nw-brand{text-align:center}.nw-brand b{display:block;color:var(--nw-accent);font-size:1.25rem;letter-spacing:.2em;text-shadow:0 0 14px var(--nw-glow)}.nw-brand span,.nw-kicker{color:var(--muted);font-size:.65rem;letter-spacing:.15em}.nw-top .mf-a11y-host{justify-self:end}.nw-view{max-width:1240px;margin:auto;padding:2rem 1.2rem}.nw-loading{text-align:center;padding:20vh 0;color:var(--muted)}.nw-hero,.nw-brief,.nw-result,.nw-world,.nw-panel,.nw-board{border:1px solid color-mix(in srgb,var(--nw-accent) 48%,var(--border));background:color-mix(in srgb,var(--surface) 91%,transparent);border-radius:12px;padding:1.4rem;box-shadow:0 0 24px color-mix(in srgb,var(--nw-accent) 10%,transparent)}.nw-hero h1,.nw-brief h1,.nw-level-head h1,.nw-result h1{margin:.45rem 0;color:var(--heading)}.nw-hero p,.nw-brief p,.nw-world p{color:var(--muted);line-height:1.55}.nw-worlds{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-top:1rem}.nw-world{display:flex;flex-direction:column;align-items:flex-start}.nw-world span{color:var(--nw-accent);font-size:.7rem}.nw-world h2{color:var(--heading)}.nw-world small{margin-bottom:1rem;color:var(--muted)}.nw-btn{border:1px solid var(--nw-accent);border-radius:6px;background:transparent;color:var(--nw-accent);padding:.65rem .85rem;font:700 .72rem "Courier New",monospace;letter-spacing:.07em;cursor:pointer}.nw-btn.primary,.nw-btn:hover{background:var(--nw-accent);color:#061321;box-shadow:0 0 16px var(--nw-glow)}.nw-rules,.nw-actions{display:flex;gap:.65rem;flex-wrap:wrap;margin-top:1rem}.nw-rules div{padding:.6rem;background:var(--surface-2);border-radius:6px;color:var(--muted)}.nw-rules b{display:inline-grid;place-items:center;width:1.2rem;height:1.2rem;border-radius:50%;background:var(--nw-accent);color:#061321}.nw-level-head{display:flex;justify-content:space-between;align-items:center}.nw-score{display:grid;place-items:center;width:66px;height:66px;border:1px solid var(--nw-accent);border-radius:50%;color:var(--nw-accent);font-size:1.1rem}.nw-score small{font-size:.5rem}.nw-progress{display:flex;gap:.5rem;margin:1rem 0}.nw-progress i{display:grid;place-items:center;width:26px;height:26px;border:1px solid var(--border);border-radius:50%;font-style:normal;color:var(--muted)}.nw-progress i.active{border-color:var(--nw-accent);color:var(--nw-accent)}.nw-progress i.done{background:var(--success);color:#061321}.nw-layout{display:grid;grid-template-columns:3fr 2fr;gap:1rem}.nw-board{min-height:520px;padding:.5rem;overflow:auto}.nw-board svg{min-width:700px;width:100%;height:100%}.nw-link{stroke:var(--nw-accent);stroke-width:4;stroke-dasharray:8 6;fill:none}.nw-node rect{fill:color-mix(in srgb,var(--surface) 88%,#071827);stroke:var(--nw-accent);stroke-width:2}.nw-node text{fill:var(--text);font:700 13px "Courier New",monospace}.nw-router rect{stroke:#b18cff}.nw-panel{display:grid;align-content:start;gap:.8rem}.nw-panel h2{margin:.1rem 0;color:var(--heading);font-size:1rem}.nw-panel fieldset{border:1px solid var(--border);border-radius:7px;display:grid;gap:.5rem}.nw-panel legend{color:var(--nw-accent);font-size:.72rem}.nw-panel label{display:grid;gap:.2rem;color:var(--muted);font-size:.7rem}.nw-panel input,.nw-panel textarea{width:100%;box-sizing:border-box;background:#06111f;color:var(--text);border:1px solid var(--border);border-radius:4px;padding:.45rem;font:inherit}.nw-goals div{display:flex;justify-content:space-between;gap:.5rem;padding:.5rem;border-bottom:1px solid var(--border);font-size:.72rem}.nw-goals span{color:var(--muted)}.nw-goals span.ok,.nw-feedback.ok{color:var(--success)}.nw-goals span.bad,.nw-feedback.bad{color:var(--danger)}.nw-feedback{min-height:1.3rem;font-size:.78rem;line-height:1.4}.nw-result{text-align:center;max-width:720px;margin:7vh auto}.nw-final-score{font-size:3.5rem;color:var(--nw-accent);text-shadow:0 0 20px var(--nw-glow)}.nw-result>p{color:var(--nw-accent);font-size:1.4rem}.nw-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:.7rem;margin:1rem 0}.nw-stats div{background:var(--surface-2);border-radius:6px;padding:.7rem;color:var(--muted);font-size:.75rem}.nw-stats b{display:block;color:var(--heading);font-size:1.1rem}@media(max-width:850px){.nw-layout,.nw-worlds{grid-template-columns:1fr}.nw-board{min-height:380px}.nw-top{grid-template-columns:1fr auto}.nw-brand{display:none}} diff --git a/src/main/resources/static/js/accessibility.js b/src/main/resources/static/js/accessibility.js index daad1ad..0900efd 100644 --- a/src/main/resources/static/js/accessibility.js +++ b/src/main/resources/static/js/accessibility.js @@ -2,7 +2,7 @@ function load(){try{return Object.assign({},defaults,JSON.parse(localStorage.getItem(KEY)||'{}'));}catch(e){return Object.assign({},defaults);}}var state=load(); function apply(){var h=document.documentElement;h.setAttribute('data-mf-font',String(state.font));h.setAttribute('data-mf-readable',state.readable);h.setAttribute('data-mf-contrast',state.contrast);h.setAttribute('data-mf-color',state.color?'safe':'standard');h.setAttribute('data-mf-mono',state.mono);h.setAttribute('data-mf-links',state.links);h.setAttribute('data-mf-motion',state.motion);h.setAttribute('data-mf-cursor',state.cursor);try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}} apply(); -function applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);} +function applySkin(){var root=document.querySelector('[data-a11y-skin]'),skin=root&&root.getAttribute('data-a11y-skin');if(!skin&&document.querySelector('.esc-app'))skin='codeescape';if(!skin&&document.querySelector('.bh-app'))skin='bughunter';if(!skin&&document.querySelector('.nw-app'))skin='networker';if(skin)document.body.classList.add('mf-a11y-skin-'+skin);} function button(id,icon,label,detail){return '';} function init(){if(document.querySelector('.mf-a11y-trigger'))return;var trigger=document.createElement('button');trigger.type='button';trigger.className='mf-a11y-trigger';trigger.setAttribute('aria-label','Barrierefreiheit öffnen');trigger.setAttribute('aria-expanded','false');trigger.setAttribute('aria-controls','mf-a11y-panel');trigger.innerHTML=''; var panel=document.createElement('section');panel.id='mf-a11y-panel';panel.className='mf-a11y-panel';panel.hidden=true;panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','false');panel.setAttribute('aria-labelledby','mf-a11y-title');panel.innerHTML='

Barrierefreiheit

'+button('font','T⁺','Text vergrößern','100 %')+button('contrast','◐','Hochkontrast')+button('color','◉','Farbenblind-Modus')+button('mono','◒','Monochrom')+button('readable','Aa','Lesemodus (Dyslexie)','Verdana + Abstände')+button('links','🔗','Links unterstreichen')+button('motion','Ⅱ','Animationen stoppen')+button('cursor','➤','Großer Cursor')+button('reset','↺','Alles zurücksetzen')+'
'; diff --git a/src/main/resources/static/js/networker.js b/src/main/resources/static/js/networker.js new file mode 100644 index 0000000..b5eef49 --- /dev/null +++ b/src/main/resources/static/js/networker.js @@ -0,0 +1,12 @@ +(function(){'use strict';const view=document.getElementById('nwView');let catalog=[],session=null,index=0,started=0,configs=[];const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const api=(url,opt)=>fetch('/api/networker'+url,{headers:{'Content-Type':'application/json'},...opt}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.message||'Serverfehler');return j;});const post=(url,body)=>api(url,{method:'POST',body:JSON.stringify(body)});const seconds=()=>Math.max(1,Math.round((Date.now()-started)/1000)); +function load(){api('/catalog').then(data=>{catalog=data.worlds||[];renderCatalog();}).catch(error)} +function renderCatalog(){view.innerHTML=`
BLUEPRINT ARCHIVE

Welche Verbindung stellst du wieder her?

Konfiguriere Geräte, prüfe Paketwege und lies die Diagnose wie ein Netzwerktechniker.

${catalog.map(w=>`
${esc(w.examScope)} · ${esc(w.difficulty).toUpperCase()}

${esc(w.title)}

${esc(w.description)}

${w.levelCount} Level
`).join('')}
`;view.querySelectorAll('[data-world]').forEach(b=>b.addEventListener('click',()=>begin(b.dataset.world)))} +function begin(slug){api('/session/'+encodeURIComponent(slug)).then(data=>{session=data;index=0;started=Date.now();configs=data.levels.map(()=>null);renderIntro()}).catch(error)} +function renderIntro(){view.innerHTML=`
${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()}

${esc(session.title)}

${esc(session.description)}

1 Werte prüfen
2 Pings simulieren
3 Diagnose lesen
`;document.getElementById('nwStart').addEventListener('click',renderLevel)} +function renderLevel(){const level=session.levels[index],net=level.network;view.innerHTML=`
LEVEL ${index+1} / ${session.levels.length} · ${esc(level.type).toUpperCase()}

${esc(level.title)}

${level.points}PTS
${session.levels.map((_,i)=>`${i+1}`).join('')}
${drawNet(net)}
`;document.getElementById('nwCheck').addEventListener('click',()=>check(false));document.getElementById('nwReveal').addEventListener('click',()=>check(true))} +function drawNet(net){const nodeById=Object.fromEntries(net.nodes.map(n=>[n.id,n]));return `${(net.links||[]).map(([a,b])=>{const x=nodeById[a],y=nodeById[b];return x&&y?``:''}).join('')}${net.nodes.map(n=>`${esc(n.label||n.id)}${esc(n.type||'node').toUpperCase()}`).join('')}`} +function drawInputs(net){const rows=net.nodes.filter(n=>n.editable?.length).map(n=>`
${esc(n.label||n.id)}${n.editable.map(f=>f==='routes'?``:``).join('')}
`).join('');return rows||'

In diesem Plan sind keine Werte editierbar.

'} +function collect(){const nodes=[];view.querySelectorAll('[data-node]').forEach(box=>{const node={id:box.dataset.node};box.querySelectorAll('[data-field]').forEach(input=>{if(input.dataset.field==='routes'){try{node.routes=JSON.parse(input.value)}catch(e){node.routes=[]}}else node[input.dataset.field]=input.value});nodes.push(node)});return{nodes}} +function check(reveal){const level=session.levels[index];post('/check',{levelId:level.levelId,reveal,config:collect()}).then(result=>{result.goals.forEach(g=>{const target=document.getElementById('goal-'+g.id);if(target){target.textContent=g.passed?'ERREICHT':'BLOCKIERT · '+g.message;target.className=g.passed?'ok':'bad'}});const feedback=document.getElementById('nwFeedback');if(result.resolved){if(result.revealed&&result.solution){level.network=result.solution;configs[index]=null;renderLevel();document.getElementById('nwFeedback').textContent='Auflösung geladen. Prüfe den korrigierten Plan und gehe weiter.'}else{feedback.textContent='Paketweg bestätigt.';feedback.className='nw-feedback ok'}const next=document.createElement('button');next.className='nw-btn primary';next.textContent=index+1{index++;if(index>=session.levels.length)finish();else renderLevel()});document.querySelector('.nw-actions').replaceChildren(next)}else{feedback.textContent=`Noch nicht stimmig · Versuch ${result.attempts}/3`;feedback.className='nw-feedback bad'}}).catch(error)} +function finish(){post('/finish',{slug:session.slug,durationSeconds:seconds(),levelIds:session.levels.map(l=>l.levelId)}).then(r=>{view.innerHTML=`
NETZWERKPROTOKOLL

${r.perfect?'Alle Paketwege fehlerfrei.':'Welt abgeschlossen.'}

${r.score}

${'★'.repeat(r.stars)}${'☆'.repeat(3-r.stars)}

${r.solved}/${r.total}Level
${r.highscore}Highscore
${r.plays}Welten
`;document.getElementById('nwAgain').addEventListener('click',load)}).catch(error)} +function error(err){view.innerHTML=`

NETZPLAN NICHT LESBAR

${esc(err.message||err)}

`;document.getElementById('nwReload').addEventListener('click',load)}load()})(); diff --git a/src/main/resources/static/js/theme.js b/src/main/resources/static/js/theme.js index 4643a73..54db984 100644 --- a/src/main/resources/static/js/theme.js +++ b/src/main/resources/static/js/theme.js @@ -32,13 +32,13 @@ document.head.appendChild(accessibilityCss); var accessibilityPlacementCss = document.createElement('link'); accessibilityPlacementCss.rel = 'stylesheet'; - accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=5'; + accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=6'; accessibilityPlacementCss.setAttribute('data-accessibility', ''); document.head.appendChild(accessibilityPlacementCss); } if (!document.querySelector('script[data-accessibility]')) { var accessibilityScript = document.createElement('script'); - accessibilityScript.src = '/js/accessibility.js?v=5'; + accessibilityScript.src = '/js/accessibility.js?v=6'; accessibilityScript.defer = true; accessibilityScript.setAttribute('data-accessibility', ''); document.head.appendChild(accessibilityScript); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 7aed815..53d5641 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -138,7 +138,7 @@ ['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/codeescape'], ['ServiceClash','sword','Der chaotische Admin-Alltag als Spiel – meistere Anfragen & halte Systeme am Laufen!','/comingsoon'], ['BugHunter','bug','Finde Fehler im Code, denke wie ein Debugger und löse knifflige Bugs.','/bughunter'], - ['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/comingsoon'], + ['NetWorker','network','Verbinde Netzwerke, konfiguriere IPs und löse Routing-Herausforderungen.','/networker'], ['FinalFlux','trophy','Dein Power-Modul für die Prüfung – gezielt lernen, strukturiert trainieren, sicher bestehen.','/finalflux'] ]; document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>` diff --git a/src/main/resources/templates/networker.html b/src/main/resources/templates/networker.html new file mode 100644 index 0000000..59802c4 --- /dev/null +++ b/src/main/resources/templates/networker.html @@ -0,0 +1 @@ +NetWorker · Mindflux
← Dashboard
NETWORKERNETZPLAN · DIAGNOSE
NETZWERKPLÄNE WERDEN KALIBRIERT …
diff --git a/src/test/java/com/example/mindflux/service/NetSimulatorTest.java b/src/test/java/com/example/mindflux/service/NetSimulatorTest.java new file mode 100644 index 0000000..1be52a5 --- /dev/null +++ b/src/test/java/com/example/mindflux/service/NetSimulatorTest.java @@ -0,0 +1,37 @@ +package com.example.mindflux.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class NetSimulatorTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test void routesThroughGatewayAndReportsMissingGateway() throws Exception { + var working = mapper.readTree(""" + {"nodes":[ + {"id":"PC","type":"pc","ip":"192.168.1.10","prefix":24,"gateway":"192.168.1.1"}, + {"id":"R1","type":"router","gateways":["192.168.1.1","192.168.2.1"],"gatewayPrefix":24}, + {"id":"SRV","type":"server","ip":"192.168.2.20","prefix":24}], + "goals":[{"id":"ping","from":"PC","to":"SRV","shouldReach":true}]} + """); + assertTrue(NetSimulator.simulate(working).allPassed()); + ((com.fasterxml.jackson.databind.node.ObjectNode) working.path("nodes").get(0)).put("gateway", ""); + var result = NetSimulator.simulate(working).goals().getFirst(); + assertFalse(result.passed()); assertEquals("missing_gateway", result.reason()); + } + + @Test void followsStaticRouteAndRejectsDuplicateAddresses() throws Exception { + var routed = mapper.readTree(""" + {"nodes":[ + {"id":"PC","type":"pc","ip":"10.0.1.10","prefix":24,"gateway":"10.0.1.1"}, + {"id":"R1","type":"router","gateways":["10.0.1.1","172.16.0.1"],"gatewayPrefix":24,"routes":[{"network":"10.0.2.0","prefix":24,"nextRouter":"R2"}]}, + {"id":"R2","type":"router","gateways":["172.16.0.2","10.0.2.1"],"gatewayPrefix":24}, + {"id":"SRV","type":"server","ip":"10.0.2.20","prefix":24}], + "goals":[{"id":"route","from":"PC","to":"SRV","shouldReach":true}]} + """); + assertTrue(NetSimulator.simulate(routed).allPassed()); + ((com.fasterxml.jackson.databind.node.ObjectNode) routed.path("nodes").get(3)).put("ip", "10.0.1.10"); + assertEquals("invalid_config", NetSimulator.simulate(routed).goals().getFirst().reason()); + } +}