From b0d8dd48dbc2126612799b0e36f0af6f1f5ea3b0 Mon Sep 17 00:00:00 2001 From: AliceDarkest Date: Mon, 13 Jul 2026 22:13:39 +0200 Subject: [PATCH] Add achievements for existing games --- .../mindflux/service/AchievementServiceImpl.java | 12 ++++++++++++ .../mindflux/service/BugHunterService.java | 7 ++++--- .../mindflux/service/CodeEscapeService.java | 6 +++--- .../service/AchievementServiceImplTest.java | 15 +++++++++------ .../mindflux/service/BugHunterServiceTest.java | 4 ++-- .../mindflux/service/CodeEscapeServiceTest.java | 2 +- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java index c852314..cc3020a 100644 --- a/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java +++ b/src/main/java/com/example/mindflux/service/AchievementServiceImpl.java @@ -25,6 +25,13 @@ public class AchievementServiceImpl implements AchievementService { CATALOG.put("Dauerbrenner", "Spiele dasselbe Spiel zehnmal."); CATALOG.put("Punktesammler", "Sammle insgesamt 5000 Punkte."); CATALOG.put("Allrounder", "Spiele vier verschiedene Spiele."); + CATALOG.put("Gedächtnis-Impuls", "Schließe eine MindMatch-Runde mit Punkten ab."); + CATALOG.put("Quiz-Kapitän", "Schließe eine TechTacular-Runde mit Punkten ab."); + CATALOG.put("Werkbank aktiv", "Schließe eine DevLab-Runde mit Punkten ab."); + CATALOG.put("Syntax-Spur", "Schließe eine SyntaxSaga-Runde mit Punkten ab."); + CATALOG.put("Fluchtprotokoll", "Schließe eine SystemEscape-Runde mit Punkten ab."); + CATALOG.put("Erste Fallakte", "Schließe eine BugHunter-Akte mit Punkten ab."); + CATALOG.put("Netzwerk-Neustart", "Schließe eine NetWorker-Welt mit Punkten ab."); } private final AchievementRepository achievementRepository; @@ -70,6 +77,11 @@ public class AchievementServiceImpl implements AchievementService { gameScoreRepository.sumScoreByUsername(username) >= 5000); award(existing, username, "Allrounder", gameScoreRepository.countDistinctGamesByUsername(username) >= 4); + String gameAchievement = Map.of( + "mindmatch", "Gedächtnis-Impuls", "techtacular", "Quiz-Kapitän", "devlab", "Werkbank aktiv", + "syntax-saga", "Syntax-Spur", "codeescape", "Fluchtprotokoll", "bughunter", "Erste Fallakte", + "networker", "Netzwerk-Neustart").get(game); + if (gameAchievement != null) award(existing, username, gameAchievement, score > 0); } @Override @Transactional diff --git a/src/main/java/com/example/mindflux/service/BugHunterService.java b/src/main/java/com/example/mindflux/service/BugHunterService.java index ac419f0..1a3faed 100644 --- a/src/main/java/com/example/mindflux/service/BugHunterService.java +++ b/src/main/java/com/example/mindflux/service/BugHunterService.java @@ -25,12 +25,13 @@ public class BugHunterService { private final BugHunterBugRepository bugs; private final BugHunterHuntProgressRepository progress; private final GameScoreService scores; + private final AchievementService achievements; private final ObjectMapper mapper; private final Random random = new Random(); public BugHunterService(BugHunterHuntRepository hunts, BugHunterFileRepository files, BugHunterBugRepository bugs, - BugHunterHuntProgressRepository progress, GameScoreService scores, ObjectMapper mapper) { - this.hunts = hunts; this.files = files; this.bugs = bugs; this.progress = progress; this.scores = scores; this.mapper = mapper; + BugHunterHuntProgressRepository progress, GameScoreService scores, AchievementService achievements, ObjectMapper mapper) { + this.hunts = hunts; this.files = files; this.bugs = bugs; this.progress = progress; this.scores = scores; this.achievements = achievements; this.mapper = mapper; } @Transactional(readOnly = true) @@ -129,7 +130,7 @@ public class BugHunterService { } else combo = 0; } boolean cleanSolve = solved == hunt.getBugs().size() && maxCombo == hunt.getBugs().size(); - if (cleanSolve && !blank(username)) markCleanSolved(username, hunt.getSlug()); + if (cleanSolve && !blank(username)) { markCleanSolved(username, hunt.getSlug()); achievements.awardSpecial(username, "Fallakten-Perfektionist", "Schließe eine BugHunter-Akte ohne Fehlversuch und ohne Auflösung ab."); } int stars = stars(base, max); ScoreResult saved = scores.recordScore(username, GAME, hunt.getSlug(), base + comboBonus, solved, request.getDurationSeconds()); httpSession.removeAttribute("bughunter.run"); return new BugHunterResult(base + comboBonus, base, max, comboBonus, maxCombo, solved, hunt.getBugs().size(), stars, saved.getHighscore(), saved.isNewHighscore(), saved.getPlays(), cleanSolve); diff --git a/src/main/java/com/example/mindflux/service/CodeEscapeService.java b/src/main/java/com/example/mindflux/service/CodeEscapeService.java index bb206bf..24c6ee7 100644 --- a/src/main/java/com/example/mindflux/service/CodeEscapeService.java +++ b/src/main/java/com/example/mindflux/service/CodeEscapeService.java @@ -2,8 +2,8 @@ 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 CodeEscapeService { public static final String GAME="codeescape"; private static final SetTYPES=Set.of("code","choice","order","match"),SCOPES=Set.of("AP1","AP2_FIAE","AP2_FISI","CROSS"); private static final int MAX_ITEMS=40; - private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;private final ObjectMapper mapper; - public CodeEscapeService(EscapeScenarioRepository s,EscapeRoomRepository r,EscapePuzzleRepository p,GameScoreService g,ObjectMapper m){scenarios=s;rooms=r;puzzles=p;scores=g;mapper=m;} + private final EscapeScenarioRepository scenarios;private final EscapeRoomRepository rooms;private final EscapePuzzleRepository puzzles;private final GameScoreService scores;private final AchievementService achievements;private final ObjectMapper mapper; + public CodeEscapeService(EscapeScenarioRepository s,EscapeRoomRepository r,EscapePuzzleRepository p,GameScoreService g,AchievementService a,ObjectMapper m){scenarios=s;rooms=r;puzzles=p;scores=g;achievements=a;mapper=m;} public static class RunState implements java.io.Serializable {public Long scenarioId;public String slug;public final ListroomIds=new ArrayList<>();public final SetpuzzleIds=new HashSet<>();public final Map attempts=new HashMap<>(),hints=new HashMap<>();public final Set revealed=new HashSet<>(),resolved=new HashSet<>(),opened=new LinkedHashSet<>();} private RunState state(HttpSession session){Object o=session.getAttribute("codeescape.run");if(o instanceof RunState s&&s.scenarioId!=null)return s;throw new IllegalArgumentException("Kein aktiver SystemEscape-Run.");} @Transactional(readOnly=true) public JsonNode catalog(){ArrayNode a=mapper.createArrayNode();for(EscapeScenario s:scenarios.findAllByActiveTrueOrderBySortOrderAscIdAsc()){if(s.getRooms().isEmpty())continue;ObjectNode n=a.addObject();n.put("slug",s.getSlug());n.put("title",s.getTitle());n.put("difficulty",s.getDifficulty());n.put("examScope",s.getExamScope());n.put("description",s.getDescription());n.put("timeLimitSeconds",s.getTimeLimitSeconds());n.put("roomCount",Math.min(4,s.getRooms().size()));n.put("poolSize",s.getRooms().size());}return mapper.createObjectNode().set("scenarios",a);} @@ -13,7 +13,7 @@ import com.example.mindflux.dto.*; import com.example.mindflux.model.*; import c @Transactional(readOnly=true) public JsonNode hint(Long id,int level,HttpSession session){RunState st=state(session);if(!st.puzzleIds.contains(id))throw new IllegalArgumentException("Rätsel gehört nicht zu diesem Run.");EscapePuzzle p=puzzles.findById(id).orElseThrow(()->new IllegalArgumentException("Rätsel nicht gefunden."));int lv=Math.max(1,Math.min(2,level));st.hints.merge(id,lv,Math::max);Listh=strings(parse(p.getPayload()),"hints");ObjectNode o=mapper.createObjectNode();o.put("level",lv);o.put("text",h.isEmpty()?"Prüfe die Aufgabenstellung noch einmal.":h.get(Math.min(lv-1,h.size()-1)));return o;} @Transactional(readOnly=true) public JsonNode door(Long id,String code,HttpSession session){RunState st=state(session);int next=st.opened.size();if(next>=st.roomIds.size()||!Objects.equals(st.roomIds.get(next),id))throw new IllegalArgumentException("Raum gehört nicht zum aktuellen Abschnitt dieses Runs.");EscapeRoom r=rooms.findById(id).orElseThrow(()->new IllegalArgumentException("Raum nicht gefunden."));boolean complete=r.getPuzzles().stream().allMatch(p->st.resolved.contains(p.getId()));boolean open=complete&&eq(r.getDoorCode(),code,false);if(open)st.opened.add(id);ObjectNode o=mapper.createObjectNode();o.put("open",open);o.put("complete",complete);if(open)o.put("successText",r.getSuccessText());else o.put("message",complete?"Code abgelehnt. Setze die Fragmente von links nach rechts ohne Leerzeichen zusammen.":"Löse zuerst alle Rätsel dieses Raums.");return o;} @Transactional public EscapeResult finish(String user,EscapeFinishRequest req,HttpSession session){RunState st=state(session);EscapeScenario s=scenarios.findBySlug(req.getSlug()).orElseThrow(()->new IllegalArgumentException("Szenario nicht gefunden."));if(!Objects.equals(st.scenarioId,s.getId())||!Objects.equals(st.slug,req.getSlug()))throw new IllegalArgumentException("Szenario gehört nicht zu diesem Run.");if(req.getDurationSeconds()<1||req.getDurationSeconds()>s.getTimeLimitSeconds()*4)throw new IllegalArgumentException("Unplausible Spieldauer.");if(req.getItems()==null||req.getItems().size()>MAX_ITEMS)throw new IllegalArgumentException("Ungültige Rätselliste.");MapbyId=new HashMap<>();MapselectedRooms=new HashMap<>();for(EscapeRoom r:s.getRooms())if(st.roomIds.contains(r.getId())){selectedRooms.put(r.getId(),r);for(EscapePuzzle p:r.getPuzzles())byId.put(p.getId(),p);}Setseen=new HashSet<>();int base=0,solved=0;for(EscapeFinishRequest.Item i:req.getItems()){if(i.getPuzzleId()==null||!st.puzzleIds.contains(i.getPuzzleId())||!seen.add(i.getPuzzleId()))throw new IllegalArgumentException("Fremde oder doppelte Rätsel-ID.");EscapePuzzle p=byId.get(i.getPuzzleId());if(p!=null&&matches(p,i.getSubmission())&&!st.revealed.contains(p.getId())){solved++;int a=Math.max(1,st.attempts.getOrDefault(p.getId(),1));double af=a==1?1:a==2?.6:.3;int hl=st.hints.getOrDefault(p.getId(),0);double hf=hl==0?1:hl==1?.8:.6;base+=(int)Math.round(p.getPoints()*af*hf);}} - int opened=st.opened.size();base+=opened*50;int max=selectedRooms.values().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==st.roomIds.size();int bonus=escaped?Math.max(0,(int)Math.round(500.0*(s.getTimeLimitSeconds()-req.getDurationSeconds())/s.getTimeLimitSeconds())):0;int total=base+bonus;int stars=stars(base,max);ScoreResult saved=scores.recordScore(user,GAME,s.getSlug(),total,solved,req.getDurationSeconds());httpClear(session);return new EscapeResult(total,base,max,bonus,solved,opened,escaped,stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays());} + int opened=st.opened.size();base+=opened*50;int max=selectedRooms.values().stream().mapToInt(r->r.getPuzzles().stream().mapToInt(EscapePuzzle::getPoints).sum()+50).sum();boolean escaped=req.isEscaped()&&opened==st.roomIds.size();int bonus=escaped?Math.max(0,(int)Math.round(500.0*(s.getTimeLimitSeconds()-req.getDurationSeconds())/s.getTimeLimitSeconds())):0;int total=base+bonus;int stars=stars(base,max);ScoreResult saved=scores.recordScore(user,GAME,s.getSlug(),total,solved,req.getDurationSeconds());if(escaped&&stars==3)achievements.awardSpecial(user,"Schleusenmeister","Entkomme aus SystemEscape mit drei Sternen.");httpClear(session);return new EscapeResult(total,base,max,bonus,solved,opened,escaped,stars,saved.getHighscore(),saved.isNewHighscore(),saved.getPlays());} private void httpClear(HttpSession s){s.removeAttribute("codeescape.run");} private boolean matches(EscapePuzzle p,JsonNode sub){JsonNode j=parse(p.getPayload());if(j==null||sub==null)return false;return switch(p.getType()){case"code"->strings(j,"solutions").stream().anyMatch(x->eq(x,sub.path("answer").asText(""),j.path("caseSensitive").asBoolean(false)));case"choice"->eq(j.path("correct").asText(),sub.path("answer").asText(),false);case"order"->listEq(strings(j,"solution"),strings(sub,"items"));case"match"->pairEq(j,sub);default->false;};} private boolean pairEq(JsonNode j,JsonNode s){Mapw=new HashMap<>();for(JsonNode p:array(j,"pairs"))if(p.size()>1)w.put(norm(p.get(0).asText()),norm(p.get(1).asText()));for(JsonNode p:array(s,"pairs"))if(p.size()>1&&!Objects.equals(w.get(norm(p.get(0).asText())),norm(p.get(1).asText())))return false;return arraySize(s,"pairs")==w.size();}private int arraySize(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a.size():0;}private boolean listEq(Lista,Listb){if(a.size()!=b.size())return false;for(int i=0;i=.8?3:r>=.5?2:1;}private JsonNode parse(String j){try{return mapper.readTree(j);}catch(Exception e){return null;}}private Iterable array(JsonNode n,String f){JsonNode a=n==null?null:n.get(f);return a!=null&&a.isArray()?a:mapper.createArrayNode();}private List strings(JsonNode n,String f){Listo=new ArrayList<>();for(JsonNode x:array(n,f))o.add(x.asText());return o;}private ArrayNode shuffle(Listx){Listc=new ArrayList<>(x);Collections.shuffle(c);ArrayNode a=mapper.createArrayNode();c.forEach(a::add);return a;}private void copy(JsonNode s,ObjectNode t,String f){if(s.hasNonNull(f))t.put(f,s.get(f).asText());} diff --git a/src/test/java/com/example/mindflux/service/AchievementServiceImplTest.java b/src/test/java/com/example/mindflux/service/AchievementServiceImplTest.java index feeccbc..3d8baa6 100644 --- a/src/test/java/com/example/mindflux/service/AchievementServiceImplTest.java +++ b/src/test/java/com/example/mindflux/service/AchievementServiceImplTest.java @@ -37,15 +37,17 @@ class AchievementServiceImplTest { service.checkAndAward("alice", "mindmatch", 500, true, 1); ArgumentCaptor captor = ArgumentCaptor.forClass(Achievement.class); - verify(achievementRepository, times(1)).save(captor.capture()); - assertEquals("Erster Sieg", captor.getValue().getTitle()); - assertEquals("alice", captor.getValue().getUsername()); + verify(achievementRepository, times(2)).save(captor.capture()); + List titel = captor.getAllValues().stream().map(Achievement::getTitle).toList(); + assertTrue(titel.contains("Erster Sieg")); assertTrue(titel.contains("Gedächtnis-Impuls")); + assertEquals("alice", captor.getAllValues().getFirst().getUsername()); } @Test void bereitsVergebeneAchievementsWerdenNichtDoppeltVergeben() { - Achievement vorhanden = new Achievement("Erster Sieg", "…", null, true); - when(achievementRepository.findByUsername("alice")).thenReturn(List.of(vorhanden)); + Achievement ersterSieg = new Achievement("Erster Sieg", "…", null, true); + Achievement mindmatch = new Achievement("Gedächtnis-Impuls", "…", null, true); + when(achievementRepository.findByUsername("alice")).thenReturn(List.of(ersterSieg, mindmatch)); service.checkAndAward("alice", "mindmatch", 500, false, 2); @@ -59,10 +61,11 @@ class AchievementServiceImplTest { service.checkAndAward("alice", "techtacular", 1200, true, 1); ArgumentCaptor captor = ArgumentCaptor.forClass(Achievement.class); - verify(achievementRepository, times(2)).save(captor.capture()); + verify(achievementRepository, times(3)).save(captor.capture()); List titel = captor.getAllValues().stream().map(Achievement::getTitle).toList(); assertTrue(titel.contains("Erster Sieg")); assertTrue(titel.contains("1000er-Club")); + assertTrue(titel.contains("Quiz-Kapitän")); } @Test diff --git a/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java index 56c2ef7..b28f29b 100644 --- a/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java +++ b/src/test/java/com/example/mindflux/service/BugHunterServiceTest.java @@ -12,10 +12,10 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class BugHunterServiceTest { - BugHunterHuntRepository hunts = mock(BugHunterHuntRepository.class); BugHunterFileRepository files = mock(BugHunterFileRepository.class); BugHunterBugRepository bugs = mock(BugHunterBugRepository.class); BugHunterHuntProgressRepository progress = mock(BugHunterHuntProgressRepository.class); GameScoreService scores = mock(GameScoreService.class); + BugHunterHuntRepository hunts = mock(BugHunterHuntRepository.class); BugHunterFileRepository files = mock(BugHunterFileRepository.class); BugHunterBugRepository bugs = mock(BugHunterBugRepository.class); BugHunterHuntProgressRepository progress = mock(BugHunterHuntProgressRepository.class); GameScoreService scores = mock(GameScoreService.class); AchievementService achievements = mock(AchievementService.class); ObjectMapper mapper = new ObjectMapper(); BugHunterService service; BugHunterHunt hunt; BugHunterBug bug; MockHttpSession session; @BeforeEach void setup() { - service = new BugHunterService(hunts, files, bugs, progress, scores, mapper); session = new MockHttpSession(); + service = new BugHunterService(hunts, files, bugs, progress, scores, achievements, mapper); session = new MockHttpSession(); hunt = new BugHunterHunt("test", "Test", "Java", "junior", "AP1", "", "Briefing", "Trace", 0); hunt.setId(1L); hunt.addFile(new BugHunterFile("Demo.java", "class Demo {\n void run() {}\n}", 0)); bug = new BugHunterBug(100, 0, "{\"file\":\"Demo.java\",\"line\":2,\"category\":\"logik\",\"evidence\":\"Spur\",\"fixOptions\":[\"fixed();\",\"broken();\"],\"fixIndex\":0,\"fixedLine\":\"fixed();\",\"explanation\":\"Warum\"}"); bug.setId(2L); hunt.addBug(bug); diff --git a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java index 1b9481f..d115fb8 100644 --- a/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java +++ b/src/test/java/com/example/mindflux/service/CodeEscapeServiceTest.java @@ -2,7 +2,7 @@ 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 org.junit.jupiter.api.*;import org.springframework.mock.web.MockHttpSession;import java.util.*;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.*;import static org.mockito.Mockito.*; class CodeEscapeServiceTest { EscapeScenarioRepository scenarios=mock(EscapeScenarioRepository.class);EscapeRoomRepository rooms=mock(EscapeRoomRepository.class);EscapePuzzleRepository puzzles=mock(EscapePuzzleRepository.class);GameScoreService scores=mock(GameScoreService.class);ObjectMapper mapper=new ObjectMapper();CodeEscapeService service;EscapeScenario scenario;EscapeRoom room;EscapePuzzle puzzle; - @BeforeEach void setup(){service=new CodeEscapeService(scenarios,rooms,puzzles,scores,mapper);scenario=new EscapeScenario("test","Test","leicht","AP1","desc",900,0);scenario.setId(1L);room=new EscapeRoom("Raum","Intro","Code?","41","Offen",0);room.setId(2L);puzzle=new EscapePuzzle("code","Binär",100,"41",0,"{\"solutions\":[\"41\"],\"hints\":[\"h1\",\"h2\"],\"explanation\":\"secret\"}");puzzle.setId(3L);room.addPuzzle(puzzle);scenario.addRoom(room);when(scenarios.findBySlug("test")).thenReturn(Optional.of(scenario));when(puzzles.findById(3L)).thenReturn(Optional.of(puzzle));when(rooms.findById(2L)).thenReturn(Optional.of(room));when(scores.recordScore(anyString(),anyString(),anyString(),anyInt(),anyInt(),anyInt())).thenAnswer(i->new ScoreResult(i.getArgument(3),i.getArgument(3),true,1));} + @BeforeEach void setup(){service=new CodeEscapeService(scenarios,rooms,puzzles,scores,mock(AchievementService.class),mapper);scenario=new EscapeScenario("test","Test","leicht","AP1","desc",900,0);scenario.setId(1L);room=new EscapeRoom("Raum","Intro","Code?","41","Offen",0);room.setId(2L);puzzle=new EscapePuzzle("code","Binär",100,"41",0,"{\"solutions\":[\"41\"],\"hints\":[\"h1\",\"h2\"],\"explanation\":\"secret\"}");puzzle.setId(3L);room.addPuzzle(puzzle);scenario.addRoom(room);when(scenarios.findBySlug("test")).thenReturn(Optional.of(scenario));when(puzzles.findById(3L)).thenReturn(Optional.of(puzzle));when(rooms.findById(2L)).thenReturn(Optional.of(room));when(scores.recordScore(anyString(),anyString(),anyString(),anyInt(),anyInt(),anyInt())).thenAnswer(i->new ScoreResult(i.getArgument(3),i.getArgument(3),true,1));} @Test void sessionLeaksNoSolutionsHintsFragmentsOrDoorCodes(){JsonNode json=service.session("test",new MockHttpSession());String raw=json.toString();assertFalse(raw.contains("solutions"));assertFalse(raw.contains("hints"));assertFalse(raw.contains("secret"));assertFalse(raw.contains("\"41\""));} @Test void sessionSelectsFourRoomsAndPinsThemToTheRun(){for(long i=4;i<=7;i++){EscapeRoom extra=new EscapeRoom("R"+i,"I","P","X","S",(int)i);extra.setId(i);EscapePuzzle p=new EscapePuzzle("code","P",10,"X",0,"{\"solutions\":[\"X\"]}");p.setId(i+10);extra.addPuzzle(p);scenario.addRoom(extra);}MockHttpSession http=new MockHttpSession();JsonNode json=service.session("test",http);assertEquals(4,json.path("rooms").size());CodeEscapeService.RunState state=(CodeEscapeService.RunState)http.getAttribute("codeescape.run");assertEquals(4,state.roomIds.size());assertEquals(4,state.puzzleIds.size());} @Test void hintAndAttemptAreServerAuthoritative(){MockHttpSession session=new MockHttpSession();service.session("test",session);service.hint(3L,1,session);service.check(3L,false,mapper.createObjectNode().put("answer","41"),session);service.door(2L,"41",session);EscapeFinishRequest req=new EscapeFinishRequest();req.setSlug("test");req.setDurationSeconds(450);req.setEscaped(true);EscapeFinishRequest.Item item=new EscapeFinishRequest.Item();item.setPuzzleId(3L);item.setSubmission(mapper.createObjectNode().put("answer","41"));req.setItems(List.of(item));EscapeResult result=service.finish("alice",req,session);assertEquals(130,result.baseScore());assertEquals(250,result.timeBonus());assertTrue(result.escaped());assertEquals(380,result.score());}