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/
|
/target/
|
||||||
|
|
||||||
# Vite build output (written into resources so it gets into the JAR)
|
|
||||||
src/main/resources/META-INF/resources/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
/.idea/
|
/.idea/
|
||||||
*.iml
|
|
||||||
|
|
||||||
# Node dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# OpenAPI-generated TypeScript client (regenerated by mvn generate-sources)
|
|
||||||
src/main/web/src/api/generated/
|
|
||||||
|
|
||||||
# TypeScript incremental build cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# OS metadata
|
|
||||||
.DS_Store
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
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/java/de/neitzel/roleplay/fascade`: external facades
|
||||||
- `src/main/resources/db/migration`: Liquibase changelog location
|
- `src/main/resources/db/migration`: Liquibase changelog location
|
||||||
|
|
||||||
## Login
|
|
||||||
|
|
||||||
The app requires authentication. A default admin user is seeded on first run:
|
|
||||||
|
|
||||||
- **Username:** `admin`
|
|
||||||
- **Password:** `changeme`
|
|
||||||
|
|
||||||
Change the password after first login (e.g. by creating a new admin user and retiring the default, or via a future password-change feature). Only users with the `admin` role can create new users (POST `/api/v1/admin/users`).
|
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
|||||||
125
pom.xml
125
pom.xml
@ -11,21 +11,15 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
|
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
|
||||||
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
|
|
||||||
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
|
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
|
||||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
<quarkus.platform.version>3.31.2</quarkus.platform.version>
|
<quarkus.platform.version>3.15.3</quarkus.platform.version>
|
||||||
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
|
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
|
||||||
<jackson.version>2.20.1</jackson.version>
|
|
||||||
<jackson.annotations.version>2.20</jackson.annotations.version>
|
|
||||||
<lombok.version>1.18.42</lombok.version>
|
<lombok.version>1.18.42</lombok.version>
|
||||||
<junit.jupiter.version>5.10.3</junit.jupiter.version>
|
<junit.jupiter.version>5.10.3</junit.jupiter.version>
|
||||||
<mockito.version>5.12.0</mockito.version>
|
<mockito.version>5.12.0</mockito.version>
|
||||||
<openapi.generator.version>7.13.0</openapi.generator.version>
|
<openapi.generator.version>7.11.0</openapi.generator.version>
|
||||||
<frontend.plugin.version>1.15.1</frontend.plugin.version>
|
|
||||||
<node.version>v22.13.1</node.version>
|
|
||||||
<npm.version>10.9.2</npm.version>
|
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@ -58,30 +52,10 @@
|
|||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-hibernate-validator</artifactId>
|
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-config-yaml</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-jdbc-h2</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-liquibase</artifactId>
|
<artifactId>quarkus-liquibase</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-security-jpa</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-rest-client-config</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
@ -105,16 +79,6 @@
|
|||||||
<version>${mockito.version}</version>
|
<version>${mockito.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.quarkus</groupId>
|
|
||||||
<artifactId>quarkus-junit5</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>io.rest-assured</groupId>
|
|
||||||
<artifactId>rest-assured</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@ -124,17 +88,6 @@
|
|||||||
<artifactId>quarkus-maven-plugin</artifactId>
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
<version>${quarkus.plugin.version}</version>
|
<version>${quarkus.plugin.version}</version>
|
||||||
<extensions>true</extensions>
|
<extensions>true</extensions>
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<goals>
|
|
||||||
<goal>build</goal>
|
|
||||||
</goals>
|
|
||||||
<phase>package</phase>
|
|
||||||
<configuration>
|
|
||||||
<skip>${quarkus.package.skip}</skip>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
@ -155,7 +108,6 @@
|
|||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<!-- On Java 25, skip to avoid VerifyError; run JAVA_HOME=/path/to/jdk21 mvn generate-sources first. -->
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.openapitools</groupId>
|
<groupId>org.openapitools</groupId>
|
||||||
<artifactId>openapi-generator-maven-plugin</artifactId>
|
<artifactId>openapi-generator-maven-plugin</artifactId>
|
||||||
@ -169,9 +121,8 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
||||||
<generatorName>jaxrs-spec</generatorName>
|
<generatorName>jaxrs-spec</generatorName>
|
||||||
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates</templateDirectory>
|
|
||||||
<output>${project.build.directory}/generated-sources/openapi</output>
|
<output>${project.build.directory}/generated-sources/openapi</output>
|
||||||
<apiPackage>de.neitzel.roleplay.generated.api</apiPackage>
|
<apiPackage>de.neitzel.roleplay.fascade.api</apiPackage>
|
||||||
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
|
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
|
||||||
<configOptions>
|
<configOptions>
|
||||||
<interfaceOnly>true</interfaceOnly>
|
<interfaceOnly>true</interfaceOnly>
|
||||||
@ -188,27 +139,6 @@
|
|||||||
<generateSupportingFiles>false</generateSupportingFiles>
|
<generateSupportingFiles>false</generateSupportingFiles>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
<execution>
|
|
||||||
<id>generate-typescript-client</id>
|
|
||||||
<goals>
|
|
||||||
<goal>generate</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<skip>${skip.openapi.generate}</skip>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
|
||||||
<generatorName>typescript-fetch</generatorName>
|
|
||||||
<output>${project.basedir}/src/main/web/src/api/generated</output>
|
|
||||||
<configOptions>
|
|
||||||
<supportsES6>true</supportsES6>
|
|
||||||
<useSingleRequestParameter>true</useSingleRequestParameter>
|
|
||||||
<enumPropertyNaming>original</enumPropertyNaming>
|
|
||||||
<modelPropertyNaming>camelCase</modelPropertyNaming>
|
|
||||||
<stringEnums>true</stringEnums>
|
|
||||||
<withInterfaces>false</withInterfaces>
|
|
||||||
</configOptions>
|
|
||||||
<generateSupportingFiles>true</generateSupportingFiles>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
@ -230,55 +160,10 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>${maven.surefire.plugin.version}</version>
|
<version>${maven.surefire.plugin.version}</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<argLine>@{argLine}</argLine>
|
<useModulePath>false</useModulePath>
|
||||||
<systemPropertyVariables>
|
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
|
||||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
|
||||||
<maven.home>${maven.home}</maven.home>
|
|
||||||
</systemPropertyVariables>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
|
||||||
<groupId>com.github.eirslett</groupId>
|
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
|
||||||
<version>${frontend.plugin.version}</version>
|
|
||||||
<configuration>
|
|
||||||
<workingDirectory>src/main/web</workingDirectory>
|
|
||||||
<installDirectory>${project.build.directory}/node</installDirectory>
|
|
||||||
</configuration>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>install-node-and-npm</id>
|
|
||||||
<phase>initialize</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>install-node-and-npm</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<nodeVersion>${node.version}</nodeVersion>
|
|
||||||
<npmVersion>${npm.version}</npmVersion>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>npm-install</id>
|
|
||||||
<phase>generate-resources</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>npm</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<arguments>install</arguments>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>npm-build</id>
|
|
||||||
<phase>generate-resources</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>npm</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<arguments>run build</arguments>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import de.neitzel.roleplay.common.StateUpdateResponse;
|
|
||||||
import de.neitzel.roleplay.fascade.OllamaClient;
|
|
||||||
import de.neitzel.roleplay.fascade.OllamaParseException;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterState;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.SituationState;
|
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -28,52 +16,32 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* {@link ConcurrentHashMap}; suitable for development and testing. A
|
* {@link ConcurrentHashMap}; suitable for development and testing. A
|
||||||
* production implementation would persist state in a database.
|
* production implementation would persist state in a database.
|
||||||
*
|
*
|
||||||
* <p>Session creation and turn submission use the two-call Ollama pattern
|
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the
|
||||||
* (narrative then state extraction) when scenario or turn data is provided.
|
* 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
|
@ApplicationScoped
|
||||||
public class InMemorySessionService implements SessionService {
|
public class InMemorySessionService implements SessionService {
|
||||||
|
|
||||||
private final OllamaClient ollamaClient;
|
|
||||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
|
||||||
private final ScenarioService scenarioService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory store mapping session IDs to their current state.
|
* In-memory store mapping session IDs to their current state.
|
||||||
*/
|
*/
|
||||||
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the service with required dependencies.
|
|
||||||
*
|
|
||||||
* @param ollamaClient client for Ollama narrative and state-update calls
|
|
||||||
* @param objectMapper mapper to serialize turn context to JSON
|
|
||||||
* @param scenarioService service to resolve scenario by id from the database
|
|
||||||
*/
|
|
||||||
@Inject
|
|
||||||
public InMemorySessionService(final OllamaClient ollamaClient,
|
|
||||||
final com.fasterxml.jackson.databind.ObjectMapper objectMapper,
|
|
||||||
final ScenarioService scenarioService) {
|
|
||||||
this.ollamaClient = ollamaClient;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.scenarioService = scenarioService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* <p>Generates a new UUID as the session ID, populates default session
|
* <p>Generates a new UUID as the session ID, populates default session
|
||||||
* metadata, and stores the session. When a scenario is provided, runs the
|
* metadata, and stores the session. The Ollama two-call pattern is not
|
||||||
* two-call Ollama pattern to produce an opening narrative and initial state.
|
* yet invoked; a placeholder narrative is returned instead.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public SessionResponse createSession(final CreateSessionRequest request) {
|
public SessionResponse createSession(final CreateSessionRequest request) {
|
||||||
String sessionId = UUID.randomUUID().toString();
|
String sessionId = UUID.randomUUID().toString();
|
||||||
String model = request.getModel();
|
|
||||||
|
|
||||||
SessionResponse session = new SessionResponse(
|
SessionResponse session = new SessionResponse(
|
||||||
sessionId,
|
sessionId,
|
||||||
model,
|
request.getModel(),
|
||||||
request.getLanguage() != null ? request.getLanguage() : "en",
|
request.getLanguage() != null ? request.getLanguage() : "en",
|
||||||
request.getSafetyLevel() != null
|
request.getSafetyLevel() != null
|
||||||
? request.getSafetyLevel().value()
|
? request.getSafetyLevel().value()
|
||||||
@ -81,116 +49,14 @@ public class InMemorySessionService implements SessionService {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
ScenarioSetup scenario = resolveScenario(request);
|
// TODO: Invoke OllamaClient two-call pattern (narrative + state extraction)
|
||||||
if (scenario != null) {
|
// to produce a real opening scene and initial state.
|
||||||
session.setSituation(buildSituationFromScenario(scenario));
|
session.setNarrative("Session initialised. Ollama integration pending.");
|
||||||
session.setCharacters(buildCharactersFromScenario(scenario));
|
|
||||||
try {
|
|
||||||
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.fromScenario(scenario));
|
|
||||||
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.INIT_NARRATIVE, contextJson);
|
|
||||||
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
|
|
||||||
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
|
|
||||||
session.setNarrative(narrative);
|
|
||||||
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
session.setNarrative("Session initialised. No scenario provided.");
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions.put(sessionId, session);
|
sessions.put(sessionId, session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the effective scenario: scenarioId from DB takes precedence over inline scenario.
|
|
||||||
*
|
|
||||||
* @param request the create session request
|
|
||||||
* @return the scenario to use, or null if none
|
|
||||||
*/
|
|
||||||
private ScenarioSetup resolveScenario(final CreateSessionRequest request) {
|
|
||||||
if (request.getScenarioId() != null) {
|
|
||||||
return scenarioService.getScenarioAsSetup(request.getScenarioId()).orElse(null);
|
|
||||||
}
|
|
||||||
return request.getScenario();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds initial situation state from the scenario setup.
|
|
||||||
*
|
|
||||||
* @param scenario the scenario from the create request
|
|
||||||
* @return situation state with setting, initialConflict and currentScene derived
|
|
||||||
*/
|
|
||||||
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
|
|
||||||
SituationState situation = new SituationState();
|
|
||||||
situation.setSetting(scenario.getSetting());
|
|
||||||
situation.setCurrentScene(
|
|
||||||
scenario.getSetting() != null && scenario.getInitialConflict() != null
|
|
||||||
? scenario.getSetting() + " " + scenario.getInitialConflict()
|
|
||||||
: scenario.getSetting() != null
|
|
||||||
? scenario.getSetting()
|
|
||||||
: scenario.getInitialConflict());
|
|
||||||
return situation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds initial character list from the scenario (user character + AI characters).
|
|
||||||
*
|
|
||||||
* @param scenario the scenario from the create request
|
|
||||||
* @return list of character states
|
|
||||||
*/
|
|
||||||
private static List<CharacterState> buildCharactersFromScenario(final ScenarioSetup scenario) {
|
|
||||||
List<CharacterState> characters = new ArrayList<>();
|
|
||||||
if (scenario.getUserCharacter() != null) {
|
|
||||||
characters.add(toCharacterState(scenario.getUserCharacter(), true));
|
|
||||||
}
|
|
||||||
if (scenario.getAiCharacters() != null) {
|
|
||||||
for (CharacterDefinition def : scenario.getAiCharacters()) {
|
|
||||||
characters.add(toCharacterState(def, false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return characters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps a character definition to initial character state.
|
|
||||||
*
|
|
||||||
* @param def the definition
|
|
||||||
* @param isUserCharacter whether this is the user-controlled character
|
|
||||||
* @return character state with id, name, role and isUserCharacter set
|
|
||||||
*/
|
|
||||||
private static CharacterState toCharacterState(final CharacterDefinition def,
|
|
||||||
final boolean isUserCharacter) {
|
|
||||||
CharacterState state = new CharacterState(def.getId(), def.getName(), isUserCharacter);
|
|
||||||
state.setRole(def.getRole());
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritDoc}
|
|
||||||
*
|
|
||||||
* <p>Updates situation and/or characters when provided; omitted fields are unchanged.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public Optional<SessionResponse> updateSession(final String sessionId,
|
|
||||||
final UpdateSessionRequest request) {
|
|
||||||
SessionResponse session = sessions.get(sessionId);
|
|
||||||
if (session == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
if (request != null) {
|
|
||||||
if (request.getSituation() != null) {
|
|
||||||
session.setSituation(request.getSituation());
|
|
||||||
}
|
|
||||||
if (request.getCharacters() != null) {
|
|
||||||
session.setCharacters(new ArrayList<>(request.getCharacters()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessions.put(sessionId, session);
|
|
||||||
return Optional.of(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@ -202,9 +68,8 @@ public class InMemorySessionService implements SessionService {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* <p>Increments the turn counter, runs the two-call Ollama pattern with
|
* <p>Increments the turn counter and returns a stub {@link TurnResponse}.
|
||||||
* the current session state and turn request, merges the state update into
|
* The Ollama two-call pattern is not yet invoked.
|
||||||
* the session, and returns the turn response.
|
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Optional<TurnResponse> submitTurn(final String sessionId,
|
public Optional<TurnResponse> submitTurn(final String sessionId,
|
||||||
@ -214,23 +79,20 @@ public class InMemorySessionService implements SessionService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Increment turn counter
|
||||||
int nextTurn = session.getTurnNumber() + 1;
|
int nextTurn = session.getTurnNumber() + 1;
|
||||||
session.setTurnNumber(nextTurn);
|
session.setTurnNumber(nextTurn);
|
||||||
String model = session.getModel();
|
|
||||||
|
|
||||||
try {
|
// TODO: Invoke OllamaClient two-call pattern (narrative + state update)
|
||||||
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
|
// using the current session state, turnRequest.getUserAction(),
|
||||||
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_NARRATIVE, contextJson);
|
// and turnRequest.getRecommendation().
|
||||||
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
|
TurnResponse response = new TurnResponse(
|
||||||
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
|
nextTurn,
|
||||||
session.setNarrative(narrative);
|
"Turn " + nextTurn + " processed. Ollama integration pending."
|
||||||
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
|
);
|
||||||
TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
|
|
||||||
sessions.put(sessionId, session);
|
sessions.put(sessionId, session);
|
||||||
return Optional.of(response);
|
return Optional.of(response);
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@ -32,16 +31,6 @@ public interface SessionService {
|
|||||||
*/
|
*/
|
||||||
Optional<SessionResponse> getSession(String sessionId);
|
Optional<SessionResponse> getSession(String sessionId);
|
||||||
|
|
||||||
/**
|
|
||||||
* Partially updates an existing session (situation and/or characters).
|
|
||||||
* Omitted fields in the request are left unchanged.
|
|
||||||
*
|
|
||||||
* @param sessionId the unique session identifier
|
|
||||||
* @param request the update payload; may be null or have null fields
|
|
||||||
* @return an {@link Optional} containing the updated session, or empty if not found
|
|
||||||
*/
|
|
||||||
Optional<SessionResponse> updateSession(String sessionId, UpdateSessionRequest request);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a user's turn within an existing session. Runs the two-call
|
* Processes a user's turn within an existing session. Runs the two-call
|
||||||
* Ollama pattern and returns the resulting narrative with updated state.
|
* Ollama pattern and returns the resulting narrative with updated state.
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifies an AI character's response within a turn.
|
* Classifies an AI character's response within a turn.
|
||||||
*/
|
*/
|
||||||
@JsonDeserialize(using = ResponseTypeDeserializer.class)
|
|
||||||
public enum ResponseType {
|
public enum ResponseType {
|
||||||
|
|
||||||
/** Spoken dialogue. */
|
/** Spoken dialogue. */
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
import de.neitzel.roleplay.business.ModelService;
|
import de.neitzel.roleplay.business.ModelService;
|
||||||
|
import de.neitzel.roleplay.fascade.api.ModelsApi;
|
||||||
import de.neitzel.roleplay.fascade.model.ModelListResponse;
|
import de.neitzel.roleplay.fascade.model.ModelListResponse;
|
||||||
import de.neitzel.roleplay.generated.api.ModelsApi;
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
|||||||
@ -3,17 +3,14 @@ package de.neitzel.roleplay.fascade;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
|
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quarkus declarative REST client for the Ollama HTTP API.
|
* Quarkus declarative REST client for the Ollama HTTP API.
|
||||||
* Configuration (base URL, timeouts) is read from
|
* Configuration (base URL, timeouts) is read from
|
||||||
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
||||||
* All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}.
|
|
||||||
*/
|
*/
|
||||||
@RegisterRestClient(configKey = "ollama-api")
|
@RegisterRestClient(configKey = "ollama-api")
|
||||||
@RegisterProvider(OllamaClientLoggingFilter.class)
|
|
||||||
@Path("/api")
|
@Path("/api")
|
||||||
public interface OllamaApi {
|
public interface OllamaApi {
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import de.neitzel.roleplay.common.StateUpdateResponse;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -20,11 +18,6 @@ import java.util.List;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class OllamaClient {
|
public class OllamaClient {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(OllamaClient.class);
|
|
||||||
|
|
||||||
/** Maximum characters of narrative/JSON to log at TRACE (avoid huge logs). */
|
|
||||||
private static final int TRACE_CONTENT_LIMIT = 200;
|
|
||||||
|
|
||||||
/** Low-level REST client for the Ollama HTTP API. */
|
/** Low-level REST client for the Ollama HTTP API. */
|
||||||
private final OllamaApi ollamaApi;
|
private final OllamaApi ollamaApi;
|
||||||
|
|
||||||
@ -50,14 +43,10 @@ public class OllamaClient {
|
|||||||
* @return model metadata, or an empty list if none are installed
|
* @return model metadata, or an empty list if none are installed
|
||||||
*/
|
*/
|
||||||
public List<OllamaModelInfo> listModels() {
|
public List<OllamaModelInfo> listModels() {
|
||||||
LOG.debug("Fetching Ollama models (GET /api/tags)");
|
|
||||||
OllamaTagsResponse response = ollamaApi.getTags();
|
OllamaTagsResponse response = ollamaApi.getTags();
|
||||||
if (response == null || response.getModels() == null) {
|
if (response == null || response.getModels() == null) {
|
||||||
LOG.debug("Received null or empty models list from Ollama");
|
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
int count = response.getModels().size();
|
|
||||||
LOG.debug("Received {} model(s) from Ollama", count);
|
|
||||||
return response.getModels();
|
return response.getModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,11 +62,6 @@ public class OllamaClient {
|
|||||||
public String generateNarrative(final String model,
|
public String generateNarrative(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
final String userContent) {
|
||||||
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
|
|
||||||
int userLen = userContent != null ? userContent.length() : 0;
|
|
||||||
LOG.debug("Calling Ollama for narrative: model={}, systemPromptLength={}, userContentLength={}",
|
|
||||||
model, systemLen, userLen);
|
|
||||||
|
|
||||||
OllamaChatRequest request = OllamaChatRequest.builder()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.messages(List.of(
|
.messages(List.of(
|
||||||
@ -93,16 +77,7 @@ public class OllamaClient {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
OllamaChatResponse response = ollamaApi.chat(request);
|
||||||
String content = response.getMessage().getContent();
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -119,11 +94,6 @@ public class OllamaClient {
|
|||||||
public StateUpdateResponse generateStateUpdate(final String model,
|
public StateUpdateResponse generateStateUpdate(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
final String userContent) {
|
||||||
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
|
|
||||||
int userLen = userContent != null ? userContent.length() : 0;
|
|
||||||
LOG.debug("Calling Ollama for state update: model={}, systemPromptLength={}, userContentLength={}",
|
|
||||||
model, systemLen, userLen);
|
|
||||||
|
|
||||||
OllamaChatRequest request = OllamaChatRequest.builder()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.format("json")
|
.format("json")
|
||||||
@ -141,21 +111,10 @@ public class OllamaClient {
|
|||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
OllamaChatResponse response = ollamaApi.chat(request);
|
||||||
String json = response.getMessage().getContent();
|
String json = response.getMessage().getContent();
|
||||||
int jsonLen = json != null ? json.length() : 0;
|
|
||||||
LOG.debug("Received state update from Ollama, JSON length={}", jsonLen);
|
|
||||||
if (LOG.isTraceEnabled() && json != null && !json.isEmpty()) {
|
|
||||||
String snippet = json.length() <= TRACE_CONTENT_LIMIT
|
|
||||||
? json
|
|
||||||
: json.substring(0, TRACE_CONTENT_LIMIT) + "...";
|
|
||||||
LOG.trace("State update JSON snippet: {}", snippet);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
StateUpdateResponse result = objectMapper.readValue(json, StateUpdateResponse.class);
|
return objectMapper.readValue(json, StateUpdateResponse.class);
|
||||||
LOG.debug("Parsed state update successfully");
|
|
||||||
return result;
|
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage());
|
|
||||||
throw new OllamaParseException(
|
throw new OllamaParseException(
|
||||||
"Failed to parse state update JSON from Ollama response", e);
|
"Failed to parse state update JSON from Ollama response", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
import de.neitzel.roleplay.business.SessionService;
|
import de.neitzel.roleplay.business.SessionService;
|
||||||
|
import de.neitzel.roleplay.fascade.api.SessionsApi;
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
|
||||||
import de.neitzel.roleplay.generated.api.SessionsApi;
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
@ -55,20 +54,5 @@ public class SessionResource implements SessionsApi {
|
|||||||
.orElseThrow(() -> new NotFoundException(
|
.orElseThrow(() -> new NotFoundException(
|
||||||
"No session found with id: " + sessionId));
|
"No session found with id: " + sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* {@inheritDoc}
|
|
||||||
*
|
|
||||||
* <p>Delegates to {@link SessionService#updateSession(String, UpdateSessionRequest)}.
|
|
||||||
* Returns 404 if the session is not found.
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public SessionResponse updateSession(final String sessionId,
|
|
||||||
final UpdateSessionRequest updateSessionRequest) {
|
|
||||||
return sessionService.updateSession(sessionId,
|
|
||||||
updateSessionRequest != null ? updateSessionRequest : new UpdateSessionRequest())
|
|
||||||
.orElseThrow(() -> new NotFoundException(
|
|
||||||
"No session found with id: " + sessionId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
import de.neitzel.roleplay.business.SessionService;
|
import de.neitzel.roleplay.business.SessionService;
|
||||||
|
import de.neitzel.roleplay.fascade.api.TurnsApi;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
import de.neitzel.roleplay.generated.api.TurnsApi;
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
|||||||
@ -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:
|
quarkus:
|
||||||
application:
|
application:
|
||||||
name: roleplay
|
name: roleplay
|
||||||
http:
|
|
||||||
root-path: /
|
|
||||||
# Form-based auth: SPA mode (no redirects; 200/401). Session in encrypted cookie.
|
|
||||||
auth:
|
|
||||||
form:
|
|
||||||
enabled: true
|
|
||||||
login-page: ""
|
|
||||||
landing-page: ""
|
|
||||||
error-page: ""
|
|
||||||
http-only-cookie: false
|
|
||||||
session:
|
|
||||||
encryption-key: roleplay-session-secret-key-min-16chars
|
|
||||||
permission:
|
|
||||||
form-login:
|
|
||||||
paths: /j_security_check
|
|
||||||
policy: permit
|
|
||||||
api:
|
|
||||||
paths: /api/v1/*
|
|
||||||
policy: authenticated
|
|
||||||
datasource:
|
|
||||||
db-kind: h2
|
|
||||||
jdbc:
|
|
||||||
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
|
|
||||||
username: sa
|
|
||||||
password: ""
|
|
||||||
liquibase:
|
liquibase:
|
||||||
change-log: db/migration/changelog.xml
|
change-log: db/migration/changelog.xml
|
||||||
migrate-at-start: true
|
migrate-at-start: false
|
||||||
rest-client:
|
rest-client:
|
||||||
ollama-api:
|
ollama-api:
|
||||||
url: http://debian:11434
|
url: http://debian:11434
|
||||||
connect-timeout: 5000
|
connect-timeout: 5000
|
||||||
read-timeout: 120000
|
read-timeout: 120000
|
||||||
logging:
|
http:
|
||||||
scope: request-response
|
root-path: /api/v1
|
||||||
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
|
|
||||||
|
|||||||
@ -4,8 +4,5 @@
|
|||||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
<include file="db/migration/v001__scenarios_and_characters.xml"/>
|
|
||||||
<include file="db/migration/v002__users_and_roles.xml"/>
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
description: Manage role-play sessions
|
||||||
- name: turns
|
- name: turns
|
||||||
description: Submit user actions within a session
|
description: Submit user actions within a session
|
||||||
- name: scenarios
|
|
||||||
description: List and retrieve saved scenario templates
|
|
||||||
- name: characters
|
|
||||||
description: List and retrieve saved character templates
|
|
||||||
- name: auth
|
|
||||||
description: Authentication (current user, logout)
|
|
||||||
- name: users
|
|
||||||
description: User management (admin only)
|
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
@ -51,232 +43,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/scenarios:
|
|
||||||
get:
|
|
||||||
operationId: listScenarios
|
|
||||||
summary: List saved scenarios
|
|
||||||
description: Returns all stored scenario templates for selection when starting a session.
|
|
||||||
tags:
|
|
||||||
- scenarios
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of scenario summaries.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ScenarioListResponse'
|
|
||||||
post:
|
|
||||||
operationId: createScenario
|
|
||||||
summary: Create a scenario
|
|
||||||
description: Creates a new scenario with optional character slots. Server generates UUID.
|
|
||||||
tags:
|
|
||||||
- scenarios
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateScenarioRequest'
|
|
||||||
responses:
|
|
||||||
"201":
|
|
||||||
description: Scenario created.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ScenarioSummary'
|
|
||||||
"400":
|
|
||||||
description: Invalid request body or referenced character not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/scenarios/{scenarioId}:
|
|
||||||
get:
|
|
||||||
operationId: getScenario
|
|
||||||
summary: Get a scenario by id
|
|
||||||
description: Returns the full scenario setup (setting, conflict, characters) for the given id.
|
|
||||||
tags:
|
|
||||||
- scenarios
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/ScenarioId'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Scenario found and returned.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ScenarioSetup'
|
|
||||||
"404":
|
|
||||||
description: Scenario not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
put:
|
|
||||||
operationId: updateScenario
|
|
||||||
summary: Update a scenario
|
|
||||||
description: Full replace. Replaces all character slots.
|
|
||||||
tags:
|
|
||||||
- scenarios
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/ScenarioId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateScenarioRequest'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Scenario updated.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ScenarioSummary'
|
|
||||||
"400":
|
|
||||||
description: Invalid request body or referenced character not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"404":
|
|
||||||
description: Scenario not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
delete:
|
|
||||||
operationId: deleteScenario
|
|
||||||
summary: Delete a scenario
|
|
||||||
description: Removes the scenario template.
|
|
||||||
tags:
|
|
||||||
- scenarios
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/ScenarioId'
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: Scenario deleted.
|
|
||||||
"404":
|
|
||||||
description: Scenario not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/characters:
|
|
||||||
get:
|
|
||||||
operationId: listCharacters
|
|
||||||
summary: List saved characters
|
|
||||||
description: Returns all stored character templates for selection when building a scenario.
|
|
||||||
tags:
|
|
||||||
- characters
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of character definitions.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CharacterListResponse'
|
|
||||||
post:
|
|
||||||
operationId: createCharacter
|
|
||||||
summary: Create a character
|
|
||||||
description: Creates a new character template. Server generates UUID if id is omitted.
|
|
||||||
tags:
|
|
||||||
- characters
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateCharacterRequest'
|
|
||||||
responses:
|
|
||||||
"201":
|
|
||||||
description: Character created.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
|
||||||
"400":
|
|
||||||
description: Invalid request body.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/characters/{characterId}:
|
|
||||||
get:
|
|
||||||
operationId: getCharacter
|
|
||||||
summary: Get a character by id
|
|
||||||
description: Returns the full character definition for the given id.
|
|
||||||
tags:
|
|
||||||
- characters
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/CharacterId'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Character found and returned.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
|
||||||
"404":
|
|
||||||
description: Character not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
put:
|
|
||||||
operationId: updateCharacter
|
|
||||||
summary: Update a character
|
|
||||||
description: Full replace of the character. All fields required except optional ones.
|
|
||||||
tags:
|
|
||||||
- characters
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/CharacterId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateCharacterRequest'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Character updated.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
|
||||||
"400":
|
|
||||||
description: Invalid request body.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"404":
|
|
||||||
description: Character not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
delete:
|
|
||||||
operationId: deleteCharacter
|
|
||||||
summary: Delete a character
|
|
||||||
description: Removes the character template. Scenarios referencing it may need updating.
|
|
||||||
tags:
|
|
||||||
- characters
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/CharacterId'
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: Character deleted.
|
|
||||||
"404":
|
|
||||||
description: Character not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/sessions:
|
/sessions:
|
||||||
post:
|
post:
|
||||||
operationId: createSession
|
operationId: createSession
|
||||||
@ -335,40 +101,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
patch:
|
|
||||||
operationId: updateSession
|
|
||||||
summary: Update session state
|
|
||||||
description: |
|
|
||||||
Partially updates an existing session. Provide situation and/or characters
|
|
||||||
to replace the current values. Omitted fields are left unchanged.
|
|
||||||
tags:
|
|
||||||
- sessions
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/SessionId'
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UpdateSessionRequest'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Session updated; full state returned.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/SessionResponse'
|
|
||||||
"404":
|
|
||||||
description: Session not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"400":
|
|
||||||
description: Invalid request body.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
/sessions/{sessionId}/turns:
|
/sessions/{sessionId}/turns:
|
||||||
post:
|
post:
|
||||||
@ -415,175 +147,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/auth/me:
|
|
||||||
get:
|
|
||||||
operationId: getAuthMe
|
|
||||||
summary: Current user info
|
|
||||||
description: Returns the authenticated user's username and roles. Requires session cookie.
|
|
||||||
tags:
|
|
||||||
- auth
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Current user.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/AuthMeResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
|
|
||||||
/auth/logout:
|
|
||||||
post:
|
|
||||||
operationId: logout
|
|
||||||
summary: Log out
|
|
||||||
description: Clears the session cookie. Requires authenticated user.
|
|
||||||
tags:
|
|
||||||
- auth
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: Logged out.
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
|
|
||||||
/auth/change-password:
|
|
||||||
post:
|
|
||||||
operationId: changePassword
|
|
||||||
summary: Change password
|
|
||||||
description: Changes the current user's password. Requires current password verification.
|
|
||||||
tags:
|
|
||||||
- auth
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ChangePasswordRequest'
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: Password changed.
|
|
||||||
"400":
|
|
||||||
description: Current password wrong or validation failed.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
|
|
||||||
/admin/users:
|
|
||||||
get:
|
|
||||||
operationId: listUsers
|
|
||||||
summary: List users (admin only)
|
|
||||||
description: Returns all users (id, username, role). Admin role required.
|
|
||||||
tags:
|
|
||||||
- users
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of users.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserListResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
"403":
|
|
||||||
description: Forbidden (admin only).
|
|
||||||
post:
|
|
||||||
operationId: createUser
|
|
||||||
summary: Create user (admin only)
|
|
||||||
description: Creates a new user with the given username, password, and role.
|
|
||||||
tags:
|
|
||||||
- users
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateUserRequest'
|
|
||||||
responses:
|
|
||||||
"201":
|
|
||||||
description: User created.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserSummary'
|
|
||||||
"400":
|
|
||||||
description: Invalid request (e.g. username already exists).
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
"403":
|
|
||||||
description: Forbidden (admin only).
|
|
||||||
|
|
||||||
/admin/users/{userId}:
|
|
||||||
put:
|
|
||||||
operationId: updateUser
|
|
||||||
summary: Update user (admin only)
|
|
||||||
description: Updates an existing user by id. Username and role required; password optional (omit to keep current).
|
|
||||||
tags:
|
|
||||||
- users
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UpdateUserRequest'
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: User updated.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserSummary'
|
|
||||||
"400":
|
|
||||||
description: Invalid request (e.g. username already exists).
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
"403":
|
|
||||||
description: Forbidden (admin only).
|
|
||||||
"404":
|
|
||||||
description: User not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
delete:
|
|
||||||
operationId: deleteUser
|
|
||||||
summary: Delete user (admin only)
|
|
||||||
description: Deletes a user by id. Cannot delete self or the last admin.
|
|
||||||
tags:
|
|
||||||
- users
|
|
||||||
parameters:
|
|
||||||
- $ref: '#/components/parameters/UserId'
|
|
||||||
responses:
|
|
||||||
"204":
|
|
||||||
description: User deleted.
|
|
||||||
"400":
|
|
||||||
description: Cannot delete self or last admin.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
"401":
|
|
||||||
description: Not authenticated.
|
|
||||||
"403":
|
|
||||||
description: Forbidden (admin only).
|
|
||||||
"404":
|
|
||||||
description: User not found.
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
@ -594,30 +157,6 @@ components:
|
|||||||
description: Unique identifier of the role-play session.
|
description: Unique identifier of the role-play session.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
ScenarioId:
|
|
||||||
name: scenarioId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
description: Unique identifier of the scenario (UUID).
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
CharacterId:
|
|
||||||
name: characterId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
description: Unique identifier of the character (UUID).
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
UserId:
|
|
||||||
name: userId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
description: Unique identifier of the user (UUID).
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
|
||||||
@ -682,130 +221,9 @@ components:
|
|||||||
default: standard
|
default: standard
|
||||||
scenario:
|
scenario:
|
||||||
$ref: '#/components/schemas/ScenarioSetup'
|
$ref: '#/components/schemas/ScenarioSetup'
|
||||||
scenarioId:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: If set, the backend loads this scenario from the database and uses it instead of an inline scenario.
|
|
||||||
required:
|
required:
|
||||||
- model
|
- model
|
||||||
|
|
||||||
ScenarioSummary:
|
|
||||||
type: object
|
|
||||||
description: Summary of a stored scenario for list views.
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: Unique identifier of the scenario.
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Human-readable scenario name.
|
|
||||||
setting:
|
|
||||||
type: string
|
|
||||||
description: Place, time, and atmosphere (optional in list).
|
|
||||||
initialConflict:
|
|
||||||
type: string
|
|
||||||
description: The hook or starting conflict (optional in list).
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
|
|
||||||
ScenarioListResponse:
|
|
||||||
type: object
|
|
||||||
description: Response containing all stored scenarios.
|
|
||||||
properties:
|
|
||||||
scenarios:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ScenarioSummary'
|
|
||||||
required:
|
|
||||||
- scenarios
|
|
||||||
|
|
||||||
CharacterListResponse:
|
|
||||||
type: object
|
|
||||||
description: Response containing all stored characters.
|
|
||||||
properties:
|
|
||||||
characters:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
|
||||||
required:
|
|
||||||
- characters
|
|
||||||
|
|
||||||
CreateCharacterRequest:
|
|
||||||
type: object
|
|
||||||
description: Request body for creating or updating a character. id optional on create (server generates UUID).
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: Optional on create; server generates if omitted.
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Display name.
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
description: Narrative role.
|
|
||||||
backstory:
|
|
||||||
type: string
|
|
||||||
description: Character background.
|
|
||||||
speakingStyle:
|
|
||||||
type: string
|
|
||||||
description: How the character speaks.
|
|
||||||
personalityTraits:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: List of personality traits.
|
|
||||||
goals:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: Character goals.
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
- role
|
|
||||||
|
|
||||||
ScenarioCharacterSlot:
|
|
||||||
type: object
|
|
||||||
description: Assignment of a character to a scenario slot (user or AI, with position).
|
|
||||||
properties:
|
|
||||||
characterId:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: Reference to a saved character.
|
|
||||||
isUserCharacter:
|
|
||||||
type: boolean
|
|
||||||
description: True if this slot is the player character.
|
|
||||||
position:
|
|
||||||
type: integer
|
|
||||||
description: Order of the character (0 for user, 1+ for AI).
|
|
||||||
required:
|
|
||||||
- characterId
|
|
||||||
- isUserCharacter
|
|
||||||
- position
|
|
||||||
|
|
||||||
CreateScenarioRequest:
|
|
||||||
type: object
|
|
||||||
description: Request body for creating or updating a scenario.
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: Human-readable scenario name.
|
|
||||||
setting:
|
|
||||||
type: string
|
|
||||||
description: Place, time, and atmosphere.
|
|
||||||
initialConflict:
|
|
||||||
type: string
|
|
||||||
description: The hook or starting conflict.
|
|
||||||
characterSlots:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/ScenarioCharacterSlot'
|
|
||||||
description: Assigned characters (one user, ordered AI).
|
|
||||||
required:
|
|
||||||
- name
|
|
||||||
|
|
||||||
ScenarioSetup:
|
ScenarioSetup:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@ -827,19 +245,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
|
||||||
UpdateSessionRequest:
|
|
||||||
type: object
|
|
||||||
description: Request body for partially updating a session.
|
|
||||||
properties:
|
|
||||||
situation:
|
|
||||||
$ref: '#/components/schemas/SituationState'
|
|
||||||
description: Replace session situation when provided.
|
|
||||||
characters:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/CharacterState'
|
|
||||||
description: Replace session character list when provided.
|
|
||||||
|
|
||||||
CharacterDefinition:
|
CharacterDefinition:
|
||||||
type: object
|
type: object
|
||||||
description: Definition of a character for session initialisation.
|
description: Definition of a character for session initialisation.
|
||||||
@ -1198,104 +603,6 @@ components:
|
|||||||
- type
|
- type
|
||||||
- title
|
- title
|
||||||
|
|
||||||
# ─── Auth and users ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
AuthMeResponse:
|
|
||||||
type: object
|
|
||||||
description: Current authenticated user.
|
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: Login name.
|
|
||||||
roles:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: Assigned roles (e.g. admin, user).
|
|
||||||
required:
|
|
||||||
- username
|
|
||||||
- roles
|
|
||||||
|
|
||||||
ChangePasswordRequest:
|
|
||||||
type: object
|
|
||||||
description: Request to change the current user's password.
|
|
||||||
properties:
|
|
||||||
currentPassword:
|
|
||||||
type: string
|
|
||||||
description: Current plain-text password (verified before change).
|
|
||||||
newPassword:
|
|
||||||
type: string
|
|
||||||
description: New plain-text password (hashed on server).
|
|
||||||
required:
|
|
||||||
- currentPassword
|
|
||||||
- newPassword
|
|
||||||
|
|
||||||
UserSummary:
|
|
||||||
type: object
|
|
||||||
description: User without password (for list and create response).
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
format: uuid
|
|
||||||
description: User UUID.
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: Login name.
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
description: Role (admin or user).
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
- username
|
|
||||||
- role
|
|
||||||
|
|
||||||
UserListResponse:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/UserSummary'
|
|
||||||
description: List of users (admin only).
|
|
||||||
|
|
||||||
CreateUserRequest:
|
|
||||||
type: object
|
|
||||||
description: Request to create a new user (admin only).
|
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: Login name.
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
description: Plain-text password (hashed on server).
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- admin
|
|
||||||
- user
|
|
||||||
description: Role to assign.
|
|
||||||
required:
|
|
||||||
- username
|
|
||||||
- password
|
|
||||||
- role
|
|
||||||
|
|
||||||
UpdateUserRequest:
|
|
||||||
type: object
|
|
||||||
description: Request to update an existing user (admin only). Password optional; omit to keep current.
|
|
||||||
properties:
|
|
||||||
username:
|
|
||||||
type: string
|
|
||||||
description: Login name.
|
|
||||||
password:
|
|
||||||
type: string
|
|
||||||
description: Optional new plain-text password (omit or blank to keep current).
|
|
||||||
role:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- admin
|
|
||||||
- user
|
|
||||||
description: Role to assign.
|
|
||||||
required:
|
|
||||||
- username
|
|
||||||
- role
|
|
||||||
|
|
||||||
# ─── Error ────────────────────────────────────────────────────────────────
|
# ─── Error ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
|
|||||||
@ -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;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import de.neitzel.roleplay.common.StateUpdateResponse;
|
|
||||||
import de.neitzel.roleplay.fascade.OllamaClient;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CharacterState;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.SituationState;
|
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
|
||||||
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.lenient;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link InMemorySessionService}.
|
* Unit tests for {@link InMemorySessionService}.
|
||||||
*/
|
*/
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class InMemorySessionServiceTest {
|
class InMemorySessionServiceTest {
|
||||||
|
|
||||||
@Mock
|
/**
|
||||||
private OllamaClient ollamaClient;
|
* Instance under test – no CDI dependencies to mock.
|
||||||
|
*/
|
||||||
@Mock
|
|
||||||
private ScenarioService scenarioService;
|
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
private InMemorySessionService sessionService;
|
private InMemorySessionService sessionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fresh service instance with mocked Ollama client and scenario service before each test.
|
* Creates a fresh service instance before each test to ensure isolation.
|
||||||
* By default, Ollama is stubbed to return a short narrative and an empty
|
|
||||||
* state update so that createSession (with scenario) and submitTurn complete.
|
|
||||||
*/
|
*/
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
sessionService = new InMemorySessionService(ollamaClient, objectMapper, scenarioService);
|
sessionService = new InMemorySessionService();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -198,132 +164,5 @@ class InMemorySessionServiceTest {
|
|||||||
// Assert
|
// Assert
|
||||||
assertEquals("en", response.getLanguage());
|
assertEquals("en", response.getLanguage());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that creating a session with a scenario populates situation and characters.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void createSessionWithScenarioPopulatesSituationAndCharacters() {
|
|
||||||
// Arrange
|
|
||||||
CharacterDefinition userChar = new CharacterDefinition("hero", "The Hero", "protagonist");
|
|
||||||
CharacterDefinition aiChar = new CharacterDefinition("mentor", "Old Sage", "wise guide");
|
|
||||||
ScenarioSetup scenario = new ScenarioSetup();
|
|
||||||
scenario.setSetting("A fog-covered harbour at dawn, 1923");
|
|
||||||
scenario.setInitialConflict("Strange noises from the cargo hold");
|
|
||||||
scenario.setUserCharacter(userChar);
|
|
||||||
scenario.setAiCharacters(List.of(aiChar));
|
|
||||||
|
|
||||||
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
|
|
||||||
request.setScenario(scenario);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
SessionResponse response = sessionService.createSession(request);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertNotNull(response.getSituation());
|
|
||||||
assertEquals("A fog-covered harbour at dawn, 1923", response.getSituation().getSetting());
|
|
||||||
assertNotNull(response.getSituation().getCurrentScene());
|
|
||||||
assertTrue(response.getSituation().getCurrentScene()
|
|
||||||
.contains("A fog-covered harbour at dawn, 1923"));
|
|
||||||
assertTrue(response.getSituation().getCurrentScene().contains("Strange noises"));
|
|
||||||
|
|
||||||
assertNotNull(response.getCharacters());
|
|
||||||
assertEquals(2, response.getCharacters().size());
|
|
||||||
CharacterState userState = response.getCharacters().stream()
|
|
||||||
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
|
|
||||||
.findFirst().orElseThrow();
|
|
||||||
assertEquals("hero", userState.getId());
|
|
||||||
assertEquals("The Hero", userState.getName());
|
|
||||||
assertEquals("protagonist", userState.getRole());
|
|
||||||
CharacterState aiState = response.getCharacters().stream()
|
|
||||||
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
|
|
||||||
.findFirst().orElseThrow();
|
|
||||||
assertEquals("mentor", aiState.getId());
|
|
||||||
assertEquals("Old Sage", aiState.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that creating a session with scenarioId uses the scenario loaded from the database.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void createSessionWithScenarioIdUsesScenarioFromDatabase() {
|
|
||||||
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
|
|
||||||
CharacterDefinition userChar = new CharacterDefinition("db_user", "DB User", "protagonist");
|
|
||||||
CharacterDefinition aiChar = new CharacterDefinition("db_ai", "DB NPC", "antagonist");
|
|
||||||
ScenarioSetup loadedScenario = new ScenarioSetup();
|
|
||||||
loadedScenario.setSetting("Database setting");
|
|
||||||
loadedScenario.setInitialConflict("Database conflict");
|
|
||||||
loadedScenario.setUserCharacter(userChar);
|
|
||||||
loadedScenario.setAiCharacters(List.of(aiChar));
|
|
||||||
when(scenarioService.getScenarioAsSetup(scenarioId)).thenReturn(Optional.of(loadedScenario));
|
|
||||||
|
|
||||||
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
|
|
||||||
request.setScenarioId(scenarioId);
|
|
||||||
request.setScenario(null);
|
|
||||||
|
|
||||||
SessionResponse response = sessionService.createSession(request);
|
|
||||||
|
|
||||||
assertNotNull(response.getSituation());
|
|
||||||
assertEquals("Database setting", response.getSituation().getSetting());
|
|
||||||
assertNotNull(response.getCharacters());
|
|
||||||
assertEquals(2, response.getCharacters().size());
|
|
||||||
CharacterState userState = response.getCharacters().stream()
|
|
||||||
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
|
|
||||||
.findFirst().orElseThrow();
|
|
||||||
assertEquals("db_user", userState.getId());
|
|
||||||
assertEquals("DB User", userState.getName());
|
|
||||||
CharacterState aiState = response.getCharacters().stream()
|
|
||||||
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
|
|
||||||
.findFirst().orElseThrow();
|
|
||||||
assertEquals("db_ai", aiState.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that updateSession updates situation and characters when provided.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void updateSessionUpdatesSituationAndCharacters() {
|
|
||||||
// Arrange
|
|
||||||
CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest");
|
|
||||||
SessionResponse session = sessionService.createSession(createRequest);
|
|
||||||
String sessionId = session.getSessionId();
|
|
||||||
|
|
||||||
SituationState newSituation = new SituationState();
|
|
||||||
newSituation.setSetting("New setting");
|
|
||||||
newSituation.setCurrentScene("New scene focus");
|
|
||||||
CharacterState newChar = new CharacterState("npc1", "First NPC", false);
|
|
||||||
newChar.setRole("supporting");
|
|
||||||
UpdateSessionRequest updateRequest = new UpdateSessionRequest();
|
|
||||||
updateRequest.setSituation(newSituation);
|
|
||||||
updateRequest.setCharacters(List.of(newChar));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
Optional<SessionResponse> updated = sessionService.updateSession(sessionId, updateRequest);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assertTrue(updated.isPresent());
|
|
||||||
assertEquals("New setting", updated.get().getSituation().getSetting());
|
|
||||||
assertEquals("New scene focus", updated.get().getSituation().getCurrentScene());
|
|
||||||
assertEquals(1, updated.get().getCharacters().size());
|
|
||||||
assertEquals("npc1", updated.get().getCharacters().get(0).getId());
|
|
||||||
|
|
||||||
Optional<SessionResponse> got = sessionService.getSession(sessionId);
|
|
||||||
assertTrue(got.isPresent());
|
|
||||||
assertEquals("New setting", got.get().getSituation().getSetting());
|
|
||||||
assertEquals(1, got.get().getCharacters().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that updateSession returns empty for unknown session.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void updateSessionReturnsEmptyForUnknownSession() {
|
|
||||||
UpdateSessionRequest request = new UpdateSessionRequest();
|
|
||||||
request.setSituation(new SituationState());
|
|
||||||
|
|
||||||
Optional<SessionResponse> result = sessionService.updateSession("unknown-id", request);
|
|
||||||
|
|
||||||
assertTrue(result.isEmpty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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).getKnowledgeGained());
|
||||||
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies that combined type "speech|action" from Ollama is accepted and
|
|
||||||
* mapped to {@link ResponseType#SPEECH}.
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
void deserialisesSpeechOrActionAsSpeech() throws Exception {
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"responses": [
|
|
||||||
{
|
|
||||||
"character_id": "narrator",
|
|
||||||
"type": "speech|action",
|
|
||||||
"content": "I will do it.",
|
|
||||||
"action": "nods firmly",
|
|
||||||
"mood_after": "determined"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"updated_situation": null,
|
|
||||||
"updated_characters": [],
|
|
||||||
"suggestions": []
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
StateUpdateResponse result = mapper.readValue(json, StateUpdateResponse.class);
|
|
||||||
assertNotNull(result.getResponses());
|
|
||||||
assertEquals(1, result.getResponses().size());
|
|
||||||
assertEquals(ResponseType.SPEECH, result.getResponses().get(0).getType());
|
|
||||||
assertEquals("I will do it.", result.getResponses().get(0).getContent());
|
|
||||||
assertEquals("nods firmly", result.getResponses().get(0).getAction());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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