Compare commits
No commits in common. "3218cb1a2a362bf29bc755af557c3f8af28f129d" and "e8bb6a64b73191726f1130444b4a394931b5012b" have entirely different histories.
3218cb1a2a
...
e8bb6a64b7
@ -1,69 +0,0 @@
|
||||
---
|
||||
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; Arrange–Act–Assert; 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 don’t
|
||||
|
||||
- **Do**: keep code in the correct package; add tests for business logic.
|
||||
- **Don’t**: 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
19
.gitignore
vendored
@ -1,21 +1,2 @@
|
||||
# Maven build output
|
||||
/target/
|
||||
|
||||
# Vite build output (written into resources so it gets into the JAR)
|
||||
src/main/resources/META-INF/resources/
|
||||
|
||||
# IDE
|
||||
/.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
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
@ -11,15 +11,6 @@ Minimal Quarkus (Java 21) project scaffold for the RolePlay service.
|
||||
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
|
||||
- `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
|
||||
|
||||
```zsh
|
||||
|
||||
125
pom.xml
125
pom.xml
@ -11,21 +11,15 @@
|
||||
<properties>
|
||||
<java.version>21</java.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>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||
<quarkus.platform.version>3.31.2</quarkus.platform.version>
|
||||
<quarkus.platform.version>3.15.3</quarkus.platform.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>
|
||||
<junit.jupiter.version>5.10.3</junit.jupiter.version>
|
||||
<mockito.version>5.12.0</mockito.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>
|
||||
<openapi.generator.version>7.11.0</openapi.generator.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
@ -58,30 +52,10 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-config-yaml</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-liquibase</artifactId>
|
||||
</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>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||
@ -105,16 +79,6 @@
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
@ -124,17 +88,6 @@
|
||||
<artifactId>quarkus-maven-plugin</artifactId>
|
||||
<version>${quarkus.plugin.version}</version>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build</goal>
|
||||
</goals>
|
||||
<phase>package</phase>
|
||||
<configuration>
|
||||
<skip>${quarkus.package.skip}</skip>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
@ -155,7 +108,6 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- On Java 25, skip to avoid VerifyError; run JAVA_HOME=/path/to/jdk21 mvn generate-sources first. -->
|
||||
<plugin>
|
||||
<groupId>org.openapitools</groupId>
|
||||
<artifactId>openapi-generator-maven-plugin</artifactId>
|
||||
@ -169,9 +121,8 @@
|
||||
<configuration>
|
||||
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
||||
<generatorName>jaxrs-spec</generatorName>
|
||||
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates</templateDirectory>
|
||||
<output>${project.build.directory}/generated-sources/openapi</output>
|
||||
<apiPackage>de.neitzel.roleplay.generated.api</apiPackage>
|
||||
<apiPackage>de.neitzel.roleplay.fascade.api</apiPackage>
|
||||
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
|
||||
<configOptions>
|
||||
<interfaceOnly>true</interfaceOnly>
|
||||
@ -188,27 +139,6 @@
|
||||
<generateSupportingFiles>false</generateSupportingFiles>
|
||||
</configuration>
|
||||
</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>
|
||||
</plugin>
|
||||
<plugin>
|
||||
@ -230,55 +160,10 @@
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${maven.surefire.plugin.version}</version>
|
||||
<configuration>
|
||||
<argLine>@{argLine}</argLine>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<maven.home>${maven.home}</maven.home>
|
||||
</systemPropertyVariables>
|
||||
<useModulePath>false</useModulePath>
|
||||
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
|
||||
</configuration>
|
||||
</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>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/main/java/de/neitzel/roleplay/RolePlayApplication.java
Normal file
33
src/main/java/de/neitzel/roleplay/RolePlayApplication.java
Normal file
@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,141 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,11 @@
|
||||
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.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.TurnResponse;
|
||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@ -28,52 +16,32 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* {@link ConcurrentHashMap}; suitable for development and testing. A
|
||||
* production implementation would persist state in a database.
|
||||
*
|
||||
* <p>Session creation and turn submission use the two-call Ollama pattern
|
||||
* (narrative then state extraction) when scenario or turn data is provided.
|
||||
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the
|
||||
* methods return stub responses so the REST layer can be exercised end-to-end.
|
||||
* The {@code TODO} markers indicate where the Ollama integration must be added.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
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.
|
||||
*/
|
||||
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}
|
||||
*
|
||||
* <p>Generates a new UUID as the session ID, populates default session
|
||||
* metadata, and stores the session. When a scenario is provided, runs the
|
||||
* two-call Ollama pattern to produce an opening narrative and initial state.
|
||||
* metadata, and stores the session. The Ollama two-call pattern is not
|
||||
* yet invoked; a placeholder narrative is returned instead.
|
||||
*/
|
||||
@Override
|
||||
public SessionResponse createSession(final CreateSessionRequest request) {
|
||||
String sessionId = UUID.randomUUID().toString();
|
||||
String model = request.getModel();
|
||||
|
||||
SessionResponse session = new SessionResponse(
|
||||
sessionId,
|
||||
model,
|
||||
request.getModel(),
|
||||
request.getLanguage() != null ? request.getLanguage() : "en",
|
||||
request.getSafetyLevel() != null
|
||||
? request.getSafetyLevel().value()
|
||||
@ -81,116 +49,14 @@ public class InMemorySessionService implements SessionService {
|
||||
0
|
||||
);
|
||||
|
||||
ScenarioSetup scenario = resolveScenario(request);
|
||||
if (scenario != null) {
|
||||
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.");
|
||||
}
|
||||
// TODO: Invoke OllamaClient two-call pattern (narrative + state extraction)
|
||||
// to produce a real opening scene and initial state.
|
||||
session.setNarrative("Session initialised. Ollama integration pending.");
|
||||
|
||||
sessions.put(sessionId, 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}
|
||||
*/
|
||||
@ -202,9 +68,8 @@ public class InMemorySessionService implements SessionService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>Increments the turn counter, runs the two-call Ollama pattern with
|
||||
* the current session state and turn request, merges the state update into
|
||||
* the session, and returns the turn response.
|
||||
* <p>Increments the turn counter and returns a stub {@link TurnResponse}.
|
||||
* The Ollama two-call pattern is not yet invoked.
|
||||
*/
|
||||
@Override
|
||||
public Optional<TurnResponse> submitTurn(final String sessionId,
|
||||
@ -214,23 +79,20 @@ public class InMemorySessionService implements SessionService {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Increment turn counter
|
||||
int nextTurn = session.getTurnNumber() + 1;
|
||||
session.setTurnNumber(nextTurn);
|
||||
String model = session.getModel();
|
||||
|
||||
try {
|
||||
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
|
||||
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_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);
|
||||
TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
|
||||
sessions.put(sessionId, session);
|
||||
return Optional.of(response);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
|
||||
}
|
||||
// TODO: Invoke OllamaClient two-call pattern (narrative + state update)
|
||||
// using the current session state, turnRequest.getUserAction(),
|
||||
// and turnRequest.getRecommendation().
|
||||
TurnResponse response = new TurnResponse(
|
||||
nextTurn,
|
||||
"Turn " + nextTurn + " processed. Ollama integration pending."
|
||||
);
|
||||
|
||||
sessions.put(sessionId, session);
|
||||
return Optional.of(response);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,240 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
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"
|
||||
+ "}";
|
||||
}
|
||||
@ -1,186 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@ import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ -32,16 +31,6 @@ public interface SessionService {
|
||||
*/
|
||||
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
|
||||
* Ollama pattern and returns the resulting narrative with updated state.
|
||||
|
||||
@ -1,245 +0,0 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,10 @@
|
||||
package de.neitzel.roleplay.common;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
/**
|
||||
* Classifies an AI character's response within a turn.
|
||||
*/
|
||||
@JsonDeserialize(using = ResponseTypeDeserializer.class)
|
||||
public enum ResponseType {
|
||||
|
||||
/** Spoken dialogue. */
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
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");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,120 +0,0 @@
|
||||
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 scenario–character links (user character first, then AI by position).
|
||||
* Never returns null; scenarios loaded from DB without characters (e.g. seed data) get an empty list.
|
||||
*/
|
||||
public List<ScenarioCharacterEntity> getScenarioCharacters() {
|
||||
if (scenarioCharacters == null) {
|
||||
scenarioCharacters = new ArrayList<>();
|
||||
}
|
||||
return scenarioCharacters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the list of scenario–character links. Null is treated as empty list.
|
||||
*/
|
||||
public void setScenarioCharacters(final List<ScenarioCharacterEntity> scenarioCharacters) {
|
||||
this.scenarioCharacters = scenarioCharacters != null ? scenarioCharacters : new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
package de.neitzel.roleplay.fascade;
|
||||
|
||||
import de.neitzel.roleplay.business.ModelService;
|
||||
import de.neitzel.roleplay.fascade.api.ModelsApi;
|
||||
import de.neitzel.roleplay.fascade.model.ModelListResponse;
|
||||
import de.neitzel.roleplay.generated.api.ModelsApi;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
|
||||
@ -3,17 +3,14 @@ package de.neitzel.roleplay.fascade;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
/**
|
||||
* Quarkus declarative REST client for the Ollama HTTP API.
|
||||
* Configuration (base URL, timeouts) is read from
|
||||
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
||||
* All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}.
|
||||
*/
|
||||
@RegisterRestClient(configKey = "ollama-api")
|
||||
@RegisterProvider(OllamaClientLoggingFilter.class)
|
||||
@Path("/api")
|
||||
public interface OllamaApi {
|
||||
|
||||
|
||||
@ -6,8 +6,6 @@ import de.neitzel.roleplay.common.StateUpdateResponse;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@ -20,11 +18,6 @@ import java.util.List;
|
||||
@ApplicationScoped
|
||||
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. */
|
||||
private final OllamaApi ollamaApi;
|
||||
|
||||
@ -50,14 +43,10 @@ public class OllamaClient {
|
||||
* @return model metadata, or an empty list if none are installed
|
||||
*/
|
||||
public List<OllamaModelInfo> listModels() {
|
||||
LOG.debug("Fetching Ollama models (GET /api/tags)");
|
||||
OllamaTagsResponse response = ollamaApi.getTags();
|
||||
if (response == null || response.getModels() == null) {
|
||||
LOG.debug("Received null or empty models list from Ollama");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int count = response.getModels().size();
|
||||
LOG.debug("Received {} model(s) from Ollama", count);
|
||||
return response.getModels();
|
||||
}
|
||||
|
||||
@ -73,11 +62,6 @@ public class OllamaClient {
|
||||
public String generateNarrative(final String model,
|
||||
final String systemPrompt,
|
||||
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()
|
||||
.model(model)
|
||||
.messages(List.of(
|
||||
@ -93,16 +77,7 @@ public class OllamaClient {
|
||||
.build();
|
||||
|
||||
OllamaChatResponse response = ollamaApi.chat(request);
|
||||
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;
|
||||
return response.getMessage().getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,11 +94,6 @@ public class OllamaClient {
|
||||
public StateUpdateResponse generateStateUpdate(final String model,
|
||||
final String systemPrompt,
|
||||
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()
|
||||
.model(model)
|
||||
.format("json")
|
||||
@ -141,21 +111,10 @@ public class OllamaClient {
|
||||
|
||||
OllamaChatResponse response = ollamaApi.chat(request);
|
||||
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 {
|
||||
StateUpdateResponse result = objectMapper.readValue(json, StateUpdateResponse.class);
|
||||
LOG.debug("Parsed state update successfully");
|
||||
return result;
|
||||
return objectMapper.readValue(json, StateUpdateResponse.class);
|
||||
} catch (JsonProcessingException e) {
|
||||
LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage());
|
||||
throw new OllamaParseException(
|
||||
"Failed to parse state update JSON from Ollama response", e);
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
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)");
|
||||
}
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
package de.neitzel.roleplay.fascade;
|
||||
|
||||
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.SessionResponse;
|
||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||
import de.neitzel.roleplay.generated.api.SessionsApi;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
@ -55,20 +54,5 @@ public class SessionResource implements SessionsApi {
|
||||
.orElseThrow(() -> new NotFoundException(
|
||||
"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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package de.neitzel.roleplay.fascade;
|
||||
|
||||
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.TurnResponse;
|
||||
import de.neitzel.roleplay.generated.api.TurnsApi;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,54 +1,13 @@
|
||||
quarkus:
|
||||
application:
|
||||
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:
|
||||
change-log: db/migration/changelog.xml
|
||||
migrate-at-start: true
|
||||
migrate-at-start: false
|
||||
rest-client:
|
||||
ollama-api:
|
||||
url: http://debian:11434
|
||||
connect-timeout: 5000
|
||||
read-timeout: 120000
|
||||
logging:
|
||||
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
|
||||
http:
|
||||
root-path: /api/v1
|
||||
|
||||
@ -4,8 +4,5 @@
|
||||
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">
|
||||
|
||||
<include file="db/migration/v001__scenarios_and_characters.xml"/>
|
||||
<include file="db/migration/v002__users_and_roles.xml"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
<?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="["curious", "determined"]"/>
|
||||
<column name="goals" value="["Solve the case", "Earn respect"]"/>
|
||||
</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="["gruff", "wary"]"/>
|
||||
<column name="goals" value="["Protect the crew", "Keep the past buried"]"/>
|
||||
</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>
|
||||
@ -1,39 +0,0 @@
|
||||
<?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>
|
||||
@ -19,14 +19,6 @@ tags:
|
||||
description: Manage role-play sessions
|
||||
- name: turns
|
||||
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:
|
||||
|
||||
@ -51,232 +43,6 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
post:
|
||||
operationId: createSession
|
||||
@ -335,40 +101,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
post:
|
||||
@ -415,175 +147,6 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
|
||||
parameters:
|
||||
@ -594,30 +157,6 @@ components:
|
||||
description: Unique identifier of the role-play session.
|
||||
schema:
|
||||
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:
|
||||
|
||||
@ -682,130 +221,9 @@ components:
|
||||
default: standard
|
||||
scenario:
|
||||
$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:
|
||||
- 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:
|
||||
type: object
|
||||
description: |
|
||||
@ -827,19 +245,6 @@ components:
|
||||
items:
|
||||
$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:
|
||||
type: object
|
||||
description: Definition of a character for session initialisation.
|
||||
@ -1198,104 +603,6 @@ components:
|
||||
- type
|
||||
- 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
ErrorResponse:
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,14 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,146 +0,0 @@
|
||||
/**
|
||||
* {{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 ");
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
{{! 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}}
|
||||
@ -1 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,289 +0,0 @@
|
||||
{{#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}}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
{{#additionalEnumTypeAnnotations}}{{{.}}}
|
||||
{{/additionalEnumTypeAnnotations}}
|
||||
@ -1,2 +0,0 @@
|
||||
{{#additionalModelTypeAnnotations}}{{{.}}}
|
||||
{{/additionalModelTypeAnnotations}}
|
||||
@ -1,32 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1 +0,0 @@
|
||||
{{#isArray}}{{baseType}}<{{#items}}{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}{{>beanValidatedType}}{{/items}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}
|
||||
@ -1 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,20 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,56 +0,0 @@
|
||||
{{#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}}
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
{{#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}}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
@{{javaxPackage}}.annotation.Generated(value = "{{generatorClass}}"{{^hideGenerationTimestamp}}, date = "{{generatedDate}}"{{/hideGenerationTimestamp}}, comments = "Generator version: {{generatorVersion}}")
|
||||
@ -1,21 +0,0 @@
|
||||
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}}
|
||||
@ -1,289 +0,0 @@
|
||||
{{#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}}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,8 +0,0 @@
|
||||
{{#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}}
|
||||
@ -1,12 +0,0 @@
|
||||
<!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
2624
src/main/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
})
|
||||
@ -1,156 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,227 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
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}</>
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
|
||||
@ -1,308 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,356 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,401 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,522 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,280 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
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
@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@ -1,36 +0,0 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@ -1,131 +0,0 @@
|
||||
package de.neitzel.roleplay.business;
|
||||
|
||||
import de.neitzel.roleplay.data.CharacterEntity;
|
||||
import de.neitzel.roleplay.data.CharacterRepository;
|
||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link CharacterService}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CharacterServiceTest {
|
||||
|
||||
@Mock
|
||||
private CharacterRepository characterRepository;
|
||||
|
||||
private CharacterService characterService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
characterService = new CharacterService(characterRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPersistsWithGeneratedIdAndReturnsDefinition() {
|
||||
CreateCharacterRequest request = new CreateCharacterRequest("Captain Morgan", "captain");
|
||||
request.setBackstory("Sea veteran");
|
||||
request.setSpeakingStyle("Gruff");
|
||||
request.setPersonalityTraits(List.of("brave", "stern"));
|
||||
request.setGoals(List.of("Find the treasure"));
|
||||
|
||||
CharacterDefinition result = characterService.create(request);
|
||||
|
||||
assertNotNull(result.getId());
|
||||
assertEquals("Captain Morgan", result.getName());
|
||||
assertEquals("captain", result.getRole());
|
||||
assertEquals("Sea veteran", result.getBackstory());
|
||||
assertEquals("Gruff", result.getSpeakingStyle());
|
||||
assertEquals(List.of("brave", "stern"), result.getPersonalityTraits());
|
||||
assertEquals(List.of("Find the treasure"), result.getGoals());
|
||||
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
|
||||
verify(characterRepository).persist(captor.capture());
|
||||
assertEquals("Captain Morgan", captor.getValue().getName());
|
||||
assertEquals("captain", captor.getValue().getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUsesRequestIdWhenProvided() {
|
||||
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
|
||||
request.setId(id);
|
||||
|
||||
CharacterDefinition result = characterService.create(request);
|
||||
|
||||
assertEquals(id.toString(), result.getId());
|
||||
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
|
||||
verify(characterRepository).persist(captor.capture());
|
||||
assertEquals(id, captor.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateReplacesAndPersists() {
|
||||
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||
CharacterEntity existing = new CharacterEntity();
|
||||
existing.setId(id);
|
||||
existing.setName("Old");
|
||||
existing.setRole("oldRole");
|
||||
when(characterRepository.findByIdOptional(id)).thenReturn(existing);
|
||||
|
||||
CreateCharacterRequest request = new CreateCharacterRequest("New Name", "newRole");
|
||||
request.setBackstory("Updated backstory");
|
||||
|
||||
CharacterDefinition result = characterService.update(id, request);
|
||||
|
||||
assertEquals(id.toString(), result.getId());
|
||||
assertEquals("New Name", result.getName());
|
||||
assertEquals("newRole", result.getRole());
|
||||
assertEquals("Updated backstory", result.getBackstory());
|
||||
verify(characterRepository).persist(existing);
|
||||
assertEquals("New Name", existing.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateThrowsWhenCharacterNotFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(characterRepository.findByIdOptional(id)).thenReturn(null);
|
||||
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
|
||||
|
||||
assertThrows(java.util.NoSuchElementException.class,
|
||||
() -> characterService.update(id, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteReturnsTrueAndDeletesWhenFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
CharacterEntity entity = new CharacterEntity();
|
||||
entity.setId(id);
|
||||
when(characterRepository.findByIdOptional(id)).thenReturn(entity);
|
||||
|
||||
boolean result = characterService.delete(id);
|
||||
|
||||
assertTrue(result);
|
||||
verify(characterRepository).delete(entity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteReturnsFalseWhenNotFound() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(characterRepository.findByIdOptional(id)).thenReturn(null);
|
||||
|
||||
boolean result = characterService.delete(id);
|
||||
|
||||
assertTrue(!result);
|
||||
}
|
||||
}
|
||||
@ -1,70 +1,36 @@
|
||||
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.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.TurnResponse;
|
||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
||||
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.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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}.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InMemorySessionServiceTest {
|
||||
|
||||
@Mock
|
||||
private OllamaClient ollamaClient;
|
||||
|
||||
@Mock
|
||||
private ScenarioService scenarioService;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Instance under test – no CDI dependencies to mock.
|
||||
*/
|
||||
private InMemorySessionService sessionService;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Creates a fresh service instance before each test to ensure isolation.
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
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);
|
||||
sessionService = new InMemorySessionService();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -198,132 +164,5 @@ class InMemorySessionServiceTest {
|
||||
// Assert
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,196 +0,0 @@
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -139,34 +139,4 @@ class StateUpdateResponseDeserializationTest {
|
||||
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,151 +0,0 @@
|
||||
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
216
tree.txt
@ -1,216 +0,0 @@
|
||||
[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] ------------------------------------------------------------------------
|
||||
Loading…
x
Reference in New Issue
Block a user