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.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.
|
||||
*/
|
||||
|
||||
@ -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<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.
|
||||
*/
|
||||
|
||||
@ -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<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).
|
||||
* Never returns null; scenarios loaded from DB without characters (e.g. seed data) get an empty list.
|
||||
*/
|
||||
public List<ScenarioCharacterEntity> 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<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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: |
|
||||
|
||||
@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<StartPage/>}/>
|
||||
<Route path="/session/:sessionId" element={<SessionPage/>}/>
|
||||
<Route element={<AppLayout/>}>
|
||||
<Route path="/" element={<StartPage/>}/>
|
||||
<Route path="/scenarios" element={<ScenariosPage/>}/>
|
||||
<Route path="/characters" element={<CharactersPage/>}/>
|
||||
<Route path="/session/:sessionId" element={<SessionPage/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</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 {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<ModelInfo[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
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<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