Implement character and scenario management features in the API
- Add create, update, and delete functionality for characters and scenarios in CharacterService and ScenarioService. - Introduce CreateCharacterRequest and CreateScenarioRequest models for handling character and scenario creation requests. - Update CharactersResource and ScenariosResource to expose new endpoints for character and scenario management. - Enhance OpenAPI specification to document new API endpoints and request/response schemas. - Implement frontend components for managing characters and scenarios, including listing, creating, editing, and deleting. - Add unit tests for CharacterService to ensure correct behavior of character creation and updates.
This commit is contained in:
parent
3ce1215487
commit
4c1584ec27
@ -3,6 +3,7 @@ package de.neitzel.roleplay.business;
|
|||||||
import de.neitzel.roleplay.data.CharacterEntity;
|
import de.neitzel.roleplay.data.CharacterEntity;
|
||||||
import de.neitzel.roleplay.data.CharacterRepository;
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
@ -47,6 +48,73 @@ public class CharacterService {
|
|||||||
return entity != null ? Optional.of(toCharacterDefinition(entity)) : Optional.empty();
|
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.
|
* Maps a character entity to the API CharacterDefinition. Uses entity id as string for API id.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
||||||
import de.neitzel.roleplay.data.ScenarioEntity;
|
import de.neitzel.roleplay.data.ScenarioEntity;
|
||||||
import de.neitzel.roleplay.data.ScenarioRepository;
|
import de.neitzel.roleplay.data.ScenarioRepository;
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
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.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -22,10 +26,15 @@ import java.util.UUID;
|
|||||||
public class ScenarioService {
|
public class ScenarioService {
|
||||||
|
|
||||||
private final ScenarioRepository scenarioRepository;
|
private final ScenarioRepository scenarioRepository;
|
||||||
|
private final CharacterRepository characterRepository;
|
||||||
|
private final EntityManager entityManager;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public ScenarioService(final ScenarioRepository scenarioRepository) {
|
public ScenarioService(final ScenarioRepository scenarioRepository, final CharacterRepository characterRepository,
|
||||||
|
final EntityManager entityManager) {
|
||||||
this.scenarioRepository = scenarioRepository;
|
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();
|
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<ScenarioCharacterSlot> 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<ScenarioCharacterSlot> 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.
|
* Maps a scenario entity to the list-summary DTO.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -35,7 +35,7 @@ public class ScenarioEntity extends PanacheEntityBase {
|
|||||||
private String initialConflict;
|
private String initialConflict;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
|
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
@OrderBy("isUserCharacter DESC, position ASC")
|
@OrderBy("userCharacter DESC, position ASC")
|
||||||
private List<ScenarioCharacterEntity> scenarioCharacters = new ArrayList<>();
|
private List<ScenarioCharacterEntity> 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).
|
* 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<ScenarioCharacterEntity> getScenarioCharacters() {
|
public List<ScenarioCharacterEntity> getScenarioCharacters() {
|
||||||
|
if (scenarioCharacters == null) {
|
||||||
|
scenarioCharacters = new ArrayList<>();
|
||||||
|
}
|
||||||
return scenarioCharacters;
|
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<ScenarioCharacterEntity> scenarioCharacters) {
|
public void setScenarioCharacters(final List<ScenarioCharacterEntity> scenarioCharacters) {
|
||||||
this.scenarioCharacters = scenarioCharacters;
|
this.scenarioCharacters = scenarioCharacters != null ? scenarioCharacters : new ArrayList<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,14 @@ package de.neitzel.roleplay.fascade;
|
|||||||
import de.neitzel.roleplay.business.CharacterService;
|
import de.neitzel.roleplay.business.CharacterService;
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterListResponse;
|
import de.neitzel.roleplay.fascade.model.CharacterListResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||||
import de.neitzel.roleplay.generated.api.CharactersApi;
|
import de.neitzel.roleplay.generated.api.CharactersApi;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,4 +38,26 @@ public class CharactersResource implements CharactersApi {
|
|||||||
return characterService.getCharacter(characterId)
|
return characterService.getCharacter(characterId)
|
||||||
.orElseThrow(() -> new NotFoundException("No character found with id: " + 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
package de.neitzel.roleplay.fascade;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
import de.neitzel.roleplay.business.ScenarioService;
|
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.ScenarioListResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
import de.neitzel.roleplay.generated.api.ScenariosApi;
|
import de.neitzel.roleplay.generated.api.ScenariosApi;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -36,4 +40,32 @@ public class ScenariosResource implements ScenariosApi {
|
|||||||
return scenarioService.getScenarioAsSetup(scenarioId)
|
return scenarioService.getScenarioAsSetup(scenarioId)
|
||||||
.orElseThrow(() -> new NotFoundException("No scenario found with id: " + 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
quarkus:
|
quarkus:
|
||||||
application:
|
application:
|
||||||
name: roleplay
|
name: roleplay
|
||||||
resteasy-reactive:
|
|
||||||
scan:
|
|
||||||
paths: de.neitzel.roleplay.fascade,de.neitzel.roleplay.business,de.neitzel.roleplay.common,de.neitzel.roleplay.data
|
|
||||||
http:
|
http:
|
||||||
root-path: /
|
root-path: /
|
||||||
datasource:
|
datasource:
|
||||||
@ -12,9 +9,6 @@ quarkus:
|
|||||||
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
|
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
|
||||||
username: sa
|
username: sa
|
||||||
password: ""
|
password: ""
|
||||||
hibernate-orm:
|
|
||||||
database:
|
|
||||||
generation: none
|
|
||||||
liquibase:
|
liquibase:
|
||||||
change-log: db/migration/changelog.xml
|
change-log: db/migration/changelog.xml
|
||||||
migrate-at-start: true
|
migrate-at-start: true
|
||||||
|
|||||||
@ -61,6 +61,31 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ScenarioListResponse'
|
$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}:
|
/scenarios/{scenarioId}:
|
||||||
get:
|
get:
|
||||||
@ -84,6 +109,56 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/characters:
|
||||||
get:
|
get:
|
||||||
@ -99,6 +174,31 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CharacterListResponse'
|
$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}:
|
/characters/{characterId}:
|
||||||
get:
|
get:
|
||||||
@ -122,6 +222,56 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$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:
|
/sessions:
|
||||||
post:
|
post:
|
||||||
@ -401,6 +551,80 @@ components:
|
|||||||
required:
|
required:
|
||||||
- characters
|
- 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:
|
ScenarioSetup:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
|
|||||||
@ -1,17 +1,24 @@
|
|||||||
import {BrowserRouter, Route, Routes} from 'react-router-dom'
|
import {BrowserRouter, Route, Routes} from 'react-router-dom'
|
||||||
|
import AppLayout from './components/AppLayout'
|
||||||
import StartPage from './pages/StartPage'
|
import StartPage from './pages/StartPage'
|
||||||
import SessionPage from './pages/SessionPage'
|
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
|
* Root application component. Sets up client-side routing with app shell:
|
||||||
* selection page and the active session page.
|
* Home (start flow), Scenarios, Characters, and Session.
|
||||||
*/
|
*/
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<StartPage/>}/>
|
<Route element={<AppLayout/>}>
|
||||||
<Route path="/session/:sessionId" element={<SessionPage/>}/>
|
<Route path="/" element={<StartPage/>}/>
|
||||||
|
<Route path="/scenarios" element={<ScenariosPage/>}/>
|
||||||
|
<Route path="/characters" element={<CharactersPage/>}/>
|
||||||
|
<Route path="/session/:sessionId" element={<SessionPage/>}/>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
54
src/main/web/src/components/AppLayout.tsx
Normal file
54
src/main/web/src/components/AppLayout.tsx
Normal file
@ -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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<AppBar position="static" elevation={0}>
|
||||||
|
<Toolbar variant="dense">
|
||||||
|
<Typography
|
||||||
|
component={RouterLink}
|
||||||
|
to="/"
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RolePlay
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
{navItems.map(({ to, label }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
component={RouterLink}
|
||||||
|
to={to}
|
||||||
|
underline={location.pathname === to ? 'always' : 'hover'}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ px: 1.5, py: 0.5, borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
308
src/main/web/src/pages/CharactersPage.tsx
Normal file
308
src/main/web/src/pages/CharactersPage.tsx
Normal file
@ -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<CharacterDefinition[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(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 (
|
||||||
|
<Box sx={{display: 'flex', justifyContent: 'center', py: 4}}>
|
||||||
|
<CircularProgress/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2}}>
|
||||||
|
<Typography variant="h4">Characters</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon/>} onClick={openCreate}>
|
||||||
|
New character
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{mb: 2}}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{characters.length === 0 ? (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={openCreate}>
|
||||||
|
Create first character
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
No characters yet. Create your first character to use in scenarios.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 2}}>
|
||||||
|
{characters.map((c) => (
|
||||||
|
<Card key={c.id} sx={{minWidth: 280, maxWidth: 360}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">{c.name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{c.role}
|
||||||
|
</Typography>
|
||||||
|
{c.backstory && (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{mt: 1}}
|
||||||
|
noWrap
|
||||||
|
>
|
||||||
|
{c.backstory}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions disableSpacing>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Edit"
|
||||||
|
onClick={() => openEdit(c)}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={() => setDeleteConfirmId(c.id)}
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{editingId ? 'Edit character' : 'New character'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Role"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={formRole}
|
||||||
|
onChange={(e) => setFormRole(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Backstory"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={formBackstory}
|
||||||
|
onChange={(e) => setFormBackstory(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Speaking style"
|
||||||
|
fullWidth
|
||||||
|
value={formSpeakingStyle}
|
||||||
|
onChange={(e) => setFormSpeakingStyle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Personality traits (comma-separated)"
|
||||||
|
fullWidth
|
||||||
|
value={formPersonalityTraits}
|
||||||
|
onChange={(e) => setFormPersonalityTraits(e.target.value)}
|
||||||
|
placeholder="e.g. brave, stern, witty"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Goals (comma-separated)"
|
||||||
|
fullWidth
|
||||||
|
value={formGoals}
|
||||||
|
onChange={(e) => setFormGoals(e.target.value)}
|
||||||
|
placeholder="e.g. Find the treasure, Protect the crew"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving || !formName.trim() || !formRole.trim()}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={deleteConfirmId !== null}
|
||||||
|
onClose={() => setDeleteConfirmId(null)}
|
||||||
|
>
|
||||||
|
<DialogTitle>Delete character?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
This cannot be undone. Scenarios using this character may need to be updated.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
356
src/main/web/src/pages/ScenariosPage.tsx
Normal file
356
src/main/web/src/pages/ScenariosPage.tsx
Normal file
@ -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<ScenarioSummary[]>([])
|
||||||
|
const [characters, setCharacters] = useState<CharacterDefinition[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [formName, setFormName] = useState('')
|
||||||
|
const [formSetting, setFormSetting] = useState('')
|
||||||
|
const [formInitialConflict, setFormInitialConflict] = useState('')
|
||||||
|
const [formUserCharacterId, setFormUserCharacterId] = useState<string>('')
|
||||||
|
const [formAiCharacterIds, setFormAiCharacterIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{display: 'flex', justifyContent: 'center', py: 4}}>
|
||||||
|
<CircularProgress/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2}}>
|
||||||
|
<Typography variant="h4">Scenarios</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon/>} onClick={openCreate}>
|
||||||
|
New scenario
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{mb: 2}}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scenarios.length === 0 ? (
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
action={
|
||||||
|
<Button color="inherit" size="small" onClick={openCreate}>
|
||||||
|
Create first scenario
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
No scenarios yet. Create your first scenario to start a story.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Box sx={{display: 'flex', flexWrap: 'wrap', gap: 2}}>
|
||||||
|
{scenarios.map((s) => (
|
||||||
|
<Card key={s.id} sx={{minWidth: 280, maxWidth: 360}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">{s.name}</Typography>
|
||||||
|
{s.setting && (
|
||||||
|
<Typography variant="body2" color="text.secondary" noWrap>
|
||||||
|
{s.setting}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{s.initialConflict && (
|
||||||
|
<Typography variant="body2" sx={{mt: 0.5}} noWrap>
|
||||||
|
{s.initialConflict}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions disableSpacing>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<PlayArrowIcon/>}
|
||||||
|
onClick={() => handleStart(s.id)}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
<IconButton aria-label="Edit" onClick={() => openEdit(s)} size="small">
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Delete"
|
||||||
|
onClick={() => setDeleteConfirmId(s.id)}
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<DeleteIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{editingId ? 'Edit scenario' : 'New scenario'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Setting"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={formSetting}
|
||||||
|
onChange={(e) => setFormSetting(e.target.value)}
|
||||||
|
placeholder="Place, time, and atmosphere"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
label="Initial conflict"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={formInitialConflict}
|
||||||
|
onChange={(e) => setFormInitialConflict(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<InputLabel>User character</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formUserCharacterId}
|
||||||
|
label="User character"
|
||||||
|
onChange={(e) => setFormUserCharacterId(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="">— None —</MenuItem>
|
||||||
|
{characters.map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.role})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
|
<InputLabel>AI characters</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple
|
||||||
|
value={formAiCharacterIds}
|
||||||
|
label="AI characters"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormAiCharacterIds(
|
||||||
|
typeof e.target.value === 'string'
|
||||||
|
? []
|
||||||
|
: (e.target.value as string[])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderValue={(selected) =>
|
||||||
|
selected
|
||||||
|
.map((id) => characters.find((c) => c.id === id)?.name ?? id)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{characters
|
||||||
|
.filter((c) => c.id !== formUserCharacterId)
|
||||||
|
.map((c) => (
|
||||||
|
<MenuItem key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.role})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving || !formName.trim()}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteConfirmId !== null} onClose={() => setDeleteConfirmId(null)}>
|
||||||
|
<DialogTitle>Delete scenario?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>This cannot be undone.</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useRef, useState} from 'react'
|
||||||
import {useNavigate} from 'react-router-dom'
|
import {useLocation, useNavigate} from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
@ -59,6 +59,8 @@ const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
|
|||||||
*/
|
*/
|
||||||
export default function StartPage() {
|
export default function StartPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const deepLinkApplied = useRef(false)
|
||||||
|
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
const [selectedModel, setSelectedModel] = useState<string>('')
|
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||||
@ -100,6 +102,25 @@ export default function StartPage() {
|
|||||||
.finally(() => setLoading(false))
|
.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) => {
|
const handleModelChange = (event: SelectChangeEvent) => {
|
||||||
setSelectedModel(event.target.value)
|
setSelectedModel(event.target.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<CharacterEntity> 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<CharacterEntity> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,20 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
import de.neitzel.roleplay.data.CharacterEntity;
|
import de.neitzel.roleplay.data.CharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
||||||
import de.neitzel.roleplay.data.ScenarioEntity;
|
import de.neitzel.roleplay.data.ScenarioEntity;
|
||||||
import de.neitzel.roleplay.data.ScenarioRepository;
|
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.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,11 +38,17 @@ class ScenarioServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ScenarioRepository scenarioRepository;
|
private ScenarioRepository scenarioRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
private ScenarioService scenarioService;
|
private ScenarioService scenarioService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
scenarioService = new ScenarioService(scenarioRepository);
|
scenarioService = new ScenarioService(scenarioRepository, characterRepository, entityManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -124,4 +138,105 @@ class ScenarioServiceTest {
|
|||||||
assertEquals("Captain Morgan", setup.getAiCharacters().get(0).getName());
|
assertEquals("Captain Morgan", setup.getAiCharacters().get(0).getName());
|
||||||
assertEquals("captain", setup.getAiCharacters().get(0).getRole());
|
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<ScenarioEntity> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user