Compare commits

..

10 Commits

Author SHA1 Message Date
Konrad Neitzel
3218cb1a2a Implement user management enhancements and password change functionality
- Add change password feature in UserService, allowing users to update their password after verifying the current one.
- Introduce UpdateUserRequest model for updating user details, including username, password, and role.
- Implement RESTful endpoints for changing passwords and updating user information in AuthResource and UsersResource.
- Enhance OpenAPI specification to document new endpoints for password change and user updates.
- Create frontend components for user management, including a dedicated UsersPage for admin users to manage user accounts.
- Add unit tests for UserService to ensure correct behavior of password changes and user updates.
2026-02-22 17:55:45 +01:00
Konrad Neitzel
1e1368e519 Add transactional support to CharacterService, ScenarioService, and UserService
- Annotate create, update, and delete methods in CharacterService and ScenarioService with @Transactional to ensure proper transaction management.
- Add @Transactional annotation to the createUser method in UserService for consistency in transaction handling across services.
- Enhance data integrity and consistency during operations involving character, scenario, and user management.
2026-02-22 12:22:41 +01:00
Konrad Neitzel
2c61ab5fc9 Add user authentication and management features
- Introduce user authentication with form-based login, including a default admin user.
- Implement UserEntity, UserRepository, and UserService for user management.
- Create RESTful endpoints for user listing and creation, restricted to admin role.
- Enhance OpenAPI specification to document new authentication and user management endpoints.
- Add frontend components for login and user management, including protected routes.
- Implement context and hooks for managing authentication state in the React application.
- Include unit tests for user service and authentication logic.
2026-02-22 11:28:04 +01:00
Konrad Neitzel
4c1584ec27 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.
2026-02-22 05:02:20 +01:00
Konrad Neitzel
3ce1215487 Update dependencies, add character and scenario management features
- Upgrade Quarkus and OpenAPI generator versions in pom.xml.
- Introduce CharacterService and ScenarioService for managing character and scenario templates.
- Implement CharacterEntity and ScenarioEntity JPA entities with corresponding repositories.
- Add RESTful APIs for listing and retrieving characters and scenarios.
- Create JSON converter for persisting lists of strings in the database.
- Update OpenAPI specification to include new endpoints for character and scenario management.
- Add Liquibase migration scripts for character and scenario tables.
- Configure application settings for Hibernate ORM and database generation.
2026-02-21 19:50:17 +01:00
Konrad Neitzel
cf93b35dd6 removed open tasks 2026-02-21 12:45:39 +01:00
Konrad Neitzel
b79334ee67 Implement Ollama integration for session management and turn processing
- Enhance InMemorySessionService to utilize the two-call Ollama pattern for session creation and turn submissions, generating narratives and state updates based on provided scenarios.
- Introduce OllamaContextBuilder to construct turn contexts for both session initialization and turn continuation.
- Add OllamaPrompts class to define system prompts for narrative generation and state extraction.
- Implement StateUpdateMapper to handle merging state updates into session responses.
- Create unit tests for InMemorySessionService to validate Ollama interactions and ensure correct session state management.
2026-02-21 12:45:20 +01:00
Konrad Neitzel
f21f1e7520 Add update session functionality and enhance scenario support
- Introduce UpdateSessionRequest model for partial updates to session state, allowing modification of situation and characters.
- Implement updateSession method in SessionService to handle updates, ensuring omitted fields remain unchanged.
- Enhance InMemorySessionService to support scenario-based session creation, populating initial situation and characters.
- Update SessionResource to delegate update requests to the SessionService.
- Add corresponding API documentation for the update session endpoint in OpenAPI specification.
- Enhance frontend components to allow editing of session scene and characters, integrating with the new update functionality.
- Include unit tests to verify the behavior of session updates and scenario handling.
2026-02-21 10:04:07 +01:00
Konrad Neitzel
ffb97f6b8e Refactor RolePlay application structure and enhance configuration
- Remove the RolePlayApplication class as part of the restructuring.
- Update pom.xml to include new dependencies for YAML configuration and H2 database support.
- Modify API package imports in ModelResource, SessionResource, and TurnResource to use the generated API package.
- Update application.yml to configure H2 datasource and enable Liquibase migrations at startup.
2026-02-20 20:01:19 +01:00
Konrad Neitzel
f91604aea6 Enhance project setup and configuration for RolePlay application
- Update .gitignore to include additional build outputs and IDE files.
- Modify pom.xml to add new plugin versions and configurations for frontend and OpenAPI TypeScript client generation.
- Introduce project guidelines in roleplay-project.mdc, detailing architecture, coding standards, and testing practices.
- Add initial documentation for the RolePlay concept and specifications.
- Implement catch-all JAX-RS resource for serving the React application and establish API base path in application.yml.
- Create foundational web components and TypeScript configuration for the frontend application.
2026-02-20 18:08:24 +01:00
98 changed files with 11801 additions and 73 deletions

View File

@ -0,0 +1,69 @@
---
description: RolePlay project context, architecture, and coding standards (Java/Quarkus, Maven, frontend)
alwaysApply: true
---
# RolePlay Project Instructions
## Project context
- **Project**: RolePlay
- **Java**: 21
- **Build**: Maven
- **Framework**: Quarkus (latest stable)
- **GroupId**: de.neitzel | **ArtifactId**: roleplay
- **Base package**: `de.neitzel.roleplay`
- **Sub-packages**: `business`, `common`, `data`, `fascade`
- Startup code lives in the base package.
- **DB migrations**: Liquibase; scripts in `src/main/resources/db/migration`.
## Architecture and package rules
- **Startup/bootstrap**: `de.neitzel.roleplay`
- **Business logic**: `de.neitzel.roleplay.business`
- **Shared utilities, cross-cutting types**: `de.neitzel.roleplay.common`
- **Persistence / data access**: `de.neitzel.roleplay.data`
- **External-facing facades (REST, API)**: `de.neitzel.roleplay.fascade`
- Keep clear package boundaries; avoid circular dependencies.
## Coding standards (Java)
- Use Lombok to reduce boilerplate where it helps.
- Prefer immutability: `final` fields, constructor injection.
- Use Quarkus idioms; avoid heavyweight frameworks.
- Keep methods small and focused; one responsibility per class.
- Use Java 21 features when they improve clarity.
- Add concise comments only for non-obvious logic.
- All classes, fields, and methods (including private) must have JavaDoc describing purpose and usage.
## Testing
- Add or update unit tests for new or changed logic.
- Use JUnit 5 and Mockito; ArrangeActAssert; keep tests deterministic.
- Mock external dependencies; add integration tests only when requested.
## Maven and dependencies
- Target Java 21 in the Maven toolchain.
- Use the latest stable Quarkus platform BOM.
- Keep dependencies minimal; no new libraries without need.
- Keep versions up to date (especially for security).
- Put plugin and dependency versions in the `properties` section of `pom.xml`.
- Use `quarkus-maven-plugin` for build/run; `maven-surefire-plugin` for tests (JUnit 5); `maven-compiler-plugin` with Java 21.
## Frontend (React / Vite)
- Frontend lives under `src/main/web` (React, TypeScript, Vite, MUI).
- OpenAPI TypeScript client is generated into `src/main/web/src/api/generated/` by Maven (`mvn generate-sources`); do not edit by hand.
- Production build output: `target/web-dist`; copied to `META-INF/resources` by `maven-resources-plugin` for the Quarkus SPA.
## Do and dont
- **Do**: keep code in the correct package; add tests for business logic.
- **Dont**: put startup code outside the base package; add unnecessary abstraction layers.
## Output expectations
- Provide complete, compilable code with package declarations and imports.
- Prefer Quarkus-friendly APIs and annotations.
- When adding or changing logic, update or add unit tests.

19
.gitignore vendored
View File

@ -1,2 +1,21 @@
# Maven build output
/target/ /target/
# Vite build output (written into resources so it gets into the JAR)
src/main/resources/META-INF/resources/
# IDE
/.idea/ /.idea/
*.iml
# Node dependencies
node_modules/
# OpenAPI-generated TypeScript client (regenerated by mvn generate-sources)
src/main/web/src/api/generated/
# TypeScript incremental build cache
*.tsbuildinfo
# OS metadata
.DS_Store

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

View File

@ -11,6 +11,15 @@ Minimal Quarkus (Java 21) project scaffold for the RolePlay service.
- `src/main/java/de/neitzel/roleplay/fascade`: external facades - `src/main/java/de/neitzel/roleplay/fascade`: external facades
- `src/main/resources/db/migration`: Liquibase changelog location - `src/main/resources/db/migration`: Liquibase changelog location
## Login
The app requires authentication. A default admin user is seeded on first run:
- **Username:** `admin`
- **Password:** `changeme`
Change the password after first login (e.g. by creating a new admin user and retiring the default, or via a future password-change feature). Only users with the `admin` role can create new users (POST `/api/v1/admin/users`).
## Build and test ## Build and test
```zsh ```zsh

125
pom.xml
View File

@ -11,15 +11,21 @@
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version> <maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version> <maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id> <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id> <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.version>3.15.3</quarkus.platform.version> <quarkus.platform.version>3.31.2</quarkus.platform.version>
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version> <quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
<jackson.version>2.20.1</jackson.version>
<jackson.annotations.version>2.20</jackson.annotations.version>
<lombok.version>1.18.42</lombok.version> <lombok.version>1.18.42</lombok.version>
<junit.jupiter.version>5.10.3</junit.jupiter.version> <junit.jupiter.version>5.10.3</junit.jupiter.version>
<mockito.version>5.12.0</mockito.version> <mockito.version>5.12.0</mockito.version>
<openapi.generator.version>7.11.0</openapi.generator.version> <openapi.generator.version>7.13.0</openapi.generator.version>
<frontend.plugin.version>1.15.1</frontend.plugin.version>
<node.version>v22.13.1</node.version>
<npm.version>10.9.2</npm.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@ -52,10 +58,30 @@
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId> <artifactId>quarkus-hibernate-validator</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-liquibase</artifactId> <artifactId>quarkus-liquibase</artifactId>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-jpa</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-config</artifactId>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId> <artifactId>quarkus-rest-client-jackson</artifactId>
@ -79,6 +105,16 @@
<version>${mockito.version}</version> <version>${mockito.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@ -88,6 +124,17 @@
<artifactId>quarkus-maven-plugin</artifactId> <artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.plugin.version}</version> <version>${quarkus.plugin.version}</version>
<extensions>true</extensions> <extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
<phase>package</phase>
<configuration>
<skip>${quarkus.package.skip}</skip>
</configuration>
</execution>
</executions>
</plugin> </plugin>
<plugin> <plugin>
<groupId>org.codehaus.mojo</groupId> <groupId>org.codehaus.mojo</groupId>
@ -108,6 +155,7 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<!-- On Java 25, skip to avoid VerifyError; run JAVA_HOME=/path/to/jdk21 mvn generate-sources first. -->
<plugin> <plugin>
<groupId>org.openapitools</groupId> <groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId> <artifactId>openapi-generator-maven-plugin</artifactId>
@ -121,8 +169,9 @@
<configuration> <configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec> <inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
<generatorName>jaxrs-spec</generatorName> <generatorName>jaxrs-spec</generatorName>
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates</templateDirectory>
<output>${project.build.directory}/generated-sources/openapi</output> <output>${project.build.directory}/generated-sources/openapi</output>
<apiPackage>de.neitzel.roleplay.fascade.api</apiPackage> <apiPackage>de.neitzel.roleplay.generated.api</apiPackage>
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage> <modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
<configOptions> <configOptions>
<interfaceOnly>true</interfaceOnly> <interfaceOnly>true</interfaceOnly>
@ -139,6 +188,27 @@
<generateSupportingFiles>false</generateSupportingFiles> <generateSupportingFiles>false</generateSupportingFiles>
</configuration> </configuration>
</execution> </execution>
<execution>
<id>generate-typescript-client</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<skip>${skip.openapi.generate}</skip>
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
<generatorName>typescript-fetch</generatorName>
<output>${project.basedir}/src/main/web/src/api/generated</output>
<configOptions>
<supportsES6>true</supportsES6>
<useSingleRequestParameter>true</useSingleRequestParameter>
<enumPropertyNaming>original</enumPropertyNaming>
<modelPropertyNaming>camelCase</modelPropertyNaming>
<stringEnums>true</stringEnums>
<withInterfaces>false</withInterfaces>
</configOptions>
<generateSupportingFiles>true</generateSupportingFiles>
</configuration>
</execution>
</executions> </executions>
</plugin> </plugin>
<plugin> <plugin>
@ -160,10 +230,55 @@
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>${maven.surefire.plugin.version}</version> <version>${maven.surefire.plugin.version}</version>
<configuration> <configuration>
<useModulePath>false</useModulePath> <argLine>@{argLine}</argLine>
<argLine>-Dnet.bytebuddy.experimental=true</argLine> <systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend.plugin.version}</version>
<configuration>
<workingDirectory>src/main/web</workingDirectory>
<installDirectory>${project.build.directory}/node</installDirectory>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<phase>initialize</phase>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion>
</configuration>
</execution>
<execution>
<id>npm-install</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>npm-build</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@ -0,0 +1,36 @@
package de.neitzel.roleplay;
import de.neitzel.roleplay.data.UserEntity;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import org.jboss.logging.Logger;
/**
* Logs login-related debug info at startup: whether the seeded admin user exists
* and whether the stored password hash matches "changeme". Helps diagnose login failures.
*/
@ApplicationScoped
public class LoginDebugStartup {
private static final Logger LOG = Logger.getLogger(LoginDebugStartup.class);
private static final String DEFAULT_ADMIN_PASSWORD = "changeme";
void onStart(@Observes StartupEvent event) {
UserEntity admin = (UserEntity) UserEntity.find("username", "admin").firstResult();
if (admin == null) {
LOG.warn("Login debug: No user 'admin' found in database. Create the admin user (e.g. via Liquibase seed).");
return;
}
String hash = admin.getPassword();
boolean passwordMatches = hash != null && BcryptUtil.matches(DEFAULT_ADMIN_PASSWORD, hash);
if (passwordMatches) {
LOG.info("Login debug: User 'admin' exists; password matches '" + DEFAULT_ADMIN_PASSWORD + "'.");
} else {
LOG.warn("Login debug: User 'admin' exists but stored password hash does NOT match '" + DEFAULT_ADMIN_PASSWORD + "'. "
+ "Reset the password or update the seed hash in v002__users_and_roles.xml.");
}
}
}

View File

@ -1,33 +0,0 @@
package de.neitzel.roleplay;
import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;
/**
* Application entry point for the RolePlay service.
*/
@QuarkusMain
public final class RolePlayApplication implements QuarkusApplication {
/**
* Starts the Quarkus application.
*
* @param args command-line arguments
*/
public static void main(final String[] args) {
Quarkus.run(RolePlayApplication.class, args);
}
/**
* Runs after the Quarkus runtime starts.
*
* @param args command-line arguments
* @return the process exit code
*/
@Override
public int run(final String... args) {
return 0;
}
}

View File

@ -0,0 +1,141 @@
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;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Business service for stored character templates. Maps entities to API DTOs.
*/
@ApplicationScoped
public class CharacterService {
private final CharacterRepository characterRepository;
@Inject
public CharacterService(final CharacterRepository characterRepository) {
this.characterRepository = characterRepository;
}
/**
* Returns all stored characters as API definitions.
*
* @return list of character definitions, ordered by name
*/
public List<CharacterDefinition> listCharacters() {
return characterRepository.listAll().stream()
.map(CharacterService::toCharacterDefinition)
.collect(Collectors.toList());
}
/**
* Returns a single character by id, if present.
*
* @param id the character UUID
* @return the character definition or empty
*/
public Optional<CharacterDefinition> getCharacter(final UUID id) {
CharacterEntity entity = characterRepository.findByIdOptional(id);
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
*/
@Transactional
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
*/
@Transactional
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
*/
@Transactional
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.
*/
public static CharacterDefinition toCharacterDefinition(final CharacterEntity entity) {
CharacterDefinition def = new CharacterDefinition(
entity.getId().toString(),
entity.getName(),
entity.getRole()
);
def.setBackstory(entity.getBackstory());
def.setSpeakingStyle(entity.getSpeakingStyle());
if (entity.getPersonalityTraits() != null && !entity.getPersonalityTraits().isEmpty()) {
def.setPersonalityTraits(entity.getPersonalityTraits());
}
if (entity.getGoals() != null && !entity.getGoals().isEmpty()) {
def.setGoals(entity.getGoals());
}
return def;
}
}

View File

@ -1,11 +1,23 @@
package de.neitzel.roleplay.business; package de.neitzel.roleplay.business;
import com.fasterxml.jackson.core.JsonProcessingException;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.OllamaParseException;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.SessionResponse; import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse; import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@ -16,32 +28,52 @@ import java.util.concurrent.ConcurrentHashMap;
* {@link ConcurrentHashMap}; suitable for development and testing. A * {@link ConcurrentHashMap}; suitable for development and testing. A
* production implementation would persist state in a database. * production implementation would persist state in a database.
* *
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the * <p>Session creation and turn submission use the two-call Ollama pattern
* methods return stub responses so the REST layer can be exercised end-to-end. * (narrative then state extraction) when scenario or turn data is provided.
* The {@code TODO} markers indicate where the Ollama integration must be added.
*/ */
@ApplicationScoped @ApplicationScoped
public class InMemorySessionService implements SessionService { public class InMemorySessionService implements SessionService {
private final OllamaClient ollamaClient;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
private final ScenarioService scenarioService;
/** /**
* In-memory store mapping session IDs to their current state. * In-memory store mapping session IDs to their current state.
*/ */
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>(); private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
/**
* Creates the service with required dependencies.
*
* @param ollamaClient client for Ollama narrative and state-update calls
* @param objectMapper mapper to serialize turn context to JSON
* @param scenarioService service to resolve scenario by id from the database
*/
@Inject
public InMemorySessionService(final OllamaClient ollamaClient,
final com.fasterxml.jackson.databind.ObjectMapper objectMapper,
final ScenarioService scenarioService) {
this.ollamaClient = ollamaClient;
this.objectMapper = objectMapper;
this.scenarioService = scenarioService;
}
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* <p>Generates a new UUID as the session ID, populates default session * <p>Generates a new UUID as the session ID, populates default session
* metadata, and stores the session. The Ollama two-call pattern is not * metadata, and stores the session. When a scenario is provided, runs the
* yet invoked; a placeholder narrative is returned instead. * two-call Ollama pattern to produce an opening narrative and initial state.
*/ */
@Override @Override
public SessionResponse createSession(final CreateSessionRequest request) { public SessionResponse createSession(final CreateSessionRequest request) {
String sessionId = UUID.randomUUID().toString(); String sessionId = UUID.randomUUID().toString();
String model = request.getModel();
SessionResponse session = new SessionResponse( SessionResponse session = new SessionResponse(
sessionId, sessionId,
request.getModel(), model,
request.getLanguage() != null ? request.getLanguage() : "en", request.getLanguage() != null ? request.getLanguage() : "en",
request.getSafetyLevel() != null request.getSafetyLevel() != null
? request.getSafetyLevel().value() ? request.getSafetyLevel().value()
@ -49,14 +81,116 @@ public class InMemorySessionService implements SessionService {
0 0
); );
// TODO: Invoke OllamaClient two-call pattern (narrative + state extraction) ScenarioSetup scenario = resolveScenario(request);
// to produce a real opening scene and initial state. if (scenario != null) {
session.setNarrative("Session initialised. Ollama integration pending."); session.setSituation(buildSituationFromScenario(scenario));
session.setCharacters(buildCharactersFromScenario(scenario));
try {
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.fromScenario(scenario));
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.INIT_NARRATIVE, contextJson);
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
session.setNarrative(narrative);
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
} catch (JsonProcessingException e) {
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
}
} else {
session.setNarrative("Session initialised. No scenario provided.");
}
sessions.put(sessionId, session); sessions.put(sessionId, session);
return session; return session;
} }
/**
* Resolves the effective scenario: scenarioId from DB takes precedence over inline scenario.
*
* @param request the create session request
* @return the scenario to use, or null if none
*/
private ScenarioSetup resolveScenario(final CreateSessionRequest request) {
if (request.getScenarioId() != null) {
return scenarioService.getScenarioAsSetup(request.getScenarioId()).orElse(null);
}
return request.getScenario();
}
/**
* Builds initial situation state from the scenario setup.
*
* @param scenario the scenario from the create request
* @return situation state with setting, initialConflict and currentScene derived
*/
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
SituationState situation = new SituationState();
situation.setSetting(scenario.getSetting());
situation.setCurrentScene(
scenario.getSetting() != null && scenario.getInitialConflict() != null
? scenario.getSetting() + " " + scenario.getInitialConflict()
: scenario.getSetting() != null
? scenario.getSetting()
: scenario.getInitialConflict());
return situation;
}
/**
* Builds initial character list from the scenario (user character + AI characters).
*
* @param scenario the scenario from the create request
* @return list of character states
*/
private static List<CharacterState> buildCharactersFromScenario(final ScenarioSetup scenario) {
List<CharacterState> characters = new ArrayList<>();
if (scenario.getUserCharacter() != null) {
characters.add(toCharacterState(scenario.getUserCharacter(), true));
}
if (scenario.getAiCharacters() != null) {
for (CharacterDefinition def : scenario.getAiCharacters()) {
characters.add(toCharacterState(def, false));
}
}
return characters;
}
/**
* Maps a character definition to initial character state.
*
* @param def the definition
* @param isUserCharacter whether this is the user-controlled character
* @return character state with id, name, role and isUserCharacter set
*/
private static CharacterState toCharacterState(final CharacterDefinition def,
final boolean isUserCharacter) {
CharacterState state = new CharacterState(def.getId(), def.getName(), isUserCharacter);
state.setRole(def.getRole());
return state;
}
/**
* {@inheritDoc}
*
* <p>Updates situation and/or characters when provided; omitted fields are unchanged.
*/
@Override
public Optional<SessionResponse> updateSession(final String sessionId,
final UpdateSessionRequest request) {
SessionResponse session = sessions.get(sessionId);
if (session == null) {
return Optional.empty();
}
if (request != null) {
if (request.getSituation() != null) {
session.setSituation(request.getSituation());
}
if (request.getCharacters() != null) {
session.setCharacters(new ArrayList<>(request.getCharacters()));
}
}
sessions.put(sessionId, session);
return Optional.of(session);
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@ -68,8 +202,9 @@ public class InMemorySessionService implements SessionService {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* <p>Increments the turn counter and returns a stub {@link TurnResponse}. * <p>Increments the turn counter, runs the two-call Ollama pattern with
* The Ollama two-call pattern is not yet invoked. * the current session state and turn request, merges the state update into
* the session, and returns the turn response.
*/ */
@Override @Override
public Optional<TurnResponse> submitTurn(final String sessionId, public Optional<TurnResponse> submitTurn(final String sessionId,
@ -79,20 +214,23 @@ public class InMemorySessionService implements SessionService {
return Optional.empty(); return Optional.empty();
} }
// Increment turn counter
int nextTurn = session.getTurnNumber() + 1; int nextTurn = session.getTurnNumber() + 1;
session.setTurnNumber(nextTurn); session.setTurnNumber(nextTurn);
String model = session.getModel();
// TODO: Invoke OllamaClient two-call pattern (narrative + state update) try {
// using the current session state, turnRequest.getUserAction(), String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
// and turnRequest.getRecommendation(). String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_NARRATIVE, contextJson);
TurnResponse response = new TurnResponse( String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
nextTurn, StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
"Turn " + nextTurn + " processed. Ollama integration pending." session.setNarrative(narrative);
); StateUpdateMapper.mergeIntoSession(session, stateUpdate);
TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
sessions.put(sessionId, session); sessions.put(sessionId, session);
return Optional.of(response); return Optional.of(response);
} catch (JsonProcessingException e) {
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
}
} }
} }

View File

