Implement NetWorker network puzzle game
mindflux-ci / build (push) Successful in 23s

This commit is contained in:
AliceDarkest
2026-07-13 22:09:31 +02:00
parent 3eef7cdb40
commit 73e74a2859
25 changed files with 292 additions and 5 deletions
@@ -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);}}
}
@@ -42,7 +42,7 @@ public class SecurityConfig {
.csrf(csrf -> csrf .csrf(csrf -> csrf
// Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen. // Spiele-APIs werden per fetch (application/json) ohne Token aufgerufen -> ausnehmen.
// Reine JSON-Endpunkte sind nicht über klassische HTML-Formulare CSRF-ausnutzbar. // 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(); return http.build();
@@ -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);} }
@@ -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";} }
@@ -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;} }
@@ -0,0 +1,3 @@
package com.example.mindflux.dto;
import java.util.*;
public class NetworkFinishRequest { private String slug; private int durationSeconds; private List<Long> 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<Long> getLevelIds(){return levelIds;} public void setLevelIds(List<Long> value){levelIds=value;} }
@@ -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) { }
@@ -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;}
}
@@ -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<NetworkLevel> 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<NetworkLevel> getLevels(){return levels;}
}
@@ -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<NetworkLevel, Long> { }
@@ -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<NetworkWorld, Long> { List<NetworkWorld> findAllByActiveTrueOrderBySortOrderAscIdAsc(); Optional<NetworkWorld> findBySlug(String slug); }
@@ -14,4 +14,5 @@ public interface AchievementService {
* Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen. * Achievements idempotent. Wird aus GameScoreService.recordScore() aufgerufen.
*/ */
void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame); void checkAndAward(String username, String game, int score, boolean newHighscore, long playsInGame);
void awardSpecial(String username, String title, String description);
} }
@@ -72,6 +72,12 @@ public class AchievementServiceImpl implements AchievementService {
gameScoreRepository.countDistinctGamesByUsername(username) >= 4); gameScoreRepository.countDistinctGamesByUsername(username) >= 4);
} }
@Override @Transactional
public void awardSpecial(String username, String title, String description) {
Set<String> 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<String> existing, String username, String title, boolean condition) { private void award(Set<String> existing, String username, String title, boolean condition) {
if (!condition || existing.contains(title)) { if (!condition || existing.contains(title)) {
return; return;
@@ -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<String> trace, String reason, String message) { }
public record Simulation(List<Goal> goals) { public boolean allPassed() { return goals.stream().allMatch(Goal::passed); } }
public static Simulation simulate(JsonNode net) {
Map<String, Node> nodes = nodes(net); List<Goal> 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<String, Node> nodes, Node from, Node target, LinkedHashSet<String> 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<String> 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<String, Node> nodes, Node router, long destination, LinkedHashSet<String> 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<String> 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<String, Node> nodes) {
Map<String, String> 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<String, Node> nodes(JsonNode net) { Map<String, Node> result = new LinkedHashMap<>(); for (JsonNode raw : net.path("nodes")) { String id=raw.path("id").asText(); if(id.isBlank()) continue; List<Long> gateways=new ArrayList<>(); for(JsonNode value:raw.path("gateways")){Long parsed=ip(value.asText());if(parsed!=null)gateways.add(parsed);} List<JsonNode> 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<String, Node> 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<Long> gateways,int gatewayPrefix,List<JsonNode> routes) { }
private record Route(boolean reachable,List<String> trace,String reason,String message) { static Route ok(List<String> trace){return new Route(true,trace,"","Paket zugestellt.");} static Route fail(List<String> trace,String reason,String message){return new Route(false,trace,reason,message);} }
}
@@ -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<Long,JsonNode> 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<String,ObjectNode> 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<String> 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<Long,JsonNode> payloads;final Map<Long,State> states=new HashMap<>();Run(String slug,Map<Long,JsonNode> payloads){this.slug=slug;this.payloads=Map.copyOf(payloads);}} static class State{int attempts;boolean done,revealed,passed;}
}
@@ -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);
+23
View File
@@ -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"}]}]}}}
]}
]}
@@ -6,6 +6,7 @@ body{padding-left:0!important;padding-bottom:0!important}
.mf-theme-float{display:flex;align-items:center;gap:.55rem} .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-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-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-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-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)} body[class*="mf-a11y-skin-"] .mf-a11y-head{background:color-mix(in srgb,var(--mf-a11y-accent) 28%,#07121f)}
@@ -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}}
@@ -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 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){}} 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(); 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 '<button type="button" class="mf-a11y-option" data-a11y="'+id+'"><strong aria-hidden="true">'+icon+'</strong><span>'+label+'</span>'+(detail?'<small>'+detail+'</small>':'')+'</button>';} function button(id,icon,label,detail){return '<button type="button" class="mf-a11y-option" data-a11y="'+id+'"><strong aria-hidden="true">'+icon+'</strong><span>'+label+'</span>'+(detail?'<small>'+detail+'</small>':'')+'</button>';}
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='<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="18" cy="5" r="3" fill="currentColor"/><path d="M13 11l5-2 5 4m-7-2-2 7-6 1m8-4 6 2 3 7M13 18a7 7 0 1 0 6 7"/></svg>'; 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='<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="18" cy="5" r="3" fill="currentColor"/><path d="M13 11l5-2 5 4m-7-2-2 7-6 1m8-4 6 2 3 7M13 18a7 7 0 1 0 6 7"/></svg>';
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='<div class="mf-a11y-head"><button type="button" class="mf-a11y-close" aria-label="Barrierefreiheit schließen">×</button><h2 id="mf-a11y-title">Barrierefreiheit</h2></div><div class="mf-a11y-grid">'+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')+'</div>'; 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='<div class="mf-a11y-head"><button type="button" class="mf-a11y-close" aria-label="Barrierefreiheit schließen">×</button><h2 id="mf-a11y-title">Barrierefreiheit</h2></div><div class="mf-a11y-grid">'+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')+'</div>';
+12
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=`<section class="nw-hero"><span class="nw-kicker">BLUEPRINT ARCHIVE</span><h1>Welche Verbindung stellst du wieder her?</h1><p>Konfiguriere Geräte, prüfe Paketwege und lies die Diagnose wie ein Netzwerktechniker.</p></section><section class="nw-worlds">${catalog.map(w=>`<article class="nw-world"><span>${esc(w.examScope)} · ${esc(w.difficulty).toUpperCase()}</span><h2>${esc(w.title)}</h2><p>${esc(w.description)}</p><small>${w.levelCount} Level</small><button class="nw-btn primary" data-world="${esc(w.slug)}">NETZPLAN ÖFFNEN</button></article>`).join('')}</section>`;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=`<section class="nw-brief"><span class="nw-kicker">${esc(session.examScope)} · ${esc(session.difficulty).toUpperCase()}</span><h1>${esc(session.title)}</h1><p>${esc(session.description)}</p><div class="nw-rules"><div><b>1</b> Werte prüfen</div><div><b>2</b> Pings simulieren</div><div><b>3</b> Diagnose lesen</div></div><button class="nw-btn primary" id="nwStart">KALIBRIERUNG STARTEN</button></section>`;document.getElementById('nwStart').addEventListener('click',renderLevel)}
function renderLevel(){const level=session.levels[index],net=level.network;view.innerHTML=`<header class="nw-level-head"><div><span class="nw-kicker">LEVEL ${index+1} / ${session.levels.length} · ${esc(level.type).toUpperCase()}</span><h1>${esc(level.title)}</h1></div><div class="nw-score">${level.points}<small>PTS</small></div></header><div class="nw-progress">${session.levels.map((_,i)=>`<i class="${i<index?'done':i===index?'active':''}">${i+1}</i>`).join('')}</div><div class="nw-layout"><section class="nw-board">${drawNet(net)}</section><aside class="nw-panel"><h2>Konfiguration</h2>${drawInputs(net)}<section class="nw-goals"><h2>Ping-Ziele</h2>${net.goals.map(g=>`<div><b>${esc(g.from)} → ${esc(g.to)}</b><span id="goal-${esc(g.id)}">offen</span></div>`).join('')}</section><div id="nwFeedback" class="nw-feedback" aria-live="polite"></div><div class="nw-actions"><button class="nw-btn primary" id="nwCheck">PINGS TESTEN</button><button class="nw-btn" id="nwReveal">LÖSUNG ANZEIGEN</button></div></aside></div>`;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 `<svg viewBox="0 0 1000 560" role="img" aria-label="Netzwerkplan">${(net.links||[]).map(([a,b])=>{const x=nodeById[a],y=nodeById[b];return x&&y?`<path class="nw-link" d="M${x.x+65} ${x.y+35} L${y.x+65} ${y.y+35}"/>`:''}).join('')}${net.nodes.map(n=>`<g class="nw-node nw-${esc(n.type)}" transform="translate(${n.x||0} ${n.y||0})"><rect width="130" height="70" rx="8"/><text x="65" y="30" text-anchor="middle">${esc(n.label||n.id)}</text><text x="65" y="52" text-anchor="middle">${esc(n.type||'node').toUpperCase()}</text></g>`).join('')}</svg>`}
function drawInputs(net){const rows=net.nodes.filter(n=>n.editable?.length).map(n=>`<fieldset data-node="${esc(n.id)}"><legend>${esc(n.label||n.id)}</legend>${n.editable.map(f=>f==='routes'?`<label>Statische Route <textarea data-field="routes" rows="3">${esc(JSON.stringify(n.routes||[]))}</textarea></label>`:`<label>${f==='prefix'?'Präfix /':' '+f.toUpperCase()}<input data-field="${f}" value="${esc(n[f]??'')}"></label>`).join('')}</fieldset>`).join('');return rows||'<p>In diesem Plan sind keine Werte editierbar.</p>'}
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<session.levels.length?'NÄCHSTES LEVEL':'WELT AUSWERTEN';next.addEventListener('click',()=>{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=`<section class="nw-result"><span class="nw-kicker">NETZWERKPROTOKOLL</span><h1>${r.perfect?'Alle Paketwege fehlerfrei.':'Welt abgeschlossen.'}</h1><div class="nw-final-score">${r.score}</div><p>${'★'.repeat(r.stars)}${'☆'.repeat(3-r.stars)}</p><div class="nw-stats"><div><b>${r.solved}/${r.total}</b>Level</div><div><b>${r.highscore}</b>Highscore</div><div><b>${r.plays}</b>Welten</div></div><button class="nw-btn primary" id="nwAgain">WEITERE WELTEN</button></section>`;document.getElementById('nwAgain').addEventListener('click',load)}).catch(error)}
function error(err){view.innerHTML=`<section class="nw-result"><h1>NETZPLAN NICHT LESBAR</h1><p>${esc(err.message||err)}</p><button class="nw-btn" id="nwReload">ERNEUT VERSUCHEN</button></section>`;document.getElementById('nwReload').addEventListener('click',load)}load()})();
+2 -2
View File
@@ -32,13 +32,13 @@
document.head.appendChild(accessibilityCss); document.head.appendChild(accessibilityCss);
var accessibilityPlacementCss = document.createElement('link'); var accessibilityPlacementCss = document.createElement('link');
accessibilityPlacementCss.rel = 'stylesheet'; accessibilityPlacementCss.rel = 'stylesheet';
accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=5'; accessibilityPlacementCss.href = '/css/accessibility-placement.css?v=6';
accessibilityPlacementCss.setAttribute('data-accessibility', ''); accessibilityPlacementCss.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityPlacementCss); document.head.appendChild(accessibilityPlacementCss);
} }
if (!document.querySelector('script[data-accessibility]')) { if (!document.querySelector('script[data-accessibility]')) {
var accessibilityScript = document.createElement('script'); var accessibilityScript = document.createElement('script');
accessibilityScript.src = '/js/accessibility.js?v=5'; accessibilityScript.src = '/js/accessibility.js?v=6';
accessibilityScript.defer = true; accessibilityScript.defer = true;
accessibilityScript.setAttribute('data-accessibility', ''); accessibilityScript.setAttribute('data-accessibility', '');
document.head.appendChild(accessibilityScript); document.head.appendChild(accessibilityScript);
+1 -1
View File
@@ -138,7 +138,7 @@
['SystemEscape','lock','Löse IT-Rätsel, sammle Zugriffsfragmente und entkomme dem System.','/codeescape'], ['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'], ['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'], ['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'] ['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])=>` document.getElementById('grid').innerHTML = GAMES.map(([t,ic,d,href])=>`
@@ -0,0 +1 @@
<!doctype html><html lang="de" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NetWorker · Mindflux</title><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/networker.css?v=1"><script src="/js/theme.js"></script></head><body><main class="nw-app"><header class="nw-top"><a href="/dashboard" class="nw-back">← Dashboard</a><div class="nw-brand"><b>NETWORKER</b><span>NETZPLAN · DIAGNOSE</span></div><span class="mf-a11y-host"></span></header><section id="nwView" class="nw-view"><div class="nw-loading">NETZWERKPLÄNE WERDEN KALIBRIERT …</div></section></main><script src="/js/networker.js?v=1"></script></body></html>
@@ -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());
}
}