Skip to content

Commit a8e5fd6

Browse files
committed
feat: persist runtime sessions in db and add kid-friendly reading mode
1 parent ede6ed9 commit a8e5fd6

File tree

9 files changed

+418
-6
lines changed

9 files changed

+418
-6
lines changed

apps/backend/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,6 @@ mvn spring-boot:run
4444
- El frontend empaquetado se copia a `src/main/resources/static` solo durante build desktop.
4545
- El pipeline narrativo incluye normalizacion de texto, memoria de entidades, grafo de relaciones y nivel cognitivo por escena.
4646
- Persistencia docente sobre JDBC + Flyway (`classrooms`, `students`, `assignments`, `attempts`).
47+
- Persistencia runtime de sesiones de juego sobre JDBC + Flyway (`game_sessions`).
4748
- Default local con H2 file DB; PostgreSQL habilitado por variables de entorno Spring datasource.
4849
- Importacion de libros restringida a `.txt` y `.pdf` con limite configurable (`app.import.max-bytes`, default 25MB).
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package com.juegodefinitivo.autobook.persistence.game;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.core.type.TypeReference;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.juegodefinitivo.autobook.domain.GameSession;
7+
import org.springframework.jdbc.core.JdbcTemplate;
8+
import org.springframework.stereotype.Repository;
9+
10+
import java.sql.Timestamp;
11+
import java.time.Instant;
12+
import java.util.LinkedHashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.Optional;
16+
17+
@Repository
18+
public class GameSessionRuntimeRepository {
19+
20+
private static final TypeReference<LinkedHashMap<String, Integer>> MAP_TYPE = new TypeReference<>() {
21+
};
22+
23+
private final JdbcTemplate jdbcTemplate;
24+
private final ObjectMapper objectMapper;
25+
26+
public GameSessionRuntimeRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
27+
this.jdbcTemplate = jdbcTemplate;
28+
this.objectMapper = objectMapper;
29+
}
30+
31+
public void save(String sessionId, GameSession session, Map<String, Integer> narrativeMemory, int challengeAttempts, int challengeCorrect, String lastMessage) {
32+
String inventoryJson = toJson(session.getInventory());
33+
String memoryJson = toJson(narrativeMemory);
34+
Instant now = Instant.now();
35+
36+
int updated = jdbcTemplate.update(
37+
"""
38+
UPDATE game_sessions
39+
SET player_name = ?,
40+
book_path = ?,
41+
book_title = ?,
42+
current_scene = ?,
43+
life = ?,
44+
knowledge = ?,
45+
courage = ?,
46+
focus = ?,
47+
score = ?,
48+
correct_answers = ?,
49+
discoveries = ?,
50+
completed = ?,
51+
inventory_json = ?,
52+
narrative_memory_json = ?,
53+
challenge_attempts = ?,
54+
challenge_correct = ?,
55+
last_message = ?,
56+
updated_at = ?
57+
WHERE session_id = ?
58+
""",
59+
session.getPlayerName(),
60+
session.getBookPath(),
61+
session.getBookTitle(),
62+
session.getCurrentScene(),
63+
session.getLife(),
64+
session.getKnowledge(),
65+
session.getCourage(),
66+
session.getFocus(),
67+
session.getScore(),
68+
session.getCorrectAnswers(),
69+
session.getDiscoveries(),
70+
session.isCompleted(),
71+
inventoryJson,
72+
memoryJson,
73+
challengeAttempts,
74+
challengeCorrect,
75+
safeMessage(lastMessage),
76+
Timestamp.from(now),
77+
sessionId
78+
);
79+
80+
if (updated == 0) {
81+
jdbcTemplate.update(
82+
"""
83+
INSERT INTO game_sessions (
84+
session_id,
85+
player_name,
86+
book_path,
87+
book_title,
88+
current_scene,
89+
life,
90+
knowledge,
91+
courage,
92+
focus,
93+
score,
94+
correct_answers,
95+
discoveries,
96+
completed,
97+
inventory_json,
98+
narrative_memory_json,
99+
challenge_attempts,
100+
challenge_correct,
101+
last_message,
102+
updated_at
103+
)
104+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
105+
""",
106+
sessionId,
107+
session.getPlayerName(),
108+
session.getBookPath(),
109+
session.getBookTitle(),
110+
session.getCurrentScene(),
111+
session.getLife(),
112+
session.getKnowledge(),
113+
session.getCourage(),
114+
session.getFocus(),
115+
session.getScore(),
116+
session.getCorrectAnswers(),
117+
session.getDiscoveries(),
118+
session.isCompleted(),
119+
inventoryJson,
120+
memoryJson,
121+
challengeAttempts,
122+
challengeCorrect,
123+
safeMessage(lastMessage),
124+
Timestamp.from(now)
125+
);
126+
}
127+
}
128+
129+
public Optional<StoredSession> load(String sessionId) {
130+
List<StoredSession> rows = jdbcTemplate.query(
131+
"""
132+
SELECT session_id, player_name, book_path, book_title, current_scene,
133+
life, knowledge, courage, focus, score, correct_answers,
134+
discoveries, completed, inventory_json, narrative_memory_json,
135+
challenge_attempts, challenge_correct, last_message
136+
FROM game_sessions
137+
WHERE session_id = ?
138+
""",
139+
(rs, rowNum) -> {
140+
GameSession session = new GameSession();
141+
session.setPlayerName(rs.getString("player_name"));
142+
session.setBookPath(rs.getString("book_path"));
143+
session.setBookTitle(rs.getString("book_title"));
144+
session.setCurrentScene(rs.getInt("current_scene"));
145+
session.setLife(rs.getInt("life"));
146+
session.setKnowledge(rs.getInt("knowledge"));
147+
session.setCourage(rs.getInt("courage"));
148+
session.setFocus(rs.getInt("focus"));
149+
session.setScore(rs.getInt("score"));
150+
session.setCorrectAnswers(rs.getInt("correct_answers"));
151+
session.setDiscoveries(rs.getInt("discoveries"));
152+
session.setCompleted(rs.getBoolean("completed"));
153+
session.replaceInventory(fromJson(rs.getString("inventory_json")));
154+
return new StoredSession(
155+
rs.getString("session_id"),
156+
session,
157+
fromJson(rs.getString("narrative_memory_json")),
158+
rs.getInt("challenge_attempts"),
159+
rs.getInt("challenge_correct"),
160+
rs.getString("last_message")
161+
);
162+
},
163+
sessionId
164+
);
165+
return rows.stream().findFirst();
166+
}
167+
168+
private String toJson(Map<String, Integer> value) {
169+
try {
170+
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
171+
} catch (JsonProcessingException ex) {
172+
throw new IllegalStateException("No se pudo serializar sesion runtime.", ex);
173+
}
174+
}
175+
176+
private Map<String, Integer> fromJson(String value) {
177+
try {
178+
if (value == null || value.isBlank()) {
179+
return new LinkedHashMap<>();
180+
}
181+
return objectMapper.readValue(value, MAP_TYPE);
182+
} catch (JsonProcessingException ex) {
183+
throw new IllegalStateException("No se pudo deserializar sesion runtime.", ex);
184+
}
185+
}
186+
187+
private String safeMessage(String value) {
188+
if (value == null || value.isBlank()) {
189+
return "Sesion restaurada.";
190+
}
191+
return value.length() > 1990 ? value.substring(0, 1990) : value;
192+
}
193+
194+
public record StoredSession(
195+
String sessionId,
196+
GameSession session,
197+
Map<String, Integer> narrativeMemory,
198+
int challengeAttempts,
199+
int challengeCorrect,
200+
String lastMessage
201+
) {
202+
}
203+
}