@ -0,0 +1,240 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CharacterSet;
import de.neitzel.roleplay.common.CharacterSnapshot;
import de.neitzel.roleplay.common.Recommendation;
import de.neitzel.roleplay.common.SituationSnapshot;
import de.neitzel.roleplay.common.TurnContext;
import de.neitzel.roleplay.common.UserAction;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.RecommendationRequest;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.UserActionRequest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Builds {@link TurnContext} (common) from API request models for Ollama calls.
* Used for session initialization (scenario only) and turn continuation
* (session state + turn request).
*/
public final class OllamaContextBuilder {
private OllamaContextBuilder() {
}
/**
* Builds turn context for session initialization. No user action or
* recommendation; recent history is empty.
*
* @param situation situation state from scenario (may be null)
* @param characters character list from scenario (may be null or empty)
* @return context for the init two-call pattern
*/
public static TurnContext forSessionInit(final SituationState situation,
final List<CharacterState> characters) {
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
CharacterSet characterSet = charactersToSet(characters);
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(null)
.recommendation(null)
.recentHistorySummary(null)
.build();
}
/**
* Builds turn context from scenario setup. Uses definitions for rich
* character snapshots (personality, goals) in the context.
*
* @param scenario the scenario from create request (must not be null)
* @return context for the init two-call pattern
*/
public static TurnContext fromScenario(final ScenarioSetup scenario) {
SituationState situation = buildSituationFromScenario(scenario);
CharacterSet characterSet = characterSetFromScenario(scenario);
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(null)
.recommendation(null)
.recentHistorySummary(null)
.build();
}
/**
* Builds turn context for a turn continuation from current session state
* and the turn request.
*
* @param session current session (situation and characters used)
* @param turnRequest user action and optional recommendation
* @return context for the turn two-call pattern
*/
public static TurnContext forTurn(final SessionResponse session,
final TurnRequest turnRequest) {
SituationSnapshot situationSnapshot = situationToSnapshot(session.getSituation());
CharacterSet characterSet = charactersToSet(session.getCharacters());
UserAction userAction = turnRequest.getUserAction() != null
? toUserAction(turnRequest.getUserAction())
: null;
Recommendation recommendation = turnRequest.getRecommendation() != null
? toRecommendation(turnRequest.getRecommendation())
: null;
return TurnContext.builder()
.currentSituation(situationSnapshot)
.characters(characterSet)
.userAction(userAction)
.recommendation(recommendation)
.recentHistorySummary("")
.build();
}
private static SituationSnapshot situationToSnapshot(final SituationState s) {
if (s == null) {
return SituationSnapshot.builder().build();
}
return SituationSnapshot.builder()
.setting(s.getSetting())
.currentScene(s.getCurrentScene())
.timeline(s.getTimeline() != null ? new ArrayList<>(s.getTimeline()) : null)
.openThreads(s.getOpenThreads() != null ? new ArrayList<>(s.getOpenThreads()) : null)
.externalPressures(s.getExternalPressures() != null ? new ArrayList<>(s.getExternalPressures()) : null)
.worldStateFlags(s.getWorldStateFlags() != null ? Map.copyOf(s.getWorldStateFlags()) : null)
.build();
}
private static CharacterSet charactersToSet(final List<CharacterState> characters) {
if (characters == null || characters.isEmpty()) {
return CharacterSet.builder()
.userCharacter(null)
.aiCharacters(Collections.emptyList())
.build();
}
CharacterSnapshot userCharacter = null;
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
for (CharacterState c : characters) {
CharacterSnapshot snap = characterStateToSnapshot(c);
if (Boolean.TRUE.equals(c.getIsUserCharacter())) {
userCharacter = snap;
} else {
aiCharacters.add(snap);
}
}
return CharacterSet.builder()
.userCharacter(userCharacter)
.aiCharacters(aiCharacters)
.build();
}
private static CharacterSnapshot characterStateToSnapshot(final CharacterState c) {
if (c == null) {
return null;
}
return CharacterSnapshot.builder()
.id(c.getId())
.name(c.getName())
.role(c.getRole())
.personalityTraits(null)
.speakingStyle(null)
.goals(null)
.currentMood(c.getCurrentMood())
.knowledge(c.getKnowledge() != null ? new ArrayList<>(c.getKnowledge()) : null)
.relationships(c.getRelationships() != null ? Map.copyOf(c.getRelationships()) : null)
.status(c.getStatus())
.recentActionsSummary(c.getRecentActionsSummary() != null ? new ArrayList<>(c.getRecentActionsSummary()) : null)
.build();
}
private static UserAction toUserAction(final UserActionRequest r) {
if (r == null) {
return null;
}
de.neitzel.roleplay.common.ActionType type = toActionType(r.getType());
return UserAction.builder()
.type(type)
.content(r.getContent())
.selectedSuggestionId(r.getSelectedSuggestionId())
.build();
}
private static de.neitzel.roleplay.common.ActionType toActionType(final UserActionRequest.TypeEnum e) {
if (e == null) {
return null;
}
return switch (e.value()) {
case "speech" -> de.neitzel.roleplay.common.ActionType.SPEECH;
case "action" -> de.neitzel.roleplay.common.ActionType.ACTION;
case "choice" -> de.neitzel.roleplay.common.ActionType.CHOICE;
default -> de.neitzel.roleplay.common.ActionType.ACTION;
};
}
private static Recommendation toRecommendation(final RecommendationRequest r) {
if (r == null) {
return null;
}
return Recommendation.builder()
.desiredTone(r.getDesiredTone())
.preferredDirection(r.getPreferredDirection())
.focusCharacters(r.getFocusCharacters() != null ? new ArrayList<>(r.getFocusCharacters()) : null)
.build();
}
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
SituationState situation = new SituationState();
situation.setSetting(scenario.getSetting());
situation.setCurrentScene(
scenario.getSetting() != null && scenario.getInitialConflict() != null
? scenario.getSetting() + " " + scenario.getInitialConflict()
: scenario.getSetting() != null
? scenario.getSetting()
: scenario.getInitialConflict());
return situation;
}
/** Builds character set for context from scenario definitions (rich snapshots). */
private static CharacterSet characterSetFromScenario(final ScenarioSetup scenario) {
CharacterSnapshot userCharacter = null;
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
if (scenario.getUserCharacter() != null) {
userCharacter = definitionToSnapshot(scenario.getUserCharacter(), true);
}
if (scenario.getAiCharacters() != null) {
for (CharacterDefinition def : scenario.getAiCharacters()) {
aiCharacters.add(definitionToSnapshot(def, false));
}
}
return CharacterSet.builder()
.userCharacter(userCharacter)
.aiCharacters(aiCharacters)
.build();
}
private static CharacterSnapshot definitionToSnapshot(final CharacterDefinition def, final boolean isUser) {
if (def == null) {
return null;
}
return CharacterSnapshot.builder()
.id(def.getId())
.name(def.getName())
.role(def.getRole())
.personalityTraits(def.getPersonalityTraits() != null ? new ArrayList<>(def.getPersonalityTraits()) : null)
.speakingStyle(def.getSpeakingStyle())
.goals(def.getGoals() != null ? new ArrayList<>(def.getGoals()) : null)
.currentMood(null)
.knowledge(null)
.relationships(null)
.status(null)
.recentActionsSummary(null)
.build();
}
}

View File

@ -0,0 +1,58 @@
package de.neitzel.roleplay.business;
/**
* System prompts for the Ollama two-call pattern. Used by session creation
* (opening narrative + state extraction) and turn continuation (narrative +
* state update). Content is aligned with the ROLEPLAY_CONCEPT document.
*/
public final class OllamaPrompts {
private OllamaPrompts() {
}
/**
* System prompt for Call 1 during session initialization: produce an
* immersive opening scene from the structured context.
*/
public static final String INIT_NARRATIVE =
"You are a role playing game narrator. Your task is to write an immersive opening scene for a new story.\n\n"
+ "Rules:\n"
+ "1. Write from the user character's perspective.\n"
+ "2. Introduce the setting, atmosphere, and at least one AI character.\n"
+ "3. At least one AI character must speak.\n"
+ "4. Write 2-4 paragraphs of vivid, immersive prose.\n"
+ "5. Do NOT include any JSON, metadata, or out-of-character commentary.";
/**
* System prompt for Call 1 during turn continuation: produce narrative
* and dialogue for the next beat.
*/
public static final String TURN_NARRATIVE =
"You are a role playing game narrator continuing an ongoing story.\n\n"
+ "Rules:\n"
+ "1. Stay strictly in-character for all AI-controlled characters.\n"
+ "2. Respect and build on the provided character and situation state.\n"
+ "3. Do NOT contradict established facts unless the context explicitly says reality has changed.\n"
+ "4. Keep the text immersive and avoid meta commentary.\n"
+ "5. At least one AI character must speak or act.\n"
+ "6. If a recommendation is provided, treat it as a soft guideline for the scene's direction.\n\n"
+ "Style:\n"
+ "- Mix short descriptive narration with dialogue.\n"
+ "- Keep responses roughly 2-5 paragraphs.";
/**
* System prompt for Call 2 (state extraction): given context and narrative,
* return a JSON object with responses, updated_situation, updated_characters,
* and suggestions. Used for both session init and turn continuation.
*/
public static final String STATE_EXTRACTION =
"You are a role playing game engine. Given the story context and a narrative scene, extract structured state updates as JSON.\n\n"
+ "You must return a JSON object matching the schema described below. Do NOT include any text outside the JSON object.\n\n"
+ "Schema:\n"
+ "{\n"
+ " \"responses\": [{\"character_id\": \"string\", \"type\": \"speech|action|reaction\", \"content\": \"string|null\", \"action\": \"string|null\", \"mood_after\": \"string\"}],\n"
+ " \"updated_situation\": {\"current_scene\": \"string\", \"new_timeline_entries\": [\"string\"], \"open_threads_changes\": {\"added\": [\"string\"], \"resolved\": [\"string\"]}, \"world_state_flags\": {}},\n"
+ " \"updated_characters\": [{\"character_id\": \"string\", \"current_mood\": \"string\", \"knowledge_gained\": [\"string\"], \"relationship_changes\": {}}],\n"
+ " \"suggestions\": [{\"id\": \"string\", \"type\": \"player_action|world_event|npc_action|twist\", \"title\": \"string\", \"description\": \"string\", \"consequences\": [\"string\"], \"risk_level\": \"low|medium|high\"}]\n"
+ "}";
}

View File

@ -0,0 +1,186 @@
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 jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Business service for stored scenario templates. Maps entities to API DTOs.
*/
@ApplicationScoped
public class ScenarioService {
private final ScenarioRepository scenarioRepository;
private final CharacterRepository characterRepository;
private final EntityManager entityManager;
@Inject
public ScenarioService(final ScenarioRepository scenarioRepository, final CharacterRepository characterRepository,
final EntityManager entityManager) {
this.scenarioRepository = scenarioRepository;
this.characterRepository = characterRepository;
this.entityManager = entityManager;
}
/**
* Returns all stored scenarios as summaries.
*
* @return list of scenario summaries
*/
public List<ScenarioSummary> listScenarios() {
return scenarioRepository.listAll().stream()
.map(ScenarioService::toScenarioSummary)
.toList();
}
/**
* Returns the full scenario setup for the given id, if present.
*
* @param id the scenario UUID
* @return the scenario setup (setting, conflict, user character, AI characters) or empty
*/
public Optional<ScenarioSetup> getScenarioAsSetup(final UUID id) {
ScenarioEntity entity = scenarioRepository.findByIdWithCharacters(id);
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
*/
@Transactional
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
*/
@Transactional
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
*/
@Transactional
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.
*/
public static ScenarioSummary toScenarioSummary(final ScenarioEntity entity) {
ScenarioSummary summary = new ScenarioSummary(entity.getId(), entity.getName());
summary.setSetting(entity.getSetting());
summary.setInitialConflict(entity.getInitialConflict());
return summary;
}
/**
* Maps a scenario entity (with characters loaded) to the full ScenarioSetup for session creation.
*/
public static ScenarioSetup toScenarioSetup(final ScenarioEntity entity) {
ScenarioSetup setup = new ScenarioSetup();
setup.setSetting(entity.getSetting());
setup.setInitialConflict(entity.getInitialConflict());
List<ScenarioCharacterEntity> links = entity.getScenarioCharacters();
if (links != null && !links.isEmpty()) {
for (ScenarioCharacterEntity link : links) {
CharacterDefinition def = CharacterService.toCharacterDefinition(link.getCharacter());
if (link.isUserCharacter()) {
setup.setUserCharacter(def);
} else {
setup.addAiCharactersItem(def);
}
}
}
return setup;
}
}

View File

@ -4,6 +4,7 @@ import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse; import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse; import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import java.util.Optional; import java.util.Optional;
@ -31,6 +32,16 @@ public interface SessionService {
*/ */
Optional<SessionResponse> getSession(String sessionId); Optional<SessionResponse> getSession(String sessionId);
/**
* Partially updates an existing session (situation and/or characters).
* Omitted fields in the request are left unchanged.
*
* @param sessionId the unique session identifier
* @param request the update payload; may be null or have null fields
* @return an {@link Optional} containing the updated session, or empty if not found
*/
Optional<SessionResponse> updateSession(String sessionId, UpdateSessionRequest request);
/** /**
* Processes a user's turn within an existing session. Runs the two-call * Processes a user's turn within an existing session. Runs the two-call
* Ollama pattern and returns the resulting narrative with updated state. * Ollama pattern and returns the resulting narrative with updated state.

View File

@ -0,0 +1,245 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CharacterResponse;
import de.neitzel.roleplay.common.CharacterUpdate;
import de.neitzel.roleplay.common.OpenThreadsChanges;
import de.neitzel.roleplay.common.ResponseType;
import de.neitzel.roleplay.common.RiskLevel;
import de.neitzel.roleplay.common.SituationUpdate;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.common.Suggestion;
import de.neitzel.roleplay.common.SuggestionType;
import de.neitzel.roleplay.fascade.model.CharacterResponseItem;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnResponse;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Maps {@link StateUpdateResponse} (common, from Ollama) to API model types and
* merges state updates into {@link SessionResponse}.
*/
public final class StateUpdateMapper {
private StateUpdateMapper() {
}
/**
* Builds a turn response from the narrative and state update.
*
* @param turnNumber turn number just completed
* @param narrative narrative from Call 1
* @param update state update from Call 2 (may be null)
* @return turn response with narrative and optional structured state
*/
public static TurnResponse toTurnResponse(final int turnNumber,
final String narrative,
final StateUpdateResponse update) {
TurnResponse response = new TurnResponse(turnNumber, narrative);
if (update != null) {
if (update.getResponses() != null) {
response.setCharacterResponses(update.getResponses().stream()
.map(StateUpdateMapper::toCharacterResponseItem)
.toList());
}
if (update.getUpdatedSituation() != null) {
response.setUpdatedSituation(toApiSituationUpdate(update.getUpdatedSituation()));
}
if (update.getUpdatedCharacters() != null) {
response.setUpdatedCharacters(update.getUpdatedCharacters().stream()
.map(StateUpdateMapper::toApiCharacterUpdate)
.toList());
}
if (update.getSuggestions() != null) {
response.setSuggestions(update.getSuggestions().stream()
.map(StateUpdateMapper::toApiSuggestion)
.toList());
}
}
return response;
}
/**
* Applies the state update to the session: merges situation, characters,
* and replaces suggestions.
*
* @param session the session to update (modified in place)
* @param update the state update from Ollama Call 2 (may be null)
*/
public static void mergeIntoSession(final SessionResponse session,
final StateUpdateResponse update) {
if (update == null) {
return;
}
if (update.getUpdatedSituation() != null) {
mergeSituation(session.getSituation(), update.getUpdatedSituation());
}
if (update.getUpdatedCharacters() != null) {
mergeCharacters(session.getCharacters(), update.getUpdatedCharacters());
}
if (update.getSuggestions() != null) {
session.setSuggestions(update.getSuggestions().stream()
.map(StateUpdateMapper::toApiSuggestion)
.toList());
}
}
private static void mergeSituation(final SituationState situation,
final SituationUpdate update) {
if (situation == null) {
return;
}
if (update.getCurrentScene() != null) {
situation.setCurrentScene(update.getCurrentScene());
}
if (update.getNewTimelineEntries() != null && !update.getNewTimelineEntries().isEmpty()) {
List<String> timeline = situation.getTimeline();
if (timeline == null) {
situation.setTimeline(new ArrayList<>(update.getNewTimelineEntries()));
} else {
timeline.addAll(update.getNewTimelineEntries());
}
}
if (update.getOpenThreadsChanges() != null) {
OpenThreadsChanges otc = update.getOpenThreadsChanges();
List<String> openThreads = situation.getOpenThreads();
if (openThreads == null) {
openThreads = new ArrayList<>();
situation.setOpenThreads(openThreads);
}
if (otc.getResolved() != null) {
openThreads.removeAll(otc.getResolved());
}
if (otc.getAdded() != null) {
openThreads.addAll(otc.getAdded());
}
}
if (update.getWorldStateFlags() != null && !update.getWorldStateFlags().isEmpty()) {
Map<String, Object> flags = situation.getWorldStateFlags();
if (flags == null) {
situation.setWorldStateFlags(new HashMap<>(update.getWorldStateFlags()));
} else {
flags.putAll(update.getWorldStateFlags());
}
}
}
private static void mergeCharacters(final List<CharacterState> characters,
final List<CharacterUpdate> updates) {
if (characters == null || updates == null) {
return;
}
for (CharacterUpdate u : updates) {
String id = u.getCharacterId();
CharacterState target = characters.stream()
.filter(c -> Objects.equals(c.getId(), id))
.findFirst()
.orElse(null);
if (target != null) {
if (u.getCurrentMood() != null) {
target.setCurrentMood(u.getCurrentMood());
}
if (u.getKnowledgeGained() != null && !u.getKnowledgeGained().isEmpty()) {
List<String> knowledge = target.getKnowledge();
if (knowledge == null) {
target.setKnowledge(new ArrayList<>(u.getKnowledgeGained()));
} else {
knowledge.addAll(u.getKnowledgeGained());
}
}
if (u.getRelationshipChanges() != null && !u.getRelationshipChanges().isEmpty()) {
Map<String, String> rels = target.getRelationships();
if (rels == null) {
target.setRelationships(new HashMap<>(u.getRelationshipChanges()));
} else {
rels.putAll(u.getRelationshipChanges());
}
}
}
}
}
private static CharacterResponseItem toCharacterResponseItem(final CharacterResponse r) {
CharacterResponseItem item = new CharacterResponseItem(
r.getCharacterId(),
toResponseTypeEnum(r.getType()));
item.setContent(r.getContent());
item.setAction(r.getAction());
item.setMoodAfter(r.getMoodAfter());
return item;
}
private static CharacterResponseItem.TypeEnum toResponseTypeEnum(final ResponseType t) {
if (t == null) {
return CharacterResponseItem.TypeEnum.ACTION;
}
return switch (t) {
case SPEECH -> CharacterResponseItem.TypeEnum.SPEECH;
case ACTION -> CharacterResponseItem.TypeEnum.ACTION;
case REACTION -> CharacterResponseItem.TypeEnum.REACTION;
};
}
private static de.neitzel.roleplay.fascade.model.SituationUpdate toApiSituationUpdate(final SituationUpdate u) {
de.neitzel.roleplay.fascade.model.SituationUpdate api = new de.neitzel.roleplay.fascade.model.SituationUpdate();
api.setCurrentScene(u.getCurrentScene());
api.setNewTimelineEntries(u.getNewTimelineEntries() != null ? new ArrayList<>(u.getNewTimelineEntries()) : null);
api.setWorldStateFlags(u.getWorldStateFlags() != null ? new HashMap<>(u.getWorldStateFlags()) : null);
if (u.getOpenThreadsChanges() != null) {
de.neitzel.roleplay.fascade.model.OpenThreadsChanges apiOtc = new de.neitzel.roleplay.fascade.model.OpenThreadsChanges();
apiOtc.setAdded(u.getOpenThreadsChanges().getAdded() != null ? new ArrayList<>(u.getOpenThreadsChanges().getAdded()) : null);
apiOtc.setResolved(u.getOpenThreadsChanges().getResolved() != null ? new ArrayList<>(u.getOpenThreadsChanges().getResolved()) : null);
api.setOpenThreadsChanges(apiOtc);
}
return api;
}
private static de.neitzel.roleplay.fascade.model.CharacterUpdate toApiCharacterUpdate(final CharacterUpdate u) {
de.neitzel.roleplay.fascade.model.CharacterUpdate api = new de.neitzel.roleplay.fascade.model.CharacterUpdate(u.getCharacterId());
api.setCurrentMood(u.getCurrentMood());
api.setKnowledgeGained(u.getKnowledgeGained() != null ? new ArrayList<>(u.getKnowledgeGained()) : null);
api.setRelationshipChanges(u.getRelationshipChanges() != null ? new HashMap<>(u.getRelationshipChanges()) : null);
return api;
}
private static de.neitzel.roleplay.fascade.model.Suggestion toApiSuggestion(final Suggestion s) {
if (s == null) {
return null;
}
de.neitzel.roleplay.fascade.model.Suggestion api = new de.neitzel.roleplay.fascade.model.Suggestion(
s.getId(), toSuggestionTypeEnum(s.getType()), s.getTitle());
api.setDescription(s.getDescription());
api.setConsequences(s.getConsequences() != null ? new ArrayList<>(s.getConsequences()) : null);
api.setRiskLevel(s.getRiskLevel() != null ? toRiskLevelEnum(s.getRiskLevel()) : null);
return api;
}
private static de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum toSuggestionTypeEnum(final SuggestionType t) {
if (t == null) {
return de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
}
return switch (t) {
case PLAYER_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
case WORLD_EVENT -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.WORLD_EVENT;
case NPC_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.NPC_ACTION;
case TWIST -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.TWIST;
};
}
private static de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum toRiskLevelEnum(final RiskLevel r) {
if (r == null) {
return null;
}
return switch (r) {
case LOW -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.LOW;
case MEDIUM -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.MEDIUM;
case HIGH -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.HIGH;
};
}
}

View File

@ -0,0 +1,177 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import de.neitzel.roleplay.data.UserEntity;
import de.neitzel.roleplay.data.UserRepository;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Business service for application users. Used by admin-only user management endpoints.
*/
@ApplicationScoped
public class UserService {
private final UserRepository userRepository;
@Inject
public UserService(final UserRepository userRepository) {
this.userRepository = userRepository;
}
/**
* Returns all users as summaries (no password).
*
* @return list of user summaries
*/
public List<UserSummary> listUsers() {
return userRepository.listAll().stream()
.map(UserService::toSummary)
.collect(Collectors.toList());
}
/**
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
*
* @param request create request (username, password, role)
* @return the created user summary
* @throws IllegalArgumentException if username is blank, password is blank, role is invalid, or username already exists
*/
@Transactional
public UserSummary createUser(final CreateUserRequest request) {
String username = request.getUsername();
String password = request.getPassword();
String role = request.getRole();
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username is required");
}
if (password == null || password.isBlank()) {
throw new IllegalArgumentException("Password is required");
}
if (role == null || role.isBlank()) {
throw new IllegalArgumentException("Role is required");
}
if (!"admin".equals(role) && !"user".equals(role)) {
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
}
if (userRepository.findByUsername(username) != null) {
throw new IllegalArgumentException("Username already exists: " + username);
}
UserEntity entity = UserEntity.add(username, password, role);
return toSummary(entity);
}
/**
* Changes the password for the given user after verifying the current password.
*
* @param username login name of the user (must be the current user in practice)
* @param currentPassword plain-text current password (verified with bcrypt)
* @param newPassword plain-text new password (will be hashed)
* @throws IllegalArgumentException if user not found, current password wrong, or new password invalid
*/
@Transactional
public void changePassword(final String username, final String currentPassword, final String newPassword) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username is required");
}
if (currentPassword == null || currentPassword.isBlank()) {
throw new IllegalArgumentException("Current password is required");
}
if (newPassword == null || newPassword.isBlank()) {
throw new IllegalArgumentException("New password is required");
}
UserEntity entity = userRepository.findByUsername(username);
if (entity == null) {
throw new IllegalArgumentException("User not found: " + username);
}
if (!BcryptUtil.matches(currentPassword, entity.getPassword())) {
throw new IllegalArgumentException("Current password is incorrect");
}
entity.setPassword(BcryptUtil.bcryptHash(newPassword));
}
/**
* Updates an existing user by id. Username and role are required; password is optional.
*
* @param id user id
* @param request update request (username, optional password, role)
* @param currentUsername username of the caller (used to allow username uniqueness to exclude self)
* @return the updated user summary
* @throws IllegalArgumentException if validation fails or user not found
*/
@Transactional
public UserSummary updateUser(final UUID id, final UpdateUserRequest request, final String currentUsername) {
if (request == null) {
throw new IllegalArgumentException("Update request is required");
}
String username = request.getUsername();
String password = request.getPassword();
String role = request.getRole();
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username is required");
}
if (role == null || role.isBlank()) {
throw new IllegalArgumentException("Role is required");
}
if (!"admin".equals(role) && !"user".equals(role)) {
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
}
UserEntity entity = userRepository.findById(id);
if (entity == null) {
throw new IllegalArgumentException("User not found: " + id);
}
if (!entity.getUsername().equals(username)) {
UserEntity existing = userRepository.findByUsername(username);
if (existing != null) {
throw new IllegalArgumentException("Username already exists: " + username);
}
}
entity.setUsername(username);
entity.setRole(role);
if (password != null && !password.isBlank()) {
entity.setPassword(BcryptUtil.bcryptHash(password));
}
return toSummary(entity);
}
/**
* Deletes a user by id. Prevents deleting self and prevents deleting the last admin.
*
* @param id user id to delete
* @param currentUsername username of the caller (cannot delete self)
* @throws IllegalArgumentException if user not found, deleting self, or deleting last admin
*/
@Transactional
public void deleteUser(final UUID id, final String currentUsername) {
if (id == null) {
throw new IllegalArgumentException("User id is required");
}
UserEntity entity = userRepository.findById(id);
if (entity == null) {
throw new IllegalArgumentException("User not found: " + id);
}
if (entity.getUsername().equals(currentUsername)) {
throw new IllegalArgumentException("Cannot delete your own user account");
}
if ("admin".equals(entity.getRole())) {
long adminCount = userRepository.count("role = ?1", "admin");
if (adminCount <= 1) {
throw new IllegalArgumentException("Cannot delete the last admin user");
}
}
entity.delete();
}
private static UserSummary toSummary(final UserEntity entity) {
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
}
}

View File

@ -0,0 +1,55 @@
package de.neitzel.roleplay.common;
/**
* Request body for changing the current user's password.
*/
public final class ChangePasswordRequest {
private String currentPassword;
private String newPassword;
/**
* Default constructor for JSON.
*/
public ChangePasswordRequest() {
}
/**
* Creates a request with the given passwords.
*
* @param currentPassword current plain-text password (verified before change)
* @param newPassword new plain-text password (will be hashed)
*/
public ChangePasswordRequest(final String currentPassword, final String newPassword) {
this.currentPassword = currentPassword;
this.newPassword = newPassword;
}
/**
* Returns the current password (for verification).
*/
public String getCurrentPassword() {
return currentPassword;
}
/**
* Sets the current password.
*/
public void setCurrentPassword(final String currentPassword) {
this.currentPassword = currentPassword;
}
/**
* Returns the new password (will be bcrypt-hashed on the server).
*/
public String getNewPassword() {
return newPassword;
}
/**
* Sets the new password.
*/
public void setNewPassword(final String newPassword) {
this.newPassword = newPassword;
}
}

View File

