diff --git a/src/main/java/de/neitzel/roleplay/business/CharacterService.java b/src/main/java/de/neitzel/roleplay/business/CharacterService.java index 45992a6..4d4657b 100644 --- a/src/main/java/de/neitzel/roleplay/business/CharacterService.java +++ b/src/main/java/de/neitzel/roleplay/business/CharacterService.java @@ -3,6 +3,7 @@ package de.neitzel.roleplay.business; import de.neitzel.roleplay.data.CharacterEntity; import de.neitzel.roleplay.data.CharacterRepository; import de.neitzel.roleplay.fascade.model.CharacterDefinition; +import de.neitzel.roleplay.fascade.model.CreateCharacterRequest; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -47,6 +48,73 @@ public class CharacterService { return entity != null ? Optional.of(toCharacterDefinition(entity)) : Optional.empty(); } + /** + * Creates a new character from the request. Uses request id if present, otherwise generates a UUID. + * + * @param request the create request (name and role required) + * @return the created character definition + */ + public CharacterDefinition create(final CreateCharacterRequest request) { + CharacterEntity entity = fromRequest(request, request.getId() != null ? request.getId() : UUID.randomUUID()); + characterRepository.persist(entity); + return toCharacterDefinition(entity); + } + + /** + * Updates an existing character by id. Full replace of all fields. + * + * @param id the character UUID + * @param request the update request + * @return the updated character definition + * @throws java.util.NoSuchElementException if the character does not exist + */ + public CharacterDefinition update(final UUID id, final CreateCharacterRequest request) { + CharacterEntity entity = characterRepository.findByIdOptional(id); + if (entity == null) { + throw new java.util.NoSuchElementException("No character found with id: " + id); + } + applyRequest(entity, request); + characterRepository.persist(entity); + return toCharacterDefinition(entity); + } + + /** + * Deletes a character by id. + * + * @param id the character UUID + * @return true if deleted, false if no character existed + */ + public boolean delete(final UUID id) { + CharacterEntity entity = characterRepository.findByIdOptional(id); + if (entity == null) { + return false; + } + characterRepository.delete(entity); + return true; + } + + /** + * Builds a new entity from the request and the given id. + */ + private static CharacterEntity fromRequest(final CreateCharacterRequest request, final UUID id) { + CharacterEntity entity = new CharacterEntity(); + entity.setId(id); + applyRequest(entity, request); + return entity; + } + + /** + * Applies request fields to an existing entity (id is not changed). + */ + private static void applyRequest(final CharacterEntity entity, final CreateCharacterRequest request) { + entity.setName(request.getName()); + entity.setRole(request.getRole()); + entity.setBackstory(request.getBackstory()); + entity.setSpeakingStyle(request.getSpeakingStyle()); + entity.setPersonalityTraits(request.getPersonalityTraits() != null ? request.getPersonalityTraits() : List.of()); + entity.setGoals(request.getGoals() != null ? request.getGoals() : List.of()); + } + /** * Maps a character entity to the API CharacterDefinition. Uses entity id as string for API id. */ diff --git a/src/main/java/de/neitzel/roleplay/business/ScenarioService.java b/src/main/java/de/neitzel/roleplay/business/ScenarioService.java index b643873..75b5a9d 100644 --- a/src/main/java/de/neitzel/roleplay/business/ScenarioService.java +++ b/src/main/java/de/neitzel/roleplay/business/ScenarioService.java @@ -1,14 +1,18 @@ package de.neitzel.roleplay.business; +import de.neitzel.roleplay.data.CharacterRepository; import de.neitzel.roleplay.data.ScenarioCharacterEntity; import de.neitzel.roleplay.data.ScenarioEntity; import de.neitzel.roleplay.data.ScenarioRepository; import de.neitzel.roleplay.fascade.model.CharacterDefinition; +import de.neitzel.roleplay.fascade.model.CreateScenarioRequest; +import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot; import de.neitzel.roleplay.fascade.model.ScenarioSetup; import de.neitzel.roleplay.fascade.model.ScenarioSummary; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; import java.util.ArrayList; import java.util.List; @@ -22,10 +26,15 @@ import java.util.UUID; public class ScenarioService { private final ScenarioRepository scenarioRepository; + private final CharacterRepository characterRepository; + private final EntityManager entityManager; @Inject - public ScenarioService(final ScenarioRepository scenarioRepository) { + public ScenarioService(final ScenarioRepository scenarioRepository, final CharacterRepository characterRepository, + final EntityManager entityManager) { this.scenarioRepository = scenarioRepository; + this.characterRepository = characterRepository; + this.entityManager = entityManager; } /** @@ -50,6 +59,97 @@ public class ScenarioService { return entity != null ? Optional.of(toScenarioSetup(entity)) : Optional.empty(); } + /** + * Creates a new scenario from the request. Validates that all referenced characters exist. + * + * @param request the create request (name required; optional setting, initialConflict, characterSlots) + * @return the created scenario summary + * @throws IllegalArgumentException if any referenced character id is not found + */ + public ScenarioSummary create(final CreateScenarioRequest request) { + UUID scenarioId = UUID.randomUUID(); + ScenarioEntity scenario = new ScenarioEntity(); + scenario.setId(scenarioId); + scenario.setName(request.getName()); + scenario.setSetting(request.getSetting()); + scenario.setInitialConflict(request.getInitialConflict()); + List slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of(); + for (ScenarioCharacterSlot slot : slots) { + var character = characterRepository.findByIdOptional(slot.getCharacterId()); + if (character == null) { + throw new IllegalArgumentException("Character not found: " + slot.getCharacterId()); + } + ScenarioCharacterEntity link = new ScenarioCharacterEntity(); + link.setId(UUID.randomUUID()); + link.setScenario(scenario); + link.setCharacter(character); + link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter())); + link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0); + scenario.getScenarioCharacters().add(link); + } + scenarioRepository.persist(scenario); + for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) { + entityManager.persist(link); + } + entityManager.flush(); + return toScenarioSummary(scenario); + } + + /** + * Updates an existing scenario by id. Full replace; replaces all character slots. + * + * @param id the scenario UUID + * @param request the update request + * @return the updated scenario summary + * @throws java.util.NoSuchElementException if the scenario does not exist + * @throws IllegalArgumentException if any referenced character id is not found + */ + public ScenarioSummary update(final UUID id, final CreateScenarioRequest request) { + ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id); + if (scenario == null) { + throw new java.util.NoSuchElementException("No scenario found with id: " + id); + } + scenario.setName(request.getName()); + scenario.setSetting(request.getSetting()); + scenario.setInitialConflict(request.getInitialConflict()); + scenario.getScenarioCharacters().clear(); + List slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of(); + for (ScenarioCharacterSlot slot : slots) { + var character = characterRepository.findByIdOptional(slot.getCharacterId()); + if (character == null) { + throw new IllegalArgumentException("Character not found: " + slot.getCharacterId()); + } + ScenarioCharacterEntity link = new ScenarioCharacterEntity(); + link.setId(UUID.randomUUID()); + link.setScenario(scenario); + link.setCharacter(character); + link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter())); + link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0); + scenario.getScenarioCharacters().add(link); + } + scenarioRepository.persist(scenario); + for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) { + entityManager.persist(link); + } + entityManager.flush(); + return toScenarioSummary(scenario); + } + + /** + * Deletes a scenario by id. Cascades to scenario-character links. + * + * @param id the scenario UUID + * @return true if deleted, false if no scenario existed + */ + public boolean delete(final UUID id) { + ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id); + if (scenario == null) { + return false; + } + scenarioRepository.delete(scenario); + return true; + } + /** * Maps a scenario entity to the list-summary DTO. */ diff --git a/src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java b/src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java index 7f0c7fd..396656b 100644 --- a/src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java +++ b/src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java @@ -35,7 +35,7 @@ public class ScenarioEntity extends PanacheEntityBase { private String initialConflict; @OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true) - @OrderBy("isUserCharacter DESC, position ASC") + @OrderBy("userCharacter DESC, position ASC") private List scenarioCharacters = new ArrayList<>(); /** @@ -102,15 +102,19 @@ public class ScenarioEntity extends PanacheEntityBase { /** * Returns the list of scenario–character links (user character first, then AI by position). + * Never returns null; scenarios loaded from DB without characters (e.g. seed data) get an empty list. */ public List getScenarioCharacters() { + if (scenarioCharacters == null) { + scenarioCharacters = new ArrayList<>(); + } return scenarioCharacters; } /** - * Sets the list of scenario–character links. + * Sets the list of scenario–character links. Null is treated as empty list. */ public void setScenarioCharacters(final List scenarioCharacters) { - this.scenarioCharacters = scenarioCharacters; + this.scenarioCharacters = scenarioCharacters != null ? scenarioCharacters : new ArrayList<>(); } } diff --git a/src/main/java/de/neitzel/roleplay/fascade/CharactersResource.java b/src/main/java/de/neitzel/roleplay/fascade/CharactersResource.java index 30232be..38ed837 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/CharactersResource.java +++ b/src/main/java/de/neitzel/roleplay/fascade/CharactersResource.java @@ -3,12 +3,14 @@ package de.neitzel.roleplay.fascade; import de.neitzel.roleplay.business.CharacterService; import de.neitzel.roleplay.fascade.model.CharacterDefinition; import de.neitzel.roleplay.fascade.model.CharacterListResponse; +import de.neitzel.roleplay.fascade.model.CreateCharacterRequest; import de.neitzel.roleplay.generated.api.CharactersApi; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.NotFoundException; +import java.util.NoSuchElementException; import java.util.UUID; /** @@ -36,4 +38,26 @@ public class CharactersResource implements CharactersApi { return characterService.getCharacter(characterId) .orElseThrow(() -> new NotFoundException("No character found with id: " + characterId)); } + + @Override + public CharacterDefinition createCharacter(final CreateCharacterRequest createCharacterRequest) { + return characterService.create(createCharacterRequest); + } + + @Override + public CharacterDefinition updateCharacter(final UUID characterId, + final CreateCharacterRequest createCharacterRequest) { + try { + return characterService.update(characterId, createCharacterRequest); + } catch (final NoSuchElementException e) { + throw new NotFoundException("No character found with id: " + characterId); + } + } + + @Override + public void deleteCharacter(final UUID characterId) { + if (!characterService.delete(characterId)) { + throw new NotFoundException("No character found with id: " + characterId); + } + } } diff --git a/src/main/java/de/neitzel/roleplay/fascade/ScenariosResource.java b/src/main/java/de/neitzel/roleplay/fascade/ScenariosResource.java index dff2f44..e26ec02 100644 --- a/src/main/java/de/neitzel/roleplay/fascade/ScenariosResource.java +++ b/src/main/java/de/neitzel/roleplay/fascade/ScenariosResource.java @@ -1,14 +1,18 @@ package de.neitzel.roleplay.fascade; import de.neitzel.roleplay.business.ScenarioService; +import de.neitzel.roleplay.fascade.model.CreateScenarioRequest; import de.neitzel.roleplay.fascade.model.ScenarioListResponse; import de.neitzel.roleplay.fascade.model.ScenarioSetup; +import de.neitzel.roleplay.fascade.model.ScenarioSummary; import de.neitzel.roleplay.generated.api.ScenariosApi; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.NotFoundException; +import java.util.NoSuchElementException; import java.util.UUID; /** @@ -36,4 +40,32 @@ public class ScenariosResource implements ScenariosApi { return scenarioService.getScenarioAsSetup(scenarioId) .orElseThrow(() -> new NotFoundException("No scenario found with id: " + scenarioId)); } + + @Override + public ScenarioSummary createScenario(final CreateScenarioRequest createScenarioRequest) { + try { + return scenarioService.create(createScenarioRequest); + } catch (final IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + } + + @Override + public ScenarioSummary updateScenario(final UUID scenarioId, + final CreateScenarioRequest createScenarioRequest) { + try { + return scenarioService.update(scenarioId, createScenarioRequest); + } catch (final NoSuchElementException e) { + throw new NotFoundException("No scenario found with id: " + scenarioId); + } catch (final IllegalArgumentException e) { + throw new BadRequestException(e.getMessage()); + } + } + + @Override + public void deleteScenario(final UUID scenarioId) { + if (!scenarioService.delete(scenarioId)) { + throw new NotFoundException("No scenario found with id: " + scenarioId); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c08c4aa..231334e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,6 @@ quarkus: application: name: roleplay - resteasy-reactive: - scan: - paths: de.neitzel.roleplay.fascade,de.neitzel.roleplay.business,de.neitzel.roleplay.common,de.neitzel.roleplay.data http: root-path: / datasource: @@ -12,9 +9,6 @@ quarkus: url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1 username: sa password: "" - hibernate-orm: - database: - generation: none liquibase: change-log: db/migration/changelog.xml migrate-at-start: true diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml index 43a7fe2..e6b639b 100644 --- a/src/main/resources/openapi-roleplay-public-v1.yml +++ b/src/main/resources/openapi-roleplay-public-v1.yml @@ -61,6 +61,31 @@ paths: application/json: schema: $ref: '#/components/schemas/ScenarioListResponse' + post: + operationId: createScenario + summary: Create a scenario + description: Creates a new scenario with optional character slots. Server generates UUID. + tags: + - scenarios + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScenarioRequest' + responses: + "201": + description: Scenario created. + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioSummary' + "400": + description: Invalid request body or referenced character not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /scenarios/{scenarioId}: get: @@ -84,6 +109,56 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + put: + operationId: updateScenario + summary: Update a scenario + description: Full replace. Replaces all character slots. + tags: + - scenarios + parameters: + - $ref: '#/components/parameters/ScenarioId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScenarioRequest' + responses: + "200": + description: Scenario updated. + content: + application/json: + schema: + $ref: '#/components/schemas/ScenarioSummary' + "400": + description: Invalid request body or referenced character not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Scenario not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + operationId: deleteScenario + summary: Delete a scenario + description: Removes the scenario template. + tags: + - scenarios + parameters: + - $ref: '#/components/parameters/ScenarioId' + responses: + "204": + description: Scenario deleted. + "404": + description: Scenario not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /characters: get: @@ -99,6 +174,31 @@ paths: application/json: schema: $ref: '#/components/schemas/CharacterListResponse' + post: + operationId: createCharacter + summary: Create a character + description: Creates a new character template. Server generates UUID if id is omitted. + tags: + - characters + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCharacterRequest' + responses: + "201": + description: Character created. + content: + application/json: + schema: + $ref: '#/components/schemas/CharacterDefinition' + "400": + description: Invalid request body. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /characters/{characterId}: get: @@ -122,6 +222,56 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + put: + operationId: updateCharacter + summary: Update a character + description: Full replace of the character. All fields required except optional ones. + tags: + - characters + parameters: + - $ref: '#/components/parameters/CharacterId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCharacterRequest' + responses: + "200": + description: Character updated. + content: + application/json: + schema: + $ref: '#/components/schemas/CharacterDefinition' + "400": + description: Invalid request body. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + "404": + description: Character not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + operationId: deleteCharacter + summary: Delete a character + description: Removes the character template. Scenarios referencing it may need updating. + tags: + - characters + parameters: + - $ref: '#/components/parameters/CharacterId' + responses: + "204": + description: Character deleted. + "404": + description: Character not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /sessions: post: @@ -401,6 +551,80 @@ components: required: - characters + CreateCharacterRequest: + type: object + description: Request body for creating or updating a character. id optional on create (server generates UUID). + properties: + id: + type: string + format: uuid + description: Optional on create; server generates if omitted. + name: + type: string + description: Display name. + role: + type: string + description: Narrative role. + backstory: + type: string + description: Character background. + speakingStyle: + type: string + description: How the character speaks. + personalityTraits: + type: array + items: + type: string + description: List of personality traits. + goals: + type: array + items: + type: string + description: Character goals. + required: + - name + - role + + ScenarioCharacterSlot: + type: object + description: Assignment of a character to a scenario slot (user or AI, with position). + properties: + characterId: + type: string + format: uuid + description: Reference to a saved character. + isUserCharacter: + type: boolean + description: True if this slot is the player character. + position: + type: integer + description: Order of the character (0 for user, 1+ for AI). + required: + - characterId + - isUserCharacter + - position + + CreateScenarioRequest: + type: object + description: Request body for creating or updating a scenario. + properties: + name: + type: string + description: Human-readable scenario name. + setting: + type: string + description: Place, time, and atmosphere. + initialConflict: + type: string + description: The hook or starting conflict. + characterSlots: + type: array + items: + $ref: '#/components/schemas/ScenarioCharacterSlot' + description: Assigned characters (one user, ordered AI). + required: + - name + ScenarioSetup: type: object description: | diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx index 81bc2bc..4e1b2ef 100644 --- a/src/main/web/src/App.tsx +++ b/src/main/web/src/App.tsx @@ -1,17 +1,24 @@ import {BrowserRouter, Route, Routes} from 'react-router-dom' +import AppLayout from './components/AppLayout' import StartPage from './pages/StartPage' import SessionPage from './pages/SessionPage' +import ScenariosPage from './pages/ScenariosPage' +import CharactersPage from './pages/CharactersPage' /** - * Root application component. Sets up client-side routing between the model - * selection page and the active session page. + * Root application component. Sets up client-side routing with app shell: + * Home (start flow), Scenarios, Characters, and Session. */ export default function App() { return ( - }/> - }/> + }> + }/> + }/> + }/> + }/> + ) diff --git a/src/main/web/src/components/AppLayout.tsx b/src/main/web/src/components/AppLayout.tsx new file mode 100644 index 0000000..5399bd3 --- /dev/null +++ b/src/main/web/src/components/AppLayout.tsx @@ -0,0 +1,54 @@ +import {AppBar, Box, Toolbar, Typography} from '@mui/material' +import {Link as RouterLink, Outlet, useLocation} from 'react-router-dom' +import {Link} from '@mui/material' + +/** + * Persistent app shell with top navigation. Renders the current route's page via Outlet. + */ +export default function AppLayout() { + const location = useLocation() + const navItems = [ + { to: '/', label: 'Home' }, + { to: '/scenarios', label: 'Scenarios' }, + { to: '/characters', label: 'Characters' }, + ] + + return ( + + + + + RolePlay + + + {navItems.map(({ to, label }) => ( + + {label} + + ))} + + + + + + + + ) +} diff --git a/src/main/web/src/pages/CharactersPage.tsx b/src/main/web/src/pages/CharactersPage.tsx new file mode 100644 index 0000000..0268c95 --- /dev/null +++ b/src/main/web/src/pages/CharactersPage.tsx @@ -0,0 +1,308 @@ +import {useCallback, useEffect, useState} from 'react' +import { + Alert, + Box, + Button, + Card, + CardContent, + CardActions, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, + Typography, +} from '@mui/material' +import AddIcon from '@mui/icons-material/Add' +import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' +import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index' +import {CharactersApi, Configuration} from '../api/generated/index' + +const API_BASE = '/api/v1' +const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE})) + +/** Parse comma- or newline-separated string into trimmed non-empty strings. */ +function parseList(value: string | undefined): string[] { + if (!value || !value.trim()) return [] + return value + .split(/[,\n]/) + .map((s) => s.trim()) + .filter(Boolean) +} + +/** Format string array for display in a text field. */ +function formatList(arr: string[] | undefined): string { + return arr?.length ? arr.join(', ') : '' +} + +/** + * Characters management page. Lists characters as cards with New/Edit/Delete. + */ +export default function CharactersPage() { + const [characters, setCharacters] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [saving, setSaving] = useState(false) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + const [formName, setFormName] = useState('') + const [formRole, setFormRole] = useState('') + const [formBackstory, setFormBackstory] = useState('') + const [formSpeakingStyle, setFormSpeakingStyle] = useState('') + const [formPersonalityTraits, setFormPersonalityTraits] = useState('') + const [formGoals, setFormGoals] = useState('') + + const loadCharacters = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await charactersApi.listCharacters() + setCharacters(res.characters ?? []) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load characters') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadCharacters() + }, [loadCharacters]) + + const openCreate = () => { + setEditingId(null) + setFormName('') + setFormRole('') + setFormBackstory('') + setFormSpeakingStyle('') + setFormPersonalityTraits('') + setFormGoals('') + setDialogOpen(true) + } + + const openEdit = (c: CharacterDefinition) => { + setEditingId(c.id) + setFormName(c.name) + setFormRole(c.role) + setFormBackstory(c.backstory ?? '') + setFormSpeakingStyle(c.speakingStyle ?? '') + setFormPersonalityTraits(formatList(c.personalityTraits)) + setFormGoals(formatList(c.goals)) + setDialogOpen(true) + } + + const closeDialog = () => { + setDialogOpen(false) + setEditingId(null) + } + + const handleSave = async () => { + if (!formName.trim() || !formRole.trim()) return + setSaving(true) + setError(null) + try { + const body: CreateCharacterRequest = { + name: formName.trim(), + role: formRole.trim(), + backstory: formBackstory.trim() || undefined, + speakingStyle: formSpeakingStyle.trim() || undefined, + personalityTraits: parseList(formPersonalityTraits).length + ? parseList(formPersonalityTraits) + : undefined, + goals: parseList(formGoals).length ? parseList(formGoals) : undefined, + } + if (editingId) { + body.id = editingId + await charactersApi.updateCharacter({characterId: editingId, createCharacterRequest: body}) + } else { + await charactersApi.createCharacter({createCharacterRequest: body}) + } + closeDialog() + await loadCharacters() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to save character') + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: string) => { + setError(null) + try { + await charactersApi.deleteCharacter({characterId: id}) + setDeleteConfirmId(null) + await loadCharacters() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to delete character') + } + } + + if (loading) { + return ( + + + + ) + } + + return ( + <> + + Characters + + + + {error && ( + setError(null)} sx={{mb: 2}}> + {error} + + )} + + {characters.length === 0 ? ( + + Create first character + + } + > + No characters yet. Create your first character to use in scenarios. + + ) : ( + + {characters.map((c) => ( + + + {c.name} + + {c.role} + + {c.backstory && ( + + {c.backstory} + + )} + + + openEdit(c)} + size="small" + > + + + setDeleteConfirmId(c.id)} + size="small" + color="error" + > + + + + + ))} + + )} + + + {editingId ? 'Edit character' : 'New character'} + + setFormName(e.target.value)} + /> + setFormRole(e.target.value)} + /> + setFormBackstory(e.target.value)} + /> + setFormSpeakingStyle(e.target.value)} + /> + setFormPersonalityTraits(e.target.value)} + placeholder="e.g. brave, stern, witty" + /> + setFormGoals(e.target.value)} + placeholder="e.g. Find the treasure, Protect the crew" + /> + + + + + + + + setDeleteConfirmId(null)} + > + Delete character? + + + This cannot be undone. Scenarios using this character may need to be updated. + + + + + + + + + ) +} diff --git a/src/main/web/src/pages/ScenariosPage.tsx b/src/main/web/src/pages/ScenariosPage.tsx new file mode 100644 index 0000000..681fad6 --- /dev/null +++ b/src/main/web/src/pages/ScenariosPage.tsx @@ -0,0 +1,356 @@ +import {useCallback, useEffect, useState} from 'react' +import {useNavigate} from 'react-router-dom' +import { + Alert, + Box, + Button, + Card, + CardContent, + CardActions, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material' +import AddIcon from '@mui/icons-material/Add' +import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' +import PlayArrowIcon from '@mui/icons-material/PlayArrow' +import type { + CharacterDefinition, + CreateScenarioRequest, + ScenarioSetup, + ScenarioSummary, +} from '../api/generated/index' +import {CharactersApi, Configuration, ScenariosApi} from '../api/generated/index' + +const API_BASE = '/api/v1' +const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE})) +const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE})) + +/** + * Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment. + */ +export default function ScenariosPage() { + const navigate = useNavigate() + const [scenarios, setScenarios] = useState([]) + const [characters, setCharacters] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [saving, setSaving] = useState(false) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + const [formName, setFormName] = useState('') + const [formSetting, setFormSetting] = useState('') + const [formInitialConflict, setFormInitialConflict] = useState('') + const [formUserCharacterId, setFormUserCharacterId] = useState('') + const [formAiCharacterIds, setFormAiCharacterIds] = useState([]) + + const loadScenarios = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await scenariosApi.listScenarios() + setScenarios(res.scenarios ?? []) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load scenarios') + } finally { + setLoading(false) + } + }, []) + + const loadCharacters = useCallback(async () => { + try { + const res = await charactersApi.listCharacters() + setCharacters(res.characters ?? []) + } catch { + setCharacters([]) + } + }, []) + + useEffect(() => { + loadScenarios() + }, [loadScenarios]) + + useEffect(() => { + loadCharacters() + }, [loadCharacters]) + + const openCreate = () => { + setEditingId(null) + setFormName('') + setFormSetting('') + setFormInitialConflict('') + setFormUserCharacterId('') + setFormAiCharacterIds([]) + setDialogOpen(true) + } + + const openEdit = async (s: ScenarioSummary) => { + setEditingId(s.id) + setFormName(s.name ?? '') + setFormSetting(s.setting ?? '') + setFormInitialConflict(s.initialConflict ?? '') + setFormUserCharacterId('') + setFormAiCharacterIds([]) + setDialogOpen(true) + try { + const setup: ScenarioSetup = await scenariosApi.getScenario({scenarioId: s.id}) + setFormSetting(setup.setting ?? formSetting) + setFormInitialConflict(setup.initialConflict ?? formInitialConflict) + if (setup.userCharacter?.id) setFormUserCharacterId(setup.userCharacter.id) + if (setup.aiCharacters?.length) + setFormAiCharacterIds(setup.aiCharacters.map((c) => c.id)) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to load scenario') + } + } + + const closeDialog = () => { + setDialogOpen(false) + setEditingId(null) + } + + const buildCharacterSlots = (): Array<{characterId: string; isUserCharacter: boolean; position: number}> => { + const slots: Array<{characterId: string; isUserCharacter: boolean; position: number}> = [] + if (formUserCharacterId) { + slots.push({characterId: formUserCharacterId, isUserCharacter: true, position: 0}) + } + formAiCharacterIds.forEach((id, i) => { + slots.push({characterId: id, isUserCharacter: false, position: i + 1}) + }) + return slots + } + + const handleSave = async () => { + if (!formName.trim()) return + setSaving(true) + setError(null) + try { + const body: CreateScenarioRequest = { + name: formName.trim(), + setting: formSetting.trim() || undefined, + initialConflict: formInitialConflict.trim() || undefined, + characterSlots: buildCharacterSlots(), // always send array so backend receives list + } + if (editingId) { + await scenariosApi.updateScenario({scenarioId: editingId, createScenarioRequest: body}) + } else { + await scenariosApi.createScenario({createScenarioRequest: body}) + } + closeDialog() + await loadScenarios() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to save scenario') + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: string) => { + setError(null) + try { + await scenariosApi.deleteScenario({scenarioId: id}) + setDeleteConfirmId(null) + await loadScenarios() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to delete scenario') + } + } + + const handleStart = (scenarioId: string) => { + navigate('/', {state: {scenarioId}}) + } + + if (loading) { + return ( + + + + ) + } + + return ( + <> + + Scenarios + + + + {error && ( + setError(null)} sx={{mb: 2}}> + {error} + + )} + + {scenarios.length === 0 ? ( + + Create first scenario + + } + > + No scenarios yet. Create your first scenario to start a story. + + ) : ( + + {scenarios.map((s) => ( + + + {s.name} + {s.setting && ( + + {s.setting} + + )} + {s.initialConflict && ( + + {s.initialConflict} + + )} + + + + openEdit(s)} size="small"> + + + setDeleteConfirmId(s.id)} + size="small" + color="error" + > + + + + + ))} + + )} + + + {editingId ? 'Edit scenario' : 'New scenario'} + + setFormName(e.target.value)} + /> + setFormSetting(e.target.value)} + placeholder="Place, time, and atmosphere" + /> + setFormInitialConflict(e.target.value)} + /> + + User character + + + + AI characters + + + + + + + + + + setDeleteConfirmId(null)}> + Delete scenario? + + This cannot be undone. + + + + + + + + ) +} diff --git a/src/main/web/src/pages/StartPage.tsx b/src/main/web/src/pages/StartPage.tsx index 9e52846..239d8ab 100644 --- a/src/main/web/src/pages/StartPage.tsx +++ b/src/main/web/src/pages/StartPage.tsx @@ -1,5 +1,5 @@ -import {useEffect, useState} from 'react' -import {useNavigate} from 'react-router-dom' +import {useEffect, useRef, useState} from 'react' +import {useLocation, useNavigate} from 'react-router-dom' import { Accordion, AccordionDetails, @@ -59,6 +59,8 @@ const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE})) */ export default function StartPage() { const navigate = useNavigate() + const location = useLocation() + const deepLinkApplied = useRef(false) const [models, setModels] = useState([]) const [selectedModel, setSelectedModel] = useState('') @@ -100,6 +102,25 @@ export default function StartPage() { .finally(() => setLoading(false)) }, []) + /** When arriving from Scenarios "Start" with state.scenarioId, pre-select that scenario and load its setup. */ + useEffect(() => { + const state = location.state as { scenarioId?: string } | null + const id = state?.scenarioId + if (!id || deepLinkApplied.current || scenarios.length === 0) return + if (!scenarios.some((s) => s.id === id)) return + deepLinkApplied.current = true + setSelectedScenarioId(id) + scenariosApi + .getScenario({scenarioId: id}) + .then((setup) => { + setSetting(setup.setting ?? '') + setInitialConflict(setup.initialConflict ?? '') + setUserCharacter(setup.userCharacter ?? null) + setAiCharacters(setup.aiCharacters ?? []) + }) + .catch(() => setError('Could not load scenario.')) + }, [scenarios, location.state]) + const handleModelChange = (event: SelectChangeEvent) => { setSelectedModel(event.target.value) } diff --git a/src/test/java/de/neitzel/roleplay/business/CharacterServiceTest.java b/src/test/java/de/neitzel/roleplay/business/CharacterServiceTest.java new file mode 100644 index 0000000..3796a07 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/CharacterServiceTest.java @@ -0,0 +1,131 @@ +package de.neitzel.roleplay.business; + +import de.neitzel.roleplay.data.CharacterEntity; +import de.neitzel.roleplay.data.CharacterRepository; +import de.neitzel.roleplay.fascade.model.CharacterDefinition; +import de.neitzel.roleplay.fascade.model.CreateCharacterRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link CharacterService}. + */ +@ExtendWith(MockitoExtension.class) +class CharacterServiceTest { + + @Mock + private CharacterRepository characterRepository; + + private CharacterService characterService; + + @BeforeEach + void setUp() { + characterService = new CharacterService(characterRepository); + } + + @Test + void createPersistsWithGeneratedIdAndReturnsDefinition() { + CreateCharacterRequest request = new CreateCharacterRequest("Captain Morgan", "captain"); + request.setBackstory("Sea veteran"); + request.setSpeakingStyle("Gruff"); + request.setPersonalityTraits(List.of("brave", "stern")); + request.setGoals(List.of("Find the treasure")); + + CharacterDefinition result = characterService.create(request); + + assertNotNull(result.getId()); + assertEquals("Captain Morgan", result.getName()); + assertEquals("captain", result.getRole()); + assertEquals("Sea veteran", result.getBackstory()); + assertEquals("Gruff", result.getSpeakingStyle()); + assertEquals(List.of("brave", "stern"), result.getPersonalityTraits()); + assertEquals(List.of("Find the treasure"), result.getGoals()); + ArgumentCaptor captor = ArgumentCaptor.forClass(CharacterEntity.class); + verify(characterRepository).persist(captor.capture()); + assertEquals("Captain Morgan", captor.getValue().getName()); + assertEquals("captain", captor.getValue().getRole()); + } + + @Test + void createUsesRequestIdWhenProvided() { + UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101"); + CreateCharacterRequest request = new CreateCharacterRequest("Name", "role"); + request.setId(id); + + CharacterDefinition result = characterService.create(request); + + assertEquals(id.toString(), result.getId()); + ArgumentCaptor captor = ArgumentCaptor.forClass(CharacterEntity.class); + verify(characterRepository).persist(captor.capture()); + assertEquals(id, captor.getValue().getId()); + } + + @Test + void updateReplacesAndPersists() { + UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101"); + CharacterEntity existing = new CharacterEntity(); + existing.setId(id); + existing.setName("Old"); + existing.setRole("oldRole"); + when(characterRepository.findByIdOptional(id)).thenReturn(existing); + + CreateCharacterRequest request = new CreateCharacterRequest("New Name", "newRole"); + request.setBackstory("Updated backstory"); + + CharacterDefinition result = characterService.update(id, request); + + assertEquals(id.toString(), result.getId()); + assertEquals("New Name", result.getName()); + assertEquals("newRole", result.getRole()); + assertEquals("Updated backstory", result.getBackstory()); + verify(characterRepository).persist(existing); + assertEquals("New Name", existing.getName()); + } + + @Test + void updateThrowsWhenCharacterNotFound() { + UUID id = UUID.randomUUID(); + when(characterRepository.findByIdOptional(id)).thenReturn(null); + CreateCharacterRequest request = new CreateCharacterRequest("Name", "role"); + + assertThrows(java.util.NoSuchElementException.class, + () -> characterService.update(id, request)); + } + + @Test + void deleteReturnsTrueAndDeletesWhenFound() { + UUID id = UUID.randomUUID(); + CharacterEntity entity = new CharacterEntity(); + entity.setId(id); + when(characterRepository.findByIdOptional(id)).thenReturn(entity); + + boolean result = characterService.delete(id); + + assertTrue(result); + verify(characterRepository).delete(entity); + } + + @Test + void deleteReturnsFalseWhenNotFound() { + UUID id = UUID.randomUUID(); + when(characterRepository.findByIdOptional(id)).thenReturn(null); + + boolean result = characterService.delete(id); + + assertTrue(!result); + } +} diff --git a/src/test/java/de/neitzel/roleplay/business/ScenarioServiceTest.java b/src/test/java/de/neitzel/roleplay/business/ScenarioServiceTest.java index e68b6ab..c022d7b 100644 --- a/src/test/java/de/neitzel/roleplay/business/ScenarioServiceTest.java +++ b/src/test/java/de/neitzel/roleplay/business/ScenarioServiceTest.java @@ -1,14 +1,20 @@ package de.neitzel.roleplay.business; +import jakarta.persistence.EntityManager; + import de.neitzel.roleplay.data.CharacterEntity; +import de.neitzel.roleplay.data.CharacterRepository; import de.neitzel.roleplay.data.ScenarioCharacterEntity; import de.neitzel.roleplay.data.ScenarioEntity; import de.neitzel.roleplay.data.ScenarioRepository; +import de.neitzel.roleplay.fascade.model.CreateScenarioRequest; +import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot; import de.neitzel.roleplay.fascade.model.ScenarioSetup; import de.neitzel.roleplay.fascade.model.ScenarioSummary; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -18,7 +24,9 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -30,11 +38,17 @@ class ScenarioServiceTest { @Mock private ScenarioRepository scenarioRepository; + @Mock + private CharacterRepository characterRepository; + + @Mock + private EntityManager entityManager; + private ScenarioService scenarioService; @BeforeEach void setUp() { - scenarioService = new ScenarioService(scenarioRepository); + scenarioService = new ScenarioService(scenarioRepository, characterRepository, entityManager); } @Test @@ -124,4 +138,105 @@ class ScenarioServiceTest { assertEquals("Captain Morgan", setup.getAiCharacters().get(0).getName()); assertEquals("captain", setup.getAiCharacters().get(0).getRole()); } + + @Test + void createScenarioPersistsAndReturnsSummary() { + UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101"); + CharacterEntity character = new CharacterEntity(); + character.setId(charId); + character.setName("Detective"); + character.setRole("detective"); + when(characterRepository.findByIdOptional(charId)).thenReturn(character); + + ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0); + CreateScenarioRequest request = new CreateScenarioRequest("Mystery"); + request.setSetting("A harbour"); + request.setInitialConflict("Noises"); + request.setCharacterSlots(List.of(slot)); + + ScenarioSummary result = scenarioService.create(request); + + assertNotNull(result.getId()); + assertEquals("Mystery", result.getName()); + assertEquals("A harbour", result.getSetting()); + assertEquals("Noises", result.getInitialConflict()); + ArgumentCaptor captor = ArgumentCaptor.forClass(ScenarioEntity.class); + verify(scenarioRepository).persist(captor.capture()); + assertEquals(1, captor.getValue().getScenarioCharacters().size()); + assertTrue(captor.getValue().getScenarioCharacters().get(0).isUserCharacter()); + } + + @Test + void createScenarioThrowsWhenCharacterNotFound() { + UUID charId = UUID.randomUUID(); + when(characterRepository.findByIdOptional(charId)).thenReturn(null); + ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, false, 1); + CreateScenarioRequest request = new CreateScenarioRequest("Mystery"); + request.setCharacterSlots(List.of(slot)); + + assertThrows(IllegalArgumentException.class, () -> scenarioService.create(request)); + } + + @Test + void updateScenarioReplacesAndPersists() { + UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201"); + UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101"); + CharacterEntity character = new CharacterEntity(); + character.setId(charId); + character.setName("Detective"); + character.setRole("detective"); + ScenarioEntity existing = new ScenarioEntity(); + existing.setId(scenarioId); + existing.setName("Old"); + existing.setScenarioCharacters(new java.util.ArrayList<>()); + + when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(existing); + when(characterRepository.findByIdOptional(charId)).thenReturn(character); + + ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0); + CreateScenarioRequest request = new CreateScenarioRequest("Updated"); + request.setSetting("New setting"); + request.setCharacterSlots(List.of(slot)); + + ScenarioSummary result = scenarioService.update(scenarioId, request); + + assertEquals(scenarioId, result.getId()); + assertEquals("Updated", result.getName()); + assertEquals("New setting", result.getSetting()); + verify(scenarioRepository).persist(existing); + assertEquals(1, existing.getScenarioCharacters().size()); + } + + @Test + void updateScenarioThrowsWhenScenarioNotFound() { + UUID scenarioId = UUID.randomUUID(); + when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null); + CreateScenarioRequest request = new CreateScenarioRequest("Name"); + + assertThrows(java.util.NoSuchElementException.class, + () -> scenarioService.update(scenarioId, request)); + } + + @Test + void deleteScenarioReturnsTrueWhenFound() { + UUID scenarioId = UUID.randomUUID(); + ScenarioEntity scenario = new ScenarioEntity(); + scenario.setId(scenarioId); + when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(scenario); + + boolean result = scenarioService.delete(scenarioId); + + assertTrue(result); + verify(scenarioRepository).delete(scenario); + } + + @Test + void deleteScenarioReturnsFalseWhenNotFound() { + UUID scenarioId = UUID.randomUUID(); + when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null); + + boolean result = scenarioService.delete(scenarioId); + + assertTrue(!result); + } }