diff --git a/src/main/java/org/zaine/app/controller/RpgSaveController.java b/src/main/java/org/zaine/app/controller/RpgSaveController.java new file mode 100644 index 0000000..6d9f00e --- /dev/null +++ b/src/main/java/org/zaine/app/controller/RpgSaveController.java @@ -0,0 +1,45 @@ +package org.zaine.app.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zaine.app.dto.RpgSaveDTO; +import org.zaine.app.service.RpgSaveService; + +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/play/rpg") +@Tag(name = "RPG Saves", description = "Persistent save slots for browser RPGs") +public class RpgSaveController { + private final RpgSaveService rpgSaveService; + + public RpgSaveController(RpgSaveService rpgSaveService) { + this.rpgSaveService = rpgSaveService; + } + + @GetMapping("/save/{slot}") + public ResponseEntity load(@PathVariable String slot) { + RpgSaveDTO save = rpgSaveService.load(slot); + if (save == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(save); + } + + @PutMapping("/save/{slot}") + public ResponseEntity save(@PathVariable String slot, @RequestBody RpgSaveDTO dto) { + return ResponseEntity.ok(rpgSaveService.save(slot, dto.getPayload())); + } + + @DeleteMapping("/save/{slot}") + public ResponseEntity delete(@PathVariable String slot) { + rpgSaveService.delete(slot); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/zaine/app/dto/RpgSaveDTO.java b/src/main/java/org/zaine/app/dto/RpgSaveDTO.java new file mode 100644 index 0000000..8483443 --- /dev/null +++ b/src/main/java/org/zaine/app/dto/RpgSaveDTO.java @@ -0,0 +1,24 @@ +package org.zaine.app.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +public class RpgSaveDTO { + private String slot; + private JsonNode payload; + + public String getSlot() { + return slot; + } + + public void setSlot(String slot) { + this.slot = slot; + } + + public JsonNode getPayload() { + return payload; + } + + public void setPayload(JsonNode payload) { + this.payload = payload; + } +} diff --git a/src/main/java/org/zaine/app/service/RpgSaveService.java b/src/main/java/org/zaine/app/service/RpgSaveService.java new file mode 100644 index 0000000..fbb3365 --- /dev/null +++ b/src/main/java/org/zaine/app/service/RpgSaveService.java @@ -0,0 +1,70 @@ +package org.zaine.app.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.zaine.app.dto.RpgSaveDTO; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +@Service +public class RpgSaveService { + private final ObjectMapper objectMapper; + private final Path saveDirectory; + + public RpgSaveService( + ObjectMapper objectMapper, + @Value("${play.rpg.save-dir:${user.home}/.org-web-rpg-saves}") String saveDirectory) { + this.objectMapper = objectMapper; + this.saveDirectory = Path.of(saveDirectory); + } + + public RpgSaveDTO load(String slot) { + Path file = savePath(slot); + if (!Files.exists(file)) { + return null; + } + try { + return objectMapper.readValue(file.toFile(), RpgSaveDTO.class); + } catch (IOException e) { + throw new RuntimeException("Failed to load RPG save", e); + } + } + + public RpgSaveDTO save(String slot, JsonNode payload) { + try { + Files.createDirectories(saveDirectory); + RpgSaveDTO dto = new RpgSaveDTO(); + dto.setSlot(slot); + dto.setPayload(payload); + + ObjectNode wrapped = objectMapper.createObjectNode(); + wrapped.put("slot", slot); + wrapped.set("payload", payload); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(savePath(slot).toFile(), wrapped); + return dto; + } catch (IOException e) { + throw new RuntimeException("Failed to save RPG progress", e); + } + } + + public void delete(String slot) { + try { + Files.deleteIfExists(savePath(slot)); + } catch (IOException e) { + throw new RuntimeException("Failed to delete RPG save", e); + } + } + + private Path savePath(String slot) { + if (slot == null || !slot.matches("[A-Za-z0-9_-]{1,64}")) { + throw new IllegalArgumentException("Invalid RPG save slot"); + } + return saveDirectory.resolve(slot + ".json").normalize(); + } +}