@ -0,0 +1,54 @@
package de.neitzel.roleplay.common;
/**
* Request body for creating a new user (admin-only).
*/
public final class CreateUserRequest {
private String username;
private String password;
private String role;
/**
* Default constructor for JSON.
*/
public CreateUserRequest() {
}
/**
* Creates a request with the given fields.
*
* @param username login name
* @param password plain-text password (will be hashed)
* @param role role (e.g. admin, user)
*/
public CreateUserRequest(final String username, final String password, final String role) {
this.username = username;
this.password = password;
this.role = role;
}
public String getUsername() {
return username;
}
public void setUsername(final String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(final String password) {
this.password = password;
}
public String getRole() {
return role;
}
public void setRole(final String role) {
this.role = role;
}
}

View File

@ -1,10 +1,12 @@
package de.neitzel.roleplay.common; package de.neitzel.roleplay.common;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
/** /**
* Classifies an AI character's response within a turn. * Classifies an AI character's response within a turn.
*/ */
@JsonDeserialize(using = ResponseTypeDeserializer.class)
public enum ResponseType { public enum ResponseType {
/** Spoken dialogue. */ /** Spoken dialogue. */

View File

@ -0,0 +1,38 @@
package de.neitzel.roleplay.common;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
/**
* Deserializes {@link ResponseType} from JSON strings. Accepts canonical values
* ({@code speech}, {@code action}, {@code reaction}) and combined values from
* Ollama (e.g. {@code speech|action}), mapping the latter to a single type.
*/
public class ResponseTypeDeserializer extends JsonDeserializer<ResponseType> {
@Override
public ResponseType deserialize(final JsonParser p, final DeserializationContext ctxt)
throws IOException {
String value = p.getText();
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.strip().toLowerCase();
if ("speech|action".equals(normalized) || "action|speech".equals(normalized)) {
return ResponseType.SPEECH;
}
return switch (normalized) {
case "speech" -> ResponseType.SPEECH;
case "action" -> ResponseType.ACTION;
case "reaction" -> ResponseType.REACTION;
default -> {
ctxt.reportInputMismatch(ResponseType.class,
"Unknown ResponseType: '%s'; accepted: speech, action, reaction, speech|action", value);
throw new IllegalStateException("reportInputMismatch should have thrown");
}
};
}
}

View File

@ -0,0 +1,73 @@
package de.neitzel.roleplay.common;
/**
* Request body for updating an existing user (admin-only). Username and role are required;
* password is optional (if absent, existing password is kept).
*/
public final class UpdateUserRequest {
private String username;
private String password;
private String role;
/**
* Default constructor for JSON.
*/
public UpdateUserRequest() {
}
/**
* Creates a request with the given fields.
*
* @param username login name
* @param password optional new plain-text password (null or blank to keep current)
* @param role role (e.g. admin, user)
*/
public UpdateUserRequest(final String username, final String password, final String role) {
this.username = username;
this.password = password;
this.role = role;
}
/**
* Returns the login name.
*/
public String getUsername() {
return username;
}
/**
* Sets the login name.
*/
public void setUsername(final String username) {
this.username = username;
}
/**
* Returns the optional new password (null or blank means keep current).
*/
public String getPassword() {
return password;
}
/**
* Sets the optional new password.
*/
public void setPassword(final String password) {
this.password = password;
}
/**
* Returns the role (admin or user).
*/
public String getRole() {
return role;
}
/**
* Sets the role.
*/
public void setRole(final String role) {
this.role = role;
}
}

View File

@ -0,0 +1,56 @@
package de.neitzel.roleplay.common;
import java.util.UUID;
/**
* DTO for listing users (id, username, role). Does not expose password.
*/
public final class UserSummary {
private UUID id;
private String username;
private String role;
/**
* Default constructor for JSON.
*/
public UserSummary() {
}
/**
* Creates a summary for a user.
*
* @param id user id
* @param username login name
* @param role role (e.g. admin, user)
*/
public UserSummary(final UUID id, final String username, final String role) {
this.id = id;
this.username = username;
this.role = role;
}
public UUID getId() {
return id;
}
public void setId(final UUID id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(final String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(final String role) {
this.role = role;
}
}

View File

@ -0,0 +1,148 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.List;
import java.util.UUID;
/**
* JPA entity for a reusable character definition stored in {@code rp_character}.
*/
@Entity
@Table(name = "rp_character")
public class CharacterEntity extends PanacheEntityBase {
@Id
@Column(name = "id", length = 36, nullable = false, updatable = false)
private UUID id;
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "role", nullable = false, length = 255)
private String role;
@Column(name = "backstory", columnDefinition = "clob")
private String backstory;
@Convert(converter = JsonListStringConverter.class)
@Column(name = "personality_traits", columnDefinition = "clob")
private List<String> personalityTraits;
@Column(name = "speaking_style", length = 1000)
private String speakingStyle;
@Convert(converter = JsonListStringConverter.class)
@Column(name = "goals", columnDefinition = "clob")
private List<String> goals;
/**
* Default constructor for JPA.
*/
public CharacterEntity() {
}
/**
* Returns the unique identifier of this character.
*/
public UUID getId() {
return id;
}
/**
* Sets the unique identifier of this character.
*/
public void setId(final UUID id) {
this.id = id;
}
/**
* Returns the display name of this character.
*/
public String getName() {
return name;
}
/**
* Sets the display name of this character.
*/
public void setName(final String name) {
this.name = name;
}
/**
* Returns the narrative role of this character.
*/
public String getRole() {
return role;
}
/**
* Sets the narrative role of this character.
*/
public void setRole(final String role) {
this.role = role;
}
/**
* Returns the backstory text, or null if not set.
*/
public String getBackstory() {
return backstory;
}
/**
* Sets the backstory text.
*/
public void setBackstory(final String backstory) {
this.backstory = backstory;
}
/**
* Returns the list of personality trait strings; never null.
*/
public List<String> getPersonalityTraits() {
return personalityTraits;
}
/**
* Sets the list of personality trait strings.
*/
public void setPersonalityTraits(final List<String> personalityTraits) {
this.personalityTraits = personalityTraits;
}
/**
* Returns the speaking style description, or null if not set.
*/
public String getSpeakingStyle() {
return speakingStyle;
}
/**
* Sets the speaking style description.
*/
public void setSpeakingStyle(final String speakingStyle) {
this.speakingStyle = speakingStyle;
}
/**
* Returns the list of goal strings; never null.
*/
public List<String> getGoals() {
return goals;
}
/**
* Sets the list of goal strings.
*/
public void setGoals(final List<String> goals) {
this.goals = goals;
}
}

View File

@ -0,0 +1,33 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Panache repository for {@link CharacterEntity}. Provides list-all and find-by-id access.
*/
@ApplicationScoped
public class CharacterRepository implements PanacheRepository<CharacterEntity> {
/**
* Returns all characters ordered by name.
*/
@Override
public List<CharacterEntity> listAll() {
return list("ORDER BY name");
}
/**
* Finds a character by its UUID.
*
* @param id the character id
* @return the entity or null if not found
*/
public CharacterEntity findByIdOptional(final UUID id) {
return find("id", id).firstResult();
}
}

View File

@ -0,0 +1,45 @@
package de.neitzel.roleplay.data;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* JPA converter that persists a {@link List} of {@link String} as JSON in a CLOB column.
*/
@Converter
public class JsonListStringConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<String>> LIST_TYPE = new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(final List<String> attribute) {
if (attribute == null || attribute.isEmpty()) {
return null;
}
try {
return MAPPER.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to serialize string list to JSON", e);
}
}
@Override
public List<String> convertToEntityAttribute(final String dbData) {
if (dbData == null || dbData.isBlank()) {
return Collections.emptyList();
}
try {
return MAPPER.readValue(dbData, LIST_TYPE);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to deserialize JSON to string list", e);
}
}
}

View File

@ -0,0 +1,115 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.UUID;
/**
* Join entity linking a scenario to a character with role (user vs AI) and ordering.
*/
@Entity
@Table(name = "scenario_character")
public class ScenarioCharacterEntity extends PanacheEntityBase {
@Id
@Column(name = "id", length = 36, nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "scenario_id", nullable = false)
private ScenarioEntity scenario;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "character_id", nullable = false)
private CharacterEntity character;
@Column(name = "is_user_character", nullable = false)
private boolean userCharacter;
@Column(name = "position", nullable = false)
private int position;
/**
* Default constructor for JPA.
*/
public ScenarioCharacterEntity() {
}
/**
* Returns the unique identifier of this link.
*/
public UUID getId() {
return id;
}
/**
* Sets the unique identifier of this link.
*/
public void setId(final UUID id) {
this.id = id;
}
/**
* Returns the scenario this link belongs to.
*/
public ScenarioEntity getScenario() {
return scenario;
}
/**
* Sets the scenario this link belongs to.
*/
public void setScenario(final ScenarioEntity scenario) {
this.scenario = scenario;
}
/**
* Returns the character referenced by this link.
*/
public CharacterEntity getCharacter() {
return character;
}
/**
* Sets the character referenced by this link.
*/
public void setCharacter(final CharacterEntity character) {
this.character = character;
}
/**
* Returns whether this slot is the user-controlled character.
*/
public boolean isUserCharacter() {
return userCharacter;
}
/**
* Sets whether this slot is the user-controlled character.
*/
public void setUserCharacter(final boolean userCharacter) {
this.userCharacter = userCharacter;
}
/**
* Returns the position for ordering (e.g. AI characters).
*/
public int getPosition() {
return position;
}
/**
* Sets the position for ordering.
*/
public void setPosition(final int position) {
this.position = position;
}
}

View File

@ -0,0 +1,120 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* JPA entity for a scenario template stored in {@code scenario}.
*/
@Entity
@Table(name = "scenario")
public class ScenarioEntity extends PanacheEntityBase {
@Id
@Column(name = "id", length = 36, nullable = false, updatable = false)
private UUID id;
@Column(name = "name", nullable = false, length = 255)
private String name;
@Column(name = "setting", columnDefinition = "clob")
private String setting;
@Column(name = "initial_conflict", columnDefinition = "clob")
private String initialConflict;
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("userCharacter DESC, position ASC")
private List<ScenarioCharacterEntity> scenarioCharacters = new ArrayList<>();
/**
* Default constructor for JPA.
*/
public ScenarioEntity() {
}
/**
* Returns the unique identifier of this scenario.
*/
public UUID getId() {
return id;
}
/**
* Sets the unique identifier of this scenario.
*/
public void setId(final UUID id) {
this.id = id;
}
/**
* Returns the human-readable name of this scenario.
*/
public String getName() {
return name;
}
/**
* Sets the human-readable name of this scenario.
*/
public void setName(final String name) {
this.name = name;
}
/**
* Returns the setting description (place, time, atmosphere), or null if not set.
*/
public String getSetting() {
return setting;
}
/**
* Sets the setting description.
*/
public void setSetting(final String setting) {
this.setting = setting;
}
/**
* Returns the initial conflict text, or null if not set.
*/
public String getInitialConflict() {
return initialConflict;
}
/**
* Sets the initial conflict text.
*/
public void setInitialConflict(final String initialConflict) {
this.initialConflict = initialConflict;
}
/**
* Returns the list of scenariocharacter 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 scenariocharacter links. Null is treated as empty list.
*/
public void setScenarioCharacters(final List<ScenarioCharacterEntity> scenarioCharacters) {
this.scenarioCharacters = scenarioCharacters != null ? scenarioCharacters : new ArrayList<>();
}
}

View File

@ -0,0 +1,38 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Panache repository for {@link ScenarioEntity}. Provides list-all and find-by-id with characters loaded.
*/
@ApplicationScoped
public class ScenarioRepository implements PanacheRepository<ScenarioEntity> {
/**
* Finds a scenario by its UUID and loads its scenario-character links and linked characters.
*
* @param id the scenario id
* @return the entity or null if not found
*/
public ScenarioEntity findByIdWithCharacters(final UUID id) {
ScenarioEntity scenario = find("id", id).firstResult();
if (scenario != null) {
scenario.getScenarioCharacters().size();
scenario.getScenarioCharacters().forEach(sc -> sc.getCharacter().getName());
}
return scenario;
}
/**
* Returns all scenarios ordered by name.
*/
@Override
public List<ScenarioEntity> listAll() {
return list("ORDER BY name");
}
}

View File

@ -0,0 +1,121 @@
package de.neitzel.roleplay.data;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import io.quarkus.security.jpa.Password;
import io.quarkus.security.jpa.Roles;
import io.quarkus.security.jpa.UserDefinition;
import io.quarkus.security.jpa.Username;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
/**
* JPA entity for an application user stored in {@code rp_user}.
* Used by Quarkus Security JPA for form-based authentication (username/password, roles).
*/
@Entity
@Table(name = "rp_user")
@UserDefinition
public class UserEntity extends PanacheEntityBase {
@Id
@Column(name = "id", length = 36, nullable = false, updatable = false)
private UUID id;
@Username
@Column(name = "username", nullable = false, unique = true, length = 255)
private String username;
@Password
@Column(name = "password", nullable = false, length = 255)
private String password;
@Roles
@Column(name = "role", nullable = false, length = 50)
private String role;
/**
* Default constructor for JPA.
*/
public UserEntity() {
}
/**
* Returns the unique identifier of this user.
*/
public UUID getId() {
return id;
}
/**
* Sets the unique identifier of this user.
*/
public void setId(final UUID id) {
this.id = id;
}
/**
* Returns the login name of this user.
*/
public String getUsername() {
return username;
}
/**
* Sets the login name of this user.
*/
public void setUsername(final String username) {
this.username = username;
}
/**
* Returns the bcrypt-hashed password of this user.
*/
public String getPassword() {
return password;
}
/**
* Sets the password (should be bcrypt-hashed, e.g. via {@link BcryptUtil#bcryptHash(String)}).
*/
public void setPassword(final String password) {
this.password = password;
}
/**
* Returns the single role of this user (e.g. {@code admin} or {@code user}).
*/
public String getRole() {
return role;
}
/**
* Sets the role of this user.
*/
public void setRole(final String role) {
this.role = role;
}
/**
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
*
* @param username login name
* @param plainPassword plain-text password (will be hashed)
* @param role role name (e.g. admin, user)
* @return the persisted entity
*/
public static UserEntity add(final String username, final String plainPassword, final String role) {
final UserEntity user = new UserEntity();
user.setId(UUID.randomUUID());
user.setUsername(username);
user.setPassword(BcryptUtil.bcryptHash(plainPassword));
user.setRole(role);
user.persist();
return user;
}
}

View File

@ -0,0 +1,43 @@
package de.neitzel.roleplay.data;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/**
* Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
*/
@ApplicationScoped
public class UserRepository implements PanacheRepository<UserEntity> {
/**
* Returns all users ordered by username.
*/
@Override
public List<UserEntity> listAll() {
return list("ORDER BY username");
}
/**
* Finds a user by id.
*
* @param id the user uuid
* @return the entity or null if not found
*/
public UserEntity findById(final UUID id) {
return find("id", id).firstResult();
}
/**
* Finds a user by username.
*
* @param username the login name
* @return the entity or null if not found
*/
public UserEntity findByUsername(final String username) {
return find("username", username).firstResult();
}
}

View File

@ -0,0 +1,104 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.UserService;
import de.neitzel.roleplay.common.ChangePasswordRequest;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.Map;
import java.util.stream.Collectors;
/**
* REST resource for auth-related endpoints: current user info, change password, and logout.
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full paths are /api/v1/auth/me, /api/v1/auth/change-password, /api/v1/auth/logout.
*/
@Path("auth")
@Produces(MediaType.APPLICATION_JSON)
public class AuthResource {
private static final String DEFAULT_COOKIE_NAME = "quarkus-credential";
private final SecurityIdentity securityIdentity;
private final String sessionCookieName;
private final UserService userService;
@Inject
public AuthResource(
final SecurityIdentity securityIdentity,
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
final String sessionCookieName,
final UserService userService) {
this.securityIdentity = securityIdentity;
this.sessionCookieName = sessionCookieName;
this.userService = userService;
}
/**
* Returns the current user's username and roles. Used by the frontend to check login state.
* Returns 401 when not authenticated (handled by Quarkus permission policy).
*/
@GET
@Path("me")
public Map<String, Object> me() {
if (securityIdentity.isAnonymous()) {
throw new ForbiddenException("Not authenticated");
}
return Map.of(
"username", securityIdentity.getPrincipal().getName(),
"roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
}
/**
* Changes the current user's password. Requires current password verification.
* Returns 204 on success; 400 if current password wrong or validation fails.
*/
@POST
@Path("change-password")
@Consumes(MediaType.APPLICATION_JSON)
public Response changePassword(final ChangePasswordRequest request) {
if (securityIdentity.isAnonymous()) {
throw new ForbiddenException("Not authenticated");
}
if (request == null) {
throw new BadRequestException("Request body is required");
}
try {
String username = securityIdentity.getPrincipal().getName();
userService.changePassword(username, request.getCurrentPassword(), request.getNewPassword());
return Response.noContent().build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
/**
* Logs out the current user by clearing the session cookie.
* Returns 204 No Content. Requires authenticated user.
*/
@POST
@Path("logout")
@Produces(MediaType.TEXT_PLAIN)
public Response logout() {
if (securityIdentity.isAnonymous()) {
throw new ForbiddenException("Not authenticated");
}
NewCookie clearCookie = new NewCookie.Builder(sessionCookieName)
.value("")
.path("/")
.maxAge(0)
.build();
return Response.noContent().cookie(clearCookie).build();
}
}

View File

@ -0,0 +1,63 @@
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;
/**
* JAX-RS resource that implements the {@link CharactersApi} interface generated from the OpenAPI spec.
*/
@ApplicationScoped
public class CharactersResource implements CharactersApi {
private final CharacterService characterService;
@Inject
public CharactersResource(final CharacterService characterService) {
this.characterService = characterService;
}
@Override
public CharacterListResponse listCharacters() {
CharacterListResponse response = new CharacterListResponse();
response.setCharacters(characterService.listCharacters());
return response;
}
@Override
public CharacterDefinition getCharacter(final UUID characterId) {
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);
}
}
}

View File

@ -1,8 +1,8 @@
package de.neitzel.roleplay.fascade; package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.ModelService; import de.neitzel.roleplay.business.ModelService;
import de.neitzel.roleplay.fascade.api.ModelsApi;
import de.neitzel.roleplay.fascade.model.ModelListResponse; import de.neitzel.roleplay.fascade.model.ModelListResponse;
import de.neitzel.roleplay.generated.api.ModelsApi;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;

View File

@ -3,14 +3,17 @@ package de.neitzel.roleplay.fascade;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/** /**
* Quarkus declarative REST client for the Ollama HTTP API. * Quarkus declarative REST client for the Ollama HTTP API.
* Configuration (base URL, timeouts) is read from * Configuration (base URL, timeouts) is read from
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}. * {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
* All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}.
*/ */
@RegisterRestClient(configKey = "ollama-api") @RegisterRestClient(configKey = "ollama-api")
@RegisterProvider(OllamaClientLoggingFilter.class)
@Path("/api") @Path("/api")
public interface OllamaApi { public interface OllamaApi {

View File

@ -6,6 +6,8 @@ import de.neitzel.roleplay.common.StateUpdateResponse;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import org.eclipse.microprofile.rest.client.inject.RestClient; import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -18,6 +20,11 @@ import java.util.List;
@ApplicationScoped @ApplicationScoped
public class OllamaClient { public class OllamaClient {
private static final Logger LOG = LoggerFactory.getLogger(OllamaClient.class);
/** Maximum characters of narrative/JSON to log at TRACE (avoid huge logs). */
private static final int TRACE_CONTENT_LIMIT = 200;
/** Low-level REST client for the Ollama HTTP API. */ /** Low-level REST client for the Ollama HTTP API. */
private final OllamaApi ollamaApi; private final OllamaApi ollamaApi;
@ -43,10 +50,14 @@ public class OllamaClient {
* @return model metadata, or an empty list if none are installed * @return model metadata, or an empty list if none are installed
*/ */
public List<OllamaModelInfo> listModels() { public List<OllamaModelInfo> listModels() {
LOG.debug("Fetching Ollama models (GET /api/tags)");
OllamaTagsResponse response = ollamaApi.getTags(); OllamaTagsResponse response = ollamaApi.getTags();
if (response == null || response.getModels() == null) { if (response == null || response.getModels() == null) {
LOG.debug("Received null or empty models list from Ollama");
return Collections.emptyList(); return Collections.emptyList();
} }
int count = response.getModels().size();
LOG.debug("Received {} model(s) from Ollama", count);
return response.getModels(); return response.getModels();
} }
@ -62,6 +73,11 @@ public class OllamaClient {
public String generateNarrative(final String model, public String generateNarrative(final String model,
final String systemPrompt, final String systemPrompt,
final String userContent) { final String userContent) {
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
int userLen = userContent != null ? userContent.length() : 0;
LOG.debug("Calling Ollama for narrative: model={}, systemPromptLength={}, userContentLength={}",
model, systemLen, userLen);
OllamaChatRequest request = OllamaChatRequest.builder() OllamaChatRequest request = OllamaChatRequest.builder()
.model(model) .model(model)
.messages(List.of( .messages(List.of(
@ -77,7 +93,16 @@ public class OllamaClient {
.build(); .build();
OllamaChatResponse response = ollamaApi.chat(request); OllamaChatResponse response = ollamaApi.chat(request);
return response.getMessage().getContent(); String content = response.getMessage().getContent();
int len = content != null ? content.length() : 0;
LOG.debug("Received narrative from Ollama, length={}", len);
if (LOG.isTraceEnabled() && content != null && !content.isEmpty()) {
String snippet = content.length() <= TRACE_CONTENT_LIMIT
? content
: content.substring(0, TRACE_CONTENT_LIMIT) + "...";
LOG.trace("Narrative snippet: {}", snippet);
}
return content;
} }
/** /**
@ -94,6 +119,11 @@ public class OllamaClient {
public StateUpdateResponse generateStateUpdate(final String model, public StateUpdateResponse generateStateUpdate(final String model,
final String systemPrompt, final String systemPrompt,
final String userContent) { final String userContent) {
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
int userLen = userContent != null ? userContent.length() : 0;
LOG.debug("Calling Ollama for state update: model={}, systemPromptLength={}, userContentLength={}",
model, systemLen, userLen);
OllamaChatRequest request = OllamaChatRequest.builder() OllamaChatRequest request = OllamaChatRequest.builder()
.model(model) .model(model)
.format("json") .format("json")
@ -111,10 +141,21 @@ public class OllamaClient {
OllamaChatResponse response = ollamaApi.chat(request); OllamaChatResponse response = ollamaApi.chat(request);
String json = response.getMessage().getContent(); String json = response.getMessage().getContent();
int jsonLen = json != null ? json.length() : 0;
LOG.debug("Received state update from Ollama, JSON length={}", jsonLen);
if (LOG.isTraceEnabled() && json != null && !json.isEmpty()) {
String snippet = json.length() <= TRACE_CONTENT_LIMIT
? json
: json.substring(0, TRACE_CONTENT_LIMIT) + "...";
LOG.trace("State update JSON snippet: {}", snippet);
}
try { try {
return objectMapper.readValue(json, StateUpdateResponse.class); StateUpdateResponse result = objectMapper.readValue(json, StateUpdateResponse.class);
LOG.debug("Parsed state update successfully");
return result;
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage());
throw new OllamaParseException( throw new OllamaParseException(
"Failed to parse state update JSON from Ollama response", e); "Failed to parse state update JSON from Ollama response", e);
} }

View File

@ -0,0 +1,53 @@
package de.neitzel.roleplay.fascade;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.client.ClientResponseContext;
import jakarta.ws.rs.client.ClientResponseFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
/**
* JAX-RS client filter that logs every outgoing request and response for the
* Ollama REST client. Registered only on {@link OllamaApi} via
* {@link org.eclipse.microprofile.rest.client.annotation.RegisterProvider}.
* Logs method, URI and (for responses) status and content-type so that
* Ollama communication can be inspected without logging full bodies here
* (body logging is configured via Quarkus REST client logging).
*/
public class OllamaClientLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
private static final Logger LOG = LoggerFactory.getLogger(OllamaClientLoggingFilter.class);
/**
* Logs the outgoing request method and URI before the request is sent.
*
* @param requestContext the request context
*/
@Override
public void filter(final ClientRequestContext requestContext) throws IOException {
String method = requestContext.getMethod();
String uri = requestContext.getUri().toString();
LOG.debug("Ollama request: {} {}", method, uri);
}
/**
* Logs the response status and content-type after the response is received.
*
* @param requestContext the request context (unused)
* @param responseContext the response context
*/
@Override
public void filter(final ClientRequestContext requestContext,
final ClientResponseContext responseContext) throws IOException {
int status = responseContext.getStatus();
String statusInfo = responseContext.getStatusInfo() != null
? responseContext.getStatusInfo().getReasonPhrase()
: "";
String contentType = responseContext.getHeaderString("Content-Type");
LOG.debug("Ollama response: {} {} Content-Type: {}",
status, statusInfo, contentType != null ? contentType : "(none)");
}
}

View File

@ -0,0 +1,71 @@
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;
/**
* JAX-RS resource that implements the {@link ScenariosApi} interface generated from the OpenAPI spec.
*/
@ApplicationScoped
public class ScenariosResource implements ScenariosApi {
private final ScenarioService scenarioService;
@Inject
public ScenariosResource(final ScenarioService scenarioService) {
this.scenarioService = scenarioService;
}
@Override
public ScenarioListResponse listScenarios() {
ScenarioListResponse response = new ScenarioListResponse();
response.setScenarios(scenarioService.listScenarios());
return response;
}
@Override
public ScenarioSetup getScenario(final UUID scenarioId) {
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);
}
}
}

View File

@ -1,9 +1,10 @@
package de.neitzel.roleplay.fascade; package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.SessionService; import de.neitzel.roleplay.business.SessionService;
import de.neitzel.roleplay.fascade.api.SessionsApi;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.SessionResponse; import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import de.neitzel.roleplay.generated.api.SessionsApi;
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;
@ -54,5 +55,20 @@ public class SessionResource implements SessionsApi {
.orElseThrow(() -> new NotFoundException( .orElseThrow(() -> new NotFoundException(
"No session found with id: " + sessionId)); "No session found with id: " + sessionId));
} }
/**
* {@inheritDoc}
*
* <p>Delegates to {@link SessionService#updateSession(String, UpdateSessionRequest)}.
* Returns 404 if the session is not found.
*/
@Override
public SessionResponse updateSession(final String sessionId,
final UpdateSessionRequest updateSessionRequest) {
return sessionService.updateSession(sessionId,
updateSessionRequest != null ? updateSessionRequest : new UpdateSessionRequest())
.orElseThrow(() -> new NotFoundException(
"No session found with id: " + sessionId));
}
} }

View File

@ -0,0 +1,69 @@
package de.neitzel.roleplay.fascade;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.io.InputStream;
/**
* Catch-all JAX-RS resource that serves {@code index.html} for every path
* that is not handled by a more specific resource. This enables React Router's
* client-side routing: when the user navigates directly to
* {@code /session/abc123} the browser receives {@code index.html} and React
* Router takes over.
*
* <p>JAX-RS selects the most specific matching path first, so all
* {@code /api/v1/...} routes defined by the generated API interfaces always
* take priority over this catch-all.
*/
@Path("/")
public class SpaFallbackResource {
/**
* Returns {@code index.html} for the application root.
*
* @return 200 response with the React application shell
*/
@GET
@Produces(MediaType.TEXT_HTML)
public Response index() {
return serveIndex();
}
/**
* Loads {@code index.html} from {@code META-INF/resources} on the
* classpath and streams it as the response body.
*
* @return 200 with index.html, or 404 if the file is not on the classpath
*/
private Response serveIndex() {
InputStream stream = getClass()
.getClassLoader()
.getResourceAsStream("META-INF/resources/index.html");
if (stream == null) {
return Response.status(Response.Status.NOT_FOUND)
.entity("index.html not found run 'mvn process-resources' first.")
.build();
}
return Response.ok(stream, MediaType.TEXT_HTML).build();
}
/**
* Returns {@code index.html} for any sub-path not matched by a more
* specific JAX-RS resource.
*
* @param path the unmatched path segment (ignored)
* @return 200 response with the React application shell
*/
@GET
@Path("{path:(?!api/).*}")
@Produces(MediaType.TEXT_HTML)
public Response fallback(@PathParam("path") String path) {
return serveIndex();
}
}

View File

@ -1,9 +1,9 @@
package de.neitzel.roleplay.fascade; package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.SessionService; import de.neitzel.roleplay.business.SessionService;
import de.neitzel.roleplay.fascade.api.TurnsApi;
import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse; import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.generated.api.TurnsApi;
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;

View File

@ -0,0 +1,102 @@
package de.neitzel.roleplay.fascade;
import de.neitzel.roleplay.business.UserService;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
/**
* REST resource for admin-only user management: list, create, update, and delete users.
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
*/
@Path("admin/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("admin")
public class UsersResource {
private final UserService userService;
private final SecurityIdentity securityIdentity;
@Inject
public UsersResource(final UserService userService, final SecurityIdentity securityIdentity) {
this.userService = userService;
this.securityIdentity = securityIdentity;
}
/**
* Returns all users (id, username, role). Admin only.
*/
@GET
public List<UserSummary> listUsers() {
return userService.listUsers();
}
/**
* Creates a new user with the given username, password, and role. Admin only.
* Returns 201 with the created user summary.
*/
@POST
public Response createUser(final CreateUserRequest request) {
try {
UserSummary created = userService.createUser(request);
return Response.status(Response.Status.CREATED).entity(created).build();
} catch (final IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
/**
* Updates an existing user by id. Admin only. Returns 200 with updated user summary.
*/
@PUT
@Path("{userId}")
public UserSummary updateUser(@PathParam("userId") final UUID userId, final UpdateUserRequest request) {
try {
String currentUsername = securityIdentity.getPrincipal().getName();
return userService.updateUser(userId, request, currentUsername);
} catch (final IllegalArgumentException e) {
if (e.getMessage() != null && e.getMessage().contains("not found")) {
throw new NotFoundException(e.getMessage());
}
throw new BadRequestException(e.getMessage());
}
}
/**
* Deletes a user by id. Admin only. Cannot delete self or the last admin. Returns 204.
*/
@DELETE
@Path("{userId}")
public Response deleteUser(@PathParam("userId") final UUID userId) {
try {
String currentUsername = securityIdentity.getPrincipal().getName();
userService.deleteUser(userId, currentUsername);
return Response.noContent().build();
} catch (final IllegalArgumentException e) {
if (e.getMessage() != null && e.getMessage().contains("not found")) {
throw new NotFoundException(e.getMessage());
}
throw new BadRequestException(e.getMessage());
}
}
}

View File

@ -1,13 +1,54 @@
quarkus: quarkus:
application: application:
name: roleplay name: roleplay
http:
root-path: /
# Form-based auth: SPA mode (no redirects; 200/401). Session in encrypted cookie.
auth:
form:
enabled: true
login-page: ""
landing-page: ""
error-page: ""
http-only-cookie: false
session:
encryption-key: roleplay-session-secret-key-min-16chars
permission:
form-login:
paths: /j_security_check
policy: permit
api:
paths: /api/v1/*
policy: authenticated
datasource:
db-kind: h2
jdbc:
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
username: sa
password: ""
liquibase: liquibase:
change-log: db/migration/changelog.xml change-log: db/migration/changelog.xml
migrate-at-start: false migrate-at-start: true
rest-client: rest-client:
ollama-api: ollama-api:
url: http://debian:11434 url: http://debian:11434
connect-timeout: 5000 connect-timeout: 5000
read-timeout: 120000 read-timeout: 120000
http: logging:
root-path: /api/v1 scope: request-response
body-limit: 2048
rest:
path: /api/v1
log:
category:
"de.neitzel.roleplay.fascade.OllamaClient":
level: DEBUG
"org.jboss.resteasy.reactive.client.logging":
level: DEBUG
# Login debugging: form auth and security identity
"io.quarkus.vertx.http.runtime.security":
level: DEBUG
"io.quarkus.security":
level: DEBUG
"de.neitzel.roleplay":
level: INFO

View File

@ -4,5 +4,8 @@
xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd"> http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
<include file="db/migration/v001__scenarios_and_characters.xml"/>
<include file="db/migration/v002__users_and_roles.xml"/>
</databaseChangeLog> </databaseChangeLog>

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
<changeSet id="001-1-create-character-table" author="roleplay">
<createTable tableName="rp_character">
<column name="id" type="uuid">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="role" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="backstory" type="clob"/>
<column name="personality_traits" type="clob"/>
<column name="speaking_style" type="varchar(1000)"/>
<column name="goals" type="clob"/>
</createTable>
<comment>Stores reusable character definitions. personality_traits and goals stored as JSON text.</comment>
</changeSet>
<changeSet id="001-2-create-scenario-table" author="roleplay">
<createTable tableName="scenario">
<column name="id" type="uuid">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="setting" type="clob"/>
<column name="initial_conflict" type="clob"/>
</createTable>
<comment>Stores scenario templates (setting and initial conflict).</comment>
</changeSet>
<changeSet id="001-3-create-scenario-character-table" author="roleplay">
<createTable tableName="scenario_character">
<column name="id" type="uuid">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="scenario_id" type="uuid">
<constraints nullable="false" foreignKeyName="fk_scenario_character_scenario"
references="scenario(id)"/>
</column>
<column name="character_id" type="uuid">
<constraints nullable="false" foreignKeyName="fk_scenario_character_character"
references="rp_character(id)"/>
</column>
<column name="is_user_character" type="boolean">
<constraints nullable="false"/>
</column>
<column name="position" type="int">
<constraints nullable="false"/>
</column>
</createTable>
<comment>Links scenarios to characters; position orders AI characters.</comment>
</changeSet>
<changeSet id="001-4-seed-example-scenario" author="roleplay">
<insert tableName="rp_character">
<column name="id" value="11111111-1111-1111-1111-111111111101"/>
<column name="name" value="The Detective"/>
<column name="role" value="rookie detective"/>
<column name="backstory" value="Recently joined the force, eager to prove themselves."/>
<column name="personality_traits" value="[&quot;curious&quot;, &quot;determined&quot;]"/>
<column name="goals" value="[&quot;Solve the case&quot;, &quot;Earn respect&quot;]"/>
</insert>
<insert tableName="rp_character">
<column name="id" value="11111111-1111-1111-1111-111111111102"/>
<column name="name" value="Captain Morgan"/>
<column name="role" value="ship captain"/>
<column name="backstory" value="Veteran of many voyages; hides a secret."/>
<column name="personality_traits" value="[&quot;gruff&quot;, &quot;wary&quot;]"/>
<column name="goals" value="[&quot;Protect the crew&quot;, &quot;Keep the past buried&quot;]"/>
</insert>
<insert tableName="scenario">
<column name="id" value="22222222-2222-2222-2222-222222222201"/>
<column name="name" value="Harbour mystery"/>
<column name="setting" value="A fog-covered harbour at dawn, 1923"/>
<column name="initial_conflict" value="Strange noises from the cargo hold"/>
</insert>
<insert tableName="scenario_character">
<column name="id" value="33333333-3333-3333-3333-333333333301"/>
<column name="scenario_id" value="22222222-2222-2222-2222-222222222201"/>
<column name="character_id" value="11111111-1111-1111-1111-111111111101"/>
<column name="is_user_character" valueBoolean="true"/>
<column name="position" valueNumeric="0"/>
</insert>
<insert tableName="scenario_character">
<column name="id" value="33333333-3333-3333-3333-333333333302"/>
<column name="scenario_id" value="22222222-2222-2222-2222-222222222201"/>
<column name="character_id" value="11111111-1111-1111-1111-111111111102"/>
<column name="is_user_character" valueBoolean="false"/>
<column name="position" valueNumeric="1"/>
</insert>
</changeSet>
</databaseChangeLog>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
<changeSet id="002-1-create-rp-user-table" author="roleplay">
<createTable tableName="rp_user">
<column name="id" type="uuid">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="username" type="varchar(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="password" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="role" type="varchar(50)">
<constraints nullable="false"/>
</column>
</createTable>
<comment>Stores application users for form-based auth. Default login: admin / changeme.</comment>
</changeSet>
<changeSet id="002-2-seed-admin-user" author="roleplay">
<insert tableName="rp_user">
<column name="id" value="11111111-1111-1111-1111-111111111111"/>
<column name="username" value="admin"/>
<column name="password" value="$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO"/>
<column name="role" value="admin"/>
</insert>
<rollback>
<delete tableName="rp_user">
<where>id = '11111111-1111-1111-1111-111111111111'</where>
</delete>
</rollback>
</changeSet>
</databaseChangeLog>

View File

@ -19,6 +19,14 @@ tags:
description: Manage role-play sessions description: Manage role-play sessions
- name: turns - name: turns
description: Submit user actions within a session description: Submit user actions within a session
- name: scenarios
description: List and retrieve saved scenario templates
- name: characters
description: List and retrieve saved character templates
- name: auth
description: Authentication (current user, logout)
- name: users
description: User management (admin only)
paths: paths:
@ -43,6 +51,232 @@ paths:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/scenarios:
get:
operationId: listScenarios
summary: List saved scenarios
description: Returns all stored scenario templates for selection when starting a session.
tags:
- scenarios
responses:
"200":
description: List of scenario summaries.
content:
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:
operationId: getScenario
summary: Get a scenario by id
description: Returns the full scenario setup (setting, conflict, characters) for the given id.
tags:
- scenarios
parameters:
- $ref: '#/components/parameters/ScenarioId'
responses:
"200":
description: Scenario found and returned.
content:
application/json:
schema:
$ref: '#/components/schemas/ScenarioSetup'
"404":
description: Scenario not found.
content:
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:
operationId: listCharacters
summary: List saved characters
description: Returns all stored character templates for selection when building a scenario.
tags:
- characters
responses:
"200":
description: List of character definitions.
content:
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:
operationId: getCharacter
summary: Get a character by id
description: Returns the full character definition for the given id.
tags:
- characters
parameters:
- $ref: '#/components/parameters/CharacterId'
responses:
"200":
description: Character found and returned.
content:
application/json:
schema:
$ref: '#/components/schemas/CharacterDefinition'
"404":
description: Character not found.
content:
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: /sessions:
post: post:
operationId: createSession operationId: createSession
@ -101,6 +335,40 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
patch:
operationId: updateSession
summary: Update session state
description: |
Partially updates an existing session. Provide situation and/or characters
to replace the current values. Omitted fields are left unchanged.
tags:
- sessions
parameters:
- $ref: '#/components/parameters/SessionId'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateSessionRequest'
responses:
"200":
description: Session updated; full state returned.
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
"404":
description: Session not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"400":
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/sessions/{sessionId}/turns: /sessions/{sessionId}/turns:
post: post:
@ -147,6 +415,175 @@ paths:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/auth/me:
get:
operationId: getAuthMe
summary: Current user info
description: Returns the authenticated user's username and roles. Requires session cookie.
tags:
- auth
responses:
"200":
description: Current user.
content:
application/json:
schema:
$ref: '#/components/schemas/AuthMeResponse'
"401":
description: Not authenticated.
/auth/logout:
post:
operationId: logout
summary: Log out
description: Clears the session cookie. Requires authenticated user.
tags:
- auth
responses:
"204":
description: Logged out.
"401":
description: Not authenticated.
/auth/change-password:
post:
operationId: changePassword
summary: Change password
description: Changes the current user's password. Requires current password verification.
tags:
- auth
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChangePasswordRequest'
responses:
"204":
description: Password changed.
"400":
description: Current password wrong or validation failed.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: Not authenticated.
/admin/users:
get:
operationId: listUsers
summary: List users (admin only)
description: Returns all users (id, username, role). Admin role required.
tags:
- users
responses:
"200":
description: List of users.
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
"401":
description: Not authenticated.
"403":
description: Forbidden (admin only).
post:
operationId: createUser
summary: Create user (admin only)
description: Creates a new user with the given username, password, and role.
tags:
- users
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
"201":
description: User created.
content:
application/json:
schema:
$ref: '#/components/schemas/UserSummary'
"400":
description: Invalid request (e.g. username already exists).
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: Not authenticated.
"403":
description: Forbidden (admin only).
/admin/users/{userId}:
put:
operationId: updateUser
summary: Update user (admin only)
description: Updates an existing user by id. Username and role required; password optional (omit to keep current).
tags:
- users
parameters:
- $ref: '#/components/parameters/UserId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateUserRequest'
responses:
"200":
description: User updated.
content:
application/json:
schema:
$ref: '#/components/schemas/UserSummary'
"400":
description: Invalid request (e.g. username already exists).
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: Not authenticated.
"403":
description: Forbidden (admin only).
"404":
description: User not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
operationId: deleteUser
summary: Delete user (admin only)
description: Deletes a user by id. Cannot delete self or the last admin.
tags:
- users
parameters:
- $ref: '#/components/parameters/UserId'
responses:
"204":
description: User deleted.
"400":
description: Cannot delete self or last admin.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
"401":
description: Not authenticated.
"403":
description: Forbidden (admin only).
"404":
description: User not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components: components:
parameters: parameters:
@ -157,6 +594,30 @@ components:
description: Unique identifier of the role-play session. description: Unique identifier of the role-play session.
schema: schema:
type: string type: string
ScenarioId:
name: scenarioId
in: path
required: true
description: Unique identifier of the scenario (UUID).
schema:
type: string
format: uuid
CharacterId:
name: characterId
in: path
required: true
description: Unique identifier of the character (UUID).
schema:
type: string
format: uuid
UserId:
name: userId
in: path
required: true
description: Unique identifier of the user (UUID).
schema:
type: string
format: uuid
schemas: schemas:
@ -221,9 +682,130 @@ components:
default: standard default: standard
scenario: scenario:
$ref: '#/components/schemas/ScenarioSetup' $ref: '#/components/schemas/ScenarioSetup'
scenarioId:
type: string
format: uuid
description: If set, the backend loads this scenario from the database and uses it instead of an inline scenario.
required: required:
- model - model
ScenarioSummary:
type: object
description: Summary of a stored scenario for list views.
properties:
id:
type: string
format: uuid
description: Unique identifier of the scenario.
name:
type: string
description: Human-readable scenario name.
setting:
type: string
description: Place, time, and atmosphere (optional in list).
initialConflict:
type: string
description: The hook or starting conflict (optional in list).
required:
- id
- name
ScenarioListResponse:
type: object
description: Response containing all stored scenarios.
properties:
scenarios:
type: array
items:
$ref: '#/components/schemas/ScenarioSummary'
required:
- scenarios
CharacterListResponse:
type: object
description: Response containing all stored characters.
properties:
characters:
type: array
items:
$ref: '#/components/schemas/CharacterDefinition'
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: ScenarioSetup:
type: object type: object
description: | description: |
@ -245,6 +827,19 @@ components:
items: items:
$ref: '#/components/schemas/CharacterDefinition' $ref: '#/components/schemas/CharacterDefinition'
UpdateSessionRequest:
type: object
description: Request body for partially updating a session.
properties:
situation:
$ref: '#/components/schemas/SituationState'
description: Replace session situation when provided.
characters:
type: array
items:
$ref: '#/components/schemas/CharacterState'
description: Replace session character list when provided.
CharacterDefinition: CharacterDefinition:
type: object type: object
description: Definition of a character for session initialisation. description: Definition of a character for session initialisation.
@ -603,6 +1198,104 @@ components:
- type - type
- title - title
# ─── Auth and users ───────────────────────────────────────────────────────
AuthMeResponse:
type: object
description: Current authenticated user.
properties:
username:
type: string
description: Login name.
roles:
type: array
items:
type: string
description: Assigned roles (e.g. admin, user).
required:
- username
- roles
ChangePasswordRequest:
type: object
description: Request to change the current user's password.
properties:
currentPassword:
type: string
description: Current plain-text password (verified before change).
newPassword:
type: string
description: New plain-text password (hashed on server).
required:
- currentPassword
- newPassword
UserSummary:
type: object
description: User without password (for list and create response).
properties:
id:
type: string
format: uuid
description: User UUID.
username:
type: string
description: Login name.
role:
type: string
description: Role (admin or user).
required:
- id
- username
- role
UserListResponse:
type: array
items:
$ref: '#/components/schemas/UserSummary'
description: List of users (admin only).
CreateUserRequest:
type: object
description: Request to create a new user (admin only).
properties:
username:
type: string
description: Login name.
password:
type: string
description: Plain-text password (hashed on server).
role:
type: string
enum:
- admin
- user
description: Role to assign.
required:
- username
- password
- role
UpdateUserRequest:
type: object
description: Request to update an existing user (admin only). Password optional; omit to keep current.
properties:
username:
type: string
description: Login name.
password:
type: string
description: Optional new plain-text password (omit or blank to keep current).
role:
type: string
enum:
- admin
- user
description: Role to assign.
required:
- username
- role
# ─── Error ──────────────────────────────────────────────────────────────── # ─── Error ────────────────────────────────────────────────────────────────
ErrorResponse: ErrorResponse:

View File

@ -0,0 +1,14 @@
{{#required}}
{{^isReadOnly}}
@NotNull
{{/isReadOnly}}
{{/required}}
{{#isContainer}}
{{! Do not add @Valid on container; we use type-argument @Valid in the pojo (List<@Valid T>) to fix HV000271 }}
{{/isContainer}}
{{^isContainer}}
{{^isPrimitiveType}}
@Valid
{{/isPrimitiveType}}
{{/isContainer}}
{{>beanValidationCore}}

View File

@ -0,0 +1,14 @@
{{#required}}
{{^isReadOnly}}
@NotNull
{{/isReadOnly}}
{{/required}}
{{#isContainer}}
{{! Do not add @Valid on container; we use type-argument @Valid in the pojo (List<@Valid T>) to fix HV000271 }}
{{/isContainer}}
{{^isContainer}}
{{^isPrimitiveType}}
@Valid
{{/isPrimitiveType}}
{{/isContainer}}
{{>beanValidationCore}}

View File

@ -0,0 +1,146 @@
/**
* {{description}}{{^description}}{{classname}}{{/description}}
*/{{#description}}
@ApiModel(description = "{{{.}}}"){{/description}}
{{#jackson}}
@JsonPropertyOrder({
{{#vars}}
{{classname}}.JSON_PROPERTY_{{nameInSnakeCase}}{{^-last}},{{/-last}}
{{/vars}}
})
{{/jackson}}
{{>additionalModelTypeAnnotations}}{{>generatedAnnotation}}{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
{{#vars}}
{{#isEnum}}
{{^isContainer}}
{{>enumClass}}
{{/isContainer}}
{{#isContainer}}
{{#mostInnerItems}}
{{>enumClass}}
{{/mostInnerItems}}
{{/isContainer}}
{{/isEnum}}
{{#jackson}}
public static final String JSON_PROPERTY_{{nameInSnakeCase}} = "{{baseName}}";
@JsonProperty(JSON_PROPERTY_{{nameInSnakeCase}})
{{/jackson}}
{{#gson}}
public static final String SERIALIZED_NAME_{{nameInSnakeCase}} = "{{baseName}}";
@SerializedName(SERIALIZED_NAME_{{nameInSnakeCase}})
{{/gson}}
{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-field-extra-annotation}}
private {{>propertyType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/vars}}
{{#vars}}
public {{classname}} {{name}}({{>propertyType}} {{name}}) {
this.{{name}} = {{name}};
return this;
}
{{#isArray}}
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
}
this.{{name}}.add({{name}}Item);
return this;
}
{{/isArray}}
{{#isMap}}
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
}
this.{{name}}.put(key, {{name}}Item);
return this;
}
{{/isMap}}
/**
{{#description}}
* {{.}}
{{/description}}
{{^description}}
* Get {{name}}
{{/description}}
{{#minimum}}
* minimum: {{.}}
{{/minimum}}
{{#maximum}}
* maximum: {{.}}
{{/maximum}}
* @return {{name}}
**/
{{#vendorExtensions.x-extra-annotation}}
{{{vendorExtensions.x-extra-annotation}}}
{{/vendorExtensions.x-extra-annotation}}
{{#jackson}}
@JsonProperty(value = "{{baseName}}"{{#isReadOnly}}, access = JsonProperty.Access.READ_ONLY{{/isReadOnly}}{{#isWriteOnly}}, access = JsonProperty.Access.WRITE_ONLY{{/isWriteOnly}})
{{/jackson}}
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}")
{{#useBeanValidation}}{{#required}}{{^isReadOnly}}
@NotNull
{{/isReadOnly}}{{/required}}{{#isContainer}}{{! No @Valid on container; type has List<@Valid T> (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}
@Valid
{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}{{/useBeanValidation}}
public {{>propertyType}} {{getter}}() {
return {{name}};
}
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{>propertyType}} {{name}}) {
this.{{name}} = {{name}};
}
{{/vars}}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
{{classname}} {{classVarName}} = ({{classname}}) o;{{#hasVars}}
return {{#parent}}super.equals(o) && {{/parent}}{{#vars}}Objects.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
{{/-last}}{{#-last}};{{/-last}}{{/vars}}{{/hasVars}}{{^hasVars}}{{#parent}}return super.equals(o);{{/parent}}{{^parent}}return true;{{/parent}}{{/hasVars}}
}
@Override
public int hashCode() {
return {{^hasVars}}{{#parent}}super.hashCode(){{/parent}}{{^parent}}1{{/parent}}{{/hasVars}}{{#hasVars}}Objects.hash({{#vars}}{{#parent}}super.hashCode(), {{/parent}}{{name}}{{^-last}}, {{/-last}}{{/vars}}){{/hasVars}};
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class {{classname}} {\n");
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
{{/vars}}sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1 @@
{{! Type with @Valid on type argument for containers (HV000271 fix). }}{{#isContainer}}{{#isArray}}{{#items.isPrimitiveType}}{{{datatypeWithEnum}}}{{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isEnum}}{{{datatypeWithEnum}}}{{/items.isEnum}}{{^items.isEnum}}List<@Valid {{{items.datatypeWithEnum}}}>{{/items.isEnum}}{{/items.isPrimitiveType}}{{/isArray}}{{^isArray}}{{#isMap}}{{#items.isPrimitiveType}}{{{datatypeWithEnum}}}{{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isEnum}}{{{datatypeWithEnum}}}{{/items.isEnum}}{{^items.isEnum}}Map<String, @Valid {{{items.datatypeWithEnum}}}>{{/items.isEnum}}{{/items.isPrimitiveType}}{{/isMap}}{{^isMap}}{{{datatypeWithEnum}}}{{/isMap}}{{/isMap}}{{/isArray}}{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}

View File

@ -0,0 +1 @@
{{#required}}{{^isReadOnly}}@NotNull {{/isReadOnly}}{{/required}}{{#isContainer}}{{! Do not add @Valid on container; use type-argument @Valid (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}{{^isDate}}{{^isDateTime}}{{^isString}}{{^isFile}}{{^isEnumOrRef}}@Valid {{/isEnumOrRef}}{{/isFile}}{{/isString}}{{/isDateTime}}{{/isDate}}{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}

View File

@ -0,0 +1,289 @@
{{#useSwaggerAnnotations}}
import io.swagger.annotations.*;
{{/useSwaggerAnnotations}}
{{#useSwaggerV3Annotations}}
import io.swagger.v3.oas.annotations.media.Schema;
{{/useSwaggerV3Annotations}}
import java.util.Objects;
{{#jackson}}
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JsonTypeName;
{{#additionalProperties}}
import java.util.Map;
import java.util.HashMap;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonFormat;
{{/additionalProperties}}
{{/jackson}}
{{#openApiNullable}}
import org.openapitools.jackson.nullable.JsonNullable;
{{/openApiNullable}}
{{#withXml}}
import {{javaxPackage}}.xml.bind.annotation.XmlElement;
import {{javaxPackage}}.xml.bind.annotation.XmlRootElement;
import {{javaxPackage}}.xml.bind.annotation.XmlAccessType;
import {{javaxPackage}}.xml.bind.annotation.XmlAccessorType;
import {{javaxPackage}}.xml.bind.annotation.XmlType;
import {{javaxPackage}}.xml.bind.annotation.XmlEnum;
import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
{{/withXml}}
{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{#description}}/**
* {{.}}
**/{{/description}}
{{#useSwaggerAnnotations}}{{#description}}@ApiModel(description = "{{{.}}}"){{/description}}{{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
@Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useMicroProfileOpenAPIAnnotations}}
{{#jackson}}
@JsonTypeName("{{name}}")
{{#additionalProperties}}
@JsonFormat(shape=JsonFormat.Shape.OBJECT)
{{/additionalProperties}}
{{/jackson}}
{{>generatedAnnotation}}{{>additionalModelTypeAnnotations}}{{>xmlPojoAnnotation}}
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
{{#vars}}
{{#isEnum}}
{{^isContainer}}
{{>enumClass}}{{! prevent indent}}
{{/isContainer}}
{{#isContainer}}
{{#mostInnerItems}}
{{>enumClass}}{{! prevent indent}}
{{/mostInnerItems}}
{{/isContainer}}
{{/isEnum}}
{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-field-extra-annotation}}
{{#vendorExtensions.x-is-jackson-optional-nullable}}
{{#isContainer}}
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();
{{/isContainer}}
{{^isContainer}}
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>{{#defaultValue}}of({{{.}}}){{/defaultValue}}{{^defaultValue}}undefined(){{/defaultValue}};
{{/isContainer}}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
private {{#useBeanValidation}}{{>beanValidatedType}}{{/useBeanValidation}}{{^useBeanValidation}}{{{datatypeWithEnum}}}{{/useBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{/vars}}
{{#generateBuilders}}
{{^additionalProperties}}
protected {{classname}}({{classname}}Builder b) {
{{#parent}}
super(b);
{{/parent}}
{{#vars}}
this.{{name}} = b.{{name}};
{{/vars}}
}
{{/additionalProperties}}
{{/generateBuilders}}
public {{classname}}() {
}
{{#jackson}}
{{#generateJsonCreator}}
{{#hasRequired}}
@JsonCreator
public {{classname}}(
{{#requiredVars}}
@JsonProperty(required = {{required}}, value = "{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}}
{{/requiredVars}}
) {
{{#parent}}
super(
{{#parentRequiredVars}}
{{name}}{{^-last}},{{/-last}}
{{/parentRequiredVars}}
);
{{/parent}}
{{#vars}}
{{#required}}
this.{{name}} = {{name}};
{{/required}}
{{/vars}}
}
{{/hasRequired}}
{{/generateJsonCreator}}
{{/jackson}}
{{#vars}}
/**
{{#description}}
* {{.}}
{{/description}}
{{#minimum}}
* minimum: {{.}}
{{/minimum}}
{{#maximum}}
* maximum: {{.}}
{{/maximum}}
**/
public {{classname}} {{name}}({{{datatypeWithEnum}}} {{name}}) {
{{#vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = {{name}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
return this;
}
{{#withXml}}
@XmlElement(name="{{baseName}}"{{#required}}, required = {{required}}{{/required}})
{{/withXml}}
{{#vendorExtensions.x-extra-annotation}}{{{vendorExtensions.x-extra-annotation}}}{{/vendorExtensions.x-extra-annotation}}{{#useSwaggerAnnotations}}
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}"){{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
@Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useMicroProfileOpenAPIAnnotations}}
{{#jackson}}@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}"){{/jackson}}
{{#vendorExtensions.x-is-jackson-optional-nullable}}
public JsonNullable<{{{datatypeWithEnum}}}> {{getter}}() {
return {{name}};
}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}public {{>beanValidatedType}} {{getter}}() {
return {{name}};
}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{#jackson}}
@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}")
{{/jackson}}
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{{datatypeWithEnum}}} {{name}}) {
{{#vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = {{name}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
}
{{#isArray}}
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
}
this.{{name}}.add({{name}}Item);
return this;
}
public {{classname}} remove{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
if ({{name}}Item != null && this.{{name}} != null) {
this.{{name}}.remove({{name}}Item);
}
return this;
}
{{/isArray}}
{{#isMap}}
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
}
this.{{name}}.put(key, {{name}}Item);
return this;
}
public {{classname}} remove{{nameInPascalCase}}Item(String key) {
if (this.{{name}} != null) {
this.{{name}}.remove(key);
}
return this;
}
{{/isMap}}
{{/vars}}
{{>additional_properties}}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}{{#hasVars}}
{{classname}} {{classVarName}} = ({{classname}}) o;
return {{#vars}}{{#isByteArray}}Arrays{{/isByteArray}}{{^isByteArray}}Objects{{/isByteArray}}.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
{{/-last}}{{/vars}}{{#parent}} &&
super.equals(o){{/parent}};{{/hasVars}}{{^hasVars}}
return {{#parent}}super.equals(o){{/parent}}{{^parent}}true{{/parent}};{{/hasVars}}
}
@Override
public int hashCode() {
return Objects.hash({{#vars}}{{^isByteArray}}{{name}}{{/isByteArray}}{{#isByteArray}}Arrays.hashCode({{name}}){{/isByteArray}}{{^-last}}, {{/-last}}{{/vars}}{{#parent}}{{#hasVars}}, {{/hasVars}}super.hashCode(){{/parent}});
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class {{classname}} {\n");
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
{{/vars}}sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
{{#generateBuilders}}{{^additionalProperties}}
public static {{classname}}Builder builder() {
return new {{classname}}BuilderImpl();
}
private static final class {{classname}}BuilderImpl extends {{classname}}Builder<{{classname}}, {{classname}}BuilderImpl> {
@Override
protected {{classname}}BuilderImpl self() {
return this;
}
@Override
public {{classname}} build() {
return new {{classname}}(this);
}
}
public static abstract class {{classname}}Builder > {{#parent}}extends {{{.}}}Builder {{/parent}} {
{{#vars}}
private {{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/vars}}
{{^parent}}
protected abstract B self();
public abstract C build();
{{/parent}}
{{#vars}}
public B {{name}}({{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}) {
this.{{name}} = {{name}};
return self();
}
{{/vars}}
}{{/additionalProperties}}{{/generateBuilders}}
}

View File

@ -0,0 +1,2 @@
{{#additionalEnumTypeAnnotations}}{{{.}}}
{{/additionalEnumTypeAnnotations}}

View File

@ -0,0 +1,2 @@
{{#additionalModelTypeAnnotations}}{{{.}}}
{{/additionalModelTypeAnnotations}}

View File

@ -0,0 +1,32 @@
{{#additionalProperties}}
/**
* Set the additional (undeclared) property with the specified name and value.
* Creates the property if it does not already exist, otherwise replaces it.
* @param key the name of the property
* @param value the value of the property
* @return self reference
*/
@JsonAnySetter
public {{classname}} putAdditionalProperty(String key, {{{datatypeWithEnum}}} value) {
this.put(key, value);
return this;
}
/**
* Return the additional (undeclared) properties.
* @return the additional (undeclared) properties
*/
@JsonAnyGetter
public Map getAdditionalProperties() {
return this;
}
/**
* Return the additional (undeclared) property with the specified name.
* @param key the name of the property
* @return the additional (undeclared) property with the specified name
*/
public {{{datatypeWithEnum}}} getAdditionalProperty(String key) {
return this.get(key);
}
{{/additionalProperties}}

View File

@ -0,0 +1 @@
{{#isArray}}{{baseType}}<{{#items}}{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}{{>beanValidatedType}}{{/items}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}

View File

@ -0,0 +1 @@
{{#required}}{{^isReadOnly}}@NotNull {{/isReadOnly}}{{/required}}{{#isContainer}}{{! No @Valid on container (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}{{^isDate}}{{^isDateTime}}{{^isString}}{{^isFile}}{{^isEnumOrRef}}@Valid {{/isEnumOrRef}}{{/isFile}}{{/isString}}{{/isDateTime}}{{/isDate}}{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}

View File

@ -0,0 +1,20 @@
{{#pattern}} @Pattern(regexp="{{{.}}}"){{/pattern}}{{!
minLength && maxLength set
}}{{#minLength}}{{#maxLength}} @Size(min={{minLength}},max={{maxLength}}){{/maxLength}}{{/minLength}}{{!
minLength set, maxLength not
}}{{#minLength}}{{^maxLength}} @Size(min={{minLength}}){{/maxLength}}{{/minLength}}{{!
minLength not set, maxLength set
}}{{^minLength}}{{#maxLength}} @Size(max={{.}}){{/maxLength}}{{/minLength}}{{!
@Size: minItems && maxItems set
}}{{#minItems}}{{#maxItems}} @Size(min={{minItems}},max={{maxItems}}){{/maxItems}}{{/minItems}}{{!
@Size: minItems set, maxItems not
}}{{#minItems}}{{^maxItems}} @Size(min={{minItems}}){{/maxItems}}{{/minItems}}{{!
@Size: minItems not set && maxItems set
}}{{^minItems}}{{#maxItems}} @Size(max={{.}}){{/maxItems}}{{/minItems}}{{!
check for integer or long / all others=decimal type with @Decimal*
isInteger set
}}{{#isInteger}}{{#minimum}} @Min({{.}}){{/minimum}}{{#maximum}} @Max({{.}}){{/maximum}}{{/isInteger}}{{!
isLong set
}}{{#isLong}}{{#minimum}} @Min({{.}}L){{/minimum}}{{#maximum}} @Max({{.}}L){{/maximum}}{{/isLong}}{{!
Not Integer, not Long => we have a decimal value!
}}{{^isInteger}}{{^isLong}}{{#minimum}} @DecimalMin({{#exclusiveMinimum}}value={{/exclusiveMinimum}}"{{minimum}}"{{#exclusiveMinimum}},inclusive=false{{/exclusiveMinimum}}){{/minimum}}{{#maximum}} @DecimalMax({{#exclusiveMaximum}}value={{/exclusiveMaximum}}"{{maximum}}"{{#exclusiveMaximum}},inclusive=false{{/exclusiveMaximum}}){{/maximum}}{{/isLong}}{{/isInteger}}

View File

@ -0,0 +1,56 @@
{{#withXml}}
@XmlType(name="{{datatypeWithEnum}}")
@XmlEnum({{dataType}}.class)
{{/withXml}}
{{>additionalEnumTypeAnnotations}}public enum {{datatypeWithEnum}} {
{{#allowableValues}}
{{#enumVars}}{{#withXml}}@XmlEnumValue({{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}{{{value}}}{{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}){{/withXml}}{{name}}({{dataType}}.valueOf({{{value}}})){{^-last}}, {{/-last}}{{#-last}};{{/-last}}{{/enumVars}}
{{/allowableValues}}
private {{dataType}} value;
{{datatypeWithEnum}} ({{dataType}} v) {
value = v;
}
public {{dataType}} value() {
return value;
}
@Override
{{#jackson}}
@JsonValue
{{/jackson}}
public String toString() {
return String.valueOf(value);
}
/**
* Convert a String into {{dataType}}, as specified in the
* See JAX RS 2.0 Specification, section 3.2, p. 12
*/
public static {{datatypeWithEnum}} fromString(String s) {
for ({{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
// using Objects.toString() to be safe if value type non-object type
// because types like 'int' etc. will be auto-boxed
if (java.util.Objects.toString(b.value).equals(s)) {
return b;
}
}
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}throw new IllegalArgumentException("Unexpected string value '" + s + "'");{{/isNullable}}
}
{{#jackson}}
@JsonCreator
public static {{datatypeWithEnum}} fromValue({{dataType}} value) {
for ({{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
if (b.value.{{^isString}}equals{{/isString}}{{#isString}}{{#useEnumCaseInsensitive}}equalsIgnoreCase{{/useEnumCaseInsensitive}}{{^useEnumCaseInsensitive}}equals{{/useEnumCaseInsensitive}}{{/isString}}(value)) {
return b;
}
}
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}{{#enumUnknownDefaultCase}}{{#allowableValues}}{{#enumVars}}{{#-last}}return {{{name}}};{{/-last}}{{/enumVars}}{{/allowableValues}}{{/enumUnknownDefaultCase}}{{^enumUnknownDefaultCase}}throw new IllegalArgumentException("Unexpected value '" + value + "'");{{/enumUnknownDefaultCase}}{{/isNullable}}
}
{{/jackson}}
}

View File

@ -0,0 +1,64 @@
{{#jackson}}
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
{{/jackson}}
/**
* {{description}}{{^description}}Gets or Sets {{{name}}}{{/description}}
*/
{{>generatedAnnotation}}
{{>additionalEnumTypeAnnotations}}public enum {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} {
{{#gson}}
{{#allowableValues}}{{#enumVars}}
@SerializedName({{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}{{{value}}}{{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}})
{{{name}}}({{{value}}}){{^-last}},
{{/-last}}{{#-last}};{{/-last}}{{/enumVars}}{{/allowableValues}}
{{/gson}}
{{^gson}}
{{#allowableValues}}{{#enumVars}}
{{{name}}}({{{value}}}){{^-last}},
{{/-last}}{{#-last}};{{/-last}}{{/enumVars}}{{/allowableValues}}
{{/gson}}
private {{{dataType}}} value;
{{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}({{{dataType}}} value) {
this.value = value;
}
/**
* Convert a String into {{dataType}}, as specified in the
* See JAX RS 2.0 Specification, section 3.2, p. 12
*/
public static {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} fromString(String s) {
for ({{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
// using Objects.toString() to be safe if value type non-object type
// because types like 'int' etc. will be auto-boxed
if (java.util.Objects.toString(b.value).equals(s)) {
return b;
}
}
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}throw new IllegalArgumentException("Unexpected string value '" + s + "'");{{/isNullable}}
}
@Override
{{#jackson}}
@JsonValue
{{/jackson}}
public String toString() {
return String.valueOf(value);
}
{{#jackson}}
@JsonCreator
public static {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} fromValue({{{dataType}}} value) {
for ({{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
if (b.value.{{^isString}}equals{{/isString}}{{#isString}}{{#useEnumCaseInsensitive}}equalsIgnoreCase{{/useEnumCaseInsensitive}}{{^useEnumCaseInsensitive}}equals{{/useEnumCaseInsensitive}}{{/isString}}(value)) {
return b;
}
}
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}{{#enumUnknownDefaultCase}}{{#allowableValues}}{{#enumVars}}{{#-last}}return {{{name}}};{{/-last}}{{/enumVars}}{{/allowableValues}}{{/enumUnknownDefaultCase}}{{^enumUnknownDefaultCase}}throw new IllegalArgumentException("Unexpected value '" + value + "'");{{/enumUnknownDefaultCase}}{{/isNullable}}
}
{{/jackson}}
}

View File

@ -0,0 +1 @@
@{{javaxPackage}}.annotation.Generated(value = "{{generatorClass}}"{{^hideGenerationTimestamp}}, date = "{{generatedDate}}"{{/hideGenerationTimestamp}}, comments = "Generator version: {{generatorVersion}}")

View File

@ -0,0 +1,21 @@
package {{package}};
{{#imports}}import {{import}};
{{/imports}}
{{#serializableModel}}
import java.io.Serializable;
{{/serializableModel}}
{{#useBeanValidation}}
import {{javaxPackage}}.validation.constraints.*;
import {{javaxPackage}}.validation.Valid;
{{/useBeanValidation}}
{{#models}}
{{#model}}
{{#isEnum}}
{{>enumOuterClass}}
{{/isEnum}}
{{^isEnum}}{{>pojo}}{{/isEnum}}
{{/model}}
{{/models}}

View File

@ -0,0 +1,289 @@
{{#useSwaggerAnnotations}}
import io.swagger.annotations.*;
{{/useSwaggerAnnotations}}
{{#useSwaggerV3Annotations}}
import io.swagger.v3.oas.annotations.media.Schema;
{{/useSwaggerV3Annotations}}
import java.util.Objects;
{{#jackson}}
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.annotation.JsonTypeName;
{{#additionalProperties}}
import java.util.Map;
import java.util.HashMap;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonFormat;
{{/additionalProperties}}
{{/jackson}}
{{#openApiNullable}}
import org.openapitools.jackson.nullable.JsonNullable;
{{/openApiNullable}}
{{#withXml}}
import {{javaxPackage}}.xml.bind.annotation.XmlElement;
import {{javaxPackage}}.xml.bind.annotation.XmlRootElement;
import {{javaxPackage}}.xml.bind.annotation.XmlAccessType;
import {{javaxPackage}}.xml.bind.annotation.XmlAccessorType;
import {{javaxPackage}}.xml.bind.annotation.XmlType;
import {{javaxPackage}}.xml.bind.annotation.XmlEnum;
import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
{{/withXml}}
{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{#description}}/**
* {{.}}
**/{{/description}}
{{#useSwaggerAnnotations}}{{#description}}@ApiModel(description = "{{{.}}}"){{/description}}{{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
@Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useMicroProfileOpenAPIAnnotations}}
{{#jackson}}
@JsonTypeName("{{name}}")
{{#additionalProperties}}
@JsonFormat(shape=JsonFormat.Shape.OBJECT)
{{/additionalProperties}}
{{/jackson}}
{{>generatedAnnotation}}{{>additionalModelTypeAnnotations}}{{>xmlPojoAnnotation}}
{{#vendorExtensions.x-class-extra-annotation}}
{{{vendorExtensions.x-class-extra-annotation}}}
{{/vendorExtensions.x-class-extra-annotation}}
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
{{#vars}}
{{#isEnum}}
{{^isContainer}}
{{>enumClass}}{{! prevent indent}}
{{/isContainer}}
{{#isContainer}}
{{#mostInnerItems}}
{{>enumClass}}{{! prevent indent}}
{{/mostInnerItems}}
{{/isContainer}}
{{/isEnum}}
{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-field-extra-annotation}}
{{#vendorExtensions.x-is-jackson-optional-nullable}}
{{#isContainer}}
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();
{{/isContainer}}
{{^isContainer}}
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>{{#defaultValue}}of({{{.}}}){{/defaultValue}}{{^defaultValue}}undefined(){{/defaultValue}};
{{/isContainer}}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
private {{#useBeanValidation}}{{>beanValidatedType}}{{/useBeanValidation}}{{^useBeanValidation}}{{{datatypeWithEnum}}}{{/useBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{/vars}}
{{#generateBuilders}}
{{^additionalProperties}}
protected {{classname}}({{classname}}Builder b) {
{{#parent}}
super(b);
{{/parent}}
{{#vars}}
this.{{name}} = b.{{name}};
{{/vars}}
}
{{/additionalProperties}}
{{/generateBuilders}}
public {{classname}}() {
}
{{#jackson}}
{{#generateJsonCreator}}
{{#hasRequired}}
@JsonCreator
public {{classname}}(
{{#requiredVars}}
@JsonProperty(required = {{required}}, value = "{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}}
{{/requiredVars}}
) {
{{#parent}}
super(
{{#parentRequiredVars}}
{{name}}{{^-last}},{{/-last}}
{{/parentRequiredVars}}
);
{{/parent}}
{{#vars}}
{{#required}}
this.{{name}} = {{name}};
{{/required}}
{{/vars}}
}
{{/hasRequired}}
{{/generateJsonCreator}}
{{/jackson}}
{{#vars}}
/**
{{#description}}
* {{.}}
{{/description}}
{{#minimum}}
* minimum: {{.}}
{{/minimum}}
{{#maximum}}
* maximum: {{.}}
{{/maximum}}
**/
public {{classname}} {{name}}({{{datatypeWithEnum}}} {{name}}) {
{{#vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = {{name}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
return this;
}
{{#withXml}}
@XmlElement(name="{{baseName}}"{{#required}}, required = {{required}}{{/required}})
{{/withXml}}
{{#vendorExtensions.x-extra-annotation}}{{{vendorExtensions.x-extra-annotation}}}{{/vendorExtensions.x-extra-annotation}}{{#useSwaggerAnnotations}}
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}"){{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
@Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useMicroProfileOpenAPIAnnotations}}
{{#jackson}}@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}"){{/jackson}}
{{#vendorExtensions.x-is-jackson-optional-nullable}}
public JsonNullable<{{{datatypeWithEnum}}}> {{getter}}() {
return {{name}};
}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}public {{>beanValidatedType}} {{getter}}() {
return {{name}};
}
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{#jackson}}
@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}")
{{/jackson}}
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{{datatypeWithEnum}}} {{name}}) {
{{#vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
{{/vendorExtensions.x-is-jackson-optional-nullable}}
{{^vendorExtensions.x-is-jackson-optional-nullable}}
this.{{name}} = {{name}};
{{/vendorExtensions.x-is-jackson-optional-nullable}}
}
{{#isArray}}
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
}
this.{{name}}.add({{name}}Item);
return this;
}
public {{classname}} remove{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
if ({{name}}Item != null && this.{{name}} != null) {
this.{{name}}.remove({{name}}Item);
}
return this;
}
{{/isArray}}
{{#isMap}}
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
if (this.{{name}} == null) {
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
}
this.{{name}}.put(key, {{name}}Item);
return this;
}
public {{classname}} remove{{nameInPascalCase}}Item(String key) {
if (this.{{name}} != null) {
this.{{name}}.remove(key);
}
return this;
}
{{/isMap}}
{{/vars}}
{{>additional_properties}}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}{{#hasVars}}
{{classname}} {{classVarName}} = ({{classname}}) o;
return {{#vars}}{{#isByteArray}}Arrays{{/isByteArray}}{{^isByteArray}}Objects{{/isByteArray}}.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
{{/-last}}{{/vars}}{{#parent}} &&
super.equals(o){{/parent}};{{/hasVars}}{{^hasVars}}
return {{#parent}}super.equals(o){{/parent}}{{^parent}}true{{/parent}};{{/hasVars}}
}
@Override
public int hashCode() {
return Objects.hash({{#vars}}{{^isByteArray}}{{name}}{{/isByteArray}}{{#isByteArray}}Arrays.hashCode({{name}}){{/isByteArray}}{{^-last}}, {{/-last}}{{/vars}}{{#parent}}{{#hasVars}}, {{/hasVars}}super.hashCode(){{/parent}});
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class {{classname}} {\n");
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
{{/vars}}sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
{{#generateBuilders}}{{^additionalProperties}}
public static {{classname}}Builder builder() {
return new {{classname}}BuilderImpl();
}
private static final class {{classname}}BuilderImpl extends {{classname}}Builder<{{classname}}, {{classname}}BuilderImpl> {
@Override
protected {{classname}}BuilderImpl self() {
return this;
}
@Override
public {{classname}} build() {
return new {{classname}}(this);
}
}
public static abstract class {{classname}}Builder > {{#parent}}extends {{{.}}}Builder {{/parent}} {
{{#vars}}
private {{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
{{/vars}}
{{^parent}}
protected abstract B self();
public abstract C build();
{{/parent}}
{{#vars}}
public B {{name}}({{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}) {
this.{{name}} = {{name}};
return self();
}
{{/vars}}
}{{/additionalProperties}}{{/generateBuilders}}
}

View File

@ -0,0 +1,8 @@
{{#jackson}}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "{{{discriminator.propertyBaseName}}}", visible = true)
@JsonSubTypes({
{{#discriminator.mappedModels}}
@JsonSubTypes.Type(value = {{modelName}}.class, name = "{{^vendorExtensions.x-discriminator-value}}{{mappingName}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}"),
{{/discriminator.mappedModels}}
})
{{/jackson}}

View File

@ -0,0 +1,8 @@
{{#withXml}}
@XmlAccessorType(XmlAccessType.FIELD)
{{#hasVars}} @XmlType(name = "{{classname}}", propOrder =
{ {{#vars}}"{{name}}"{{^-last}}, {{/-last}}{{/vars}}
}){{/hasVars}}
{{^hasVars}}@XmlType(name = "{{classname}}"){{/hasVars}}
{{^parent}}@XmlRootElement(name="{{classname}}"){{/parent}}
{{/withXml}}

12
src/main/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RolePlay</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2624
src/main/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "roleplay-web",
"private": true,
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@mui/icons-material": "^6.4.4",
"@mui/material": "^6.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.1.0"
}
}

View File

@ -0,0 +1,40 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import AppLayout from './components/AppLayout'
import ProtectedRoute from './components/ProtectedRoute'
import { AuthProvider } from './contexts/AuthContext'
import StartPage from './pages/StartPage'
import SessionPage from './pages/SessionPage'
import ScenariosPage from './pages/ScenariosPage'
import CharactersPage from './pages/CharactersPage'
import UsersPage from './pages/UsersPage'
import LoginPage from './pages/LoginPage'
/**
* Root application component. Sets up client-side routing with app shell:
* Login (public), then Home, Scenarios, Characters, Session (protected).
*/
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/" element={<StartPage />} />
<Route path="/scenarios" element={<ScenariosPage />} />
<Route path="/characters" element={<CharactersPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/session/:sessionId" element={<SessionPage />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
)
}

View File

@ -0,0 +1,19 @@
/**
* Central re-export of the generated OpenAPI client and its models.
*
* The actual sources under ./generated/ are produced by
* openapi-generator-maven-plugin (typescript-fetch generator) during
* `mvn generate-sources`. Run `mvn generate-sources` once before opening
* the project in an IDE to make these imports resolve.
*/
export * from './generated/index'
import { Configuration } from './generated/runtime'
/** Base URL used by the generated fetch client. Always points to /api/v1. */
export const API_BASE = '/api/v1'
/** Default configuration for API clients; sends session cookie (credentials: 'include'). */
export const apiConfiguration = new Configuration({
basePath: API_BASE,
credentials: 'include',
})

View File

@ -0,0 +1,156 @@
import {useState} from 'react'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
CircularProgress,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import DirectionsRunIcon from '@mui/icons-material/DirectionsRun'
import SendIcon from '@mui/icons-material/Send'
import type {RecommendationRequest, TurnRequest} from '../api/generated/index'
import {UserActionRequestTypeEnum,} from '../api/generated/index'
/***** Props for the ActionInput component. */
interface ActionInputProps {
/** Called when the user submits an action. */
onSubmit: (request: TurnRequest) => void
/** Whether the submit button should be disabled (e.g. waiting for response). */
disabled?: boolean
}
type ActionType = 'speech' | 'action'
/**
* Provides the user interface for composing and submitting a turn action.
* Includes a type toggle (speech / action), a free-text input, and an
* optional collapsible section for narrative recommendations.
*/
export default function ActionInput({onSubmit, disabled}: ActionInputProps) {
const [actionType, setActionType] = useState<ActionType>('speech')
const [content, setContent] = useState<string>('')
const [desiredTone, setDesiredTone] = useState<string>('')
const [preferredDirection, setPreferredDirection] = useState<string>('')
const handleSubmit = () => {
if (!content.trim()) return
const recommendation: RecommendationRequest | undefined =
desiredTone.trim() || preferredDirection.trim()
? {
desiredTone: desiredTone.trim() || undefined,
preferredDirection: preferredDirection.trim() || undefined,
}
: undefined
const typeEnum =
actionType === 'speech'
? UserActionRequestTypeEnum.speech
: UserActionRequestTypeEnum.action
const request: TurnRequest = {
userAction: {
type: typeEnum,
content: content.trim(),
},
recommendation,
}
onSubmit(request)
setContent('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSubmit()
}
}
return (
<Box display="flex" flexDirection="column" gap={2}>
<ToggleButtonGroup
value={actionType}
exclusive
onChange={(_, v: ActionType | null) => v && setActionType(v)}
size="small"
disabled={disabled}
>
<ToggleButton value="speech" aria-label="speech">
<RecordVoiceOverIcon sx={{mr: 0.5, fontSize: 18}}/>
Speech
</ToggleButton>
<ToggleButton value="action" aria-label="action">
<DirectionsRunIcon sx={{mr: 0.5, fontSize: 18}}/>
Action
</ToggleButton>
</ToggleButtonGroup>
<TextField
multiline
minRows={2}
maxRows={6}
fullWidth
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
actionType === 'speech'
? 'What does your character say?'
: 'What does your character do?'
}
disabled={disabled}
helperText="Ctrl+Enter to submit"
/>
<Accordion disableGutters elevation={0} sx={{border: '1px solid', borderColor: 'divider', borderRadius: 1}}>
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
<Typography variant="body2" color="text.secondary">
Narrative recommendation (optional)
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={2}>
<TextField
label="Desired tone"
value={desiredTone}
onChange={(e) => setDesiredTone(e.target.value)}
helperText='e.g. "tense", "humorous", "mysterious"'
disabled={disabled}
fullWidth
size="small"
/>
<TextField
label="Preferred direction"
value={preferredDirection}
onChange={(e) => setPreferredDirection(e.target.value)}
helperText="Free-text hint for the narrator"
disabled={disabled}
fullWidth
size="small"
multiline
maxRows={3}
/>
</Box>
</AccordionDetails>
</Accordion>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
endIcon={disabled ? <CircularProgress size={16} color="inherit"/> : <SendIcon/>}
onClick={handleSubmit}
disabled={!content.trim() || disabled}
>
{disabled ? 'Processing…' : 'Submit'}
</Button>
</Box>
</Box>
)
}

View File

@ -0,0 +1,227 @@
import { useState } from 'react'
import {
AppBar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
Menu,
MenuItem,
TextField,
Toolbar,
Typography,
} from '@mui/material'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { Link as RouterLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AuthApi } from '../api/generated/index'
import { apiConfiguration } from '../api'
import { useAuth } from '../contexts/AuthContext'
const authApi = new AuthApi(apiConfiguration)
/**
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
* When authenticated, shows a user menu (username click) with Change Password, Edit users (admin only), and Logout.
*/
export default function AppLayout() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null)
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changePasswordError, setChangePasswordError] = useState<string | null>(null)
const [changePasswordSubmitting, setChangePasswordSubmitting] = useState(false)
const navItems = [
{ to: '/', label: 'Home' },
{ to: '/scenarios', label: 'Scenarios' },
{ to: '/characters', label: 'Characters' },
]
const isAdmin = user?.roles?.includes('admin') ?? false
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchor(event.currentTarget)
}
const handleUserMenuClose = () => {
setMenuAnchor(null)
}
const handleChangePasswordClick = () => {
handleUserMenuClose()
setChangePasswordError(null)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setChangePasswordOpen(true)
}
const handleChangePasswordClose = () => {
setChangePasswordOpen(false)
setChangePasswordError(null)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
}
const handleChangePasswordSubmit = async () => {
setChangePasswordError(null)
if (newPassword !== confirmPassword) {
setChangePasswordError('New password and confirmation do not match')
return
}
if (newPassword.length < 1) {
setChangePasswordError('New password is required')
return
}
setChangePasswordSubmitting(true)
try {
await authApi.changePassword({
changePasswordRequest: {
currentPassword,
newPassword,
},
})
handleChangePasswordClose()
} catch (e: unknown) {
const msg =
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
? (e.body as { message: string }).message
: 'Failed to change password'
setChangePasswordError(msg)
} finally {
setChangePasswordSubmitting(false)
}
}
const handleEditUsersClick = () => {
handleUserMenuClose()
navigate('/admin/users')
}
const handleLogoutClick = () => {
handleUserMenuClose()
logout()
}
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, alignItems: 'center' }}>
{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>
))}
{user && (
<>
<Button
color="inherit"
size="small"
onClick={handleUserMenuOpen}
endIcon={<KeyboardArrowDownIcon />}
aria-label="User menu"
aria-controls={menuAnchor ? 'user-menu' : undefined}
aria-haspopup="true"
aria-expanded={menuAnchor ? 'true' : 'false'}
>
{user.username}
</Button>
<Menu
id="user-menu"
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleUserMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
MenuListProps={{ 'aria-labelledby': 'user-menu-button' }}
>
<MenuItem onClick={handleChangePasswordClick}>Change Password</MenuItem>
{isAdmin && <MenuItem onClick={handleEditUsersClick}>Edit users</MenuItem>}
<MenuItem onClick={handleLogoutClick}>Logout</MenuItem>
</Menu>
</>
)}
</Box>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
<Outlet />
</Box>
<Dialog open={changePasswordOpen} onClose={handleChangePasswordClose} maxWidth="xs" fullWidth>
<DialogTitle>Change Password</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
id="current-password"
label="Current password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
fullWidth
autoComplete="current-password"
/>
<TextField
id="new-password"
label="New password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
fullWidth
autoComplete="new-password"
/>
<TextField
id="confirm-password"
label="Confirm new password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
fullWidth
autoComplete="new-password"
/>
{changePasswordError && (
<Typography color="error" variant="body2">
{changePasswordError}
</Typography>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleChangePasswordClose}>Cancel</Button>
<Button onClick={handleChangePasswordSubmit} variant="contained" disabled={changePasswordSubmitting}>
{changePasswordSubmitting ? 'Changing…' : 'Change password'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@ -0,0 +1,35 @@
import {Divider, Paper, Typography} from '@mui/material'
/** Props for the NarrativeView component. */
interface NarrativeViewProps {
/** The narrative text to display. */
narrative: string
/** Current turn number, displayed as subtitle. */
turnNumber: number
}
/**
* Displays the AI-generated narrative text for the current turn inside a
* styled MUI Paper card with atmospheric typography.
*/
export default function NarrativeView({narrative, turnNumber}: NarrativeViewProps) {
return (
<Paper elevation={3} sx={{p: 3, borderRadius: 2}}>
<Typography variant="overline" color="text.secondary">
{turnNumber === 0 ? 'Opening Scene' : `Turn ${turnNumber}`}
</Typography>
<Divider sx={{my: 1}}/>
{narrative.split('\n').filter(Boolean).map((paragraph, i) => (
<Typography
key={i}
variant="body1"
paragraph
sx={{lineHeight: 1.8, fontStyle: 'italic', mb: 1.5}}
>
{paragraph}
</Typography>
))}
</Paper>
)
}

View File

@ -0,0 +1,41 @@
import { useEffect, type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Box, CircularProgress, Typography } from '@mui/material'
interface ProtectedRouteProps {
children: ReactNode
}
/**
* Wraps content that requires authentication. If not logged in, redirects to /login.
* Shows a loading spinner while auth state is being determined.
*/
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
const { user, loading } = useAuth()
const location = useLocation()
if (loading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '40vh',
gap: 2,
}}
>
<CircularProgress />
<Typography color="text.secondary">Checking authentication</Typography>
</Box>
)
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@ -0,0 +1,85 @@
import {Box, Chip, Paper, Typography} from '@mui/material'
import type {Suggestion} from '../api/generated/index'
/** Props for the SuggestionList component. */
interface SuggestionListProps {
/** Suggestions to display. */
suggestions: Suggestion[]
/** Called when the user selects a suggestion. */
onSelect: (suggestion: Suggestion) => void
/** Whether interaction is currently disabled (e.g. while submitting). */
disabled?: boolean
}
/** Colour map from risk level to MUI chip colour. */
const riskColour: Record<string, 'success' | 'warning' | 'error'> = {
low: 'success',
medium: 'warning',
high: 'error',
}
/** Icon map from suggestion type to emoji label. */
const typeIcon: Record<string, string> = {
player_action: '🗡️',
world_event: '🌍',
npc_action: '🧑',
twist: '🌀',
}
/**
* Displays a grid of suggestion cards. Each card can be clicked to select
* that suggestion as the user's next action (type = "choice").
*/
export default function SuggestionList({suggestions, onSelect, disabled}: SuggestionListProps) {
if (suggestions.length === 0) return null
return (
<Box>
<Typography variant="overline" color="text.secondary" display="block" mb={1}>
What could happen next?
</Typography>
<Box
display="grid"
gridTemplateColumns="repeat(auto-fill, minmax(220px, 1fr))"
gap={2}
>
{suggestions.map((s) => (
<Paper
key={s.id}
elevation={2}
sx={{
p: 2,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
border: '1px solid transparent',
transition: 'border-color 0.2s, transform 0.15s',
'&:hover': disabled ? {} : {
borderColor: 'primary.main',
transform: 'translateY(-2px)',
},
}}
onClick={() => !disabled && onSelect(s)}
>
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
<Typography variant="body2" fontWeight={700} sx={{flex: 1, pr: 1}}>
{typeIcon[s.type] ?? '•'} {s.title}
</Typography>
{s.riskLevel && (
<Chip
label={s.riskLevel}
color={riskColour[s.riskLevel] ?? 'default'}
size="small"
sx={{fontSize: '0.65rem', height: 20}}
/>
)}
</Box>
<Typography variant="caption" color="text.secondary">
{s.description}
</Typography>
</Paper>
))}
</Box>
</Box>
)
}

View File

@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import { useNavigate } from 'react-router-dom'
const API_BASE = '/api/v1'
export interface AuthUser {
username: string
roles: string[]
}
interface AuthContextValue {
user: AuthUser | null
loading: boolean
error: boolean
logout: () => Promise<void>
refresh: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const value = useContext(AuthContext)
if (!value) {
throw new Error('useAuth must be used within AuthProvider')
}
return value
}
interface AuthProviderProps {
children: ReactNode
}
/**
* Provides auth state (current user, loading, error) and logout.
* Fetches GET /api/v1/auth/me with credentials so the session cookie is sent.
*/
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const navigate = useNavigate()
const fetchMe = useCallback(async () => {
setLoading(true)
setError(false)
try {
const res = await fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
setUser({
username: data.username ?? '',
roles: Array.isArray(data.roles) ? data.roles : [],
})
} else {
setUser(null)
if (res.status === 401) {
setError(false)
} else {
setError(true)
}
}
} catch {
setUser(null)
setError(true)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchMe()
}, [fetchMe])
const logout = useCallback(async () => {
try {
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
} finally {
setUser(null)
navigate('/login', { replace: true })
}
}, [navigate])
const value: AuthContextValue = {
user,
loading,
error,
logout,
refresh: fetchMe,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

View File

@ -0,0 +1,36 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import {CssBaseline, ThemeProvider, createTheme} from '@mui/material'
import App from './App'
/** Dark-toned MUI theme that fits the atmospheric role-play genre. */
const theme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#b39ddb',
},
secondary: {
main: '#80cbc4',
},
background: {
default: '#1a1a2e',
paper: '#16213e',
},
},
typography: {
fontFamily: '"Georgia", "Palatino", serif',
h4: {fontWeight: 700},
h5: {fontWeight: 600},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline/>
<App/>
</ThemeProvider>
</StrictMode>,
)

View 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 } from '../api/generated/index'
import { apiConfiguration } from '../api'
const charactersApi = new CharactersApi(apiConfiguration)
/** 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>
</>
)
}

View File

@ -0,0 +1,106 @@
import { useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
Alert,
Box,
Button,
Container,
Paper,
TextField,
Typography,
} from '@mui/material'
import { useAuth } from '../contexts/AuthContext'
/**
* Login page. POSTs credentials to /j_security_check (form-based auth).
* On success refreshes auth state and redirects to home or the previously attempted URL.
*/
export default function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const { refresh } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const formData = new URLSearchParams()
formData.append('j_username', username)
formData.append('j_password', password)
const res = await fetch('/j_security_check', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString(),
credentials: 'include',
})
if (res.ok) {
await refresh()
navigate(from, { replace: true })
} else {
setError('Invalid credentials')
}
} catch {
setError('Login failed')
} finally {
setSubmitting(false)
}
}
return (
<Container maxWidth="sm">
<Box sx={{ pt: 4 }}>
<Paper elevation={1} sx={{ p: 3 }}>
<Typography variant="h5" component="h1" gutterBottom>
Sign in
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Default: admin / changeme
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
margin="normal"
required
autoComplete="username"
autoFocus
/>
<TextField
fullWidth
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
disabled={submitting}
sx={{ mt: 3, mb: 2 }}
>
{submitting ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</Paper>
</Box>
</Container>
)
}

View 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, ScenariosApi } from '../api/generated/index'
import { apiConfiguration } from '../api'
const scenariosApi = new ScenariosApi(apiConfiguration)
const charactersApi = new CharactersApi(apiConfiguration)
/**
* 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>
</>
)
}

View File

@ -0,0 +1,401 @@
import type {ChangeEvent} from 'react'
import {useEffect, useState} from 'react'
import {useNavigate, useParams} from 'react-router-dom'
import {
Alert,
AppBar,
Box,
Button,
Card,
CardContent,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
IconButton,
List,
ListItem,
ListItemText,
TextField,
Toolbar,
Tooltip,
Typography,
} from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import PersonIcon from '@mui/icons-material/Person'
import PlaceIcon from '@mui/icons-material/Place'
import type {
CharacterState,
SessionResponse,
SituationState,
Suggestion,
TurnRequest,
UpdateSessionRequest,
} from '../api/generated/index'
import { SessionsApi, TurnsApi, UserActionRequestTypeEnum } from '../api/generated/index'
import NarrativeView from '../components/NarrativeView'
import SuggestionList from '../components/SuggestionList'
import ActionInput from '../components/ActionInput'
import { apiConfiguration } from '../api'
const sessionsApi = new SessionsApi(apiConfiguration)
const turnsApi = new TurnsApi(apiConfiguration)
/**
* Active role-play session page. Loads the session state, displays the
* current narrative, shows suggestions, and accepts user input for the next
* turn.
*/
export default function SessionPage() {
const {sessionId} = useParams<{ sessionId: string }>()
const navigate = useNavigate()
const [session, setSession] = useState<SessionResponse | null>(null)
const [narrative, setNarrative] = useState<string>('')
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [turnNumber, setTurnNumber] = useState<number>(0)
const [loading, setLoading] = useState<boolean>(true)
const [submitting, setSubmitting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [sceneDialogOpen, setSceneDialogOpen] = useState<boolean>(false)
const [sceneDraft, setSceneDraft] = useState<SituationState>({})
const [charactersDialogOpen, setCharactersDialogOpen] = useState<boolean>(false)
const [charactersDraft, setCharactersDraft] = useState<CharacterState[]>([])
/** Load existing session on mount. */
useEffect(() => {
if (!sessionId) return
sessionsApi.getSession({sessionId})
.then((s) => {
setSession(s)
setNarrative(s.narrative ?? '')
setSuggestions(s.suggestions ?? [])
setTurnNumber(s.turnNumber)
})
.catch(() => setError('Session not found or server unavailable.'))
.finally(() => setLoading(false))
}, [sessionId])
/** Submit a user action turn. */
const handleTurnSubmit = async (request: TurnRequest) => {
if (!sessionId) return
setSubmitting(true)
setError(null)
try {
const turn = await turnsApi.submitTurn({sessionId, turnRequest: request})
setNarrative(turn.narrative)
setSuggestions(turn.suggestions ?? [])
setTurnNumber(turn.turnNumber)
} catch {
setError('Failed to submit turn. Please try again.')
} finally {
setSubmitting(false)
}
}
/** Handle a suggestion click submit it as a "choice" action. */
const handleSuggestionSelect = (suggestion: Suggestion) => {
const request: TurnRequest = {
userAction: {
type: UserActionRequestTypeEnum.choice,
content: suggestion.title,
selectedSuggestionId: suggestion.id,
},
}
void handleTurnSubmit(request)
}
/** Open scene edit dialog with current situation. */
const openSceneDialog = () => {
setSceneDraft({
setting: session?.situation?.setting ?? '',
currentScene: session?.situation?.currentScene ?? '',
})
setSceneDialogOpen(true)
}
/** Save scene and PATCH session. */
const saveScene = async () => {
if (!sessionId) return
setError(null)
try {
const request: UpdateSessionRequest = { situation: sceneDraft }
const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request })
setSession(updated)
setNarrative(updated.narrative ?? '')
setSuggestions(updated.suggestions ?? [])
setTurnNumber(updated.turnNumber)
setSceneDialogOpen(false)
} catch {
setError('Failed to update scene.')
}
}
/** Open characters edit dialog with current characters. */
const openCharactersDialog = () => {
setCharactersDraft(session?.characters ? [...session.characters] : [])
setCharactersDialogOpen(true)
}
/** Save characters and PATCH session. */
const saveCharacters = async () => {
if (!sessionId) return
setError(null)
try {
const request: UpdateSessionRequest = { characters: charactersDraft }
const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request })
setSession(updated)
setNarrative(updated.narrative ?? '')
setSuggestions(updated.suggestions ?? [])
setTurnNumber(updated.turnNumber)
setCharactersDialogOpen(false)
} catch {
setError('Failed to update characters.')
}
}
/** Add a new character to the draft list. */
const addCharacterDraft = () => {
setCharactersDraft((prev) => [...prev, { id: '', name: '', role: '', isUserCharacter: false }])
}
/** Update a character in the draft list. */
const updateCharacterDraft = (index: number, patch: Partial<CharacterState>) => {
setCharactersDraft((prev) => {
const next = [...prev]
next[index] = { ...next[index], ...patch }
return next
})
}
/** Remove a character from the draft list. */
const removeCharacterDraft = (index: number) => {
setCharactersDraft((prev) => prev.filter((_, i) => i !== index))
}
return (
<Box display="flex" flexDirection="column" minHeight="100vh">
<AppBar position="static" elevation={0} color="transparent"
sx={{borderBottom: '1px solid', borderColor: 'divider'}}>
<Toolbar>
<Tooltip title="Back to model selection">
<IconButton edge="start" onClick={() => navigate('/')} sx={{mr: 1}}>
<ArrowBackIcon/>
</IconButton>
</Tooltip>
<AutoStoriesIcon sx={{mr: 1, color: 'primary.main'}}/>
<Typography variant="h6" sx={{flex: 1}}>
RolePlay
</Typography>
{session && (
<Typography variant="caption" color="text.secondary">
{session.model} · {session.language}
</Typography>
)}
</Toolbar>
</AppBar>
<Container maxWidth="md" sx={{flex: 1, py: 4}}>
{error && (
<Alert severity="error" sx={{mb: 3}}>
{error}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress/>
</Box>
) : (
<Box display="flex" flexDirection="column" gap={3}>
{narrative && (
<NarrativeView narrative={narrative} turnNumber={turnNumber}/>
)}
{(session?.situation?.setting || session?.situation?.currentScene) && (
<Card variant="outlined">
<CardContent sx={{'&:last-child': {pb: 2}}}>
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box display="flex" alignItems="center" gap={1}>
<PlaceIcon color="action"/>
<Typography variant="subtitle2" color="text.secondary">Scene</Typography>
</Box>
<Button size="small" startIcon={<EditIcon/>} onClick={openSceneDialog}>
Edit scene
</Button>
</Box>
{session.situation.setting && (
<Typography variant="body2" sx={{mt: 1}}>
<strong>Setting:</strong> {session.situation.setting}
</Typography>
)}
{session.situation.currentScene && (
<Typography variant="body2" sx={{mt: 0.5}}>
<strong>Current:</strong> {session.situation.currentScene}
</Typography>
)}
</CardContent>
</Card>
)}
{session?.characters && session.characters.length > 0 && (
<Card variant="outlined">
<CardContent sx={{'&:last-child': {pb: 2}}}>
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box display="flex" alignItems="center" gap={1}>
<PersonIcon color="action"/>
<Typography variant="subtitle2" color="text.secondary">Characters</Typography>
</Box>
<Button size="small" startIcon={<EditIcon/>} onClick={openCharactersDialog}>
Edit characters
</Button>
</Box>
<List dense disablePadding sx={{mt: 1}}>
{session.characters.map((c, i) => (
<ListItem key={c.id ?? i} dense disablePadding>
<ListItemText
primary={c.name}
secondary={c.role ? `${c.role}${c.isUserCharacter ? ' (you)' : ''}` : c.isUserCharacter ? '(you)' : undefined}
/>
</ListItem>
))}
</List>
</CardContent>
</Card>
)}
{suggestions.length > 0 && (
<>
<Divider/>
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
disabled={submitting}
/>
</>
)}
<Divider/>
<ActionInput onSubmit={handleTurnSubmit} disabled={submitting}/>
</Box>
)}
</Container>
<Dialog open={sceneDialogOpen} onClose={() => setSceneDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit scene</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<Typography variant="body2" color="text.secondary">
Setting (place, time, atmosphere)
</Typography>
<TextField
label="Setting"
value={sceneDraft.setting ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, setting: e.target.value }))}
fullWidth
multiline
minRows={2}
/>
<Typography variant="body2" color="text.secondary">
Current scene (what is in focus)
</Typography>
<TextField
label="Current scene"
value={sceneDraft.currentScene ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, currentScene: e.target.value }))}
fullWidth
multiline
minRows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setSceneDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={saveScene}>Save</Button>
</DialogActions>
</Dialog>
<Dialog open={charactersDialogOpen} onClose={() => setCharactersDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit characters</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<List dense disablePadding>
{charactersDraft.map((c, i) => (
<ListItem
key={c.id ? `${c.id}-${i}` : i}
sx={{flexWrap: 'wrap', alignItems: 'flex-start', gap: 1}}
secondaryAction={
<IconButton size="small" onClick={() => removeCharacterDraft(i)} aria-label="Remove character">
<DeleteIcon/>
</IconButton>
}
>
<Box display="flex" flexDirection="column" gap={0.5} sx={{flex: 1, minWidth: 0}}>
<TextField
size="small"
label="ID"
value={c.id}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { id: e.target.value })}
fullWidth
/>
<TextField
size="small"
label="Name"
value={c.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { name: e.target.value })}
fullWidth
/>
<TextField
size="small"
label="Role"
value={c.role ?? ''}
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { role: e.target.value })}
fullWidth
/>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="caption">User character</Typography>
<Button
size="small"
variant={c.isUserCharacter ? 'contained' : 'outlined'}
onClick={() => updateCharacterDraft(i, { isUserCharacter: true })}
>
Yes
</Button>
<Button
size="small"
variant={!c.isUserCharacter ? 'contained' : 'outlined'}
onClick={() => updateCharacterDraft(i, { isUserCharacter: false })}
>
No
</Button>
</Box>
</Box>
</ListItem>
))}
</List>
<Button size="small" startIcon={<PersonIcon/>} onClick={addCharacterDraft}>
Add character
</Button>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setCharactersDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={saveCharacters}
disabled={charactersDraft.some((c) => !c.id?.trim() || !c.name?.trim())}
>
Save
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@ -0,0 +1,522 @@
import {useEffect, useRef, useState} from 'react'
import {useLocation, useNavigate} from 'react-router-dom'
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
IconButton,
InputLabel,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
Select,
SelectChangeEvent,
TextField,
Typography,
} from '@mui/material'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import type {
CharacterDefinition,
CreateSessionRequest,
ModelInfo,
ScenarioSetup,
ScenarioSummary,
} from '../api/generated/index'
import {
CreateSessionRequestSafetyLevelEnum,
ModelsApi,
ScenariosApi,
SessionsApi,
} from '../api/generated/index'
import { apiConfiguration } from '../api'
const modelsApi = new ModelsApi(apiConfiguration)
const sessionsApi = new SessionsApi(apiConfiguration)
const scenariosApi = new ScenariosApi(apiConfiguration)
/**
* Landing page where the user selects an Ollama model and optional settings
* before starting a new role-play session.
*/
export default function StartPage() {
const navigate = useNavigate()
const location = useLocation()
const deepLinkApplied = useRef(false)
const [models, setModels] = useState<ModelInfo[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
const [language, setLanguage] = useState<string>('en')
const [loading, setLoading] = useState<boolean>(true)
const [starting, setStarting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
/** Saved scenarios from API; selectedScenarioId is '' for custom or a UUID. */
const [scenarios, setScenarios] = useState<ScenarioSummary[]>([])
const [selectedScenarioId, setSelectedScenarioId] = useState<string>('')
/** Optional scenario (collapsible). */
const [scenarioExpanded, setScenarioExpanded] = useState<boolean>(false)
const [setting, setSetting] = useState<string>('')
const [initialConflict, setInitialConflict] = useState<string>('')
const [userCharacter, setUserCharacter] = useState<CharacterDefinition | null>(null)
const [aiCharacters, setAiCharacters] = useState<CharacterDefinition[]>([])
/** Dialog for add/edit character: 'user' | 'ai', and index (-1 = add). */
const [characterDialog, setCharacterDialog] = useState<{
mode: 'user' | 'ai'
index: number
draft: CharacterDefinition
} | null>(null)
/** Load available models and saved scenarios on mount. */
useEffect(() => {
Promise.all([
modelsApi.listModels(),
scenariosApi.listScenarios().catch(() => ({scenarios: []})),
])
.then(([modelsResp, scenariosResp]) => {
setModels(modelsResp.models ?? [])
if (modelsResp.models && modelsResp.models.length > 0) {
setSelectedModel(modelsResp.models[0].name)
}
setScenarios(scenariosResp.scenarios ?? [])
})
.catch(() => setError('Could not load models. Is the Quarkus server running?'))
.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)
}
/** When user selects a saved scenario, load its full setup and prefill the form. */
const handleScenarioSelect = async (event: SelectChangeEvent<string>) => {
const id = event.target.value
setSelectedScenarioId(id)
if (!id) return
try {
const setup = await scenariosApi.getScenario({scenarioId: id})
setSetting(setup.setting ?? '')
setInitialConflict(setup.initialConflict ?? '')
setUserCharacter(setup.userCharacter ?? null)
setAiCharacters(setup.aiCharacters ?? [])
} catch {
setError('Could not load scenario.')
}
}
/** Build scenario from form state if any field is filled. */
const buildScenario = (): ScenarioSetup | undefined => {
const hasSetting = setting.trim() !== ''
const hasConflict = initialConflict.trim() !== ''
const hasUser = userCharacter && userCharacter.id.trim() && userCharacter.name.trim() && userCharacter.role.trim()
const hasAi = aiCharacters.length > 0 && aiCharacters.every(
(c) => c.id.trim() !== '' && c.name.trim() !== '' && c.role.trim() !== ''
)
if (!hasSetting && !hasConflict && !hasUser && !hasAi) return undefined
return {
setting: hasSetting ? setting.trim() : undefined,
initialConflict: hasConflict ? initialConflict.trim() : undefined,
userCharacter: hasUser ? userCharacter : undefined,
aiCharacters: hasAi ? aiCharacters : undefined,
}
}
/** Open dialog to add or edit user character. */
const openUserCharacterDialog = () => {
setCharacterDialog({
mode: 'user',
index: -1,
draft: userCharacter ?? { id: 'player', name: '', role: '' },
})
}
/** Open dialog to add or edit an AI character. */
const openAiCharacterDialog = (index: number) => {
const list = index >= 0 ? aiCharacters[index] : { id: '', name: '', role: '' }
setCharacterDialog({
mode: 'ai',
index,
draft: { ...list, personalityTraits: list.personalityTraits ?? [], goals: list.goals ?? [] },
})
}
/** Save character from dialog and close. */
const saveCharacterDialog = () => {
if (!characterDialog) return
const { draft } = characterDialog
if (!draft.id?.trim() || !draft.name?.trim() || !draft.role?.trim()) return
if (characterDialog.mode === 'user') {
setUserCharacter({ ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() })
} else {
const next = [...aiCharacters]
const char = { ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() }
const idx = characterDialog.index
if (idx !== undefined && idx >= 0) {
next[idx] = char
} else {
next.push(char)
}
setAiCharacters(next)
}
setCharacterDialog(null)
}
/** Remove an AI character. */
const removeAiCharacter = (index: number) => {
setAiCharacters((prev) => prev.filter((_, i) => i !== index))
}
/** Create a new session and navigate to the session page. */
const handleStart = async () => {
if (!selectedModel) return
setStarting(true)
setError(null)
try {
const request: CreateSessionRequest = {
model: selectedModel,
language,
safetyLevel: CreateSessionRequestSafetyLevelEnum.standard,
scenarioId: selectedScenarioId || undefined,
scenario: selectedScenarioId ? undefined : buildScenario(),
}
const session = await sessionsApi.createSession({createSessionRequest: request})
navigate(`/session/${session.sessionId}`)
} catch {
setError('Failed to create session. Please try again.')
} finally {
setStarting(false)
}
}
return (
<Container maxWidth="sm" sx={{mt: 8}}>
<Paper elevation={4} sx={{p: 5, borderRadius: 3}}>
<Box display="flex" alignItems="center" gap={2} mb={4}>
<AutoStoriesIcon sx={{fontSize: 48, color: 'primary.main'}}/>
<Typography variant="h4" component="h1">
RolePlay
</Typography>
</Box>
<Typography variant="body1" color="text.secondary" mb={4}>
Choose an AI model and start your story.
</Typography>
{error && (
<Alert severity="error" sx={{mb: 3}}>
{error}
</Alert>
)}
{loading ? (
<Box display="flex" justifyContent="center" py={4}>
<CircularProgress/>
</Box>
) : (
<Box display="flex" flexDirection="column" gap={3}>
<FormControl fullWidth disabled={starting}>
<InputLabel id="model-label">AI Model</InputLabel>
<Select
labelId="model-label"
value={selectedModel}
label="AI Model"
onChange={handleModelChange}
>
{models.map((m) => (
<MenuItem key={m.name} value={m.name}>
{m.displayName ?? m.name}
{m.family ? ` · ${m.family}` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Language"
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText="BCP-47 language tag, e.g. en, de, fr"
disabled={starting}
fullWidth
/>
<FormControl fullWidth disabled={starting} sx={{mb: 1}}>
<InputLabel id="scenario-label">Saved scenario</InputLabel>
<Select
labelId="scenario-label"
value={selectedScenarioId}
label="Saved scenario"
onChange={handleScenarioSelect}
>
<MenuItem value="">
<em>Custom (none)</em>
</MenuItem>
{scenarios.map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.name}
</MenuItem>
))}
</Select>
</FormControl>
<Accordion
expanded={scenarioExpanded}
onChange={() => setScenarioExpanded((b) => !b)}
disabled={starting}
sx={{width: '100%'}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
<Typography>Scenario (optional)</Typography>
</AccordionSummary>
<AccordionDetails>
<Box display="flex" flexDirection="column" gap={2}>
<TextField
label="Setting"
value={setting}
onChange={(e) => setSetting(e.target.value)}
placeholder="Place, time, and atmosphere"
fullWidth
size="small"
/>
<TextField
label="Initial conflict"
value={initialConflict}
onChange={(e) => setInitialConflict(e.target.value)}
placeholder="The hook or starting conflict"
fullWidth
size="small"
/>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Your character
</Typography>
{userCharacter ? (
<ListItem
dense
secondaryAction={
<IconButton edge="end" size="small" onClick={openUserCharacterDialog}>
<EditIcon/>
</IconButton>
}
>
<ListItemText
primary={userCharacter.name}
secondary={`${userCharacter.role} (id: ${userCharacter.id})`}
/>
</ListItem>
) : (
<Button size="small" startIcon={<PersonAddIcon/>} onClick={openUserCharacterDialog}>
Add your character
</Button>
)}
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
AI characters
</Typography>
<List dense disablePadding>
{aiCharacters.map((c, i) => (
<ListItem
key={c.id + i}
dense
secondaryAction={
<Box component="span">
<IconButton size="small" onClick={() => openAiCharacterDialog(i)}>
<EditIcon/>
</IconButton>
<IconButton size="small" onClick={() => removeAiCharacter(i)}>
<DeleteIcon/>
</IconButton>
</Box>
}
>
<ListItemText primary={c.name} secondary={c.role}/>
</ListItem>
))}
</List>
<Button size="small" startIcon={<PersonAddIcon/>} onClick={() => openAiCharacterDialog(-1)}>
Add AI character
</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
<Button
variant="contained"
size="large"
onClick={handleStart}
disabled={!selectedModel || starting || loading}
startIcon={starting ? <CircularProgress size={18} color="inherit"/> : <AutoStoriesIcon/>}
>
{starting ? 'Starting…' : 'Begin Story'}
</Button>
</Box>
)}
</Paper>
<Dialog open={characterDialog !== null} onClose={() => setCharacterDialog(null)} maxWidth="sm" fullWidth>
<DialogTitle>
{characterDialog?.mode === 'user' ? 'Your character' : (characterDialog?.index ?? -1) >= 0 ? 'Edit AI character' : 'Add AI character'}
</DialogTitle>
<DialogContent>
{characterDialog && (
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
<TextField
label="ID"
value={characterDialog.draft.id}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, id: e.target.value}})
}
required
fullWidth
size="small"
helperText="Unique identifier, e.g. captain_morgan"
/>
<TextField
label="Name"
value={characterDialog.draft.name}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, name: e.target.value}})
}
required
fullWidth
size="small"
/>
<TextField
label="Role"
value={characterDialog.draft.role}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, role: e.target.value}})
}
required
fullWidth
size="small"
/>
{characterDialog.mode === 'ai' && (
<>
<TextField
label="Backstory"
value={characterDialog.draft.backstory ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
}
multiline
rows={2}
fullWidth
size="small"
/>
<TextField
label="Speaking style"
value={characterDialog.draft.speakingStyle ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
}
fullWidth
size="small"
/>
<TextField
label="Personality traits"
value={Array.isArray(characterDialog.draft.personalityTraits) ? characterDialog.draft.personalityTraits.join(', ') : ''}
onChange={(e) =>
setCharacterDialog({
...characterDialog,
draft: {
...characterDialog.draft,
personalityTraits: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
},
})
}
fullWidth
size="small"
helperText="Comma-separated"
/>
<TextField
label="Goals"
value={Array.isArray(characterDialog.draft.goals) ? characterDialog.draft.goals.join(', ') : ''}
onChange={(e) =>
setCharacterDialog({
...characterDialog,
draft: {
...characterDialog.draft,
goals: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
},
})
}
fullWidth
size="small"
helperText="Comma-separated"
/>
</>
)}
{characterDialog.mode === 'user' && (
<>
<TextField
label="Backstory"
value={characterDialog.draft.backstory ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
}
multiline
rows={2}
fullWidth
size="small"
/>
<TextField
label="Speaking style"
value={characterDialog.draft.speakingStyle ?? ''}
onChange={(e) =>
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
}
fullWidth
size="small"
/>
</>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setCharacterDialog(null)}>Cancel</Button>
<Button variant="contained" onClick={saveCharacterDialog} disabled={!characterDialog?.draft.id?.trim() || !characterDialog?.draft.name?.trim() || !characterDialog?.draft.role?.trim()}>
Save
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

View File

@ -0,0 +1,280 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
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 { CreateUserRequest, UpdateUserRequest, UserSummary } from '../api/generated/index'
import { UsersApi } from '../api/generated/index'
import { apiConfiguration } from '../api'
import { useAuth } from '../contexts/AuthContext'
const usersApi = new UsersApi(apiConfiguration)
const ROLES = ['user', 'admin'] as const
/**
* Admin users management page. Lists users with Add/Edit/Delete. Admin only (backend enforces 403).
*/
export default function UsersPage() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<UserSummary[]>([])
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 [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formRole, setFormRole] = useState<string>('user')
const loadUsers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await usersApi.listUsers()
setUsers(list)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load users')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadUsers()
}, [loadUsers])
const openCreate = () => {
setEditingId(null)
setFormUsername('')
setFormPassword('')
setFormRole('user')
setDialogOpen(true)
}
const openEdit = (u: UserSummary) => {
setEditingId(u.id)
setFormUsername(u.username ?? '')
setFormPassword('')
setFormRole(u.role ?? 'user')
setDialogOpen(true)
}
const closeDialog = () => {
setDialogOpen(false)
setEditingId(null)
}
const handleSave = async () => {
if (!formUsername.trim() || !formRole) return
if (!editingId && !formPassword.trim()) {
setError('Password is required for new user')
return
}
setSaving(true)
setError(null)
try {
if (editingId) {
const body: UpdateUserRequest = {
username: formUsername.trim(),
role: formRole as UpdateUserRequest['role'],
}
if (formPassword.trim()) {
body.password = formPassword
}
await usersApi.updateUser({ userId: editingId, updateUserRequest: body })
} else {
const body: CreateUserRequest = {
username: formUsername.trim(),
password: formPassword,
role: formRole as CreateUserRequest['role'],
}
await usersApi.createUser({ createUserRequest: body })
}
closeDialog()
await loadUsers()
} catch (e: unknown) {
const msg =
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
? (e.body as { message: string }).message
: 'Failed to save user'
setError(msg)
} finally {
setSaving(false)
}
}
const handleDelete = async (id: string) => {
setError(null)
try {
await usersApi.deleteUser({ userId: id })
setDeleteConfirmId(null)
await loadUsers()
} catch (e: unknown) {
const msg =
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
? (e.body as { message: string }).message
: 'Failed to delete user'
setError(msg)
}
}
const isCurrentUser = (u: UserSummary) => currentUser?.username === u.username
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">Users</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
Add user
</Button>
</Box>
{error && (
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert severity="info">No users.</Alert>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Role</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.username}</TableCell>
<TableCell>{u.role}</TableCell>
<TableCell align="right">
<Button
size="small"
startIcon={<EditIcon />}
onClick={() => openEdit(u)}
sx={{ mr: 1 }}
>
Edit
</Button>
<Button
size="small"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setDeleteConfirmId(u.id)}
disabled={isCurrentUser(u)}
title={isCurrentUser(u) ? 'Cannot delete your own account' : undefined}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{editingId ? 'Edit user' : 'Add user'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
label="Username"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
fullWidth
required
autoComplete="username"
/>
<TextField
label={editingId ? 'New password (leave blank to keep current)' : 'Password'}
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
fullWidth
required={!editingId}
autoComplete={editingId ? 'new-password' : 'new-password'}
/>
<FormControl fullWidth>
<InputLabel>Role</InputLabel>
<Select
value={formRole}
label="Role"
onChange={(e) => setFormRole(e.target.value)}
>
{ROLES.map((r) => (
<MenuItem key={r} value={r}>
{r}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Cancel</Button>
<Button
onClick={handleSave}
variant="contained"
disabled={saving || !formUsername.trim() || (!editingId && !formPassword.trim())}
>
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteConfirmId)} onClose={() => setDeleteConfirmId(null)}>
<DialogTitle>Delete user?</DialogTitle>
<DialogContent>
<Typography>
This cannot be undone. You cannot delete your own account.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
>
Delete
</Button>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": [
"src"
]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": [
"ES2023",
"DOM"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/',
build: {
outDir: '../resources/META-INF/resources',
emptyOutDir: true,
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Form-based login; must reach Quarkus in dev
'/j_security_check': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RolePlay</title>
<script type="module" crossorigin src="/assets/index-PUayaP4F.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,36 @@
package de.neitzel.roleplay.business;
import io.quarkus.elytron.security.common.BcryptUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Generates BCrypt hash for the default admin password for use in Liquibase seed.
* Run: mvn test -Dtest=BcryptHashGenerator#generateChangemeHash
* Then paste the printed hash into v002__users_and_roles.xml seed changeset.
*/
final class BcryptHashGenerator {
/** BCrypt hash stored in v002__users_and_roles.xml for admin user (password: changeme). */
private static final String SEEDED_ADMIN_PASSWORD_HASH =
"$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO";
@Test
void generateChangemeHash() {
String hash = BcryptUtil.bcryptHash("changeme");
System.out.println("BCrypt hash for 'changeme': " + hash);
assert hash != null && hash.startsWith("$2");
}
/**
* Verifies the seeded admin password hash in the migration matches "changeme".
* If this fails, run generateChangemeHash() and update v002__users_and_roles.xml with the new hash.
*/
@Test
void seededAdminHashMatchesChangeme() {
boolean matches = BcryptUtil.matches("changeme", SEEDED_ADMIN_PASSWORD_HASH);
assertTrue(matches, "Seeded admin password hash in v002__users_and_roles.xml must match 'changeme'. "
+ "Run generateChangemeHash() and update the migration with the printed hash.");
}
}

View File

@ -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);
}
}

View File

@ -1,36 +1,70 @@
package de.neitzel.roleplay.business; package de.neitzel.roleplay.business;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.neitzel.roleplay.common.StateUpdateResponse;
import de.neitzel.roleplay.fascade.OllamaClient;
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
import de.neitzel.roleplay.fascade.model.CharacterState;
import de.neitzel.roleplay.fascade.model.CreateSessionRequest; import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
import de.neitzel.roleplay.fascade.model.SessionResponse; import de.neitzel.roleplay.fascade.model.SessionResponse;
import de.neitzel.roleplay.fascade.model.SituationState;
import de.neitzel.roleplay.fascade.model.TurnRequest; import de.neitzel.roleplay.fascade.model.TurnRequest;
import de.neitzel.roleplay.fascade.model.TurnResponse; import de.neitzel.roleplay.fascade.model.TurnResponse;
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
import de.neitzel.roleplay.fascade.model.UserActionRequest; import de.neitzel.roleplay.fascade.model.UserActionRequest;
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.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional; import java.util.Optional;
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.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/** /**
* Unit tests for {@link InMemorySessionService}. * Unit tests for {@link InMemorySessionService}.
*/ */
@ExtendWith(MockitoExtension.class)
class InMemorySessionServiceTest { class InMemorySessionServiceTest {
/** @Mock
* Instance under test no CDI dependencies to mock. private OllamaClient ollamaClient;
*/
@Mock
private ScenarioService scenarioService;
private final ObjectMapper objectMapper = new ObjectMapper();
private InMemorySessionService sessionService; private InMemorySessionService sessionService;
/** /**
* Creates a fresh service instance before each test to ensure isolation. * Creates a fresh service instance with mocked Ollama client and scenario service before each test.
* By default, Ollama is stubbed to return a short narrative and an empty
* state update so that createSession (with scenario) and submitTurn complete.
*/ */
@BeforeEach @BeforeEach
void setUp() { void setUp() {
sessionService = new InMemorySessionService(); sessionService = new InMemorySessionService(ollamaClient, objectMapper, scenarioService);
StateUpdateResponse emptyUpdate = StateUpdateResponse.builder()
.responses(null)
.updatedSituation(null)
.updatedCharacters(null)
.suggestions(null)
.build();
lenient().when(ollamaClient.generateNarrative(anyString(), anyString(), anyString()))
.thenReturn("A short narrative.");
lenient().when(ollamaClient.generateStateUpdate(anyString(), anyString(), anyString()))
.thenReturn(emptyUpdate);
} }
/** /**
@ -164,5 +198,132 @@ class InMemorySessionServiceTest {
// Assert // Assert
assertEquals("en", response.getLanguage()); assertEquals("en", response.getLanguage());
} }
/**
* Verifies that creating a session with a scenario populates situation and characters.
*/
@Test
void createSessionWithScenarioPopulatesSituationAndCharacters() {
// Arrange
CharacterDefinition userChar = new CharacterDefinition("hero", "The Hero", "protagonist");
CharacterDefinition aiChar = new CharacterDefinition("mentor", "Old Sage", "wise guide");
ScenarioSetup scenario = new ScenarioSetup();
scenario.setSetting("A fog-covered harbour at dawn, 1923");
scenario.setInitialConflict("Strange noises from the cargo hold");
scenario.setUserCharacter(userChar);
scenario.setAiCharacters(List.of(aiChar));
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
request.setScenario(scenario);
// Act
SessionResponse response = sessionService.createSession(request);
// Assert
assertNotNull(response.getSituation());
assertEquals("A fog-covered harbour at dawn, 1923", response.getSituation().getSetting());
assertNotNull(response.getSituation().getCurrentScene());
assertTrue(response.getSituation().getCurrentScene()
.contains("A fog-covered harbour at dawn, 1923"));
assertTrue(response.getSituation().getCurrentScene().contains("Strange noises"));
assertNotNull(response.getCharacters());
assertEquals(2, response.getCharacters().size());
CharacterState userState = response.getCharacters().stream()
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
.findFirst().orElseThrow();
assertEquals("hero", userState.getId());
assertEquals("The Hero", userState.getName());
assertEquals("protagonist", userState.getRole());
CharacterState aiState = response.getCharacters().stream()
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
.findFirst().orElseThrow();
assertEquals("mentor", aiState.getId());
assertEquals("Old Sage", aiState.getName());
}
/**
* Verifies that creating a session with scenarioId uses the scenario loaded from the database.
*/
@Test
void createSessionWithScenarioIdUsesScenarioFromDatabase() {
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
CharacterDefinition userChar = new CharacterDefinition("db_user", "DB User", "protagonist");
CharacterDefinition aiChar = new CharacterDefinition("db_ai", "DB NPC", "antagonist");
ScenarioSetup loadedScenario = new ScenarioSetup();
loadedScenario.setSetting("Database setting");
loadedScenario.setInitialConflict("Database conflict");
loadedScenario.setUserCharacter(userChar);
loadedScenario.setAiCharacters(List.of(aiChar));
when(scenarioService.getScenarioAsSetup(scenarioId)).thenReturn(Optional.of(loadedScenario));
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
request.setScenarioId(scenarioId);
request.setScenario(null);
SessionResponse response = sessionService.createSession(request);
assertNotNull(response.getSituation());
assertEquals("Database setting", response.getSituation().getSetting());
assertNotNull(response.getCharacters());
assertEquals(2, response.getCharacters().size());
CharacterState userState = response.getCharacters().stream()
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
.findFirst().orElseThrow();
assertEquals("db_user", userState.getId());
assertEquals("DB User", userState.getName());
CharacterState aiState = response.getCharacters().stream()
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
.findFirst().orElseThrow();
assertEquals("db_ai", aiState.getId());
}
/**
* Verifies that updateSession updates situation and characters when provided.
*/
@Test
void updateSessionUpdatesSituationAndCharacters() {
// Arrange
CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest");
SessionResponse session = sessionService.createSession(createRequest);
String sessionId = session.getSessionId();
SituationState newSituation = new SituationState();
newSituation.setSetting("New setting");
newSituation.setCurrentScene("New scene focus");
CharacterState newChar = new CharacterState("npc1", "First NPC", false);
newChar.setRole("supporting");
UpdateSessionRequest updateRequest = new UpdateSessionRequest();
updateRequest.setSituation(newSituation);
updateRequest.setCharacters(List.of(newChar));
// Act
Optional<SessionResponse> updated = sessionService.updateSession(sessionId, updateRequest);
// Assert
assertTrue(updated.isPresent());
assertEquals("New setting", updated.get().getSituation().getSetting());
assertEquals("New scene focus", updated.get().getSituation().getCurrentScene());
assertEquals(1, updated.get().getCharacters().size());
assertEquals("npc1", updated.get().getCharacters().get(0).getId());
Optional<SessionResponse> got = sessionService.getSession(sessionId);
assertTrue(got.isPresent());
assertEquals("New setting", got.get().getSituation().getSetting());
assertEquals(1, got.get().getCharacters().size());
}
/**
* Verifies that updateSession returns empty for unknown session.
*/
@Test
void updateSessionReturnsEmptyForUnknownSession() {
UpdateSessionRequest request = new UpdateSessionRequest();
request.setSituation(new SituationState());
Optional<SessionResponse> result = sessionService.updateSession("unknown-id", request);
assertTrue(result.isEmpty());
}
} }

View File

@ -0,0 +1,242 @@
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;
import java.util.List;
import java.util.Optional;
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 ScenarioService}.
*/
@ExtendWith(MockitoExtension.class)
class ScenarioServiceTest {
@Mock
private ScenarioRepository scenarioRepository;
@Mock
private CharacterRepository characterRepository;
@Mock
private EntityManager entityManager;
private ScenarioService scenarioService;
@BeforeEach
void setUp() {
scenarioService = new ScenarioService(scenarioRepository, characterRepository, entityManager);
}
@Test
void listScenariosReturnsMappedSummaries() {
ScenarioEntity entity = new ScenarioEntity();
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222201"));
entity.setName("Harbour mystery");
entity.setSetting("A fog-covered harbour");
entity.setInitialConflict("Strange noises");
when(scenarioRepository.listAll()).thenReturn(List.of(entity));
List<ScenarioSummary> result = scenarioService.listScenarios();
assertEquals(1, result.size());
ScenarioSummary summary = result.get(0);
assertEquals(UUID.fromString("22222222-2222-2222-2222-222222222201"), summary.getId());
assertEquals("Harbour mystery", summary.getName());
assertEquals("A fog-covered harbour", summary.getSetting());
assertEquals("Strange noises", summary.getInitialConflict());
}
@Test
void listScenariosReturnsEmptyWhenNoneStored() {
when(scenarioRepository.listAll()).thenReturn(List.of());
List<ScenarioSummary> result = scenarioService.listScenarios();
assertTrue(result.isEmpty());
}
@Test
void getScenarioAsSetupReturnsEmptyWhenNotFound() {
UUID id = UUID.randomUUID();
when(scenarioRepository.findByIdWithCharacters(id)).thenReturn(null);
Optional<ScenarioSetup> result = scenarioService.getScenarioAsSetup(id);
assertTrue(result.isEmpty());
}
@Test
void getScenarioAsSetupReturnsSetupWithUserAndAiCharacters() {
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
UUID userCharId = UUID.fromString("11111111-1111-1111-1111-111111111101");
UUID aiCharId = UUID.fromString("11111111-1111-1111-1111-111111111102");
CharacterEntity userCharEntity = new CharacterEntity();
userCharEntity.setId(userCharId);
userCharEntity.setName("The Detective");
userCharEntity.setRole("detective");
CharacterEntity aiCharEntity = new CharacterEntity();
aiCharEntity.setId(aiCharId);
aiCharEntity.setName("Captain Morgan");
aiCharEntity.setRole("captain");
ScenarioCharacterEntity userLink = new ScenarioCharacterEntity();
userLink.setUserCharacter(true);
userLink.setPosition(0);
userLink.setCharacter(userCharEntity);
ScenarioCharacterEntity aiLink = new ScenarioCharacterEntity();
aiLink.setUserCharacter(false);
aiLink.setPosition(1);
aiLink.setCharacter(aiCharEntity);
ScenarioEntity scenarioEntity = new ScenarioEntity();
scenarioEntity.setId(scenarioId);
scenarioEntity.setName("Harbour mystery");
scenarioEntity.setSetting("A fog-covered harbour at dawn");
scenarioEntity.setInitialConflict("Strange noises from the cargo hold");
scenarioEntity.setScenarioCharacters(List.of(userLink, aiLink));
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(scenarioEntity);
Optional<ScenarioSetup> result = scenarioService.getScenarioAsSetup(scenarioId);
assertTrue(result.isPresent());
ScenarioSetup setup = result.get();
assertEquals("A fog-covered harbour at dawn", setup.getSetting());
assertEquals("Strange noises from the cargo hold", setup.getInitialConflict());
assertNotNull(setup.getUserCharacter());
assertEquals("The Detective", setup.getUserCharacter().getName());
assertEquals("detective", setup.getUserCharacter().getRole());
assertNotNull(setup.getAiCharacters());
assertEquals(1, setup.getAiCharacters().size());
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);
}
}

View File

@ -0,0 +1,196 @@
package de.neitzel.roleplay.business;
import de.neitzel.roleplay.common.CreateUserRequest;
import de.neitzel.roleplay.common.UpdateUserRequest;
import de.neitzel.roleplay.common.UserSummary;
import de.neitzel.roleplay.data.UserEntity;
import de.neitzel.roleplay.data.UserRepository;
import io.quarkus.elytron.security.common.BcryptUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link UserService}.
*/
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(userRepository);
}
@Test
void listUsersReturnsMappedSummaries() {
UserEntity entity = new UserEntity();
entity.setId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
entity.setUsername("alice");
entity.setRole("user");
when(userRepository.listAll()).thenReturn(List.of(entity));
List<UserSummary> result = userService.listUsers();
assertEquals(1, result.size());
UserSummary summary = result.get(0);
assertEquals(UUID.fromString("11111111-1111-1111-1111-111111111111"), summary.getId());
assertEquals("alice", summary.getUsername());
assertEquals("user", summary.getRole());
}
@Test
void createUserRejectsBlankUsername() {
CreateUserRequest request = new CreateUserRequest("", "secret", "user");
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
assertEquals("Username is required", e.getMessage());
}
@Test
void createUserRejectsBlankPassword() {
CreateUserRequest request = new CreateUserRequest("alice", "", "user");
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
assertEquals("Password is required", e.getMessage());
}
@Test
void createUserRejectsInvalidRole() {
CreateUserRequest request = new CreateUserRequest("alice", "secret", "superuser");
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
assertEquals("Role must be 'admin' or 'user'", e.getMessage());
}
@Test
void createUserRejectsDuplicateUsername() {
CreateUserRequest request = new CreateUserRequest("alice", "secret", "user");
when(userRepository.findByUsername("alice")).thenReturn(new UserEntity());
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
assertEquals("Username already exists: alice", e.getMessage());
}
@Test
void changePasswordUpdatesPasswordWhenCurrentMatches() {
UserEntity entity = new UserEntity();
entity.setId(UUID.randomUUID());
entity.setUsername("alice");
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
entity.setRole("user");
when(userRepository.findByUsername("alice")).thenReturn(entity);
userService.changePassword("alice", "oldpass", "newpass");
assertTrue(BcryptUtil.matches("newpass", entity.getPassword()));
}
@Test
void changePasswordThrowsWhenCurrentPasswordWrong() {
UserEntity entity = new UserEntity();
entity.setUsername("alice");
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
when(userRepository.findByUsername("alice")).thenReturn(entity);
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.changePassword("alice", "wrong", "newpass"));
assertEquals("Current password is incorrect", e.getMessage());
}
@Test
void changePasswordThrowsWhenUserNotFound() {
when(userRepository.findByUsername("nobody")).thenReturn(null);
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.changePassword("nobody", "old", "new"));
assertEquals("User not found: nobody", e.getMessage());
}
@Test
void updateUserSuccessWithoutNewPassword() {
UserEntity entity = new UserEntity();
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
entity.setUsername("alice");
entity.setRole("user");
when(userRepository.findById(UUID.fromString("22222222-2222-2222-2222-222222222222"))).thenReturn(entity);
// Same username as entity: uniqueness check not invoked
UpdateUserRequest request = new UpdateUserRequest("alice", null, "admin");
UserSummary result = userService.updateUser(entity.getId(), request, "admin");
assertEquals("alice", result.getUsername());
assertEquals("admin", result.getRole());
assertEquals("admin", entity.getRole());
}
@Test
void updateUserThrowsWhenUserNotFound() {
when(userRepository.findById(UUID.fromString("99999999-9999-9999-9999-999999999999"))).thenReturn(null);
UpdateUserRequest request = new UpdateUserRequest("bob", "secret", "user");
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.updateUser(UUID.fromString("99999999-9999-9999-9999-999999999999"), request, "admin"));
assertEquals("User not found: 99999999-9999-9999-9999-999999999999", e.getMessage());
}
@Test
void updateUserThrowsWhenUsernameTakenByOther() {
UserEntity entity = new UserEntity();
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
entity.setUsername("alice");
entity.setRole("user");
UserEntity other = new UserEntity();
other.setUsername("bob");
when(userRepository.findById(entity.getId())).thenReturn(entity);
when(userRepository.findByUsername("bob")).thenReturn(other);
UpdateUserRequest request = new UpdateUserRequest("bob", null, "user");
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.updateUser(entity.getId(), request, "admin"));
assertEquals("Username already exists: bob", e.getMessage());
}
@Test
void deleteUserThrowsWhenDeletingSelf() {
UserEntity entity = new UserEntity();
entity.setId(UUID.randomUUID());
entity.setUsername("admin");
entity.setRole("admin");
when(userRepository.findById(entity.getId())).thenReturn(entity);
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.deleteUser(entity.getId(), "admin"));
assertEquals("Cannot delete your own user account", e.getMessage());
}
@Test
void deleteUserThrowsWhenDeletingLastAdmin() {
UserEntity entity = new UserEntity();
entity.setId(UUID.randomUUID());
entity.setUsername("soleadmin");
entity.setRole("admin");
when(userRepository.findById(entity.getId())).thenReturn(entity);
when(userRepository.count("role = ?1", "admin")).thenReturn(1L);
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> userService.deleteUser(entity.getId(), "user"));
assertEquals("Cannot delete the last admin user", e.getMessage());
}
}

View File

@ -139,4 +139,34 @@ class StateUpdateResponseDeserializationTest {
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained()); assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges()); assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
} }
/**
* Verifies that combined type "speech|action" from Ollama is accepted and
* mapped to {@link ResponseType#SPEECH}.
*/
@Test
void deserialisesSpeechOrActionAsSpeech() throws Exception {
String json = """
{
"responses": [
{
"character_id": "narrator",
"type": "speech|action",
"content": "I will do it.",
"action": "nods firmly",
"mood_after": "determined"
}
],
"updated_situation": null,
"updated_characters": [],
"suggestions": []
}
""";
StateUpdateResponse result = mapper.readValue(json, StateUpdateResponse.class);
assertNotNull(result.getResponses());
assertEquals(1, result.getResponses().size());
assertEquals(ResponseType.SPEECH, result.getResponses().get(0).getType());
assertEquals("I will do it.", result.getResponses().get(0).getContent());
assertEquals("nods firmly", result.getResponses().get(0).getAction());
}
} }

View File

@ -0,0 +1,145 @@
package de.neitzel.roleplay.fascade;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.Cookies;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.Matchers.hasItem;
/**
* Integration tests for form-based auth: login, auth/me, and protected API access.
*/
@QuarkusTest
class AuthIntegrationTest {
@Test
void unauthenticatedRequestToApiReturns401() {
given()
.when()
.get("/api/v1/scenarios")
.then()
.statusCode(401);
}
@Test
void unauthenticatedRequestToAuthMeReturns401() {
given()
.when()
.get("/api/v1/auth/me")
.then()
.statusCode(401);
}
@Test
void loginThenAuthMeAndScenariosSucceed() {
Cookies cookies = given()
.contentType("application/x-www-form-urlencoded")
.formParam("j_username", "admin")
.formParam("j_password", "changeme")
.when()
.post("/j_security_check")
.then()
.statusCode(200)
.extract()
.detailedCookies();
given()
.cookies(cookies)
.when()
.get("/api/v1/auth/me")
.then()
.statusCode(200)
.body("username", is("admin"))
.body("roles", hasItem("admin"));
given()
.cookies(cookies)
.when()
.get("/api/v1/scenarios")
.then()
.statusCode(200);
}
@Test
void invalidLoginReturns401() {
given()
.contentType("application/x-www-form-urlencoded")
.formParam("j_username", "admin")
.formParam("j_password", "wrong")
.when()
.post("/j_security_check")
.then()
.statusCode(401);
}
@Test
void changePasswordSucceedsWithValidCurrentPassword() {
Cookies cookies = given()
.contentType("application/x-www-form-urlencoded")
.formParam("j_username", "admin")
.formParam("j_password", "changeme")
.when()
.post("/j_security_check")
.then()
.statusCode(200)
.extract()
.detailedCookies();
given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("currentPassword", "changeme", "newPassword", "newpass123"))
.when()
.post("/api/v1/auth/change-password")
.then()
.statusCode(204);
// Restore original password so other tests are not affected
given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("currentPassword", "newpass123", "newPassword", "changeme"))
.when()
.post("/api/v1/auth/change-password")
.then()
.statusCode(204);
}
@Test
void changePasswordReturns400WhenCurrentPasswordWrong() {
Cookies cookies = given()
.contentType("application/x-www-form-urlencoded")
.formParam("j_username", "admin")
.formParam("j_password", "changeme")
.when()
.post("/j_security_check")
.then()
.statusCode(200)
.extract()
.detailedCookies();
given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("currentPassword", "wrong", "newPassword", "newpass"))
.when()
.post("/api/v1/auth/change-password")
.then()
.statusCode(400);
}
@Test
void unauthenticatedChangePasswordReturns401() {
given()
.contentType("application/json")
.body(Map.of("currentPassword", "old", "newPassword", "new"))
.when()
.post("/api/v1/auth/change-password")
.then()
.statusCode(401);
}
}

View File

@ -0,0 +1,151 @@
package de.neitzel.roleplay.fascade;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.Cookies;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
/**
* Integration tests for admin user management: list, create, update, delete users.
*/
@QuarkusTest
class UsersIntegrationTest {
private static Cookies loginAsAdmin() {
return given()
.contentType("application/x-www-form-urlencoded")
.formParam("j_username", "admin")
.formParam("j_password", "changeme")
.when()
.post("/j_security_check")
.then()
.statusCode(200)
.extract()
.detailedCookies();
}
@Test
void unauthenticatedListUsersReturns401() {
given()
.when()
.get("/api/v1/admin/users")
.then()
.statusCode(401);
}
@Test
void listUsersAsAdminReturns200() {
Cookies cookies = loginAsAdmin();
given()
.cookies(cookies)
.when()
.get("/api/v1/admin/users")
.then()
.statusCode(200)
.body("size()", greaterThanOrEqualTo(1));
}
@Test
void createUserAsAdminReturns201() {
Cookies cookies = loginAsAdmin();
String username = "testuser_" + System.currentTimeMillis();
given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("username", username, "password", "secret", "role", "user"))
.when()
.post("/api/v1/admin/users")
.then()
.statusCode(201)
.body("username", is(username))
.body("role", is("user"))
.body("id", notNullValue());
}
@Test
void updateUserAsAdminReturns200() {
Cookies cookies = loginAsAdmin();
String username = "edituser_" + System.currentTimeMillis();
String id = given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("username", username, "password", "secret", "role", "user"))
.when()
.post("/api/v1/admin/users")
.then()
.statusCode(201)
.extract()
.path("id");
given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("username", username + "_updated", "role", "admin"))
.when()
.put("/api/v1/admin/users/" + id)
.then()
.statusCode(200)
.body("username", is(username + "_updated"))
.body("role", is("admin"));
}
@Test
void deleteUserAsAdminReturns204() {
Cookies cookies = loginAsAdmin();
String username = "deluser_" + System.currentTimeMillis();
String id = given()
.cookies(cookies)
.contentType("application/json")
.body(Map.of("username", username, "password", "secret", "role", "user"))
.when()
.post("/api/v1/admin/users")
.then()
.statusCode(201)
.extract()
.path("id");
given()
.cookies(cookies)
.when()
.delete("/api/v1/admin/users/" + id)
.then()
.statusCode(204);
}
@Test
void deleteUserSelfReturns400() {
Cookies cookies = loginAsAdmin();
String adminId = given()
.cookies(cookies)
.when()
.get("/api/v1/admin/users")
.then()
.statusCode(200)
.extract()
.path("find { it.username == 'admin' }.id");
given()
.cookies(cookies)
.when()
.delete("/api/v1/admin/users/" + adminId)
.then()
.statusCode(400);
}
@Test
void deleteUserNotFoundReturns404() {
Cookies cookies = loginAsAdmin();
given()
.cookies(cookies)
.when()
.delete("/api/v1/admin/users/00000000-0000-0000-0000-000000000000")
.then()
.statusCode(404);
}
}

216
tree.txt Normal file
View File

@ -0,0 +1,216 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------< de.neitzel:roleplay >-------------------------
[INFO] Building roleplay 0.1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- dependency:3.7.0:tree (default-cli) @ roleplay ---
[INFO] de.neitzel:roleplay:jar:0.1.0-SNAPSHOT
[INFO] +- io.quarkus:quarkus-arc:jar:3.31.1:compile
[INFO] | +- io.quarkus.arc:arc:jar:3.31.1:compile
[INFO] | | +- jakarta.enterprise:jakarta.enterprise.cdi-api:jar:4.1.0:compile
[INFO] | | | +- jakarta.enterprise:jakarta.enterprise.lang-model:jar:4.1.0:compile
[INFO] | | | \- jakarta.interceptor:jakarta.interceptor-api:jar:2.2.0:compile
[INFO] | | +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile
[INFO] | | +- jakarta.transaction:jakarta.transaction-api:jar:2.0.1:compile
[INFO] | | +- io.smallrye.reactive:mutiny:jar:3.1.0:compile
[INFO] | | | +- io.smallrye.common:smallrye-common-annotation:jar:2.15.0:compile
[INFO] | | | \- org.jctools:jctools-core:jar:4.0.5:compile
[INFO] | | \- org.jboss.logging:jboss-logging:jar:3.6.2.Final:compile
[INFO] | +- io.quarkus:quarkus-core:jar:3.31.1:compile
[INFO] | | +- jakarta.inject:jakarta.inject-api:jar:2.0.1:compile
[INFO] | | +- io.smallrye.common:smallrye-common-os:jar:2.15.0:compile
[INFO] | | +- io.quarkus:quarkus-ide-launcher:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-development-mode-spi:jar:3.31.1:compile
[INFO] | | +- io.smallrye.config:smallrye-config:jar:3.15.1:compile
[INFO] | | | \- io.smallrye.config:smallrye-config-core:jar:3.15.1:compile
[INFO] | | +- org.jboss.logmanager:jboss-logmanager:jar:3.2.0.Final:compile
[INFO] | | | +- io.smallrye.common:smallrye-common-cpu:jar:2.15.0:compile
[INFO] | | | +- io.smallrye.common:smallrye-common-expression:jar:2.15.0:compile
[INFO] | | | +- io.smallrye.common:smallrye-common-net:jar:2.15.0:compile
[INFO] | | | +- io.smallrye.common:smallrye-common-ref:jar:2.15.0:compile
[INFO] | | | \- jakarta.json:jakarta.json-api:jar:2.1.3:compile
[INFO] | | +- org.jboss.threads:jboss-threads:jar:3.9.2:compile
[INFO] | | | \- io.smallrye.common:smallrye-common-function:jar:2.15.0:compile
[INFO] | | +- org.slf4j:slf4j-api:jar:2.0.17:compile
[INFO] | | +- org.jboss.slf4j:slf4j-jboss-logmanager:jar:2.0.2.Final:compile
[INFO] | | +- org.wildfly.common:wildfly-common:jar:2.0.1:compile
[INFO] | | +- io.quarkus:quarkus-registry:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-bootstrap-runner:jar:3.31.1:compile
[INFO] | | | +- io.quarkus:quarkus-classloader-commons:jar:3.31.1:compile
[INFO] | | | \- io.smallrye.common:smallrye-common-io:jar:2.15.0:compile
[INFO] | | \- io.quarkus:quarkus-fs-util:jar:1.3.0:compile
[INFO] | \- org.eclipse.microprofile.context-propagation:microprofile-context-propagation-api:jar:1.3:compile
[INFO] +- io.quarkus:quarkus-rest:jar:3.31.1:compile
[INFO] | +- io.quarkus:quarkus-rest-common:jar:3.31.1:compile
[INFO] | | +- io.quarkus.resteasy.reactive:resteasy-reactive-common:jar:3.31.1:compile
[INFO] | | | +- io.quarkus.resteasy.reactive:resteasy-reactive-common-types:jar:3.31.1:compile
[INFO] | | | \- org.reactivestreams:reactive-streams:jar:1.0.4:compile
[INFO] | | +- io.quarkus:quarkus-mutiny:jar:3.31.1:compile
[INFO] | | | +- io.quarkus:quarkus-smallrye-context-propagation:jar:3.31.1:compile
[INFO] | | | | \- io.smallrye:smallrye-context-propagation:jar:2.3.0:compile
[INFO] | | | | +- io.smallrye:smallrye-context-propagation-api:jar:2.3.0:compile
[INFO] | | | | \- io.smallrye:smallrye-context-propagation-storage:jar:2.3.0:compile
[INFO] | | | \- io.smallrye.reactive:mutiny-smallrye-context-propagation:jar:3.1.0:compile
[INFO] | | \- io.quarkus:quarkus-vertx:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-netty:jar:3.31.1:compile
[INFO] | | | \- io.netty:netty-codec:jar:4.1.130.Final:compile
[INFO] | | +- io.netty:netty-codec-haproxy:jar:4.1.130.Final:compile
[INFO] | | +- io.quarkus:quarkus-vertx-latebound-mdc-provider:jar:3.31.1:compile
[INFO] | | \- io.smallrye:smallrye-fault-tolerance-vertx:jar:6.10.0:compile
[INFO] | +- io.quarkus.resteasy.reactive:resteasy-reactive-vertx:jar:3.31.1:compile
[INFO] | | +- io.vertx:vertx-web:jar:4.5.24:compile
[INFO] | | | +- io.vertx:vertx-web-common:jar:4.5.24:compile
[INFO] | | | +- io.vertx:vertx-auth-common:jar:4.5.24:compile
[INFO] | | | \- io.vertx:vertx-bridge-common:jar:4.5.24:compile
[INFO] | | +- io.smallrye.reactive:smallrye-mutiny-vertx-core:jar:3.21.4:compile
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-runtime:jar:3.21.4:compile
[INFO] | | | \- io.smallrye.reactive:vertx-mutiny-generator:jar:3.21.4:compile
[INFO] | | | \- io.vertx:vertx-codegen:jar:4.5.24:compile
[INFO] | | +- io.quarkus.resteasy.reactive:resteasy-reactive:jar:3.31.1:compile
[INFO] | | +- io.quarkus.vertx.utils:quarkus-vertx-utils:jar:3.31.1:compile
[INFO] | | +- org.jboss.logging:commons-logging-jboss-logging:jar:1.0.0.Final:compile
[INFO] | | \- jakarta.xml.bind:jakarta.xml.bind-api:jar:4.0.4:compile
[INFO] | | \- jakarta.activation:jakarta.activation-api:jar:2.1.4:compile
[INFO] | +- io.quarkus:quarkus-vertx-http:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-security-runtime-spi:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-tls-registry:jar:3.31.1:compile
[INFO] | | | +- io.quarkus:quarkus-tls-registry-spi:jar:3.31.1:compile
[INFO] | | | \- io.smallrye.certs:smallrye-private-key-pem-parser:jar:0.9.2:compile
[INFO] | | +- io.quarkus:quarkus-credentials:jar:3.31.1:compile
[INFO] | | +- io.smallrye.common:smallrye-common-vertx-context:jar:2.15.0:compile
[INFO] | | +- io.quarkus.security:quarkus-security:jar:2.3.2:compile
[INFO] | | +- io.smallrye.reactive:smallrye-mutiny-vertx-web:jar:3.21.4:compile
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-web-common:jar:3.21.4:compile
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-auth-common:jar:3.21.4:compile
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-bridge-common:jar:3.21.4:compile
[INFO] | | | \- io.smallrye.reactive:smallrye-mutiny-vertx-uri-template:jar:3.21.4:compile
[INFO] | | | \- io.vertx:vertx-uri-template:jar:4.5.24:compile
[INFO] | | +- org.crac:crac:jar:1.5.0:compile
[INFO] | | \- com.aayushatharva.brotli4j:brotli4j:jar:1.16.0:compile
[INFO] | | +- com.aayushatharva.brotli4j:service:jar:1.16.0:compile
[INFO] | | \- com.aayushatharva.brotli4j:native-osx-aarch64:jar:1.16.0:compile
[INFO] | +- io.quarkus:quarkus-jsonp:jar:3.31.1:compile
[INFO] | | \- org.eclipse.parsson:parsson:jar:1.1.7:compile
[INFO] | \- io.quarkus:quarkus-virtual-threads:jar:3.31.1:compile
[INFO] | \- io.vertx:vertx-core:jar:4.5.24:compile
[INFO] | +- io.netty:netty-common:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-buffer:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-transport:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-handler:jar:4.1.130.Final:compile
[INFO] | | \- io.netty:netty-transport-native-unix-common:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-handler-proxy:jar:4.1.130.Final:compile
[INFO] | | \- io.netty:netty-codec-socks:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-codec-http:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-codec-http2:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-resolver:jar:4.1.130.Final:compile
[INFO] | +- io.netty:netty-resolver-dns:jar:4.1.130.Final:compile
[INFO] | | \- io.netty:netty-codec-dns:jar:4.1.130.Final:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-core:jar:2.20.1:compile
[INFO] +- io.quarkus:quarkus-rest-jackson:jar:3.31.1:compile
[INFO] | \- io.quarkus:quarkus-rest-jackson-common:jar:3.31.1:compile
[INFO] | \- io.quarkus:quarkus-jackson:jar:3.31.1:compile
[INFO] | +- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.20.1:compile
[INFO] | +- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:jar:2.20.1:compile
[INFO] | \- com.fasterxml.jackson.module:jackson-module-parameter-names:jar:2.20.1:compile
[INFO] +- io.quarkus:quarkus-hibernate-validator:jar:3.31.1:compile
[INFO] | +- org.hibernate.validator:hibernate-validator:jar:9.1.0.Final:compile
[INFO] | | +- jakarta.validation:jakarta.validation-api:jar:3.1.1:compile
[INFO] | | \- com.fasterxml:classmate:jar:1.7.1:compile
[INFO] | +- org.glassfish.expressly:expressly:jar:6.0.0:compile
[INFO] | | \- jakarta.el:jakarta.el-api:jar:6.0.1:compile
[INFO] | +- io.smallrye.config:smallrye-config-validator:jar:3.15.1:compile
[INFO] | \- jakarta.ws.rs:jakarta.ws.rs-api:jar:3.1.0:compile
[INFO] +- io.quarkus:quarkus-config-yaml:jar:3.31.1:compile
[INFO] | +- io.smallrye.config:smallrye-config-source-yaml:jar:3.15.1:compile
[INFO] | | +- io.smallrye.config:smallrye-config-common:jar:3.15.1:compile
[INFO] | | | \- io.smallrye.common:smallrye-common-classloader:jar:2.15.0:compile
[INFO] | | \- io.smallrye.common:smallrye-common-constraint:jar:2.15.0:compile
[INFO] | \- org.eclipse.microprofile.config:microprofile-config-api:jar:3.1:compile
[INFO] +- io.quarkus:quarkus-jdbc-h2:jar:3.31.1:compile
[INFO] | +- com.h2database:h2:jar:2.4.240:compile
[INFO] | \- org.locationtech.jts:jts-core:jar:1.19.0:compile
[INFO] +- io.quarkus:quarkus-liquibase:jar:3.31.1:compile
[INFO] | +- io.quarkus:quarkus-liquibase-common:jar:3.31.1:compile
[INFO] | +- org.liquibase:liquibase-core:jar:4.33.0:compile
[INFO] | | +- com.opencsv:opencsv:jar:5.11.2:compile
[INFO] | | +- org.apache.commons:commons-collections4:jar:4.5.0:compile
[INFO] | | +- org.apache.commons:commons-text:jar:1.15.0:compile
[INFO] | | +- org.apache.commons:commons-lang3:jar:3.20.0:compile
[INFO] | | \- commons-io:commons-io:jar:2.21.0:compile
[INFO] | +- org.osgi:osgi.core:jar:6.0.0:compile
[INFO] | +- org.yaml:snakeyaml:jar:2.5:compile
[INFO] | +- io.quarkus:quarkus-jaxb:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-jaxp:jar:3.31.1:compile
[INFO] | | \- org.glassfish.jaxb:jaxb-runtime:jar:4.0.6:compile
[INFO] | | \- org.glassfish.jaxb:jaxb-core:jar:4.0.6:compile
[INFO] | | +- org.eclipse.angus:angus-activation:jar:2.0.3:runtime
[INFO] | | +- org.glassfish.jaxb:txw2:jar:4.0.6:compile
[INFO] | | \- com.sun.istack:istack-commons-runtime:jar:4.1.2:compile
[INFO] | +- io.quarkus:quarkus-agroal:jar:3.31.1:compile
[INFO] | | +- io.quarkus:quarkus-datasource:jar:3.31.1:compile
[INFO] | | +- io.agroal:agroal-api:jar:2.8:compile
[INFO] | | +- io.agroal:agroal-narayana:jar:2.8:compile
[INFO] | | | \- org.jboss:jboss-transaction-spi:jar:8.0.0.Final:compile
[INFO] | | \- io.agroal:agroal-pool:jar:2.8:compile
[INFO] | +- io.quarkus:quarkus-datasource-common:jar:3.31.1:compile
[INFO] | \- io.quarkus:quarkus-narayana-jta:jar:3.31.1:compile
[INFO] | +- io.quarkus:quarkus-transaction-annotations:jar:3.31.1:compile
[INFO] | +- io.smallrye:smallrye-context-propagation-jta:jar:2.3.0:compile
[INFO] | +- io.smallrye.reactive:smallrye-reactive-converter-api:jar:3.0.3:compile
[INFO] | +- io.smallrye.reactive:smallrye-reactive-converter-mutiny:jar:3.0.3:compile
[INFO] | +- io.smallrye.reactive:mutiny-zero-flow-adapters:jar:1.1.1:compile
[INFO] | +- org.jboss.narayana.jta:narayana-jta:jar:7.3.3.Final:compile
[INFO] | | +- jakarta.resource:jakarta.resource-api:jar:2.1.0:compile
[INFO] | | +- org.jboss.invocation:jboss-invocation:jar:2.0.0.Final:compile
[INFO] | | \- org.eclipse.microprofile.reactive-streams-operators:microprofile-reactive-streams-operators-api:jar:3.0.1:compile
[INFO] | \- org.jboss.narayana.jts:narayana-jts-integration:jar:7.3.3.Final:compile
[INFO] +- io.quarkus:quarkus-hibernate-orm-panache:jar:3.31.1:compile
[INFO] | +- io.quarkus:quarkus-hibernate-orm:jar:3.31.1:compile
[INFO] | | +- org.hibernate.orm:hibernate-core:jar:7.2.1.Final:compile
[INFO] | | | +- org.hibernate.models:hibernate-models:jar:1.0.1:runtime
[INFO] | | | \- org.antlr:antlr4-runtime:jar:4.13.2:compile
[INFO] | | +- net.bytebuddy:byte-buddy:jar:1.17.8:compile
[INFO] | | +- org.hibernate.orm:hibernate-graalvm:jar:7.2.1.Final:compile
[INFO] | | +- jakarta.persistence:jakarta.persistence-api:jar:3.2.0:compile
[INFO] | | +- org.hibernate.local-cache:quarkus-local-cache:jar:0.5.0:compile
[INFO] | | \- io.quarkus:quarkus-caffeine:jar:3.31.1:compile
[INFO] | | \- com.github.ben-manes.caffeine:caffeine:jar:3.2.3:compile
[INFO] | | \- com.google.errorprone:error_prone_annotations:jar:2.46.0:compile
[INFO] | +- io.quarkus:quarkus-hibernate-orm-panache-common:jar:3.31.1:compile
[INFO] | | \- io.quarkus:quarkus-panache-hibernate-common:jar:3.31.1:compile
[INFO] | \- io.quarkus:quarkus-panache-common:jar:3.31.1:compile
[INFO] +- io.quarkus:quarkus-rest-client-config:jar:3.31.1:compile
[INFO] | +- io.quarkus:quarkus-proxy-registry:jar:3.31.1:compile
[INFO] | +- org.eclipse.microprofile.rest.client:microprofile-rest-client-api:jar:4.0:compile
[INFO] | \- io.smallrye:jandex:jar:3.5.3:compile
[INFO] +- io.quarkus:quarkus-rest-client-jackson:jar:3.31.1:compile
[INFO] | +- io.quarkus.resteasy.reactive:resteasy-reactive-jackson:jar:3.31.1:compile
[INFO] | | \- com.fasterxml.jackson.core:jackson-databind:jar:2.20.1:compile
[INFO] | | \- com.fasterxml.jackson.core:jackson-annotations:jar:2.20:compile
[INFO] | \- io.quarkus:quarkus-rest-client:jar:3.31.1:compile
[INFO] | \- io.quarkus:quarkus-rest-client-jaxrs:jar:3.31.1:compile
[INFO] | \- io.quarkus.resteasy.reactive:resteasy-reactive-client:jar:3.31.1:compile
[INFO] | +- io.smallrye.stork:stork-api:jar:2.7.3:compile
[INFO] | \- io.vertx:vertx-web-client:jar:4.5.24:compile
[INFO] +- org.projectlombok:lombok:jar:1.18.42:provided
[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.10.3:test
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:6.0.2:test
[INFO] | | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | | +- org.junit.platform:junit-platform-commons:jar:6.0.2:test
[INFO] | | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] | | \- org.jspecify:jspecify:jar:1.0.0:compile
[INFO] | +- org.junit.jupiter:junit-jupiter-params:jar:6.0.2:test
[INFO] | \- org.junit.jupiter:junit-jupiter-engine:jar:6.0.2:test
[INFO] | \- org.junit.platform:junit-platform-engine:jar:6.0.2:test
[INFO] \- org.mockito:mockito-junit-jupiter:jar:5.12.0:test
[INFO] \- org.mockito:mockito-core:jar:5.21.0:test
[INFO] +- net.bytebuddy:byte-buddy-agent:jar:1.17.8:test
[INFO] \- org.objenesis:objenesis:jar:3.3:test
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.062 s
[INFO] Finished at: 2026-02-21T19:02:53+01:00
[INFO] ------------------------------------------------------------------------