apps/backend/src/main/java/com/juegodefinitivo/autobook/service/GameFacadeService.java

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import com.juegodefinitivo.autobook.ingest.BookLoaderService;
1818
import com.juegodefinitivo.autobook.narrative.NarrativeBuilder;
1919
import com.juegodefinitivo.autobook.narrative.NarrativeScene;
20+
import com.juegodefinitivo.autobook.persistence.game.GameSessionRuntimeRepository;
2021
import org.springframework.stereotype.Service;
2122

2223
import java.io.InputStream;
@@ -41,6 +42,7 @@ public class GameFacadeService {
4142
private final GameEngineService engine;
4243
private final AutoplayService autoplayService;
4344
private final NarrativeGraphService narrativeGraphService;
45+
private final GameSessionRuntimeRepository runtimeRepository;
4446

4547
private final Map<String, SessionState> sessions = new ConcurrentHashMap<>();
4648

@@ -52,7 +54,8 @@ public GameFacadeService(
5254
NarrativeBuilder narrativeBuilder,
5355
GameEngineService engine,
5456
AutoplayService autoplayService,
55-
NarrativeGraphService narrativeGraphService
57+
NarrativeGraphService narrativeGraphService,
58+
GameSessionRuntimeRepository runtimeRepository
5659
) {
5760
this.config = config;
5861
this.catalog = catalog;
@@ -62,6 +65,7 @@ public GameFacadeService(
6265
this.engine = engine;
6366
this.autoplayService = autoplayService;
6467
this.narrativeGraphService = narrativeGraphService;
68+
this.runtimeRepository = runtimeRepository;
6569
}
6670

6771
public void bootstrapSamples() {
@@ -104,6 +108,7 @@ public GameStateResponse startGame(String playerName, String bookPath) {
104108
boot = absorbEntities(id, boot, scenes.get(0));
105109
}
106110
sessions.put(id, boot);
111+
persistRuntime(id, sessions.get(id));
107112
return toResponse(id, sessions.get(id));
108113
}
109114

@@ -149,6 +154,7 @@ public GameStateResponse applyAction(String sessionId, String actionValue, Integ
149154

150155
SessionState updated = state.withMessage(outcome.message());
151156
sessions.put(sessionId, updated);
157+
persistRuntime(sessionId, updated);
152158
return toResponse(sessionId, updated);
153159
}
154160

@@ -193,6 +199,7 @@ public GameStateResponse applyAutoplay(String sessionId, String ageBand, String
193199
sessions.put(sessionId, state);
194200
}
195201

202+
persistRuntime(sessionId, state);
196203
return toResponse(sessionId, state);
197204
}
198205

@@ -209,12 +216,42 @@ private PlayerAction parseAction(String value) {
209216

210217
private SessionState requireSession(String id) {
211218
SessionState state = sessions.get(id);
219+
if (state == null) {
220+
state = runtimeRepository.load(id)
221+
.map(this::toSessionState)
222+
.orElse(null);
223+
if (state != null) {
224+
sessions.put(id, state);
225+
}
226+
}
212227
if (state == null) {
213228
throw new IllegalArgumentException("Sesion no encontrada.");
214229
}
215230
return state;
216231
}
217232

233+
private SessionState toSessionState(GameSessionRuntimeRepository.StoredSession stored) {
234+
Path path = Path.of(stored.session().getBookPath()).toAbsolutePath().normalize();
235+
List<NarrativeScene> scenes = narrativeBuilder.build(loader.loadScenes(path, config.sceneMaxChars(), config.sceneLinesPerChunk()));
236+
if (scenes.isEmpty()) {
237+
throw new IllegalStateException("La sesion almacenada no tiene escenas disponibles.");
238+
}
239+
int maxSceneIndex = Math.max(0, scenes.size() - 1);
240+
if (stored.session().isCompleted()) {
241+
stored.session().setCurrentScene(scenes.size());
242+
} else {
243+
stored.session().setCurrentScene(Math.min(stored.session().getCurrentScene(), maxSceneIndex));
244+
}
245+
return new SessionState(
246+
stored.session(),
247+
scenes,
248+
stored.lastMessage(),
249+
new LinkedHashMap<>(stored.narrativeMemory()),
250+
stored.challengeAttempts(),
251+
stored.challengeCorrect()
252+
);
253+
}
254+
218255
private GameStateResponse toResponse(String id, SessionState state) {
219256
GameSession session = state.session();
220257
SceneView sceneView = null;
@@ -271,6 +308,17 @@ private SessionState absorbEntities(String sessionId, SessionState state, Narrat
271308
return state.withMemory(memory);
272309
}
273310

311+
private void persistRuntime(String sessionId, SessionState state) {
312+
runtimeRepository.save(
313+
sessionId,
314+
state.session(),
315+
state.narrativeMemory(),
316+
state.challengeAttempts(),
317+
state.challengeCorrect(),
318+
state.lastMessage()
319+
);
320+
}
321+
274322
private void copySampleIfMissing(String resource, String name) {
275323
Path target = config.booksDir().resolve(name);
276324
if (Files.exists(target)) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
CREATE TABLE IF NOT EXISTS game_sessions (
2+
session_id VARCHAR(80) PRIMARY KEY,
3+
player_name VARCHAR(160) NOT NULL,
4+
book_path VARCHAR(1500) NOT NULL,
5+
book_title VARCHAR(500) NOT NULL,
6+
current_scene INT NOT NULL,
7+
life INT NOT NULL,
8+
knowledge INT NOT NULL,
9+
courage INT NOT NULL,
10+
focus INT NOT NULL,
11+
score INT NOT NULL,
12+
correct_answers INT NOT NULL,
13+
discoveries INT NOT NULL,
14+
completed BOOLEAN NOT NULL,
15+
inventory_json CLOB NOT NULL,
16+
narrative_memory_json CLOB NOT NULL,
17+
challenge_attempts INT NOT NULL,
18+
challenge_correct INT NOT NULL,
19+
last_message VARCHAR(2000) NOT NULL,
20+
updated_at TIMESTAMP NOT NULL
21+
);
22+
23+
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated_at ON game_sessions (updated_at);

0 commit comments

Comments
 (0)