Compare commits
10 Commits
e8bb6a64b7
...
3218cb1a2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3218cb1a2a | ||
|
|
1e1368e519 | ||
|
|
2c61ab5fc9 | ||
|
|
4c1584ec27 | ||
|
|
3ce1215487 | ||
|
|
cf93b35dd6 | ||
|
|
b79334ee67 | ||
|
|
f21f1e7520 | ||
|
|
ffb97f6b8e | ||
|
|
f91604aea6 |
69
.cursor/rules/roleplay-project.mdc
Normal file
69
.cursor/rules/roleplay-project.mdc
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
description: RolePlay project context, architecture, and coding standards (Java/Quarkus, Maven, frontend)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# RolePlay – Project Instructions
|
||||||
|
|
||||||
|
## Project context
|
||||||
|
|
||||||
|
- **Project**: RolePlay
|
||||||
|
- **Java**: 21
|
||||||
|
- **Build**: Maven
|
||||||
|
- **Framework**: Quarkus (latest stable)
|
||||||
|
- **GroupId**: de.neitzel | **ArtifactId**: roleplay
|
||||||
|
- **Base package**: `de.neitzel.roleplay`
|
||||||
|
- **Sub-packages**: `business`, `common`, `data`, `fascade`
|
||||||
|
- Startup code lives in the base package.
|
||||||
|
- **DB migrations**: Liquibase; scripts in `src/main/resources/db/migration`.
|
||||||
|
|
||||||
|
## Architecture and package rules
|
||||||
|
|
||||||
|
- **Startup/bootstrap**: `de.neitzel.roleplay`
|
||||||
|
- **Business logic**: `de.neitzel.roleplay.business`
|
||||||
|
- **Shared utilities, cross-cutting types**: `de.neitzel.roleplay.common`
|
||||||
|
- **Persistence / data access**: `de.neitzel.roleplay.data`
|
||||||
|
- **External-facing facades (REST, API)**: `de.neitzel.roleplay.fascade`
|
||||||
|
- Keep clear package boundaries; avoid circular dependencies.
|
||||||
|
|
||||||
|
## Coding standards (Java)
|
||||||
|
|
||||||
|
- Use Lombok to reduce boilerplate where it helps.
|
||||||
|
- Prefer immutability: `final` fields, constructor injection.
|
||||||
|
- Use Quarkus idioms; avoid heavyweight frameworks.
|
||||||
|
- Keep methods small and focused; one responsibility per class.
|
||||||
|
- Use Java 21 features when they improve clarity.
|
||||||
|
- Add concise comments only for non-obvious logic.
|
||||||
|
- All classes, fields, and methods (including private) must have JavaDoc describing purpose and usage.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Add or update unit tests for new or changed logic.
|
||||||
|
- Use JUnit 5 and Mockito; 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,2 +1,21 @@
|
|||||||
|
# Maven build output
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
|
# Vite build output (written into resources so it gets into the JAR)
|
||||||
|
src/main/resources/META-INF/resources/
|
||||||
|
|
||||||
|
# IDE
|
||||||
/.idea/
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# OpenAPI-generated TypeScript client (regenerated by mvn generate-sources)
|
||||||
|
src/main/web/src/api/generated/
|
||||||
|
|
||||||
|
# TypeScript incremental build cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# OS metadata
|
||||||
|
.DS_Store
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic"
|
||||||
|
}
|
||||||
@ -11,6 +11,15 @@ Minimal Quarkus (Java 21) project scaffold for the RolePlay service.
|
|||||||
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
|
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
|
||||||
- `src/main/resources/db/migration`: Liquibase changelog location
|
- `src/main/resources/db/migration`: Liquibase changelog location
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
The app requires authentication. A default admin user is seeded on first run:
|
||||||
|
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `changeme`
|
||||||
|
|
||||||
|
Change the password after first login (e.g. by creating a new admin user and retiring the default, or via a future password-change feature). Only users with the `admin` role can create new users (POST `/api/v1/admin/users`).
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
|||||||
125
pom.xml
125
pom.xml
@ -11,15 +11,21 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
|
<maven.compiler.plugin.version>3.12.1</maven.compiler.plugin.version>
|
||||||
|
<maven.resources.plugin.version>3.3.1</maven.resources.plugin.version>
|
||||||
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
|
<maven.surefire.plugin.version>3.2.5</maven.surefire.plugin.version>
|
||||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||||
<quarkus.platform.version>3.15.3</quarkus.platform.version>
|
<quarkus.platform.version>3.31.2</quarkus.platform.version>
|
||||||
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
|
<quarkus.plugin.version>${quarkus.platform.version}</quarkus.plugin.version>
|
||||||
|
<jackson.version>2.20.1</jackson.version>
|
||||||
|
<jackson.annotations.version>2.20</jackson.annotations.version>
|
||||||
<lombok.version>1.18.42</lombok.version>
|
<lombok.version>1.18.42</lombok.version>
|
||||||
<junit.jupiter.version>5.10.3</junit.jupiter.version>
|
<junit.jupiter.version>5.10.3</junit.jupiter.version>
|
||||||
<mockito.version>5.12.0</mockito.version>
|
<mockito.version>5.12.0</mockito.version>
|
||||||
<openapi.generator.version>7.11.0</openapi.generator.version>
|
<openapi.generator.version>7.13.0</openapi.generator.version>
|
||||||
|
<frontend.plugin.version>1.15.1</frontend.plugin.version>
|
||||||
|
<node.version>v22.13.1</node.version>
|
||||||
|
<npm.version>10.9.2</npm.version>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@ -52,10 +58,30 @@
|
|||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-hibernate-validator</artifactId>
|
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-config-yaml</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-jdbc-h2</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-liquibase</artifactId>
|
<artifactId>quarkus-liquibase</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-security-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-rest-client-config</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||||
@ -79,6 +105,16 @@
|
|||||||
<version>${mockito.version}</version>
|
<version>${mockito.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@ -88,6 +124,17 @@
|
|||||||
<artifactId>quarkus-maven-plugin</artifactId>
|
<artifactId>quarkus-maven-plugin</artifactId>
|
||||||
<version>${quarkus.plugin.version}</version>
|
<version>${quarkus.plugin.version}</version>
|
||||||
<extensions>true</extensions>
|
<extensions>true</extensions>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build</goal>
|
||||||
|
</goals>
|
||||||
|
<phase>package</phase>
|
||||||
|
<configuration>
|
||||||
|
<skip>${quarkus.package.skip}</skip>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
@ -108,6 +155,7 @@
|
|||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<!-- On Java 25, skip to avoid VerifyError; run JAVA_HOME=/path/to/jdk21 mvn generate-sources first. -->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.openapitools</groupId>
|
<groupId>org.openapitools</groupId>
|
||||||
<artifactId>openapi-generator-maven-plugin</artifactId>
|
<artifactId>openapi-generator-maven-plugin</artifactId>
|
||||||
@ -121,8 +169,9 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
||||||
<generatorName>jaxrs-spec</generatorName>
|
<generatorName>jaxrs-spec</generatorName>
|
||||||
|
<templateDirectory>${project.basedir}/src/main/resources/openapi-templates</templateDirectory>
|
||||||
<output>${project.build.directory}/generated-sources/openapi</output>
|
<output>${project.build.directory}/generated-sources/openapi</output>
|
||||||
<apiPackage>de.neitzel.roleplay.fascade.api</apiPackage>
|
<apiPackage>de.neitzel.roleplay.generated.api</apiPackage>
|
||||||
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
|
<modelPackage>de.neitzel.roleplay.fascade.model</modelPackage>
|
||||||
<configOptions>
|
<configOptions>
|
||||||
<interfaceOnly>true</interfaceOnly>
|
<interfaceOnly>true</interfaceOnly>
|
||||||
@ -139,6 +188,27 @@
|
|||||||
<generateSupportingFiles>false</generateSupportingFiles>
|
<generateSupportingFiles>false</generateSupportingFiles>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>generate-typescript-client</id>
|
||||||
|
<goals>
|
||||||
|
<goal>generate</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<skip>${skip.openapi.generate}</skip>
|
||||||
|
<inputSpec>${project.basedir}/src/main/resources/openapi-roleplay-public-v1.yml</inputSpec>
|
||||||
|
<generatorName>typescript-fetch</generatorName>
|
||||||
|
<output>${project.basedir}/src/main/web/src/api/generated</output>
|
||||||
|
<configOptions>
|
||||||
|
<supportsES6>true</supportsES6>
|
||||||
|
<useSingleRequestParameter>true</useSingleRequestParameter>
|
||||||
|
<enumPropertyNaming>original</enumPropertyNaming>
|
||||||
|
<modelPropertyNaming>camelCase</modelPropertyNaming>
|
||||||
|
<stringEnums>true</stringEnums>
|
||||||
|
<withInterfaces>false</withInterfaces>
|
||||||
|
</configOptions>
|
||||||
|
<generateSupportingFiles>true</generateSupportingFiles>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
@ -160,10 +230,55 @@
|
|||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<version>${maven.surefire.plugin.version}</version>
|
<version>${maven.surefire.plugin.version}</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<useModulePath>false</useModulePath>
|
<argLine>@{argLine}</argLine>
|
||||||
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
|
<systemPropertyVariables>
|
||||||
|
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||||
|
<maven.home>${maven.home}</maven.home>
|
||||||
|
</systemPropertyVariables>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>${frontend.plugin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>src/main/web</workingDirectory>
|
||||||
|
<installDirectory>${project.build.directory}/node</installDirectory>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<phase>initialize</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>install-node-and-npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<nodeVersion>${node.version}</nodeVersion>
|
||||||
|
<npmVersion>${npm.version}</npmVersion>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>npm</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
36
src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
Normal file
36
src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package de.neitzel.roleplay;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import io.quarkus.runtime.StartupEvent;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.event.Observes;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs login-related debug info at startup: whether the seeded admin user exists
|
||||||
|
* and whether the stored password hash matches "changeme". Helps diagnose login failures.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class LoginDebugStartup {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(LoginDebugStartup.class);
|
||||||
|
|
||||||
|
private static final String DEFAULT_ADMIN_PASSWORD = "changeme";
|
||||||
|
|
||||||
|
void onStart(@Observes StartupEvent event) {
|
||||||
|
UserEntity admin = (UserEntity) UserEntity.find("username", "admin").firstResult();
|
||||||
|
if (admin == null) {
|
||||||
|
LOG.warn("Login debug: No user 'admin' found in database. Create the admin user (e.g. via Liquibase seed).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String hash = admin.getPassword();
|
||||||
|
boolean passwordMatches = hash != null && BcryptUtil.matches(DEFAULT_ADMIN_PASSWORD, hash);
|
||||||
|
if (passwordMatches) {
|
||||||
|
LOG.info("Login debug: User 'admin' exists; password matches '" + DEFAULT_ADMIN_PASSWORD + "'.");
|
||||||
|
} else {
|
||||||
|
LOG.warn("Login debug: User 'admin' exists but stored password hash does NOT match '" + DEFAULT_ADMIN_PASSWORD + "'. "
|
||||||
|
+ "Reset the password or update the seed hash in v002__users_and_roles.xml.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,33 +0,0 @@
|
|||||||
package de.neitzel.roleplay;
|
|
||||||
|
|
||||||
import io.quarkus.runtime.Quarkus;
|
|
||||||
import io.quarkus.runtime.QuarkusApplication;
|
|
||||||
import io.quarkus.runtime.annotations.QuarkusMain;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Application entry point for the RolePlay service.
|
|
||||||
*/
|
|
||||||
@QuarkusMain
|
|
||||||
public final class RolePlayApplication implements QuarkusApplication {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the Quarkus application.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments
|
|
||||||
*/
|
|
||||||
public static void main(final String[] args) {
|
|
||||||
Quarkus.run(RolePlayApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs after the Quarkus runtime starts.
|
|
||||||
*
|
|
||||||
* @param args command-line arguments
|
|
||||||
* @return the process exit code
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public int run(final String... args) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
141
src/main/java/de/neitzel/roleplay/business/CharacterService.java
Normal file
141
src/main/java/de/neitzel/roleplay/business/CharacterService.java
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.CharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business service for stored character templates. Maps entities to API DTOs.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CharacterService {
|
||||||
|
|
||||||
|
private final CharacterRepository characterRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public CharacterService(final CharacterRepository characterRepository) {
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all stored characters as API definitions.
|
||||||
|
*
|
||||||
|
* @return list of character definitions, ordered by name
|
||||||
|
*/
|
||||||
|
public List<CharacterDefinition> listCharacters() {
|
||||||
|
return characterRepository.listAll().stream()
|
||||||
|
.map(CharacterService::toCharacterDefinition)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single character by id, if present.
|
||||||
|
*
|
||||||
|
* @param id the character UUID
|
||||||
|
* @return the character definition or empty
|
||||||
|
*/
|
||||||
|
public Optional<CharacterDefinition> getCharacter(final UUID id) {
|
||||||
|
CharacterEntity entity = characterRepository.findByIdOptional(id);
|
||||||
|
return entity != null ? Optional.of(toCharacterDefinition(entity)) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new character from the request. Uses request id if present, otherwise generates a UUID.
|
||||||
|
*
|
||||||
|
* @param request the create request (name and role required)
|
||||||
|
* @return the created character definition
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CharacterDefinition create(final CreateCharacterRequest request) {
|
||||||
|
CharacterEntity entity = fromRequest(request, request.getId() != null ? request.getId() : UUID.randomUUID());
|
||||||
|
characterRepository.persist(entity);
|
||||||
|
return toCharacterDefinition(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing character by id. Full replace of all fields.
|
||||||
|
*
|
||||||
|
* @param id the character UUID
|
||||||
|
* @param request the update request
|
||||||
|
* @return the updated character definition
|
||||||
|
* @throws java.util.NoSuchElementException if the character does not exist
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public CharacterDefinition update(final UUID id, final CreateCharacterRequest request) {
|
||||||
|
CharacterEntity entity = characterRepository.findByIdOptional(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new java.util.NoSuchElementException("No character found with id: " + id);
|
||||||
|
}
|
||||||
|
applyRequest(entity, request);
|
||||||
|
characterRepository.persist(entity);
|
||||||
|
return toCharacterDefinition(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a character by id.
|
||||||
|
*
|
||||||
|
* @param id the character UUID
|
||||||
|
* @return true if deleted, false if no character existed
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean delete(final UUID id) {
|
||||||
|
CharacterEntity entity = characterRepository.findByIdOptional(id);
|
||||||
|
if (entity == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
characterRepository.delete(entity);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a new entity from the request and the given id.
|
||||||
|
*/
|
||||||
|
private static CharacterEntity fromRequest(final CreateCharacterRequest request, final UUID id) {
|
||||||
|
CharacterEntity entity = new CharacterEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
applyRequest(entity, request);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies request fields to an existing entity (id is not changed).
|
||||||
|
*/
|
||||||
|
private static void applyRequest(final CharacterEntity entity, final CreateCharacterRequest request) {
|
||||||
|
entity.setName(request.getName());
|
||||||
|
entity.setRole(request.getRole());
|
||||||
|
entity.setBackstory(request.getBackstory());
|
||||||
|
entity.setSpeakingStyle(request.getSpeakingStyle());
|
||||||
|
entity.setPersonalityTraits(request.getPersonalityTraits() != null ? request.getPersonalityTraits() : List.of());
|
||||||
|
entity.setGoals(request.getGoals() != null ? request.getGoals() : List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a character entity to the API CharacterDefinition. Uses entity id as string for API id.
|
||||||
|
*/
|
||||||
|
public static CharacterDefinition toCharacterDefinition(final CharacterEntity entity) {
|
||||||
|
CharacterDefinition def = new CharacterDefinition(
|
||||||
|
entity.getId().toString(),
|
||||||
|
entity.getName(),
|
||||||
|
entity.getRole()
|
||||||
|
);
|
||||||
|
def.setBackstory(entity.getBackstory());
|
||||||
|
def.setSpeakingStyle(entity.getSpeakingStyle());
|
||||||
|
if (entity.getPersonalityTraits() != null && !entity.getPersonalityTraits().isEmpty()) {
|
||||||
|
def.setPersonalityTraits(entity.getPersonalityTraits());
|
||||||
|
}
|
||||||
|
if (entity.getGoals() != null && !entity.getGoals().isEmpty()) {
|
||||||
|
def.setGoals(entity.getGoals());
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,23 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import de.neitzel.roleplay.common.StateUpdateResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.OllamaClient;
|
||||||
|
import de.neitzel.roleplay.fascade.OllamaParseException;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterState;
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SituationState;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -16,32 +28,52 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* {@link ConcurrentHashMap}; suitable for development and testing. A
|
* {@link ConcurrentHashMap}; suitable for development and testing. A
|
||||||
* production implementation would persist state in a database.
|
* production implementation would persist state in a database.
|
||||||
*
|
*
|
||||||
* <p>Turn orchestration (the two-call Ollama pattern) is not yet wired; the
|
* <p>Session creation and turn submission use the two-call Ollama pattern
|
||||||
* methods return stub responses so the REST layer can be exercised end-to-end.
|
* (narrative then state extraction) when scenario or turn data is provided.
|
||||||
* The {@code TODO} markers indicate where the Ollama integration must be added.
|
|
||||||
*/
|
*/
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class InMemorySessionService implements SessionService {
|
public class InMemorySessionService implements SessionService {
|
||||||
|
|
||||||
|
private final OllamaClient ollamaClient;
|
||||||
|
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||||
|
private final ScenarioService scenarioService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory store mapping session IDs to their current state.
|
* In-memory store mapping session IDs to their current state.
|
||||||
*/
|
*/
|
||||||
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
|
private final Map<String, SessionResponse> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the service with required dependencies.
|
||||||
|
*
|
||||||
|
* @param ollamaClient client for Ollama narrative and state-update calls
|
||||||
|
* @param objectMapper mapper to serialize turn context to JSON
|
||||||
|
* @param scenarioService service to resolve scenario by id from the database
|
||||||
|
*/
|
||||||
|
@Inject
|
||||||
|
public InMemorySessionService(final OllamaClient ollamaClient,
|
||||||
|
final com.fasterxml.jackson.databind.ObjectMapper objectMapper,
|
||||||
|
final ScenarioService scenarioService) {
|
||||||
|
this.ollamaClient = ollamaClient;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.scenarioService = scenarioService;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* <p>Generates a new UUID as the session ID, populates default session
|
* <p>Generates a new UUID as the session ID, populates default session
|
||||||
* metadata, and stores the session. The Ollama two-call pattern is not
|
* metadata, and stores the session. When a scenario is provided, runs the
|
||||||
* yet invoked; a placeholder narrative is returned instead.
|
* two-call Ollama pattern to produce an opening narrative and initial state.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public SessionResponse createSession(final CreateSessionRequest request) {
|
public SessionResponse createSession(final CreateSessionRequest request) {
|
||||||
String sessionId = UUID.randomUUID().toString();
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
String model = request.getModel();
|
||||||
|
|
||||||
SessionResponse session = new SessionResponse(
|
SessionResponse session = new SessionResponse(
|
||||||
sessionId,
|
sessionId,
|
||||||
request.getModel(),
|
model,
|
||||||
request.getLanguage() != null ? request.getLanguage() : "en",
|
request.getLanguage() != null ? request.getLanguage() : "en",
|
||||||
request.getSafetyLevel() != null
|
request.getSafetyLevel() != null
|
||||||
? request.getSafetyLevel().value()
|
? request.getSafetyLevel().value()
|
||||||
@ -49,14 +81,116 @@ public class InMemorySessionService implements SessionService {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Invoke OllamaClient two-call pattern (narrative + state extraction)
|
ScenarioSetup scenario = resolveScenario(request);
|
||||||
// to produce a real opening scene and initial state.
|
if (scenario != null) {
|
||||||
session.setNarrative("Session initialised. Ollama integration pending.");
|
session.setSituation(buildSituationFromScenario(scenario));
|
||||||
|
session.setCharacters(buildCharactersFromScenario(scenario));
|
||||||
|
try {
|
||||||
|
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.fromScenario(scenario));
|
||||||
|
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.INIT_NARRATIVE, contextJson);
|
||||||
|
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
|
||||||
|
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
|
||||||
|
session.setNarrative(narrative);
|
||||||
|
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.setNarrative("Session initialised. No scenario provided.");
|
||||||
|
}
|
||||||
|
|
||||||
sessions.put(sessionId, session);
|
sessions.put(sessionId, session);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective scenario: scenarioId from DB takes precedence over inline scenario.
|
||||||
|
*
|
||||||
|
* @param request the create session request
|
||||||
|
* @return the scenario to use, or null if none
|
||||||
|
*/
|
||||||
|
private ScenarioSetup resolveScenario(final CreateSessionRequest request) {
|
||||||
|
if (request.getScenarioId() != null) {
|
||||||
|
return scenarioService.getScenarioAsSetup(request.getScenarioId()).orElse(null);
|
||||||
|
}
|
||||||
|
return request.getScenario();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds initial situation state from the scenario setup.
|
||||||
|
*
|
||||||
|
* @param scenario the scenario from the create request
|
||||||
|
* @return situation state with setting, initialConflict and currentScene derived
|
||||||
|
*/
|
||||||
|
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
|
||||||
|
SituationState situation = new SituationState();
|
||||||
|
situation.setSetting(scenario.getSetting());
|
||||||
|
situation.setCurrentScene(
|
||||||
|
scenario.getSetting() != null && scenario.getInitialConflict() != null
|
||||||
|
? scenario.getSetting() + " " + scenario.getInitialConflict()
|
||||||
|
: scenario.getSetting() != null
|
||||||
|
? scenario.getSetting()
|
||||||
|
: scenario.getInitialConflict());
|
||||||
|
return situation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds initial character list from the scenario (user character + AI characters).
|
||||||
|
*
|
||||||
|
* @param scenario the scenario from the create request
|
||||||
|
* @return list of character states
|
||||||
|
*/
|
||||||
|
private static List<CharacterState> buildCharactersFromScenario(final ScenarioSetup scenario) {
|
||||||
|
List<CharacterState> characters = new ArrayList<>();
|
||||||
|
if (scenario.getUserCharacter() != null) {
|
||||||
|
characters.add(toCharacterState(scenario.getUserCharacter(), true));
|
||||||
|
}
|
||||||
|
if (scenario.getAiCharacters() != null) {
|
||||||
|
for (CharacterDefinition def : scenario.getAiCharacters()) {
|
||||||
|
characters.add(toCharacterState(def, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a character definition to initial character state.
|
||||||
|
*
|
||||||
|
* @param def the definition
|
||||||
|
* @param isUserCharacter whether this is the user-controlled character
|
||||||
|
* @return character state with id, name, role and isUserCharacter set
|
||||||
|
*/
|
||||||
|
private static CharacterState toCharacterState(final CharacterDefinition def,
|
||||||
|
final boolean isUserCharacter) {
|
||||||
|
CharacterState state = new CharacterState(def.getId(), def.getName(), isUserCharacter);
|
||||||
|
state.setRole(def.getRole());
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Updates situation and/or characters when provided; omitted fields are unchanged.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<SessionResponse> updateSession(final String sessionId,
|
||||||
|
final UpdateSessionRequest request) {
|
||||||
|
SessionResponse session = sessions.get(sessionId);
|
||||||
|
if (session == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (request != null) {
|
||||||
|
if (request.getSituation() != null) {
|
||||||
|
session.setSituation(request.getSituation());
|
||||||
|
}
|
||||||
|
if (request.getCharacters() != null) {
|
||||||
|
session.setCharacters(new ArrayList<>(request.getCharacters()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions.put(sessionId, session);
|
||||||
|
return Optional.of(session);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
@ -68,8 +202,9 @@ public class InMemorySessionService implements SessionService {
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* <p>Increments the turn counter and returns a stub {@link TurnResponse}.
|
* <p>Increments the turn counter, runs the two-call Ollama pattern with
|
||||||
* The Ollama two-call pattern is not yet invoked.
|
* the current session state and turn request, merges the state update into
|
||||||
|
* the session, and returns the turn response.
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Optional<TurnResponse> submitTurn(final String sessionId,
|
public Optional<TurnResponse> submitTurn(final String sessionId,
|
||||||
@ -79,20 +214,23 @@ public class InMemorySessionService implements SessionService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment turn counter
|
|
||||||
int nextTurn = session.getTurnNumber() + 1;
|
int nextTurn = session.getTurnNumber() + 1;
|
||||||
session.setTurnNumber(nextTurn);
|
session.setTurnNumber(nextTurn);
|
||||||
|
String model = session.getModel();
|
||||||
|
|
||||||
// TODO: Invoke OllamaClient two-call pattern (narrative + state update)
|
try {
|
||||||
// using the current session state, turnRequest.getUserAction(),
|
String contextJson = objectMapper.writeValueAsString(OllamaContextBuilder.forTurn(session, turnRequest));
|
||||||
// and turnRequest.getRecommendation().
|
String narrative = ollamaClient.generateNarrative(model, OllamaPrompts.TURN_NARRATIVE, contextJson);
|
||||||
TurnResponse response = new TurnResponse(
|
String userContentForCall2 = contextJson + "\n\nNarrative that was just generated:\n" + narrative;
|
||||||
nextTurn,
|
StateUpdateResponse stateUpdate = ollamaClient.generateStateUpdate(model, OllamaPrompts.STATE_EXTRACTION, userContentForCall2);
|
||||||
"Turn " + nextTurn + " processed. Ollama integration pending."
|
session.setNarrative(narrative);
|
||||||
);
|
StateUpdateMapper.mergeIntoSession(session, stateUpdate);
|
||||||
|
TurnResponse response = StateUpdateMapper.toTurnResponse(nextTurn, narrative, stateUpdate);
|
||||||
sessions.put(sessionId, session);
|
sessions.put(sessionId, session);
|
||||||
return Optional.of(response);
|
return Optional.of(response);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new OllamaParseException("Failed to serialize turn context for Ollama", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,240 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CharacterSet;
|
||||||
|
import de.neitzel.roleplay.common.CharacterSnapshot;
|
||||||
|
import de.neitzel.roleplay.common.Recommendation;
|
||||||
|
import de.neitzel.roleplay.common.SituationSnapshot;
|
||||||
|
import de.neitzel.roleplay.common.TurnContext;
|
||||||
|
import de.neitzel.roleplay.common.UserAction;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterState;
|
||||||
|
import de.neitzel.roleplay.fascade.model.RecommendationRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SituationState;
|
||||||
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds {@link TurnContext} (common) from API request models for Ollama calls.
|
||||||
|
* Used for session initialization (scenario only) and turn continuation
|
||||||
|
* (session state + turn request).
|
||||||
|
*/
|
||||||
|
public final class OllamaContextBuilder {
|
||||||
|
|
||||||
|
private OllamaContextBuilder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds turn context for session initialization. No user action or
|
||||||
|
* recommendation; recent history is empty.
|
||||||
|
*
|
||||||
|
* @param situation situation state from scenario (may be null)
|
||||||
|
* @param characters character list from scenario (may be null or empty)
|
||||||
|
* @return context for the init two-call pattern
|
||||||
|
*/
|
||||||
|
public static TurnContext forSessionInit(final SituationState situation,
|
||||||
|
final List<CharacterState> characters) {
|
||||||
|
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
|
||||||
|
CharacterSet characterSet = charactersToSet(characters);
|
||||||
|
return TurnContext.builder()
|
||||||
|
.currentSituation(situationSnapshot)
|
||||||
|
.characters(characterSet)
|
||||||
|
.userAction(null)
|
||||||
|
.recommendation(null)
|
||||||
|
.recentHistorySummary(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds turn context from scenario setup. Uses definitions for rich
|
||||||
|
* character snapshots (personality, goals) in the context.
|
||||||
|
*
|
||||||
|
* @param scenario the scenario from create request (must not be null)
|
||||||
|
* @return context for the init two-call pattern
|
||||||
|
*/
|
||||||
|
public static TurnContext fromScenario(final ScenarioSetup scenario) {
|
||||||
|
SituationState situation = buildSituationFromScenario(scenario);
|
||||||
|
CharacterSet characterSet = characterSetFromScenario(scenario);
|
||||||
|
SituationSnapshot situationSnapshot = situationToSnapshot(situation);
|
||||||
|
return TurnContext.builder()
|
||||||
|
.currentSituation(situationSnapshot)
|
||||||
|
.characters(characterSet)
|
||||||
|
.userAction(null)
|
||||||
|
.recommendation(null)
|
||||||
|
.recentHistorySummary(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds turn context for a turn continuation from current session state
|
||||||
|
* and the turn request.
|
||||||
|
*
|
||||||
|
* @param session current session (situation and characters used)
|
||||||
|
* @param turnRequest user action and optional recommendation
|
||||||
|
* @return context for the turn two-call pattern
|
||||||
|
*/
|
||||||
|
public static TurnContext forTurn(final SessionResponse session,
|
||||||
|
final TurnRequest turnRequest) {
|
||||||
|
SituationSnapshot situationSnapshot = situationToSnapshot(session.getSituation());
|
||||||
|
CharacterSet characterSet = charactersToSet(session.getCharacters());
|
||||||
|
UserAction userAction = turnRequest.getUserAction() != null
|
||||||
|
? toUserAction(turnRequest.getUserAction())
|
||||||
|
: null;
|
||||||
|
Recommendation recommendation = turnRequest.getRecommendation() != null
|
||||||
|
? toRecommendation(turnRequest.getRecommendation())
|
||||||
|
: null;
|
||||||
|
return TurnContext.builder()
|
||||||
|
.currentSituation(situationSnapshot)
|
||||||
|
.characters(characterSet)
|
||||||
|
.userAction(userAction)
|
||||||
|
.recommendation(recommendation)
|
||||||
|
.recentHistorySummary("")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SituationSnapshot situationToSnapshot(final SituationState s) {
|
||||||
|
if (s == null) {
|
||||||
|
return SituationSnapshot.builder().build();
|
||||||
|
}
|
||||||
|
return SituationSnapshot.builder()
|
||||||
|
.setting(s.getSetting())
|
||||||
|
.currentScene(s.getCurrentScene())
|
||||||
|
.timeline(s.getTimeline() != null ? new ArrayList<>(s.getTimeline()) : null)
|
||||||
|
.openThreads(s.getOpenThreads() != null ? new ArrayList<>(s.getOpenThreads()) : null)
|
||||||
|
.externalPressures(s.getExternalPressures() != null ? new ArrayList<>(s.getExternalPressures()) : null)
|
||||||
|
.worldStateFlags(s.getWorldStateFlags() != null ? Map.copyOf(s.getWorldStateFlags()) : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterSet charactersToSet(final List<CharacterState> characters) {
|
||||||
|
if (characters == null || characters.isEmpty()) {
|
||||||
|
return CharacterSet.builder()
|
||||||
|
.userCharacter(null)
|
||||||
|
.aiCharacters(Collections.emptyList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
CharacterSnapshot userCharacter = null;
|
||||||
|
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
|
||||||
|
for (CharacterState c : characters) {
|
||||||
|
CharacterSnapshot snap = characterStateToSnapshot(c);
|
||||||
|
if (Boolean.TRUE.equals(c.getIsUserCharacter())) {
|
||||||
|
userCharacter = snap;
|
||||||
|
} else {
|
||||||
|
aiCharacters.add(snap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CharacterSet.builder()
|
||||||
|
.userCharacter(userCharacter)
|
||||||
|
.aiCharacters(aiCharacters)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterSnapshot characterStateToSnapshot(final CharacterState c) {
|
||||||
|
if (c == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return CharacterSnapshot.builder()
|
||||||
|
.id(c.getId())
|
||||||
|
.name(c.getName())
|
||||||
|
.role(c.getRole())
|
||||||
|
.personalityTraits(null)
|
||||||
|
.speakingStyle(null)
|
||||||
|
.goals(null)
|
||||||
|
.currentMood(c.getCurrentMood())
|
||||||
|
.knowledge(c.getKnowledge() != null ? new ArrayList<>(c.getKnowledge()) : null)
|
||||||
|
.relationships(c.getRelationships() != null ? Map.copyOf(c.getRelationships()) : null)
|
||||||
|
.status(c.getStatus())
|
||||||
|
.recentActionsSummary(c.getRecentActionsSummary() != null ? new ArrayList<>(c.getRecentActionsSummary()) : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserAction toUserAction(final UserActionRequest r) {
|
||||||
|
if (r == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
de.neitzel.roleplay.common.ActionType type = toActionType(r.getType());
|
||||||
|
return UserAction.builder()
|
||||||
|
.type(type)
|
||||||
|
.content(r.getContent())
|
||||||
|
.selectedSuggestionId(r.getSelectedSuggestionId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.common.ActionType toActionType(final UserActionRequest.TypeEnum e) {
|
||||||
|
if (e == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (e.value()) {
|
||||||
|
case "speech" -> de.neitzel.roleplay.common.ActionType.SPEECH;
|
||||||
|
case "action" -> de.neitzel.roleplay.common.ActionType.ACTION;
|
||||||
|
case "choice" -> de.neitzel.roleplay.common.ActionType.CHOICE;
|
||||||
|
default -> de.neitzel.roleplay.common.ActionType.ACTION;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Recommendation toRecommendation(final RecommendationRequest r) {
|
||||||
|
if (r == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Recommendation.builder()
|
||||||
|
.desiredTone(r.getDesiredTone())
|
||||||
|
.preferredDirection(r.getPreferredDirection())
|
||||||
|
.focusCharacters(r.getFocusCharacters() != null ? new ArrayList<>(r.getFocusCharacters()) : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SituationState buildSituationFromScenario(final ScenarioSetup scenario) {
|
||||||
|
SituationState situation = new SituationState();
|
||||||
|
situation.setSetting(scenario.getSetting());
|
||||||
|
situation.setCurrentScene(
|
||||||
|
scenario.getSetting() != null && scenario.getInitialConflict() != null
|
||||||
|
? scenario.getSetting() + " " + scenario.getInitialConflict()
|
||||||
|
: scenario.getSetting() != null
|
||||||
|
? scenario.getSetting()
|
||||||
|
: scenario.getInitialConflict());
|
||||||
|
return situation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds character set for context from scenario definitions (rich snapshots). */
|
||||||
|
private static CharacterSet characterSetFromScenario(final ScenarioSetup scenario) {
|
||||||
|
CharacterSnapshot userCharacter = null;
|
||||||
|
List<CharacterSnapshot> aiCharacters = new ArrayList<>();
|
||||||
|
if (scenario.getUserCharacter() != null) {
|
||||||
|
userCharacter = definitionToSnapshot(scenario.getUserCharacter(), true);
|
||||||
|
}
|
||||||
|
if (scenario.getAiCharacters() != null) {
|
||||||
|
for (CharacterDefinition def : scenario.getAiCharacters()) {
|
||||||
|
aiCharacters.add(definitionToSnapshot(def, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CharacterSet.builder()
|
||||||
|
.userCharacter(userCharacter)
|
||||||
|
.aiCharacters(aiCharacters)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterSnapshot definitionToSnapshot(final CharacterDefinition def, final boolean isUser) {
|
||||||
|
if (def == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return CharacterSnapshot.builder()
|
||||||
|
.id(def.getId())
|
||||||
|
.name(def.getName())
|
||||||
|
.role(def.getRole())
|
||||||
|
.personalityTraits(def.getPersonalityTraits() != null ? new ArrayList<>(def.getPersonalityTraits()) : null)
|
||||||
|
.speakingStyle(def.getSpeakingStyle())
|
||||||
|
.goals(def.getGoals() != null ? new ArrayList<>(def.getGoals()) : null)
|
||||||
|
.currentMood(null)
|
||||||
|
.knowledge(null)
|
||||||
|
.relationships(null)
|
||||||
|
.status(null)
|
||||||
|
.recentActionsSummary(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompts for the Ollama two-call pattern. Used by session creation
|
||||||
|
* (opening narrative + state extraction) and turn continuation (narrative +
|
||||||
|
* state update). Content is aligned with the ROLEPLAY_CONCEPT document.
|
||||||
|
*/
|
||||||
|
public final class OllamaPrompts {
|
||||||
|
|
||||||
|
private OllamaPrompts() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for Call 1 during session initialization: produce an
|
||||||
|
* immersive opening scene from the structured context.
|
||||||
|
*/
|
||||||
|
public static final String INIT_NARRATIVE =
|
||||||
|
"You are a role playing game narrator. Your task is to write an immersive opening scene for a new story.\n\n"
|
||||||
|
+ "Rules:\n"
|
||||||
|
+ "1. Write from the user character's perspective.\n"
|
||||||
|
+ "2. Introduce the setting, atmosphere, and at least one AI character.\n"
|
||||||
|
+ "3. At least one AI character must speak.\n"
|
||||||
|
+ "4. Write 2-4 paragraphs of vivid, immersive prose.\n"
|
||||||
|
+ "5. Do NOT include any JSON, metadata, or out-of-character commentary.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for Call 1 during turn continuation: produce narrative
|
||||||
|
* and dialogue for the next beat.
|
||||||
|
*/
|
||||||
|
public static final String TURN_NARRATIVE =
|
||||||
|
"You are a role playing game narrator continuing an ongoing story.\n\n"
|
||||||
|
+ "Rules:\n"
|
||||||
|
+ "1. Stay strictly in-character for all AI-controlled characters.\n"
|
||||||
|
+ "2. Respect and build on the provided character and situation state.\n"
|
||||||
|
+ "3. Do NOT contradict established facts unless the context explicitly says reality has changed.\n"
|
||||||
|
+ "4. Keep the text immersive and avoid meta commentary.\n"
|
||||||
|
+ "5. At least one AI character must speak or act.\n"
|
||||||
|
+ "6. If a recommendation is provided, treat it as a soft guideline for the scene's direction.\n\n"
|
||||||
|
+ "Style:\n"
|
||||||
|
+ "- Mix short descriptive narration with dialogue.\n"
|
||||||
|
+ "- Keep responses roughly 2-5 paragraphs.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System prompt for Call 2 (state extraction): given context and narrative,
|
||||||
|
* return a JSON object with responses, updated_situation, updated_characters,
|
||||||
|
* and suggestions. Used for both session init and turn continuation.
|
||||||
|
*/
|
||||||
|
public static final String STATE_EXTRACTION =
|
||||||
|
"You are a role playing game engine. Given the story context and a narrative scene, extract structured state updates as JSON.\n\n"
|
||||||
|
+ "You must return a JSON object matching the schema described below. Do NOT include any text outside the JSON object.\n\n"
|
||||||
|
+ "Schema:\n"
|
||||||
|
+ "{\n"
|
||||||
|
+ " \"responses\": [{\"character_id\": \"string\", \"type\": \"speech|action|reaction\", \"content\": \"string|null\", \"action\": \"string|null\", \"mood_after\": \"string\"}],\n"
|
||||||
|
+ " \"updated_situation\": {\"current_scene\": \"string\", \"new_timeline_entries\": [\"string\"], \"open_threads_changes\": {\"added\": [\"string\"], \"resolved\": [\"string\"]}, \"world_state_flags\": {}},\n"
|
||||||
|
+ " \"updated_characters\": [{\"character_id\": \"string\", \"current_mood\": \"string\", \"knowledge_gained\": [\"string\"], \"relationship_changes\": {}}],\n"
|
||||||
|
+ " \"suggestions\": [{\"id\": \"string\", \"type\": \"player_action|world_event|npc_action|twist\", \"title\": \"string\", \"description\": \"string\", \"consequences\": [\"string\"], \"risk_level\": \"low|medium|high\"}]\n"
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
186
src/main/java/de/neitzel/roleplay/business/ScenarioService.java
Normal file
186
src/main/java/de/neitzel/roleplay/business/ScenarioService.java
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioEntity;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioRepository;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business service for stored scenario templates. Maps entities to API DTOs.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenarioService {
|
||||||
|
|
||||||
|
private final ScenarioRepository scenarioRepository;
|
||||||
|
private final CharacterRepository characterRepository;
|
||||||
|
private final EntityManager entityManager;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ScenarioService(final ScenarioRepository scenarioRepository, final CharacterRepository characterRepository,
|
||||||
|
final EntityManager entityManager) {
|
||||||
|
this.scenarioRepository = scenarioRepository;
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
|
this.entityManager = entityManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all stored scenarios as summaries.
|
||||||
|
*
|
||||||
|
* @return list of scenario summaries
|
||||||
|
*/
|
||||||
|
public List<ScenarioSummary> listScenarios() {
|
||||||
|
return scenarioRepository.listAll().stream()
|
||||||
|
.map(ScenarioService::toScenarioSummary)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full scenario setup for the given id, if present.
|
||||||
|
*
|
||||||
|
* @param id the scenario UUID
|
||||||
|
* @return the scenario setup (setting, conflict, user character, AI characters) or empty
|
||||||
|
*/
|
||||||
|
public Optional<ScenarioSetup> getScenarioAsSetup(final UUID id) {
|
||||||
|
ScenarioEntity entity = scenarioRepository.findByIdWithCharacters(id);
|
||||||
|
return entity != null ? Optional.of(toScenarioSetup(entity)) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new scenario from the request. Validates that all referenced characters exist.
|
||||||
|
*
|
||||||
|
* @param request the create request (name required; optional setting, initialConflict, characterSlots)
|
||||||
|
* @return the created scenario summary
|
||||||
|
* @throws IllegalArgumentException if any referenced character id is not found
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ScenarioSummary create(final CreateScenarioRequest request) {
|
||||||
|
UUID scenarioId = UUID.randomUUID();
|
||||||
|
ScenarioEntity scenario = new ScenarioEntity();
|
||||||
|
scenario.setId(scenarioId);
|
||||||
|
scenario.setName(request.getName());
|
||||||
|
scenario.setSetting(request.getSetting());
|
||||||
|
scenario.setInitialConflict(request.getInitialConflict());
|
||||||
|
List<ScenarioCharacterSlot> slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of();
|
||||||
|
for (ScenarioCharacterSlot slot : slots) {
|
||||||
|
var character = characterRepository.findByIdOptional(slot.getCharacterId());
|
||||||
|
if (character == null) {
|
||||||
|
throw new IllegalArgumentException("Character not found: " + slot.getCharacterId());
|
||||||
|
}
|
||||||
|
ScenarioCharacterEntity link = new ScenarioCharacterEntity();
|
||||||
|
link.setId(UUID.randomUUID());
|
||||||
|
link.setScenario(scenario);
|
||||||
|
link.setCharacter(character);
|
||||||
|
link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter()));
|
||||||
|
link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0);
|
||||||
|
scenario.getScenarioCharacters().add(link);
|
||||||
|
}
|
||||||
|
scenarioRepository.persist(scenario);
|
||||||
|
for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) {
|
||||||
|
entityManager.persist(link);
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
return toScenarioSummary(scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing scenario by id. Full replace; replaces all character slots.
|
||||||
|
*
|
||||||
|
* @param id the scenario UUID
|
||||||
|
* @param request the update request
|
||||||
|
* @return the updated scenario summary
|
||||||
|
* @throws java.util.NoSuchElementException if the scenario does not exist
|
||||||
|
* @throws IllegalArgumentException if any referenced character id is not found
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ScenarioSummary update(final UUID id, final CreateScenarioRequest request) {
|
||||||
|
ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id);
|
||||||
|
if (scenario == null) {
|
||||||
|
throw new java.util.NoSuchElementException("No scenario found with id: " + id);
|
||||||
|
}
|
||||||
|
scenario.setName(request.getName());
|
||||||
|
scenario.setSetting(request.getSetting());
|
||||||
|
scenario.setInitialConflict(request.getInitialConflict());
|
||||||
|
scenario.getScenarioCharacters().clear();
|
||||||
|
List<ScenarioCharacterSlot> slots = request.getCharacterSlots() != null ? request.getCharacterSlots() : List.of();
|
||||||
|
for (ScenarioCharacterSlot slot : slots) {
|
||||||
|
var character = characterRepository.findByIdOptional(slot.getCharacterId());
|
||||||
|
if (character == null) {
|
||||||
|
throw new IllegalArgumentException("Character not found: " + slot.getCharacterId());
|
||||||
|
}
|
||||||
|
ScenarioCharacterEntity link = new ScenarioCharacterEntity();
|
||||||
|
link.setId(UUID.randomUUID());
|
||||||
|
link.setScenario(scenario);
|
||||||
|
link.setCharacter(character);
|
||||||
|
link.setUserCharacter(Boolean.TRUE.equals(slot.getIsUserCharacter()));
|
||||||
|
link.setPosition(slot.getPosition() != null ? slot.getPosition() : 0);
|
||||||
|
scenario.getScenarioCharacters().add(link);
|
||||||
|
}
|
||||||
|
scenarioRepository.persist(scenario);
|
||||||
|
for (ScenarioCharacterEntity link : scenario.getScenarioCharacters()) {
|
||||||
|
entityManager.persist(link);
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
return toScenarioSummary(scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a scenario by id. Cascades to scenario-character links.
|
||||||
|
*
|
||||||
|
* @param id the scenario UUID
|
||||||
|
* @return true if deleted, false if no scenario existed
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean delete(final UUID id) {
|
||||||
|
ScenarioEntity scenario = scenarioRepository.findByIdWithCharacters(id);
|
||||||
|
if (scenario == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
scenarioRepository.delete(scenario);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a scenario entity to the list-summary DTO.
|
||||||
|
*/
|
||||||
|
public static ScenarioSummary toScenarioSummary(final ScenarioEntity entity) {
|
||||||
|
ScenarioSummary summary = new ScenarioSummary(entity.getId(), entity.getName());
|
||||||
|
summary.setSetting(entity.getSetting());
|
||||||
|
summary.setInitialConflict(entity.getInitialConflict());
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a scenario entity (with characters loaded) to the full ScenarioSetup for session creation.
|
||||||
|
*/
|
||||||
|
public static ScenarioSetup toScenarioSetup(final ScenarioEntity entity) {
|
||||||
|
ScenarioSetup setup = new ScenarioSetup();
|
||||||
|
setup.setSetting(entity.getSetting());
|
||||||
|
setup.setInitialConflict(entity.getInitialConflict());
|
||||||
|
List<ScenarioCharacterEntity> links = entity.getScenarioCharacters();
|
||||||
|
if (links != null && !links.isEmpty()) {
|
||||||
|
for (ScenarioCharacterEntity link : links) {
|
||||||
|
CharacterDefinition def = CharacterService.toCharacterDefinition(link.getCharacter());
|
||||||
|
if (link.isUserCharacter()) {
|
||||||
|
setup.setUserCharacter(def);
|
||||||
|
} else {
|
||||||
|
setup.addAiCharactersItem(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
|||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@ -31,6 +32,16 @@ public interface SessionService {
|
|||||||
*/
|
*/
|
||||||
Optional<SessionResponse> getSession(String sessionId);
|
Optional<SessionResponse> getSession(String sessionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partially updates an existing session (situation and/or characters).
|
||||||
|
* Omitted fields in the request are left unchanged.
|
||||||
|
*
|
||||||
|
* @param sessionId the unique session identifier
|
||||||
|
* @param request the update payload; may be null or have null fields
|
||||||
|
* @return an {@link Optional} containing the updated session, or empty if not found
|
||||||
|
*/
|
||||||
|
Optional<SessionResponse> updateSession(String sessionId, UpdateSessionRequest request);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a user's turn within an existing session. Runs the two-call
|
* Processes a user's turn within an existing session. Runs the two-call
|
||||||
* Ollama pattern and returns the resulting narrative with updated state.
|
* Ollama pattern and returns the resulting narrative with updated state.
|
||||||
|
|||||||
@ -0,0 +1,245 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CharacterResponse;
|
||||||
|
import de.neitzel.roleplay.common.CharacterUpdate;
|
||||||
|
import de.neitzel.roleplay.common.OpenThreadsChanges;
|
||||||
|
import de.neitzel.roleplay.common.ResponseType;
|
||||||
|
import de.neitzel.roleplay.common.RiskLevel;
|
||||||
|
import de.neitzel.roleplay.common.SituationUpdate;
|
||||||
|
import de.neitzel.roleplay.common.StateUpdateResponse;
|
||||||
|
import de.neitzel.roleplay.common.Suggestion;
|
||||||
|
import de.neitzel.roleplay.common.SuggestionType;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterResponseItem;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterState;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SituationState;
|
||||||
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps {@link StateUpdateResponse} (common, from Ollama) to API model types and
|
||||||
|
* merges state updates into {@link SessionResponse}.
|
||||||
|
*/
|
||||||
|
public final class StateUpdateMapper {
|
||||||
|
|
||||||
|
private StateUpdateMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a turn response from the narrative and state update.
|
||||||
|
*
|
||||||
|
* @param turnNumber turn number just completed
|
||||||
|
* @param narrative narrative from Call 1
|
||||||
|
* @param update state update from Call 2 (may be null)
|
||||||
|
* @return turn response with narrative and optional structured state
|
||||||
|
*/
|
||||||
|
public static TurnResponse toTurnResponse(final int turnNumber,
|
||||||
|
final String narrative,
|
||||||
|
final StateUpdateResponse update) {
|
||||||
|
TurnResponse response = new TurnResponse(turnNumber, narrative);
|
||||||
|
if (update != null) {
|
||||||
|
if (update.getResponses() != null) {
|
||||||
|
response.setCharacterResponses(update.getResponses().stream()
|
||||||
|
.map(StateUpdateMapper::toCharacterResponseItem)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
if (update.getUpdatedSituation() != null) {
|
||||||
|
response.setUpdatedSituation(toApiSituationUpdate(update.getUpdatedSituation()));
|
||||||
|
}
|
||||||
|
if (update.getUpdatedCharacters() != null) {
|
||||||
|
response.setUpdatedCharacters(update.getUpdatedCharacters().stream()
|
||||||
|
.map(StateUpdateMapper::toApiCharacterUpdate)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
if (update.getSuggestions() != null) {
|
||||||
|
response.setSuggestions(update.getSuggestions().stream()
|
||||||
|
.map(StateUpdateMapper::toApiSuggestion)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the state update to the session: merges situation, characters,
|
||||||
|
* and replaces suggestions.
|
||||||
|
*
|
||||||
|
* @param session the session to update (modified in place)
|
||||||
|
* @param update the state update from Ollama Call 2 (may be null)
|
||||||
|
*/
|
||||||
|
public static void mergeIntoSession(final SessionResponse session,
|
||||||
|
final StateUpdateResponse update) {
|
||||||
|
if (update == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (update.getUpdatedSituation() != null) {
|
||||||
|
mergeSituation(session.getSituation(), update.getUpdatedSituation());
|
||||||
|
}
|
||||||
|
if (update.getUpdatedCharacters() != null) {
|
||||||
|
mergeCharacters(session.getCharacters(), update.getUpdatedCharacters());
|
||||||
|
}
|
||||||
|
if (update.getSuggestions() != null) {
|
||||||
|
session.setSuggestions(update.getSuggestions().stream()
|
||||||
|
.map(StateUpdateMapper::toApiSuggestion)
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void mergeSituation(final SituationState situation,
|
||||||
|
final SituationUpdate update) {
|
||||||
|
if (situation == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (update.getCurrentScene() != null) {
|
||||||
|
situation.setCurrentScene(update.getCurrentScene());
|
||||||
|
}
|
||||||
|
if (update.getNewTimelineEntries() != null && !update.getNewTimelineEntries().isEmpty()) {
|
||||||
|
List<String> timeline = situation.getTimeline();
|
||||||
|
if (timeline == null) {
|
||||||
|
situation.setTimeline(new ArrayList<>(update.getNewTimelineEntries()));
|
||||||
|
} else {
|
||||||
|
timeline.addAll(update.getNewTimelineEntries());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (update.getOpenThreadsChanges() != null) {
|
||||||
|
OpenThreadsChanges otc = update.getOpenThreadsChanges();
|
||||||
|
List<String> openThreads = situation.getOpenThreads();
|
||||||
|
if (openThreads == null) {
|
||||||
|
openThreads = new ArrayList<>();
|
||||||
|
situation.setOpenThreads(openThreads);
|
||||||
|
}
|
||||||
|
if (otc.getResolved() != null) {
|
||||||
|
openThreads.removeAll(otc.getResolved());
|
||||||
|
}
|
||||||
|
if (otc.getAdded() != null) {
|
||||||
|
openThreads.addAll(otc.getAdded());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (update.getWorldStateFlags() != null && !update.getWorldStateFlags().isEmpty()) {
|
||||||
|
Map<String, Object> flags = situation.getWorldStateFlags();
|
||||||
|
if (flags == null) {
|
||||||
|
situation.setWorldStateFlags(new HashMap<>(update.getWorldStateFlags()));
|
||||||
|
} else {
|
||||||
|
flags.putAll(update.getWorldStateFlags());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void mergeCharacters(final List<CharacterState> characters,
|
||||||
|
final List<CharacterUpdate> updates) {
|
||||||
|
if (characters == null || updates == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (CharacterUpdate u : updates) {
|
||||||
|
String id = u.getCharacterId();
|
||||||
|
CharacterState target = characters.stream()
|
||||||
|
.filter(c -> Objects.equals(c.getId(), id))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (target != null) {
|
||||||
|
if (u.getCurrentMood() != null) {
|
||||||
|
target.setCurrentMood(u.getCurrentMood());
|
||||||
|
}
|
||||||
|
if (u.getKnowledgeGained() != null && !u.getKnowledgeGained().isEmpty()) {
|
||||||
|
List<String> knowledge = target.getKnowledge();
|
||||||
|
if (knowledge == null) {
|
||||||
|
target.setKnowledge(new ArrayList<>(u.getKnowledgeGained()));
|
||||||
|
} else {
|
||||||
|
knowledge.addAll(u.getKnowledgeGained());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (u.getRelationshipChanges() != null && !u.getRelationshipChanges().isEmpty()) {
|
||||||
|
Map<String, String> rels = target.getRelationships();
|
||||||
|
if (rels == null) {
|
||||||
|
target.setRelationships(new HashMap<>(u.getRelationshipChanges()));
|
||||||
|
} else {
|
||||||
|
rels.putAll(u.getRelationshipChanges());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterResponseItem toCharacterResponseItem(final CharacterResponse r) {
|
||||||
|
CharacterResponseItem item = new CharacterResponseItem(
|
||||||
|
r.getCharacterId(),
|
||||||
|
toResponseTypeEnum(r.getType()));
|
||||||
|
item.setContent(r.getContent());
|
||||||
|
item.setAction(r.getAction());
|
||||||
|
item.setMoodAfter(r.getMoodAfter());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CharacterResponseItem.TypeEnum toResponseTypeEnum(final ResponseType t) {
|
||||||
|
if (t == null) {
|
||||||
|
return CharacterResponseItem.TypeEnum.ACTION;
|
||||||
|
}
|
||||||
|
return switch (t) {
|
||||||
|
case SPEECH -> CharacterResponseItem.TypeEnum.SPEECH;
|
||||||
|
case ACTION -> CharacterResponseItem.TypeEnum.ACTION;
|
||||||
|
case REACTION -> CharacterResponseItem.TypeEnum.REACTION;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.fascade.model.SituationUpdate toApiSituationUpdate(final SituationUpdate u) {
|
||||||
|
de.neitzel.roleplay.fascade.model.SituationUpdate api = new de.neitzel.roleplay.fascade.model.SituationUpdate();
|
||||||
|
api.setCurrentScene(u.getCurrentScene());
|
||||||
|
api.setNewTimelineEntries(u.getNewTimelineEntries() != null ? new ArrayList<>(u.getNewTimelineEntries()) : null);
|
||||||
|
api.setWorldStateFlags(u.getWorldStateFlags() != null ? new HashMap<>(u.getWorldStateFlags()) : null);
|
||||||
|
if (u.getOpenThreadsChanges() != null) {
|
||||||
|
de.neitzel.roleplay.fascade.model.OpenThreadsChanges apiOtc = new de.neitzel.roleplay.fascade.model.OpenThreadsChanges();
|
||||||
|
apiOtc.setAdded(u.getOpenThreadsChanges().getAdded() != null ? new ArrayList<>(u.getOpenThreadsChanges().getAdded()) : null);
|
||||||
|
apiOtc.setResolved(u.getOpenThreadsChanges().getResolved() != null ? new ArrayList<>(u.getOpenThreadsChanges().getResolved()) : null);
|
||||||
|
api.setOpenThreadsChanges(apiOtc);
|
||||||
|
}
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.fascade.model.CharacterUpdate toApiCharacterUpdate(final CharacterUpdate u) {
|
||||||
|
de.neitzel.roleplay.fascade.model.CharacterUpdate api = new de.neitzel.roleplay.fascade.model.CharacterUpdate(u.getCharacterId());
|
||||||
|
api.setCurrentMood(u.getCurrentMood());
|
||||||
|
api.setKnowledgeGained(u.getKnowledgeGained() != null ? new ArrayList<>(u.getKnowledgeGained()) : null);
|
||||||
|
api.setRelationshipChanges(u.getRelationshipChanges() != null ? new HashMap<>(u.getRelationshipChanges()) : null);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.fascade.model.Suggestion toApiSuggestion(final Suggestion s) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
de.neitzel.roleplay.fascade.model.Suggestion api = new de.neitzel.roleplay.fascade.model.Suggestion(
|
||||||
|
s.getId(), toSuggestionTypeEnum(s.getType()), s.getTitle());
|
||||||
|
api.setDescription(s.getDescription());
|
||||||
|
api.setConsequences(s.getConsequences() != null ? new ArrayList<>(s.getConsequences()) : null);
|
||||||
|
api.setRiskLevel(s.getRiskLevel() != null ? toRiskLevelEnum(s.getRiskLevel()) : null);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum toSuggestionTypeEnum(final SuggestionType t) {
|
||||||
|
if (t == null) {
|
||||||
|
return de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
|
||||||
|
}
|
||||||
|
return switch (t) {
|
||||||
|
case PLAYER_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.PLAYER_ACTION;
|
||||||
|
case WORLD_EVENT -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.WORLD_EVENT;
|
||||||
|
case NPC_ACTION -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.NPC_ACTION;
|
||||||
|
case TWIST -> de.neitzel.roleplay.fascade.model.Suggestion.TypeEnum.TWIST;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum toRiskLevelEnum(final RiskLevel r) {
|
||||||
|
if (r == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (r) {
|
||||||
|
case LOW -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.LOW;
|
||||||
|
case MEDIUM -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.MEDIUM;
|
||||||
|
case HIGH -> de.neitzel.roleplay.fascade.model.Suggestion.RiskLevelEnum.HIGH;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/main/java/de/neitzel/roleplay/business/UserService.java
Normal file
177
src/main/java/de/neitzel/roleplay/business/UserService.java
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business service for application users. Used by admin-only user management endpoints.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public UserService(final UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users as summaries (no password).
|
||||||
|
*
|
||||||
|
* @return list of user summaries
|
||||||
|
*/
|
||||||
|
public List<UserSummary> listUsers() {
|
||||||
|
return userRepository.listAll().stream()
|
||||||
|
.map(UserService::toSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
|
||||||
|
*
|
||||||
|
* @param request create request (username, password, role)
|
||||||
|
* @return the created user summary
|
||||||
|
* @throws IllegalArgumentException if username is blank, password is blank, role is invalid, or username already exists
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserSummary createUser(final CreateUserRequest request) {
|
||||||
|
String username = request.getUsername();
|
||||||
|
String password = request.getPassword();
|
||||||
|
String role = request.getRole();
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (password == null || password.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Password is required");
|
||||||
|
}
|
||||||
|
if (role == null || role.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Role is required");
|
||||||
|
}
|
||||||
|
if (!"admin".equals(role) && !"user".equals(role)) {
|
||||||
|
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
|
||||||
|
}
|
||||||
|
if (userRepository.findByUsername(username) != null) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + username);
|
||||||
|
}
|
||||||
|
UserEntity entity = UserEntity.add(username, password, role);
|
||||||
|
return toSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the password for the given user after verifying the current password.
|
||||||
|
*
|
||||||
|
* @param username login name of the user (must be the current user in practice)
|
||||||
|
* @param currentPassword plain-text current password (verified with bcrypt)
|
||||||
|
* @param newPassword plain-text new password (will be hashed)
|
||||||
|
* @throws IllegalArgumentException if user not found, current password wrong, or new password invalid
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(final String username, final String currentPassword, final String newPassword) {
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (currentPassword == null || currentPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Current password is required");
|
||||||
|
}
|
||||||
|
if (newPassword == null || newPassword.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("New password is required");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findByUsername(username);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + username);
|
||||||
|
}
|
||||||
|
if (!BcryptUtil.matches(currentPassword, entity.getPassword())) {
|
||||||
|
throw new IllegalArgumentException("Current password is incorrect");
|
||||||
|
}
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash(newPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user by id. Username and role are required; password is optional.
|
||||||
|
*
|
||||||
|
* @param id user id
|
||||||
|
* @param request update request (username, optional password, role)
|
||||||
|
* @param currentUsername username of the caller (used to allow username uniqueness to exclude self)
|
||||||
|
* @return the updated user summary
|
||||||
|
* @throws IllegalArgumentException if validation fails or user not found
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserSummary updateUser(final UUID id, final UpdateUserRequest request, final String currentUsername) {
|
||||||
|
if (request == null) {
|
||||||
|
throw new IllegalArgumentException("Update request is required");
|
||||||
|
}
|
||||||
|
String username = request.getUsername();
|
||||||
|
String password = request.getPassword();
|
||||||
|
String role = request.getRole();
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (role == null || role.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Role is required");
|
||||||
|
}
|
||||||
|
if (!"admin".equals(role) && !"user".equals(role)) {
|
||||||
|
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + id);
|
||||||
|
}
|
||||||
|
if (!entity.getUsername().equals(username)) {
|
||||||
|
UserEntity existing = userRepository.findByUsername(username);
|
||||||
|
if (existing != null) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setUsername(username);
|
||||||
|
entity.setRole(role);
|
||||||
|
if (password != null && !password.isBlank()) {
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash(password));
|
||||||
|
}
|
||||||
|
return toSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user by id. Prevents deleting self and prevents deleting the last admin.
|
||||||
|
*
|
||||||
|
* @param id user id to delete
|
||||||
|
* @param currentUsername username of the caller (cannot delete self)
|
||||||
|
* @throws IllegalArgumentException if user not found, deleting self, or deleting last admin
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deleteUser(final UUID id, final String currentUsername) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new IllegalArgumentException("User id is required");
|
||||||
|
}
|
||||||
|
UserEntity entity = userRepository.findById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new IllegalArgumentException("User not found: " + id);
|
||||||
|
}
|
||||||
|
if (entity.getUsername().equals(currentUsername)) {
|
||||||
|
throw new IllegalArgumentException("Cannot delete your own user account");
|
||||||
|
}
|
||||||
|
if ("admin".equals(entity.getRole())) {
|
||||||
|
long adminCount = userRepository.count("role = ?1", "admin");
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new IllegalArgumentException("Cannot delete the last admin user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSummary toSummary(final UserEntity entity) {
|
||||||
|
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for changing the current user's password.
|
||||||
|
*/
|
||||||
|
public final class ChangePasswordRequest {
|
||||||
|
|
||||||
|
private String currentPassword;
|
||||||
|
private String newPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public ChangePasswordRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given passwords.
|
||||||
|
*
|
||||||
|
* @param currentPassword current plain-text password (verified before change)
|
||||||
|
* @param newPassword new plain-text password (will be hashed)
|
||||||
|
*/
|
||||||
|
public ChangePasswordRequest(final String currentPassword, final String newPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current password (for verification).
|
||||||
|
*/
|
||||||
|
public String getCurrentPassword() {
|
||||||
|
return currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current password.
|
||||||
|
*/
|
||||||
|
public void setCurrentPassword(final String currentPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the new password (will be bcrypt-hashed on the server).
|
||||||
|
*/
|
||||||
|
public String getNewPassword() {
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the new password.
|
||||||
|
*/
|
||||||
|
public void setNewPassword(final String newPassword) {
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for creating a new user (admin-only).
|
||||||
|
*/
|
||||||
|
public final class CreateUserRequest {
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public CreateUserRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given fields.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param password plain-text password (will be hashed)
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public CreateUserRequest(final String username, final String password, final String role) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
package de.neitzel.roleplay.common;
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifies an AI character's response within a turn.
|
* Classifies an AI character's response within a turn.
|
||||||
*/
|
*/
|
||||||
|
@JsonDeserialize(using = ResponseTypeDeserializer.class)
|
||||||
public enum ResponseType {
|
public enum ResponseType {
|
||||||
|
|
||||||
/** Spoken dialogue. */
|
/** Spoken dialogue. */
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||||
|
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes {@link ResponseType} from JSON strings. Accepts canonical values
|
||||||
|
* ({@code speech}, {@code action}, {@code reaction}) and combined values from
|
||||||
|
* Ollama (e.g. {@code speech|action}), mapping the latter to a single type.
|
||||||
|
*/
|
||||||
|
public class ResponseTypeDeserializer extends JsonDeserializer<ResponseType> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseType deserialize(final JsonParser p, final DeserializationContext ctxt)
|
||||||
|
throws IOException {
|
||||||
|
String value = p.getText();
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.strip().toLowerCase();
|
||||||
|
if ("speech|action".equals(normalized) || "action|speech".equals(normalized)) {
|
||||||
|
return ResponseType.SPEECH;
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "speech" -> ResponseType.SPEECH;
|
||||||
|
case "action" -> ResponseType.ACTION;
|
||||||
|
case "reaction" -> ResponseType.REACTION;
|
||||||
|
default -> {
|
||||||
|
ctxt.reportInputMismatch(ResponseType.class,
|
||||||
|
"Unknown ResponseType: '%s'; accepted: speech, action, reaction, speech|action", value);
|
||||||
|
throw new IllegalStateException("reportInputMismatch should have thrown");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating an existing user (admin-only). Username and role are required;
|
||||||
|
* password is optional (if absent, existing password is kept).
|
||||||
|
*/
|
||||||
|
public final class UpdateUserRequest {
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public UpdateUserRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given fields.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param password optional new plain-text password (null or blank to keep current)
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public UpdateUserRequest(final String username, final String password, final String role) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the login name.
|
||||||
|
*/
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the login name.
|
||||||
|
*/
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the optional new password (null or blank means keep current).
|
||||||
|
*/
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the optional new password.
|
||||||
|
*/
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the role (admin or user).
|
||||||
|
*/
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the role.
|
||||||
|
*/
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/main/java/de/neitzel/roleplay/common/UserSummary.java
Normal file
56
src/main/java/de/neitzel/roleplay/common/UserSummary.java
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for listing users (id, username, role). Does not expose password.
|
||||||
|
*/
|
||||||
|
public final class UserSummary {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String username;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public UserSummary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a summary for a user.
|
||||||
|
*
|
||||||
|
* @param id user id
|
||||||
|
* @param username login name
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public UserSummary(final UUID id, final String username, final String role) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/main/java/de/neitzel/roleplay/data/CharacterEntity.java
Normal file
148
src/main/java/de/neitzel/roleplay/data/CharacterEntity.java
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Convert;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity for a reusable character definition stored in {@code rp_character}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "rp_character")
|
||||||
|
public class CharacterEntity extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", length = 36, nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "role", nullable = false, length = 255)
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
@Column(name = "backstory", columnDefinition = "clob")
|
||||||
|
private String backstory;
|
||||||
|
|
||||||
|
@Convert(converter = JsonListStringConverter.class)
|
||||||
|
@Column(name = "personality_traits", columnDefinition = "clob")
|
||||||
|
private List<String> personalityTraits;
|
||||||
|
|
||||||
|
@Column(name = "speaking_style", length = 1000)
|
||||||
|
private String speakingStyle;
|
||||||
|
|
||||||
|
@Convert(converter = JsonListStringConverter.class)
|
||||||
|
@Column(name = "goals", columnDefinition = "clob")
|
||||||
|
private List<String> goals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA.
|
||||||
|
*/
|
||||||
|
public CharacterEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this character.
|
||||||
|
*/
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the unique identifier of this character.
|
||||||
|
*/
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the display name of this character.
|
||||||
|
*/
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the display name of this character.
|
||||||
|
*/
|
||||||
|
public void setName(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the narrative role of this character.
|
||||||
|
*/
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the narrative role of this character.
|
||||||
|
*/
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the backstory text, or null if not set.
|
||||||
|
*/
|
||||||
|
public String getBackstory() {
|
||||||
|
return backstory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the backstory text.
|
||||||
|
*/
|
||||||
|
public void setBackstory(final String backstory) {
|
||||||
|
this.backstory = backstory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of personality trait strings; never null.
|
||||||
|
*/
|
||||||
|
public List<String> getPersonalityTraits() {
|
||||||
|
return personalityTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the list of personality trait strings.
|
||||||
|
*/
|
||||||
|
public void setPersonalityTraits(final List<String> personalityTraits) {
|
||||||
|
this.personalityTraits = personalityTraits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the speaking style description, or null if not set.
|
||||||
|
*/
|
||||||
|
public String getSpeakingStyle() {
|
||||||
|
return speakingStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the speaking style description.
|
||||||
|
*/
|
||||||
|
public void setSpeakingStyle(final String speakingStyle) {
|
||||||
|
this.speakingStyle = speakingStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of goal strings; never null.
|
||||||
|
*/
|
||||||
|
public List<String> getGoals() {
|
||||||
|
return goals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the list of goal strings.
|
||||||
|
*/
|
||||||
|
public void setGoals(final List<String> goals) {
|
||||||
|
this.goals = goals;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link CharacterEntity}. Provides list-all and find-by-id access.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CharacterRepository implements PanacheRepository<CharacterEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all characters ordered by name.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<CharacterEntity> listAll() {
|
||||||
|
return list("ORDER BY name");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a character by its UUID.
|
||||||
|
*
|
||||||
|
* @param id the character id
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public CharacterEntity findByIdOptional(final UUID id) {
|
||||||
|
return find("id", id).firstResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA converter that persists a {@link List} of {@link String} as JSON in a CLOB column.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class JsonListStringConverter implements AttributeConverter<List<String>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final TypeReference<List<String>> LIST_TYPE = new TypeReference<>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(final List<String> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("Failed to serialize string list to JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> convertToEntityAttribute(final String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, LIST_TYPE);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalArgumentException("Failed to deserialize JSON to string list", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join entity linking a scenario to a character with role (user vs AI) and ordering.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "scenario_character")
|
||||||
|
public class ScenarioCharacterEntity extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", length = 36, nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "scenario_id", nullable = false)
|
||||||
|
private ScenarioEntity scenario;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "character_id", nullable = false)
|
||||||
|
private CharacterEntity character;
|
||||||
|
|
||||||
|
@Column(name = "is_user_character", nullable = false)
|
||||||
|
private boolean userCharacter;
|
||||||
|
|
||||||
|
@Column(name = "position", nullable = false)
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA.
|
||||||
|
*/
|
||||||
|
public ScenarioCharacterEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this link.
|
||||||
|
*/
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the unique identifier of this link.
|
||||||
|
*/
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the scenario this link belongs to.
|
||||||
|
*/
|
||||||
|
public ScenarioEntity getScenario() {
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the scenario this link belongs to.
|
||||||
|
*/
|
||||||
|
public void setScenario(final ScenarioEntity scenario) {
|
||||||
|
this.scenario = scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the character referenced by this link.
|
||||||
|
*/
|
||||||
|
public CharacterEntity getCharacter() {
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the character referenced by this link.
|
||||||
|
*/
|
||||||
|
public void setCharacter(final CharacterEntity character) {
|
||||||
|
this.character = character;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether this slot is the user-controlled character.
|
||||||
|
*/
|
||||||
|
public boolean isUserCharacter() {
|
||||||
|
return userCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether this slot is the user-controlled character.
|
||||||
|
*/
|
||||||
|
public void setUserCharacter(final boolean userCharacter) {
|
||||||
|
this.userCharacter = userCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the position for ordering (e.g. AI characters).
|
||||||
|
*/
|
||||||
|
public int getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the position for ordering.
|
||||||
|
*/
|
||||||
|
public void setPosition(final int position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java
Normal file
120
src/main/java/de/neitzel/roleplay/data/ScenarioEntity.java
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity for a scenario template stored in {@code scenario}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "scenario")
|
||||||
|
public class ScenarioEntity extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", length = 36, nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "setting", columnDefinition = "clob")
|
||||||
|
private String setting;
|
||||||
|
|
||||||
|
@Column(name = "initial_conflict", columnDefinition = "clob")
|
||||||
|
private String initialConflict;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "scenario", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("userCharacter DESC, position ASC")
|
||||||
|
private List<ScenarioCharacterEntity> scenarioCharacters = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA.
|
||||||
|
*/
|
||||||
|
public ScenarioEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this scenario.
|
||||||
|
*/
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the unique identifier of this scenario.
|
||||||
|
*/
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the human-readable name of this scenario.
|
||||||
|
*/
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the human-readable name of this scenario.
|
||||||
|
*/
|
||||||
|
public void setName(final String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the setting description (place, time, atmosphere), or null if not set.
|
||||||
|
*/
|
||||||
|
public String getSetting() {
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the setting description.
|
||||||
|
*/
|
||||||
|
public void setSetting(final String setting) {
|
||||||
|
this.setting = setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the initial conflict text, or null if not set.
|
||||||
|
*/
|
||||||
|
public String getInitialConflict() {
|
||||||
|
return initialConflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the initial conflict text.
|
||||||
|
*/
|
||||||
|
public void setInitialConflict(final String initialConflict) {
|
||||||
|
this.initialConflict = initialConflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of 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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link ScenarioEntity}. Provides list-all and find-by-id with characters loaded.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenarioRepository implements PanacheRepository<ScenarioEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a scenario by its UUID and loads its scenario-character links and linked characters.
|
||||||
|
*
|
||||||
|
* @param id the scenario id
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public ScenarioEntity findByIdWithCharacters(final UUID id) {
|
||||||
|
ScenarioEntity scenario = find("id", id).firstResult();
|
||||||
|
if (scenario != null) {
|
||||||
|
scenario.getScenarioCharacters().size();
|
||||||
|
scenario.getScenarioCharacters().forEach(sc -> sc.getCharacter().getName());
|
||||||
|
}
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all scenarios ordered by name.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ScenarioEntity> listAll() {
|
||||||
|
return list("ORDER BY name");
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/main/java/de/neitzel/roleplay/data/UserEntity.java
Normal file
121
src/main/java/de/neitzel/roleplay/data/UserEntity.java
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import io.quarkus.security.jpa.Password;
|
||||||
|
import io.quarkus.security.jpa.Roles;
|
||||||
|
import io.quarkus.security.jpa.UserDefinition;
|
||||||
|
import io.quarkus.security.jpa.Username;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity for an application user stored in {@code rp_user}.
|
||||||
|
* Used by Quarkus Security JPA for form-based authentication (username/password, roles).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "rp_user")
|
||||||
|
@UserDefinition
|
||||||
|
public class UserEntity extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", length = 36, nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Username
|
||||||
|
@Column(name = "username", nullable = false, unique = true, length = 255)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Password
|
||||||
|
@Column(name = "password", nullable = false, length = 255)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Roles
|
||||||
|
@Column(name = "role", nullable = false, length = 50)
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA.
|
||||||
|
*/
|
||||||
|
public UserEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this user.
|
||||||
|
*/
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the unique identifier of this user.
|
||||||
|
*/
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the login name of this user.
|
||||||
|
*/
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the login name of this user.
|
||||||
|
*/
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the bcrypt-hashed password of this user.
|
||||||
|
*/
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the password (should be bcrypt-hashed, e.g. via {@link BcryptUtil#bcryptHash(String)}).
|
||||||
|
*/
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the single role of this user (e.g. {@code admin} or {@code user}).
|
||||||
|
*/
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the role of this user.
|
||||||
|
*/
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param plainPassword plain-text password (will be hashed)
|
||||||
|
* @param role role name (e.g. admin, user)
|
||||||
|
* @return the persisted entity
|
||||||
|
*/
|
||||||
|
public static UserEntity add(final String username, final String plainPassword, final String role) {
|
||||||
|
final UserEntity user = new UserEntity();
|
||||||
|
user.setId(UUID.randomUUID());
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPassword(BcryptUtil.bcryptHash(plainPassword));
|
||||||
|
user.setRole(role);
|
||||||
|
user.persist();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/de/neitzel/roleplay/data/UserRepository.java
Normal file
43
src/main/java/de/neitzel/roleplay/data/UserRepository.java
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserRepository implements PanacheRepository<UserEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users ordered by username.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<UserEntity> listAll() {
|
||||||
|
return list("ORDER BY username");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by id.
|
||||||
|
*
|
||||||
|
* @param id the user uuid
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public UserEntity findById(final UUID id) {
|
||||||
|
return find("id", id).firstResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by username.
|
||||||
|
*
|
||||||
|
* @param username the login name
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public UserEntity findByUsername(final String username) {
|
||||||
|
return find("username", username).firstResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
Normal file
104
src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.UserService;
|
||||||
|
import de.neitzel.roleplay.common.ChangePasswordRequest;
|
||||||
|
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.ForbiddenException;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST resource for auth-related endpoints: current user info, change password, and logout.
|
||||||
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full paths are /api/v1/auth/me, /api/v1/auth/change-password, /api/v1/auth/logout.
|
||||||
|
*/
|
||||||
|
@Path("auth")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public class AuthResource {
|
||||||
|
|
||||||
|
private static final String DEFAULT_COOKIE_NAME = "quarkus-credential";
|
||||||
|
|
||||||
|
private final SecurityIdentity securityIdentity;
|
||||||
|
private final String sessionCookieName;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public AuthResource(
|
||||||
|
final SecurityIdentity securityIdentity,
|
||||||
|
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
|
||||||
|
final String sessionCookieName,
|
||||||
|
final UserService userService) {
|
||||||
|
this.securityIdentity = securityIdentity;
|
||||||
|
this.sessionCookieName = sessionCookieName;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current user's username and roles. Used by the frontend to check login state.
|
||||||
|
* Returns 401 when not authenticated (handled by Quarkus permission policy).
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("me")
|
||||||
|
public Map<String, Object> me() {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
return Map.of(
|
||||||
|
"username", securityIdentity.getPrincipal().getName(),
|
||||||
|
"roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the current user's password. Requires current password verification.
|
||||||
|
* Returns 204 on success; 400 if current password wrong or validation fails.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("change-password")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
public Response changePassword(final ChangePasswordRequest request) {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
if (request == null) {
|
||||||
|
throw new BadRequestException("Request body is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String username = securityIdentity.getPrincipal().getName();
|
||||||
|
userService.changePassword(username, request.getCurrentPassword(), request.getNewPassword());
|
||||||
|
return Response.noContent().build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out the current user by clearing the session cookie.
|
||||||
|
* Returns 204 No Content. Requires authenticated user.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("logout")
|
||||||
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
|
public Response logout() {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
NewCookie clearCookie = new NewCookie.Builder(sessionCookieName)
|
||||||
|
.value("")
|
||||||
|
.path("/")
|
||||||
|
.maxAge(0)
|
||||||
|
.build();
|
||||||
|
return Response.noContent().cookie(clearCookie).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.CharacterService;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterListResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||||
|
import de.neitzel.roleplay.generated.api.CharactersApi;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JAX-RS resource that implements the {@link CharactersApi} interface generated from the OpenAPI spec.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class CharactersResource implements CharactersApi {
|
||||||
|
|
||||||
|
private final CharacterService characterService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public CharactersResource(final CharacterService characterService) {
|
||||||
|
this.characterService = characterService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharacterListResponse listCharacters() {
|
||||||
|
CharacterListResponse response = new CharacterListResponse();
|
||||||
|
response.setCharacters(characterService.listCharacters());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharacterDefinition getCharacter(final UUID characterId) {
|
||||||
|
return characterService.getCharacter(characterId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("No character found with id: " + characterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharacterDefinition createCharacter(final CreateCharacterRequest createCharacterRequest) {
|
||||||
|
return characterService.create(createCharacterRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CharacterDefinition updateCharacter(final UUID characterId,
|
||||||
|
final CreateCharacterRequest createCharacterRequest) {
|
||||||
|
try {
|
||||||
|
return characterService.update(characterId, createCharacterRequest);
|
||||||
|
} catch (final NoSuchElementException e) {
|
||||||
|
throw new NotFoundException("No character found with id: " + characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteCharacter(final UUID characterId) {
|
||||||
|
if (!characterService.delete(characterId)) {
|
||||||
|
throw new NotFoundException("No character found with id: " + characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,14 +3,17 @@ package de.neitzel.roleplay.fascade;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quarkus declarative REST client for the Ollama HTTP API.
|
* Quarkus declarative REST client for the Ollama HTTP API.
|
||||||
* Configuration (base URL, timeouts) is read from
|
* Configuration (base URL, timeouts) is read from
|
||||||
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
* {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}.
|
||||||
|
* All outgoing requests and responses are logged via {@link OllamaClientLoggingFilter}.
|
||||||
*/
|
*/
|
||||||
@RegisterRestClient(configKey = "ollama-api")
|
@RegisterRestClient(configKey = "ollama-api")
|
||||||
|
@RegisterProvider(OllamaClientLoggingFilter.class)
|
||||||
@Path("/api")
|
@Path("/api")
|
||||||
public interface OllamaApi {
|
public interface OllamaApi {
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import de.neitzel.roleplay.common.StateUpdateResponse;
|
|||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -18,6 +20,11 @@ import java.util.List;
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
public class OllamaClient {
|
public class OllamaClient {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(OllamaClient.class);
|
||||||
|
|
||||||
|
/** Maximum characters of narrative/JSON to log at TRACE (avoid huge logs). */
|
||||||
|
private static final int TRACE_CONTENT_LIMIT = 200;
|
||||||
|
|
||||||
/** Low-level REST client for the Ollama HTTP API. */
|
/** Low-level REST client for the Ollama HTTP API. */
|
||||||
private final OllamaApi ollamaApi;
|
private final OllamaApi ollamaApi;
|
||||||
|
|
||||||
@ -43,10 +50,14 @@ public class OllamaClient {
|
|||||||
* @return model metadata, or an empty list if none are installed
|
* @return model metadata, or an empty list if none are installed
|
||||||
*/
|
*/
|
||||||
public List<OllamaModelInfo> listModels() {
|
public List<OllamaModelInfo> listModels() {
|
||||||
|
LOG.debug("Fetching Ollama models (GET /api/tags)");
|
||||||
OllamaTagsResponse response = ollamaApi.getTags();
|
OllamaTagsResponse response = ollamaApi.getTags();
|
||||||
if (response == null || response.getModels() == null) {
|
if (response == null || response.getModels() == null) {
|
||||||
|
LOG.debug("Received null or empty models list from Ollama");
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
int count = response.getModels().size();
|
||||||
|
LOG.debug("Received {} model(s) from Ollama", count);
|
||||||
return response.getModels();
|
return response.getModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +73,11 @@ public class OllamaClient {
|
|||||||
public String generateNarrative(final String model,
|
public String generateNarrative(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
final String userContent) {
|
||||||
|
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
|
||||||
|
int userLen = userContent != null ? userContent.length() : 0;
|
||||||
|
LOG.debug("Calling Ollama for narrative: model={}, systemPromptLength={}, userContentLength={}",
|
||||||
|
model, systemLen, userLen);
|
||||||
|
|
||||||
OllamaChatRequest request = OllamaChatRequest.builder()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.messages(List.of(
|
.messages(List.of(
|
||||||
@ -77,7 +93,16 @@ public class OllamaClient {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
OllamaChatResponse response = ollamaApi.chat(request);
|
||||||
return response.getMessage().getContent();
|
String content = response.getMessage().getContent();
|
||||||
|
int len = content != null ? content.length() : 0;
|
||||||
|
LOG.debug("Received narrative from Ollama, length={}", len);
|
||||||
|
if (LOG.isTraceEnabled() && content != null && !content.isEmpty()) {
|
||||||
|
String snippet = content.length() <= TRACE_CONTENT_LIMIT
|
||||||
|
? content
|
||||||
|
: content.substring(0, TRACE_CONTENT_LIMIT) + "...";
|
||||||
|
LOG.trace("Narrative snippet: {}", snippet);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,6 +119,11 @@ public class OllamaClient {
|
|||||||
public StateUpdateResponse generateStateUpdate(final String model,
|
public StateUpdateResponse generateStateUpdate(final String model,
|
||||||
final String systemPrompt,
|
final String systemPrompt,
|
||||||
final String userContent) {
|
final String userContent) {
|
||||||
|
int systemLen = systemPrompt != null ? systemPrompt.length() : 0;
|
||||||
|
int userLen = userContent != null ? userContent.length() : 0;
|
||||||
|
LOG.debug("Calling Ollama for state update: model={}, systemPromptLength={}, userContentLength={}",
|
||||||
|
model, systemLen, userLen);
|
||||||
|
|
||||||
OllamaChatRequest request = OllamaChatRequest.builder()
|
OllamaChatRequest request = OllamaChatRequest.builder()
|
||||||
.model(model)
|
.model(model)
|
||||||
.format("json")
|
.format("json")
|
||||||
@ -111,10 +141,21 @@ public class OllamaClient {
|
|||||||
|
|
||||||
OllamaChatResponse response = ollamaApi.chat(request);
|
OllamaChatResponse response = ollamaApi.chat(request);
|
||||||
String json = response.getMessage().getContent();
|
String json = response.getMessage().getContent();
|
||||||
|
int jsonLen = json != null ? json.length() : 0;
|
||||||
|
LOG.debug("Received state update from Ollama, JSON length={}", jsonLen);
|
||||||
|
if (LOG.isTraceEnabled() && json != null && !json.isEmpty()) {
|
||||||
|
String snippet = json.length() <= TRACE_CONTENT_LIMIT
|
||||||
|
? json
|
||||||
|
: json.substring(0, TRACE_CONTENT_LIMIT) + "...";
|
||||||
|
LOG.trace("State update JSON snippet: {}", snippet);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(json, StateUpdateResponse.class);
|
StateUpdateResponse result = objectMapper.readValue(json, StateUpdateResponse.class);
|
||||||
|
LOG.debug("Parsed state update successfully");
|
||||||
|
return result;
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
|
LOG.debug("Failed to parse state update JSON from Ollama response: {}", e.getMessage());
|
||||||
throw new OllamaParseException(
|
throw new OllamaParseException(
|
||||||
"Failed to parse state update JSON from Ollama response", e);
|
"Failed to parse state update JSON from Ollama response", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.client.ClientRequestContext;
|
||||||
|
import jakarta.ws.rs.client.ClientRequestFilter;
|
||||||
|
import jakarta.ws.rs.client.ClientResponseContext;
|
||||||
|
import jakarta.ws.rs.client.ClientResponseFilter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JAX-RS client filter that logs every outgoing request and response for the
|
||||||
|
* Ollama REST client. Registered only on {@link OllamaApi} via
|
||||||
|
* {@link org.eclipse.microprofile.rest.client.annotation.RegisterProvider}.
|
||||||
|
* Logs method, URI and (for responses) status and content-type so that
|
||||||
|
* Ollama communication can be inspected without logging full bodies here
|
||||||
|
* (body logging is configured via Quarkus REST client logging).
|
||||||
|
*/
|
||||||
|
public class OllamaClientLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(OllamaClientLoggingFilter.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the outgoing request method and URI before the request is sent.
|
||||||
|
*
|
||||||
|
* @param requestContext the request context
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void filter(final ClientRequestContext requestContext) throws IOException {
|
||||||
|
String method = requestContext.getMethod();
|
||||||
|
String uri = requestContext.getUri().toString();
|
||||||
|
LOG.debug("Ollama request: {} {}", method, uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the response status and content-type after the response is received.
|
||||||
|
*
|
||||||
|
* @param requestContext the request context (unused)
|
||||||
|
* @param responseContext the response context
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void filter(final ClientRequestContext requestContext,
|
||||||
|
final ClientResponseContext responseContext) throws IOException {
|
||||||
|
int status = responseContext.getStatus();
|
||||||
|
String statusInfo = responseContext.getStatusInfo() != null
|
||||||
|
? responseContext.getStatusInfo().getReasonPhrase()
|
||||||
|
: "";
|
||||||
|
String contentType = responseContext.getHeaderString("Content-Type");
|
||||||
|
LOG.debug("Ollama response: {} {} Content-Type: {}",
|
||||||
|
status, statusInfo, contentType != null ? contentType : "(none)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.ScenarioService;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioListResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
|
import de.neitzel.roleplay.generated.api.ScenariosApi;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JAX-RS resource that implements the {@link ScenariosApi} interface generated from the OpenAPI spec.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ScenariosResource implements ScenariosApi {
|
||||||
|
|
||||||
|
private final ScenarioService scenarioService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ScenariosResource(final ScenarioService scenarioService) {
|
||||||
|
this.scenarioService = scenarioService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScenarioListResponse listScenarios() {
|
||||||
|
ScenarioListResponse response = new ScenarioListResponse();
|
||||||
|
response.setScenarios(scenarioService.listScenarios());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScenarioSetup getScenario(final UUID scenarioId) {
|
||||||
|
return scenarioService.getScenarioAsSetup(scenarioId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("No scenario found with id: " + scenarioId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScenarioSummary createScenario(final CreateScenarioRequest createScenarioRequest) {
|
||||||
|
try {
|
||||||
|
return scenarioService.create(createScenarioRequest);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ScenarioSummary updateScenario(final UUID scenarioId,
|
||||||
|
final CreateScenarioRequest createScenarioRequest) {
|
||||||
|
try {
|
||||||
|
return scenarioService.update(scenarioId, createScenarioRequest);
|
||||||
|
} catch (final NoSuchElementException e) {
|
||||||
|
throw new NotFoundException("No scenario found with id: " + scenarioId);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteScenario(final UUID scenarioId) {
|
||||||
|
if (!scenarioService.delete(scenarioId)) {
|
||||||
|
throw new NotFoundException("No scenario found with id: " + scenarioId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
package de.neitzel.roleplay.fascade;
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
import de.neitzel.roleplay.business.SessionService;
|
import de.neitzel.roleplay.business.SessionService;
|
||||||
import de.neitzel.roleplay.fascade.api.SessionsApi;
|
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
|
import de.neitzel.roleplay.generated.api.SessionsApi;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.NotFoundException;
|
import jakarta.ws.rs.NotFoundException;
|
||||||
@ -54,5 +55,20 @@ public class SessionResource implements SessionsApi {
|
|||||||
.orElseThrow(() -> new NotFoundException(
|
.orElseThrow(() -> new NotFoundException(
|
||||||
"No session found with id: " + sessionId));
|
"No session found with id: " + sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* <p>Delegates to {@link SessionService#updateSession(String, UpdateSessionRequest)}.
|
||||||
|
* Returns 404 if the session is not found.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public SessionResponse updateSession(final String sessionId,
|
||||||
|
final UpdateSessionRequest updateSessionRequest) {
|
||||||
|
return sessionService.updateSession(sessionId,
|
||||||
|
updateSessionRequest != null ? updateSessionRequest : new UpdateSessionRequest())
|
||||||
|
.orElseThrow(() -> new NotFoundException(
|
||||||
|
"No session found with id: " + sessionId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch-all JAX-RS resource that serves {@code index.html} for every path
|
||||||
|
* that is not handled by a more specific resource. This enables React Router's
|
||||||
|
* client-side routing: when the user navigates directly to
|
||||||
|
* {@code /session/abc123} the browser receives {@code index.html} and React
|
||||||
|
* Router takes over.
|
||||||
|
*
|
||||||
|
* <p>JAX-RS selects the most specific matching path first, so all
|
||||||
|
* {@code /api/v1/...} routes defined by the generated API interfaces always
|
||||||
|
* take priority over this catch-all.
|
||||||
|
*/
|
||||||
|
@Path("/")
|
||||||
|
public class SpaFallbackResource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code index.html} for the application root.
|
||||||
|
*
|
||||||
|
* @return 200 response with the React application shell
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public Response index() {
|
||||||
|
return serveIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code index.html} from {@code META-INF/resources} on the
|
||||||
|
* classpath and streams it as the response body.
|
||||||
|
*
|
||||||
|
* @return 200 with index.html, or 404 if the file is not on the classpath
|
||||||
|
*/
|
||||||
|
private Response serveIndex() {
|
||||||
|
InputStream stream = getClass()
|
||||||
|
.getClassLoader()
|
||||||
|
.getResourceAsStream("META-INF/resources/index.html");
|
||||||
|
if (stream == null) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity("index.html not found – run 'mvn process-resources' first.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return Response.ok(stream, MediaType.TEXT_HTML).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code index.html} for any sub-path not matched by a more
|
||||||
|
* specific JAX-RS resource.
|
||||||
|
*
|
||||||
|
* @param path the unmatched path segment (ignored)
|
||||||
|
* @return 200 response with the React application shell
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("{path:(?!api/).*}")
|
||||||
|
@Produces(MediaType.TEXT_HTML)
|
||||||
|
public Response fallback(@PathParam("path") String path) {
|
||||||
|
return serveIndex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
|
|||||||
102
src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
Normal file
102
src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.UserService;
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST resource for admin-only user management: list, create, update, and delete users.
|
||||||
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
|
||||||
|
*/
|
||||||
|
@Path("admin/users")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class UsersResource {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
private final SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public UsersResource(final UserService userService, final SecurityIdentity securityIdentity) {
|
||||||
|
this.userService = userService;
|
||||||
|
this.securityIdentity = securityIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users (id, username, role). Admin only.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
public List<UserSummary> listUsers() {
|
||||||
|
return userService.listUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, password, and role. Admin only.
|
||||||
|
* Returns 201 with the created user summary.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
public Response createUser(final CreateUserRequest request) {
|
||||||
|
try {
|
||||||
|
UserSummary created = userService.createUser(request);
|
||||||
|
return Response.status(Response.Status.CREATED).entity(created).build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing user by id. Admin only. Returns 200 with updated user summary.
|
||||||
|
*/
|
||||||
|
@PUT
|
||||||
|
@Path("{userId}")
|
||||||
|
public UserSummary updateUser(@PathParam("userId") final UUID userId, final UpdateUserRequest request) {
|
||||||
|
try {
|
||||||
|
String currentUsername = securityIdentity.getPrincipal().getName();
|
||||||
|
return userService.updateUser(userId, request, currentUsername);
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("not found")) {
|
||||||
|
throw new NotFoundException(e.getMessage());
|
||||||
|
}
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user by id. Admin only. Cannot delete self or the last admin. Returns 204.
|
||||||
|
*/
|
||||||
|
@DELETE
|
||||||
|
@Path("{userId}")
|
||||||
|
public Response deleteUser(@PathParam("userId") final UUID userId) {
|
||||||
|
try {
|
||||||
|
String currentUsername = securityIdentity.getPrincipal().getName();
|
||||||
|
userService.deleteUser(userId, currentUsername);
|
||||||
|
return Response.noContent().build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("not found")) {
|
||||||
|
throw new NotFoundException(e.getMessage());
|
||||||
|
}
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,54 @@
|
|||||||
quarkus:
|
quarkus:
|
||||||
application:
|
application:
|
||||||
name: roleplay
|
name: roleplay
|
||||||
|
http:
|
||||||
|
root-path: /
|
||||||
|
# Form-based auth: SPA mode (no redirects; 200/401). Session in encrypted cookie.
|
||||||
|
auth:
|
||||||
|
form:
|
||||||
|
enabled: true
|
||||||
|
login-page: ""
|
||||||
|
landing-page: ""
|
||||||
|
error-page: ""
|
||||||
|
http-only-cookie: false
|
||||||
|
session:
|
||||||
|
encryption-key: roleplay-session-secret-key-min-16chars
|
||||||
|
permission:
|
||||||
|
form-login:
|
||||||
|
paths: /j_security_check
|
||||||
|
policy: permit
|
||||||
|
api:
|
||||||
|
paths: /api/v1/*
|
||||||
|
policy: authenticated
|
||||||
|
datasource:
|
||||||
|
db-kind: h2
|
||||||
|
jdbc:
|
||||||
|
url: jdbc:h2:mem:roleplay;DB_CLOSE_DELAY=-1
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
liquibase:
|
liquibase:
|
||||||
change-log: db/migration/changelog.xml
|
change-log: db/migration/changelog.xml
|
||||||
migrate-at-start: false
|
migrate-at-start: true
|
||||||
rest-client:
|
rest-client:
|
||||||
ollama-api:
|
ollama-api:
|
||||||
url: http://debian:11434
|
url: http://debian:11434
|
||||||
connect-timeout: 5000
|
connect-timeout: 5000
|
||||||
read-timeout: 120000
|
read-timeout: 120000
|
||||||
http:
|
logging:
|
||||||
root-path: /api/v1
|
scope: request-response
|
||||||
|
body-limit: 2048
|
||||||
|
rest:
|
||||||
|
path: /api/v1
|
||||||
|
log:
|
||||||
|
category:
|
||||||
|
"de.neitzel.roleplay.fascade.OllamaClient":
|
||||||
|
level: DEBUG
|
||||||
|
"org.jboss.resteasy.reactive.client.logging":
|
||||||
|
level: DEBUG
|
||||||
|
# Login debugging: form auth and security identity
|
||||||
|
"io.quarkus.vertx.http.runtime.security":
|
||||||
|
level: DEBUG
|
||||||
|
"io.quarkus.security":
|
||||||
|
level: DEBUG
|
||||||
|
"de.neitzel.roleplay":
|
||||||
|
level: INFO
|
||||||
|
|||||||
@ -4,5 +4,8 @@
|
|||||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
|
<include file="db/migration/v001__scenarios_and_characters.xml"/>
|
||||||
|
<include file="db/migration/v002__users_and_roles.xml"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
|
<changeSet id="001-1-create-character-table" author="roleplay">
|
||||||
|
<createTable tableName="rp_character">
|
||||||
|
<column name="id" type="uuid">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="varchar(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="role" type="varchar(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="backstory" type="clob"/>
|
||||||
|
<column name="personality_traits" type="clob"/>
|
||||||
|
<column name="speaking_style" type="varchar(1000)"/>
|
||||||
|
<column name="goals" type="clob"/>
|
||||||
|
</createTable>
|
||||||
|
<comment>Stores reusable character definitions. personality_traits and goals stored as JSON text.</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="001-2-create-scenario-table" author="roleplay">
|
||||||
|
<createTable tableName="scenario">
|
||||||
|
<column name="id" type="uuid">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="varchar(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="setting" type="clob"/>
|
||||||
|
<column name="initial_conflict" type="clob"/>
|
||||||
|
</createTable>
|
||||||
|
<comment>Stores scenario templates (setting and initial conflict).</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="001-3-create-scenario-character-table" author="roleplay">
|
||||||
|
<createTable tableName="scenario_character">
|
||||||
|
<column name="id" type="uuid">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="scenario_id" type="uuid">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_scenario_character_scenario"
|
||||||
|
references="scenario(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="character_id" type="uuid">
|
||||||
|
<constraints nullable="false" foreignKeyName="fk_scenario_character_character"
|
||||||
|
references="rp_character(id)"/>
|
||||||
|
</column>
|
||||||
|
<column name="is_user_character" type="boolean">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="position" type="int">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
<comment>Links scenarios to characters; position orders AI characters.</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="001-4-seed-example-scenario" author="roleplay">
|
||||||
|
<insert tableName="rp_character">
|
||||||
|
<column name="id" value="11111111-1111-1111-1111-111111111101"/>
|
||||||
|
<column name="name" value="The Detective"/>
|
||||||
|
<column name="role" value="rookie detective"/>
|
||||||
|
<column name="backstory" value="Recently joined the force, eager to prove themselves."/>
|
||||||
|
<column name="personality_traits" value="["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>
|
||||||
39
src/main/resources/db/migration/v002__users_and_roles.xml
Normal file
39
src/main/resources/db/migration/v002__users_and_roles.xml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
|
<changeSet id="002-1-create-rp-user-table" author="roleplay">
|
||||||
|
<createTable tableName="rp_user">
|
||||||
|
<column name="id" type="uuid">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="username" type="varchar(255)">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="password" type="varchar(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="role" type="varchar(50)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
<comment>Stores application users for form-based auth. Default login: admin / changeme.</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="002-2-seed-admin-user" author="roleplay">
|
||||||
|
<insert tableName="rp_user">
|
||||||
|
<column name="id" value="11111111-1111-1111-1111-111111111111"/>
|
||||||
|
<column name="username" value="admin"/>
|
||||||
|
<column name="password" value="$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO"/>
|
||||||
|
<column name="role" value="admin"/>
|
||||||
|
</insert>
|
||||||
|
<rollback>
|
||||||
|
<delete tableName="rp_user">
|
||||||
|
<where>id = '11111111-1111-1111-1111-111111111111'</where>
|
||||||
|
</delete>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
@ -19,6 +19,14 @@ tags:
|
|||||||
description: Manage role-play sessions
|
description: Manage role-play sessions
|
||||||
- name: turns
|
- name: turns
|
||||||
description: Submit user actions within a session
|
description: Submit user actions within a session
|
||||||
|
- name: scenarios
|
||||||
|
description: List and retrieve saved scenario templates
|
||||||
|
- name: characters
|
||||||
|
description: List and retrieve saved character templates
|
||||||
|
- name: auth
|
||||||
|
description: Authentication (current user, logout)
|
||||||
|
- name: users
|
||||||
|
description: User management (admin only)
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
@ -43,6 +51,232 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/scenarios:
|
||||||
|
get:
|
||||||
|
operationId: listScenarios
|
||||||
|
summary: List saved scenarios
|
||||||
|
description: Returns all stored scenario templates for selection when starting a session.
|
||||||
|
tags:
|
||||||
|
- scenarios
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of scenario summaries.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ScenarioListResponse'
|
||||||
|
post:
|
||||||
|
operationId: createScenario
|
||||||
|
summary: Create a scenario
|
||||||
|
description: Creates a new scenario with optional character slots. Server generates UUID.
|
||||||
|
tags:
|
||||||
|
- scenarios
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateScenarioRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Scenario created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ScenarioSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body or referenced character not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/scenarios/{scenarioId}:
|
||||||
|
get:
|
||||||
|
operationId: getScenario
|
||||||
|
summary: Get a scenario by id
|
||||||
|
description: Returns the full scenario setup (setting, conflict, characters) for the given id.
|
||||||
|
tags:
|
||||||
|
- scenarios
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Scenario found and returned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ScenarioSetup'
|
||||||
|
"404":
|
||||||
|
description: Scenario not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
put:
|
||||||
|
operationId: updateScenario
|
||||||
|
summary: Update a scenario
|
||||||
|
description: Full replace. Replaces all character slots.
|
||||||
|
tags:
|
||||||
|
- scenarios
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateScenarioRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Scenario updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ScenarioSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body or referenced character not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: Scenario not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
operationId: deleteScenario
|
||||||
|
summary: Delete a scenario
|
||||||
|
description: Removes the scenario template.
|
||||||
|
tags:
|
||||||
|
- scenarios
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/ScenarioId'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Scenario deleted.
|
||||||
|
"404":
|
||||||
|
description: Scenario not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/characters:
|
||||||
|
get:
|
||||||
|
operationId: listCharacters
|
||||||
|
summary: List saved characters
|
||||||
|
description: Returns all stored character templates for selection when building a scenario.
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of character definitions.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CharacterListResponse'
|
||||||
|
post:
|
||||||
|
operationId: createCharacter
|
||||||
|
summary: Create a character
|
||||||
|
description: Creates a new character template. Server generates UUID if id is omitted.
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateCharacterRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Character created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/characters/{characterId}:
|
||||||
|
get:
|
||||||
|
operationId: getCharacter
|
||||||
|
summary: Get a character by id
|
||||||
|
description: Returns the full character definition for the given id.
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/CharacterId'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Character found and returned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
"404":
|
||||||
|
description: Character not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
put:
|
||||||
|
operationId: updateCharacter
|
||||||
|
summary: Update a character
|
||||||
|
description: Full replace of the character. All fields required except optional ones.
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/CharacterId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateCharacterRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Character updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: Character not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
operationId: deleteCharacter
|
||||||
|
summary: Delete a character
|
||||||
|
description: Removes the character template. Scenarios referencing it may need updating.
|
||||||
|
tags:
|
||||||
|
- characters
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/CharacterId'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Character deleted.
|
||||||
|
"404":
|
||||||
|
description: Character not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/sessions:
|
/sessions:
|
||||||
post:
|
post:
|
||||||
operationId: createSession
|
operationId: createSession
|
||||||
@ -101,6 +335,40 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
patch:
|
||||||
|
operationId: updateSession
|
||||||
|
summary: Update session state
|
||||||
|
description: |
|
||||||
|
Partially updates an existing session. Provide situation and/or characters
|
||||||
|
to replace the current values. Omitted fields are left unchanged.
|
||||||
|
tags:
|
||||||
|
- sessions
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/SessionId'
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateSessionRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Session updated; full state returned.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SessionResponse'
|
||||||
|
"404":
|
||||||
|
description: Session not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
/sessions/{sessionId}/turns:
|
/sessions/{sessionId}/turns:
|
||||||
post:
|
post:
|
||||||
@ -147,6 +415,175 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/auth/me:
|
||||||
|
get:
|
||||||
|
operationId: getAuthMe
|
||||||
|
summary: Current user info
|
||||||
|
description: Returns the authenticated user's username and roles. Requires session cookie.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthMeResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/auth/logout:
|
||||||
|
post:
|
||||||
|
operationId: logout
|
||||||
|
summary: Log out
|
||||||
|
description: Clears the session cookie. Requires authenticated user.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Logged out.
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/auth/change-password:
|
||||||
|
post:
|
||||||
|
operationId: changePassword
|
||||||
|
summary: Change password
|
||||||
|
description: Changes the current user's password. Requires current password verification.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ChangePasswordRequest'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed.
|
||||||
|
"400":
|
||||||
|
description: Current password wrong or validation failed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/admin/users:
|
||||||
|
get:
|
||||||
|
operationId: listUsers
|
||||||
|
summary: List users (admin only)
|
||||||
|
description: Returns all users (id, username, role). Admin role required.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of users.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserListResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
post:
|
||||||
|
operationId: createUser
|
||||||
|
summary: Create user (admin only)
|
||||||
|
description: Creates a new user with the given username, password, and role.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateUserRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: User created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request (e.g. username already exists).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
|
||||||
|
/admin/users/{userId}:
|
||||||
|
put:
|
||||||
|
operationId: updateUser
|
||||||
|
summary: Update user (admin only)
|
||||||
|
description: Updates an existing user by id. Username and role required; password optional (omit to keep current).
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateUserRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: User updated.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request (e.g. username already exists).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
"404":
|
||||||
|
description: User not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
delete:
|
||||||
|
operationId: deleteUser
|
||||||
|
summary: Delete user (admin only)
|
||||||
|
description: Deletes a user by id. Cannot delete self or the last admin.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/UserId'
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: User deleted.
|
||||||
|
"400":
|
||||||
|
description: Cannot delete self or last admin.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
"404":
|
||||||
|
description: User not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
@ -157,6 +594,30 @@ components:
|
|||||||
description: Unique identifier of the role-play session.
|
description: Unique identifier of the role-play session.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
ScenarioId:
|
||||||
|
name: scenarioId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Unique identifier of the scenario (UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
CharacterId:
|
||||||
|
name: characterId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Unique identifier of the character (UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
UserId:
|
||||||
|
name: userId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Unique identifier of the user (UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
|
||||||
schemas:
|
schemas:
|
||||||
|
|
||||||
@ -221,9 +682,130 @@ components:
|
|||||||
default: standard
|
default: standard
|
||||||
scenario:
|
scenario:
|
||||||
$ref: '#/components/schemas/ScenarioSetup'
|
$ref: '#/components/schemas/ScenarioSetup'
|
||||||
|
scenarioId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: If set, the backend loads this scenario from the database and uses it instead of an inline scenario.
|
||||||
required:
|
required:
|
||||||
- model
|
- model
|
||||||
|
|
||||||
|
ScenarioSummary:
|
||||||
|
type: object
|
||||||
|
description: Summary of a stored scenario for list views.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Unique identifier of the scenario.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable scenario name.
|
||||||
|
setting:
|
||||||
|
type: string
|
||||||
|
description: Place, time, and atmosphere (optional in list).
|
||||||
|
initialConflict:
|
||||||
|
type: string
|
||||||
|
description: The hook or starting conflict (optional in list).
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
|
||||||
|
ScenarioListResponse:
|
||||||
|
type: object
|
||||||
|
description: Response containing all stored scenarios.
|
||||||
|
properties:
|
||||||
|
scenarios:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScenarioSummary'
|
||||||
|
required:
|
||||||
|
- scenarios
|
||||||
|
|
||||||
|
CharacterListResponse:
|
||||||
|
type: object
|
||||||
|
description: Response containing all stored characters.
|
||||||
|
properties:
|
||||||
|
characters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
required:
|
||||||
|
- characters
|
||||||
|
|
||||||
|
CreateCharacterRequest:
|
||||||
|
type: object
|
||||||
|
description: Request body for creating or updating a character. id optional on create (server generates UUID).
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Optional on create; server generates if omitted.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Display name.
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
description: Narrative role.
|
||||||
|
backstory:
|
||||||
|
type: string
|
||||||
|
description: Character background.
|
||||||
|
speakingStyle:
|
||||||
|
type: string
|
||||||
|
description: How the character speaks.
|
||||||
|
personalityTraits:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: List of personality traits.
|
||||||
|
goals:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Character goals.
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- role
|
||||||
|
|
||||||
|
ScenarioCharacterSlot:
|
||||||
|
type: object
|
||||||
|
description: Assignment of a character to a scenario slot (user or AI, with position).
|
||||||
|
properties:
|
||||||
|
characterId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Reference to a saved character.
|
||||||
|
isUserCharacter:
|
||||||
|
type: boolean
|
||||||
|
description: True if this slot is the player character.
|
||||||
|
position:
|
||||||
|
type: integer
|
||||||
|
description: Order of the character (0 for user, 1+ for AI).
|
||||||
|
required:
|
||||||
|
- characterId
|
||||||
|
- isUserCharacter
|
||||||
|
- position
|
||||||
|
|
||||||
|
CreateScenarioRequest:
|
||||||
|
type: object
|
||||||
|
description: Request body for creating or updating a scenario.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable scenario name.
|
||||||
|
setting:
|
||||||
|
type: string
|
||||||
|
description: Place, time, and atmosphere.
|
||||||
|
initialConflict:
|
||||||
|
type: string
|
||||||
|
description: The hook or starting conflict.
|
||||||
|
characterSlots:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScenarioCharacterSlot'
|
||||||
|
description: Assigned characters (one user, ordered AI).
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
|
||||||
ScenarioSetup:
|
ScenarioSetup:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@ -245,6 +827,19 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CharacterDefinition'
|
$ref: '#/components/schemas/CharacterDefinition'
|
||||||
|
|
||||||
|
UpdateSessionRequest:
|
||||||
|
type: object
|
||||||
|
description: Request body for partially updating a session.
|
||||||
|
properties:
|
||||||
|
situation:
|
||||||
|
$ref: '#/components/schemas/SituationState'
|
||||||
|
description: Replace session situation when provided.
|
||||||
|
characters:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CharacterState'
|
||||||
|
description: Replace session character list when provided.
|
||||||
|
|
||||||
CharacterDefinition:
|
CharacterDefinition:
|
||||||
type: object
|
type: object
|
||||||
description: Definition of a character for session initialisation.
|
description: Definition of a character for session initialisation.
|
||||||
@ -603,6 +1198,104 @@ components:
|
|||||||
- type
|
- type
|
||||||
- title
|
- title
|
||||||
|
|
||||||
|
# ─── Auth and users ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AuthMeResponse:
|
||||||
|
type: object
|
||||||
|
description: Current authenticated user.
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
roles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Assigned roles (e.g. admin, user).
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- roles
|
||||||
|
|
||||||
|
ChangePasswordRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to change the current user's password.
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
description: Current plain-text password (verified before change).
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
description: New plain-text password (hashed on server).
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
- newPassword
|
||||||
|
|
||||||
|
UserSummary:
|
||||||
|
type: object
|
||||||
|
description: User without password (for list and create response).
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: User UUID.
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
description: Role (admin or user).
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- username
|
||||||
|
- role
|
||||||
|
|
||||||
|
UserListResponse:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
description: List of users (admin only).
|
||||||
|
|
||||||
|
CreateUserRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to create a new user (admin only).
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Plain-text password (hashed on server).
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- admin
|
||||||
|
- user
|
||||||
|
description: Role to assign.
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
- role
|
||||||
|
|
||||||
|
UpdateUserRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to update an existing user (admin only). Password optional; omit to keep current.
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Optional new plain-text password (omit or blank to keep current).
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- admin
|
||||||
|
- user
|
||||||
|
description: Role to assign.
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- role
|
||||||
|
|
||||||
# ─── Error ────────────────────────────────────────────────────────────────
|
# ─── Error ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
{{#required}}
|
||||||
|
{{^isReadOnly}}
|
||||||
|
@NotNull
|
||||||
|
{{/isReadOnly}}
|
||||||
|
{{/required}}
|
||||||
|
{{#isContainer}}
|
||||||
|
{{! Do not add @Valid on container; we use type-argument @Valid in the pojo (List<@Valid T>) to fix HV000271 }}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{^isContainer}}
|
||||||
|
{{^isPrimitiveType}}
|
||||||
|
@Valid
|
||||||
|
{{/isPrimitiveType}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{>beanValidationCore}}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
{{#required}}
|
||||||
|
{{^isReadOnly}}
|
||||||
|
@NotNull
|
||||||
|
{{/isReadOnly}}
|
||||||
|
{{/required}}
|
||||||
|
{{#isContainer}}
|
||||||
|
{{! Do not add @Valid on container; we use type-argument @Valid in the pojo (List<@Valid T>) to fix HV000271 }}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{^isContainer}}
|
||||||
|
{{^isPrimitiveType}}
|
||||||
|
@Valid
|
||||||
|
{{/isPrimitiveType}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{>beanValidationCore}}
|
||||||
146
src/main/resources/openapi-templates/JavaJaxRS/pojo.mustache
Normal file
146
src/main/resources/openapi-templates/JavaJaxRS/pojo.mustache
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* {{description}}{{^description}}{{classname}}{{/description}}
|
||||||
|
*/{{#description}}
|
||||||
|
@ApiModel(description = "{{{.}}}"){{/description}}
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonPropertyOrder({
|
||||||
|
{{#vars}}
|
||||||
|
{{classname}}.JSON_PROPERTY_{{nameInSnakeCase}}{{^-last}},{{/-last}}
|
||||||
|
{{/vars}}
|
||||||
|
})
|
||||||
|
{{/jackson}}
|
||||||
|
{{>additionalModelTypeAnnotations}}{{>generatedAnnotation}}{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}
|
||||||
|
{{#vendorExtensions.x-class-extra-annotation}}
|
||||||
|
{{{vendorExtensions.x-class-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-class-extra-annotation}}
|
||||||
|
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
|
||||||
|
{{#vars}}
|
||||||
|
{{#isEnum}}
|
||||||
|
{{^isContainer}}
|
||||||
|
{{>enumClass}}
|
||||||
|
|
||||||
|
{{/isContainer}}
|
||||||
|
{{#isContainer}}
|
||||||
|
{{#mostInnerItems}}
|
||||||
|
{{>enumClass}}
|
||||||
|
|
||||||
|
{{/mostInnerItems}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{/isEnum}}
|
||||||
|
{{#jackson}}
|
||||||
|
public static final String JSON_PROPERTY_{{nameInSnakeCase}} = "{{baseName}}";
|
||||||
|
@JsonProperty(JSON_PROPERTY_{{nameInSnakeCase}})
|
||||||
|
{{/jackson}}
|
||||||
|
{{#gson}}
|
||||||
|
public static final String SERIALIZED_NAME_{{nameInSnakeCase}} = "{{baseName}}";
|
||||||
|
@SerializedName(SERIALIZED_NAME_{{nameInSnakeCase}})
|
||||||
|
{{/gson}}
|
||||||
|
{{#vendorExtensions.x-field-extra-annotation}}
|
||||||
|
{{{.}}}
|
||||||
|
{{/vendorExtensions.x-field-extra-annotation}}
|
||||||
|
private {{>propertyType}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
|
||||||
|
|
||||||
|
{{/vars}}
|
||||||
|
{{#vars}}
|
||||||
|
public {{classname}} {{name}}({{>propertyType}} {{name}}) {
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{#isArray}}
|
||||||
|
|
||||||
|
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
this.{{name}}.add({{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isArray}}
|
||||||
|
{{#isMap}}
|
||||||
|
|
||||||
|
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
this.{{name}}.put(key, {{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isMap}}
|
||||||
|
|
||||||
|
/**
|
||||||
|
{{#description}}
|
||||||
|
* {{.}}
|
||||||
|
{{/description}}
|
||||||
|
{{^description}}
|
||||||
|
* Get {{name}}
|
||||||
|
{{/description}}
|
||||||
|
{{#minimum}}
|
||||||
|
* minimum: {{.}}
|
||||||
|
{{/minimum}}
|
||||||
|
{{#maximum}}
|
||||||
|
* maximum: {{.}}
|
||||||
|
{{/maximum}}
|
||||||
|
* @return {{name}}
|
||||||
|
**/
|
||||||
|
{{#vendorExtensions.x-extra-annotation}}
|
||||||
|
{{{vendorExtensions.x-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-extra-annotation}}
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonProperty(value = "{{baseName}}"{{#isReadOnly}}, access = JsonProperty.Access.READ_ONLY{{/isReadOnly}}{{#isWriteOnly}}, access = JsonProperty.Access.WRITE_ONLY{{/isWriteOnly}})
|
||||||
|
{{/jackson}}
|
||||||
|
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}")
|
||||||
|
{{#useBeanValidation}}{{#required}}{{^isReadOnly}}
|
||||||
|
@NotNull
|
||||||
|
{{/isReadOnly}}{{/required}}{{#isContainer}}{{! No @Valid on container; type has List<@Valid T> (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}
|
||||||
|
@Valid
|
||||||
|
{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}{{/useBeanValidation}}
|
||||||
|
public {{>propertyType}} {{getter}}() {
|
||||||
|
return {{name}};
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{>propertyType}} {{name}}) {
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
}
|
||||||
|
|
||||||
|
{{/vars}}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
{{classname}} {{classVarName}} = ({{classname}}) o;{{#hasVars}}
|
||||||
|
return {{#parent}}super.equals(o) && {{/parent}}{{#vars}}Objects.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
|
||||||
|
{{/-last}}{{#-last}};{{/-last}}{{/vars}}{{/hasVars}}{{^hasVars}}{{#parent}}return super.equals(o);{{/parent}}{{^parent}}return true;{{/parent}}{{/hasVars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return {{^hasVars}}{{#parent}}super.hashCode(){{/parent}}{{^parent}}1{{/parent}}{{/hasVars}}{{#hasVars}}Objects.hash({{#vars}}{{#parent}}super.hashCode(), {{/parent}}{{name}}{{^-last}}, {{/-last}}{{/vars}}){{/hasVars}};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("class {{classname}} {\n");
|
||||||
|
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
|
||||||
|
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
|
||||||
|
{{/vars}}sb.append("}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given object to string with each line indented by 4 spaces
|
||||||
|
* (except the first line).
|
||||||
|
*/
|
||||||
|
private String toIndentedString(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return o.toString().replace("\n", "\n ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{{! Type with @Valid on type argument for containers (HV000271 fix). }}{{#isContainer}}{{#isArray}}{{#items.isPrimitiveType}}{{{datatypeWithEnum}}}{{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isEnum}}{{{datatypeWithEnum}}}{{/items.isEnum}}{{^items.isEnum}}List<@Valid {{{items.datatypeWithEnum}}}>{{/items.isEnum}}{{/items.isPrimitiveType}}{{/isArray}}{{^isArray}}{{#isMap}}{{#items.isPrimitiveType}}{{{datatypeWithEnum}}}{{/items.isPrimitiveType}}{{^items.isPrimitiveType}}{{#items.isEnum}}{{{datatypeWithEnum}}}{{/items.isEnum}}{{^items.isEnum}}Map<String, @Valid {{{items.datatypeWithEnum}}}>{{/items.isEnum}}{{/items.isPrimitiveType}}{{/isMap}}{{^isMap}}{{{datatypeWithEnum}}}{{/isMap}}{{/isMap}}{{/isArray}}{{/isContainer}}{{^isContainer}}{{{datatypeWithEnum}}}{{/isContainer}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{{#required}}{{^isReadOnly}}@NotNull {{/isReadOnly}}{{/required}}{{#isContainer}}{{! Do not add @Valid on container; use type-argument @Valid (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}{{^isDate}}{{^isDateTime}}{{^isString}}{{^isFile}}{{^isEnumOrRef}}@Valid {{/isEnumOrRef}}{{/isFile}}{{/isString}}{{/isDateTime}}{{/isDate}}{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}
|
||||||
@ -0,0 +1,289 @@
|
|||||||
|
{{#useSwaggerAnnotations}}
|
||||||
|
import io.swagger.annotations.*;
|
||||||
|
{{/useSwaggerAnnotations}}
|
||||||
|
{{#useSwaggerV3Annotations}}
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
{{/useSwaggerV3Annotations}}
|
||||||
|
import java.util.Objects;
|
||||||
|
{{#jackson}}
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||||
|
{{#additionalProperties}}
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{#openApiNullable}}
|
||||||
|
import org.openapitools.jackson.nullable.JsonNullable;
|
||||||
|
{{/openApiNullable}}
|
||||||
|
{{#withXml}}
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlElement;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlRootElement;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlAccessType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlAccessorType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlEnum;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
|
||||||
|
{{/withXml}}
|
||||||
|
|
||||||
|
{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{#description}}/**
|
||||||
|
* {{.}}
|
||||||
|
**/{{/description}}
|
||||||
|
{{#useSwaggerAnnotations}}{{#description}}@ApiModel(description = "{{{.}}}"){{/description}}{{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
|
||||||
|
@Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
|
||||||
|
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useMicroProfileOpenAPIAnnotations}}
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonTypeName("{{name}}")
|
||||||
|
{{#additionalProperties}}
|
||||||
|
@JsonFormat(shape=JsonFormat.Shape.OBJECT)
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{>generatedAnnotation}}{{>additionalModelTypeAnnotations}}{{>xmlPojoAnnotation}}
|
||||||
|
{{#vendorExtensions.x-class-extra-annotation}}
|
||||||
|
{{{vendorExtensions.x-class-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-class-extra-annotation}}
|
||||||
|
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
|
||||||
|
{{#vars}}
|
||||||
|
{{#isEnum}}
|
||||||
|
{{^isContainer}}
|
||||||
|
{{>enumClass}}{{! prevent indent}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{#isContainer}}
|
||||||
|
{{#mostInnerItems}}
|
||||||
|
{{>enumClass}}{{! prevent indent}}
|
||||||
|
{{/mostInnerItems}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{/isEnum}}
|
||||||
|
{{#vendorExtensions.x-field-extra-annotation}}
|
||||||
|
{{{.}}}
|
||||||
|
{{/vendorExtensions.x-field-extra-annotation}}
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{#isContainer}}
|
||||||
|
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();
|
||||||
|
{{/isContainer}}
|
||||||
|
{{^isContainer}}
|
||||||
|
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>{{#defaultValue}}of({{{.}}}){{/defaultValue}}{{^defaultValue}}undefined(){{/defaultValue}};
|
||||||
|
{{/isContainer}}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
private {{#useBeanValidation}}{{>beanValidatedType}}{{/useBeanValidation}}{{^useBeanValidation}}{{{datatypeWithEnum}}}{{/useBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{/vars}}
|
||||||
|
|
||||||
|
{{#generateBuilders}}
|
||||||
|
{{^additionalProperties}}
|
||||||
|
protected {{classname}}({{classname}}Builder b) {
|
||||||
|
{{#parent}}
|
||||||
|
super(b);
|
||||||
|
{{/parent}}
|
||||||
|
{{#vars}}
|
||||||
|
this.{{name}} = b.{{name}};
|
||||||
|
{{/vars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/generateBuilders}}
|
||||||
|
public {{classname}}() {
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
{{#generateJsonCreator}}
|
||||||
|
{{#hasRequired}}
|
||||||
|
@JsonCreator
|
||||||
|
public {{classname}}(
|
||||||
|
{{#requiredVars}}
|
||||||
|
@JsonProperty(required = {{required}}, value = "{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}}
|
||||||
|
{{/requiredVars}}
|
||||||
|
) {
|
||||||
|
{{#parent}}
|
||||||
|
super(
|
||||||
|
{{#parentRequiredVars}}
|
||||||
|
{{name}}{{^-last}},{{/-last}}
|
||||||
|
{{/parentRequiredVars}}
|
||||||
|
);
|
||||||
|
{{/parent}}
|
||||||
|
{{#vars}}
|
||||||
|
{{#required}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/required}}
|
||||||
|
{{/vars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{/hasRequired}}
|
||||||
|
{{/generateJsonCreator}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{#vars}}
|
||||||
|
/**
|
||||||
|
{{#description}}
|
||||||
|
* {{.}}
|
||||||
|
{{/description}}
|
||||||
|
{{#minimum}}
|
||||||
|
* minimum: {{.}}
|
||||||
|
{{/minimum}}
|
||||||
|
{{#maximum}}
|
||||||
|
* maximum: {{.}}
|
||||||
|
{{/maximum}}
|
||||||
|
**/
|
||||||
|
public {{classname}} {{name}}({{{datatypeWithEnum}}} {{name}}) {
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#withXml}}
|
||||||
|
@XmlElement(name="{{baseName}}"{{#required}}, required = {{required}}{{/required}})
|
||||||
|
{{/withXml}}
|
||||||
|
{{#vendorExtensions.x-extra-annotation}}{{{vendorExtensions.x-extra-annotation}}}{{/vendorExtensions.x-extra-annotation}}{{#useSwaggerAnnotations}}
|
||||||
|
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}"){{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
|
||||||
|
@Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
|
||||||
|
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useMicroProfileOpenAPIAnnotations}}
|
||||||
|
{{#jackson}}@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}"){{/jackson}}
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
public JsonNullable<{{{datatypeWithEnum}}}> {{getter}}() {
|
||||||
|
return {{name}};
|
||||||
|
}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}public {{>beanValidatedType}} {{getter}}() {
|
||||||
|
return {{name}};
|
||||||
|
}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}")
|
||||||
|
{{/jackson}}
|
||||||
|
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{{datatypeWithEnum}}} {{name}}) {
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#isArray}}
|
||||||
|
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.{{name}}.add({{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public {{classname}} remove{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if ({{name}}Item != null && this.{{name}} != null) {
|
||||||
|
this.{{name}}.remove({{name}}Item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isArray}}
|
||||||
|
{{#isMap}}
|
||||||
|
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.{{name}}.put(key, {{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public {{classname}} remove{{nameInPascalCase}}Item(String key) {
|
||||||
|
if (this.{{name}} != null) {
|
||||||
|
this.{{name}}.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isMap}}
|
||||||
|
{{/vars}}
|
||||||
|
{{>additional_properties}}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}{{#hasVars}}
|
||||||
|
{{classname}} {{classVarName}} = ({{classname}}) o;
|
||||||
|
return {{#vars}}{{#isByteArray}}Arrays{{/isByteArray}}{{^isByteArray}}Objects{{/isByteArray}}.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
|
||||||
|
{{/-last}}{{/vars}}{{#parent}} &&
|
||||||
|
super.equals(o){{/parent}};{{/hasVars}}{{^hasVars}}
|
||||||
|
return {{#parent}}super.equals(o){{/parent}}{{^parent}}true{{/parent}};{{/hasVars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash({{#vars}}{{^isByteArray}}{{name}}{{/isByteArray}}{{#isByteArray}}Arrays.hashCode({{name}}){{/isByteArray}}{{^-last}}, {{/-last}}{{/vars}}{{#parent}}{{#hasVars}}, {{/hasVars}}super.hashCode(){{/parent}});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("class {{classname}} {\n");
|
||||||
|
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
|
||||||
|
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
|
||||||
|
{{/vars}}sb.append("}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given object to string with each line indented by 4 spaces
|
||||||
|
* (except the first line).
|
||||||
|
*/
|
||||||
|
private String toIndentedString(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return o.toString().replace("\n", "\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#generateBuilders}}{{^additionalProperties}}
|
||||||
|
public static {{classname}}Builder builder() {
|
||||||
|
return new {{classname}}BuilderImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class {{classname}}BuilderImpl extends {{classname}}Builder<{{classname}}, {{classname}}BuilderImpl> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected {{classname}}BuilderImpl self() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public {{classname}} build() {
|
||||||
|
return new {{classname}}(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static abstract class {{classname}}Builder > {{#parent}}extends {{{.}}}Builder {{/parent}} {
|
||||||
|
{{#vars}}
|
||||||
|
private {{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
|
||||||
|
{{/vars}}
|
||||||
|
{{^parent}}
|
||||||
|
protected abstract B self();
|
||||||
|
|
||||||
|
public abstract C build();
|
||||||
|
{{/parent}}
|
||||||
|
|
||||||
|
{{#vars}}
|
||||||
|
public B {{name}}({{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}) {
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
return self();
|
||||||
|
}
|
||||||
|
{{/vars}}
|
||||||
|
}{{/additionalProperties}}{{/generateBuilders}}
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
{{#additionalEnumTypeAnnotations}}{{{.}}}
|
||||||
|
{{/additionalEnumTypeAnnotations}}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
{{#additionalModelTypeAnnotations}}{{{.}}}
|
||||||
|
{{/additionalModelTypeAnnotations}}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
{{#additionalProperties}}
|
||||||
|
/**
|
||||||
|
* Set the additional (undeclared) property with the specified name and value.
|
||||||
|
* Creates the property if it does not already exist, otherwise replaces it.
|
||||||
|
* @param key the name of the property
|
||||||
|
* @param value the value of the property
|
||||||
|
* @return self reference
|
||||||
|
*/
|
||||||
|
@JsonAnySetter
|
||||||
|
public {{classname}} putAdditionalProperty(String key, {{{datatypeWithEnum}}} value) {
|
||||||
|
this.put(key, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the additional (undeclared) properties.
|
||||||
|
* @return the additional (undeclared) properties
|
||||||
|
*/
|
||||||
|
@JsonAnyGetter
|
||||||
|
public Map getAdditionalProperties() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the additional (undeclared) property with the specified name.
|
||||||
|
* @param key the name of the property
|
||||||
|
* @return the additional (undeclared) property with the specified name
|
||||||
|
*/
|
||||||
|
public {{{datatypeWithEnum}}} getAdditionalProperty(String key) {
|
||||||
|
return this.get(key);
|
||||||
|
}
|
||||||
|
{{/additionalProperties}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{{#isArray}}{{baseType}}<{{#items}}{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}{{>beanValidatedType}}{{/items}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{{#required}}{{^isReadOnly}}@NotNull {{/isReadOnly}}{{/required}}{{#isContainer}}{{! No @Valid on container (HV000271) }}{{/isContainer}}{{^isContainer}}{{^isPrimitiveType}}{{^isDate}}{{^isDateTime}}{{^isString}}{{^isFile}}{{^isEnumOrRef}}@Valid {{/isEnumOrRef}}{{/isFile}}{{/isString}}{{/isDateTime}}{{/isDate}}{{/isPrimitiveType}}{{/isContainer}}{{>beanValidationCore}}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
{{#pattern}} @Pattern(regexp="{{{.}}}"){{/pattern}}{{!
|
||||||
|
minLength && maxLength set
|
||||||
|
}}{{#minLength}}{{#maxLength}} @Size(min={{minLength}},max={{maxLength}}){{/maxLength}}{{/minLength}}{{!
|
||||||
|
minLength set, maxLength not
|
||||||
|
}}{{#minLength}}{{^maxLength}} @Size(min={{minLength}}){{/maxLength}}{{/minLength}}{{!
|
||||||
|
minLength not set, maxLength set
|
||||||
|
}}{{^minLength}}{{#maxLength}} @Size(max={{.}}){{/maxLength}}{{/minLength}}{{!
|
||||||
|
@Size: minItems && maxItems set
|
||||||
|
}}{{#minItems}}{{#maxItems}} @Size(min={{minItems}},max={{maxItems}}){{/maxItems}}{{/minItems}}{{!
|
||||||
|
@Size: minItems set, maxItems not
|
||||||
|
}}{{#minItems}}{{^maxItems}} @Size(min={{minItems}}){{/maxItems}}{{/minItems}}{{!
|
||||||
|
@Size: minItems not set && maxItems set
|
||||||
|
}}{{^minItems}}{{#maxItems}} @Size(max={{.}}){{/maxItems}}{{/minItems}}{{!
|
||||||
|
check for integer or long / all others=decimal type with @Decimal*
|
||||||
|
isInteger set
|
||||||
|
}}{{#isInteger}}{{#minimum}} @Min({{.}}){{/minimum}}{{#maximum}} @Max({{.}}){{/maximum}}{{/isInteger}}{{!
|
||||||
|
isLong set
|
||||||
|
}}{{#isLong}}{{#minimum}} @Min({{.}}L){{/minimum}}{{#maximum}} @Max({{.}}L){{/maximum}}{{/isLong}}{{!
|
||||||
|
Not Integer, not Long => we have a decimal value!
|
||||||
|
}}{{^isInteger}}{{^isLong}}{{#minimum}} @DecimalMin({{#exclusiveMinimum}}value={{/exclusiveMinimum}}"{{minimum}}"{{#exclusiveMinimum}},inclusive=false{{/exclusiveMinimum}}){{/minimum}}{{#maximum}} @DecimalMax({{#exclusiveMaximum}}value={{/exclusiveMaximum}}"{{maximum}}"{{#exclusiveMaximum}},inclusive=false{{/exclusiveMaximum}}){{/maximum}}{{/isLong}}{{/isInteger}}
|
||||||
56
src/main/resources/openapi-templates/enumClass.mustache
Normal file
56
src/main/resources/openapi-templates/enumClass.mustache
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{{#withXml}}
|
||||||
|
@XmlType(name="{{datatypeWithEnum}}")
|
||||||
|
@XmlEnum({{dataType}}.class)
|
||||||
|
{{/withXml}}
|
||||||
|
{{>additionalEnumTypeAnnotations}}public enum {{datatypeWithEnum}} {
|
||||||
|
|
||||||
|
{{#allowableValues}}
|
||||||
|
{{#enumVars}}{{#withXml}}@XmlEnumValue({{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}{{{value}}}{{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}){{/withXml}}{{name}}({{dataType}}.valueOf({{{value}}})){{^-last}}, {{/-last}}{{#-last}};{{/-last}}{{/enumVars}}
|
||||||
|
{{/allowableValues}}
|
||||||
|
|
||||||
|
|
||||||
|
private {{dataType}} value;
|
||||||
|
|
||||||
|
{{datatypeWithEnum}} ({{dataType}} v) {
|
||||||
|
value = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public {{dataType}} value() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonValue
|
||||||
|
{{/jackson}}
|
||||||
|
public String toString() {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a String into {{dataType}}, as specified in the
|
||||||
|
* See JAX RS 2.0 Specification, section 3.2, p. 12
|
||||||
|
*/
|
||||||
|
public static {{datatypeWithEnum}} fromString(String s) {
|
||||||
|
for ({{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
|
||||||
|
// using Objects.toString() to be safe if value type non-object type
|
||||||
|
// because types like 'int' etc. will be auto-boxed
|
||||||
|
if (java.util.Objects.toString(b.value).equals(s)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}throw new IllegalArgumentException("Unexpected string value '" + s + "'");{{/isNullable}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonCreator
|
||||||
|
public static {{datatypeWithEnum}} fromValue({{dataType}} value) {
|
||||||
|
for ({{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
|
||||||
|
if (b.value.{{^isString}}equals{{/isString}}{{#isString}}{{#useEnumCaseInsensitive}}equalsIgnoreCase{{/useEnumCaseInsensitive}}{{^useEnumCaseInsensitive}}equals{{/useEnumCaseInsensitive}}{{/isString}}(value)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}{{#enumUnknownDefaultCase}}{{#allowableValues}}{{#enumVars}}{{#-last}}return {{{name}}};{{/-last}}{{/enumVars}}{{/allowableValues}}{{/enumUnknownDefaultCase}}{{^enumUnknownDefaultCase}}throw new IllegalArgumentException("Unexpected value '" + value + "'");{{/enumUnknownDefaultCase}}{{/isNullable}}
|
||||||
|
}
|
||||||
|
{{/jackson}}
|
||||||
|
}
|
||||||
64
src/main/resources/openapi-templates/enumOuterClass.mustache
Normal file
64
src/main/resources/openapi-templates/enumOuterClass.mustache
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{{#jackson}}
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
{{/jackson}}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {{description}}{{^description}}Gets or Sets {{{name}}}{{/description}}
|
||||||
|
*/
|
||||||
|
{{>generatedAnnotation}}
|
||||||
|
|
||||||
|
{{>additionalEnumTypeAnnotations}}public enum {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} {
|
||||||
|
{{#gson}}
|
||||||
|
{{#allowableValues}}{{#enumVars}}
|
||||||
|
@SerializedName({{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}}{{{value}}}{{#isInteger}}"{{/isInteger}}{{#isDouble}}"{{/isDouble}}{{#isLong}}"{{/isLong}}{{#isFloat}}"{{/isFloat}})
|
||||||
|
{{{name}}}({{{value}}}){{^-last}},
|
||||||
|
{{/-last}}{{#-last}};{{/-last}}{{/enumVars}}{{/allowableValues}}
|
||||||
|
{{/gson}}
|
||||||
|
{{^gson}}
|
||||||
|
{{#allowableValues}}{{#enumVars}}
|
||||||
|
{{{name}}}({{{value}}}){{^-last}},
|
||||||
|
{{/-last}}{{#-last}};{{/-last}}{{/enumVars}}{{/allowableValues}}
|
||||||
|
{{/gson}}
|
||||||
|
|
||||||
|
private {{{dataType}}} value;
|
||||||
|
|
||||||
|
{{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}({{{dataType}}} value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a String into {{dataType}}, as specified in the
|
||||||
|
* See JAX RS 2.0 Specification, section 3.2, p. 12
|
||||||
|
*/
|
||||||
|
public static {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} fromString(String s) {
|
||||||
|
for ({{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{#datatypeWithEnum}}{{{.}}}{{/datatypeWithEnum}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
|
||||||
|
// using Objects.toString() to be safe if value type non-object type
|
||||||
|
// because types like 'int' etc. will be auto-boxed
|
||||||
|
if (java.util.Objects.toString(b.value).equals(s)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}throw new IllegalArgumentException("Unexpected string value '" + s + "'");{{/isNullable}}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonValue
|
||||||
|
{{/jackson}}
|
||||||
|
public String toString() {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonCreator
|
||||||
|
public static {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} fromValue({{{dataType}}} value) {
|
||||||
|
for ({{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}} b : {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{{classname}}}{{/datatypeWithEnum}}.values()) {
|
||||||
|
if (b.value.{{^isString}}equals{{/isString}}{{#isString}}{{#useEnumCaseInsensitive}}equalsIgnoreCase{{/useEnumCaseInsensitive}}{{^useEnumCaseInsensitive}}equals{{/useEnumCaseInsensitive}}{{/isString}}(value)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{#isNullable}}return null;{{/isNullable}}{{^isNullable}}{{#enumUnknownDefaultCase}}{{#allowableValues}}{{#enumVars}}{{#-last}}return {{{name}}};{{/-last}}{{/enumVars}}{{/allowableValues}}{{/enumUnknownDefaultCase}}{{^enumUnknownDefaultCase}}throw new IllegalArgumentException("Unexpected value '" + value + "'");{{/enumUnknownDefaultCase}}{{/isNullable}}
|
||||||
|
}
|
||||||
|
{{/jackson}}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
@{{javaxPackage}}.annotation.Generated(value = "{{generatorClass}}"{{^hideGenerationTimestamp}}, date = "{{generatedDate}}"{{/hideGenerationTimestamp}}, comments = "Generator version: {{generatorVersion}}")
|
||||||
21
src/main/resources/openapi-templates/model.mustache
Normal file
21
src/main/resources/openapi-templates/model.mustache
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package {{package}};
|
||||||
|
|
||||||
|
{{#imports}}import {{import}};
|
||||||
|
{{/imports}}
|
||||||
|
{{#serializableModel}}
|
||||||
|
import java.io.Serializable;
|
||||||
|
{{/serializableModel}}
|
||||||
|
{{#useBeanValidation}}
|
||||||
|
import {{javaxPackage}}.validation.constraints.*;
|
||||||
|
import {{javaxPackage}}.validation.Valid;
|
||||||
|
{{/useBeanValidation}}
|
||||||
|
|
||||||
|
{{#models}}
|
||||||
|
{{#model}}
|
||||||
|
{{#isEnum}}
|
||||||
|
{{>enumOuterClass}}
|
||||||
|
|
||||||
|
{{/isEnum}}
|
||||||
|
{{^isEnum}}{{>pojo}}{{/isEnum}}
|
||||||
|
{{/model}}
|
||||||
|
{{/models}}
|
||||||
289
src/main/resources/openapi-templates/pojo.mustache
Normal file
289
src/main/resources/openapi-templates/pojo.mustache
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
{{#useSwaggerAnnotations}}
|
||||||
|
import io.swagger.annotations.*;
|
||||||
|
{{/useSwaggerAnnotations}}
|
||||||
|
{{#useSwaggerV3Annotations}}
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
{{/useSwaggerV3Annotations}}
|
||||||
|
import java.util.Objects;
|
||||||
|
{{#jackson}}
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||||
|
{{#additionalProperties}}
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{#openApiNullable}}
|
||||||
|
import org.openapitools.jackson.nullable.JsonNullable;
|
||||||
|
{{/openApiNullable}}
|
||||||
|
{{#withXml}}
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlElement;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlRootElement;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlAccessType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlAccessorType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlType;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlEnum;
|
||||||
|
import {{javaxPackage}}.xml.bind.annotation.XmlEnumValue;
|
||||||
|
{{/withXml}}
|
||||||
|
|
||||||
|
{{#discriminator}}{{>typeInfoAnnotation}}{{/discriminator}}{{#description}}/**
|
||||||
|
* {{.}}
|
||||||
|
**/{{/description}}
|
||||||
|
{{#useSwaggerAnnotations}}{{#description}}@ApiModel(description = "{{{.}}}"){{/description}}{{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
|
||||||
|
@Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
|
||||||
|
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#title}}title="{{{.}}}", {{/title}}{{#description}}description="{{{.}}}"{{/description}}{{^description}}description=""{{/description}}){{/useMicroProfileOpenAPIAnnotations}}
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonTypeName("{{name}}")
|
||||||
|
{{#additionalProperties}}
|
||||||
|
@JsonFormat(shape=JsonFormat.Shape.OBJECT)
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{>generatedAnnotation}}{{>additionalModelTypeAnnotations}}{{>xmlPojoAnnotation}}
|
||||||
|
{{#vendorExtensions.x-class-extra-annotation}}
|
||||||
|
{{{vendorExtensions.x-class-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-class-extra-annotation}}
|
||||||
|
public class {{classname}} {{#parent}}extends {{{.}}}{{/parent}} {{#vendorExtensions.x-implements}}{{#-first}}implements {{{.}}}{{/-first}}{{^-first}}, {{{.}}}{{/-first}}{{/vendorExtensions.x-implements}} {
|
||||||
|
{{#vars}}
|
||||||
|
{{#isEnum}}
|
||||||
|
{{^isContainer}}
|
||||||
|
{{>enumClass}}{{! prevent indent}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{#isContainer}}
|
||||||
|
{{#mostInnerItems}}
|
||||||
|
{{>enumClass}}{{! prevent indent}}
|
||||||
|
{{/mostInnerItems}}
|
||||||
|
{{/isContainer}}
|
||||||
|
{{/isEnum}}
|
||||||
|
{{#vendorExtensions.x-field-extra-annotation}}
|
||||||
|
{{{.}}}
|
||||||
|
{{/vendorExtensions.x-field-extra-annotation}}
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{#isContainer}}
|
||||||
|
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>undefined();
|
||||||
|
{{/isContainer}}
|
||||||
|
{{^isContainer}}
|
||||||
|
private JsonNullable<{{{datatypeWithEnum}}}> {{name}} = JsonNullable.<{{{datatypeWithEnum}}}>{{#defaultValue}}of({{{.}}}){{/defaultValue}}{{^defaultValue}}undefined(){{/defaultValue}};
|
||||||
|
{{/isContainer}}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
private {{#useBeanValidation}}{{>beanValidatedType}}{{/useBeanValidation}}{{^useBeanValidation}}{{{datatypeWithEnum}}}{{/useBeanValidation}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{/vars}}
|
||||||
|
|
||||||
|
{{#generateBuilders}}
|
||||||
|
{{^additionalProperties}}
|
||||||
|
protected {{classname}}({{classname}}Builder b) {
|
||||||
|
{{#parent}}
|
||||||
|
super(b);
|
||||||
|
{{/parent}}
|
||||||
|
{{#vars}}
|
||||||
|
this.{{name}} = b.{{name}};
|
||||||
|
{{/vars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{/additionalProperties}}
|
||||||
|
{{/generateBuilders}}
|
||||||
|
public {{classname}}() {
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
{{#generateJsonCreator}}
|
||||||
|
{{#hasRequired}}
|
||||||
|
@JsonCreator
|
||||||
|
public {{classname}}(
|
||||||
|
{{#requiredVars}}
|
||||||
|
@JsonProperty(required = {{required}}, value = "{{baseName}}") {{>beanValidatedType}} {{name}}{{^-last}},{{/-last}}
|
||||||
|
{{/requiredVars}}
|
||||||
|
) {
|
||||||
|
{{#parent}}
|
||||||
|
super(
|
||||||
|
{{#parentRequiredVars}}
|
||||||
|
{{name}}{{^-last}},{{/-last}}
|
||||||
|
{{/parentRequiredVars}}
|
||||||
|
);
|
||||||
|
{{/parent}}
|
||||||
|
{{#vars}}
|
||||||
|
{{#required}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/required}}
|
||||||
|
{{/vars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{/hasRequired}}
|
||||||
|
{{/generateJsonCreator}}
|
||||||
|
{{/jackson}}
|
||||||
|
{{#vars}}
|
||||||
|
/**
|
||||||
|
{{#description}}
|
||||||
|
* {{.}}
|
||||||
|
{{/description}}
|
||||||
|
{{#minimum}}
|
||||||
|
* minimum: {{.}}
|
||||||
|
{{/minimum}}
|
||||||
|
{{#maximum}}
|
||||||
|
* maximum: {{.}}
|
||||||
|
{{/maximum}}
|
||||||
|
**/
|
||||||
|
public {{classname}} {{name}}({{{datatypeWithEnum}}} {{name}}) {
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#withXml}}
|
||||||
|
@XmlElement(name="{{baseName}}"{{#required}}, required = {{required}}{{/required}})
|
||||||
|
{{/withXml}}
|
||||||
|
{{#vendorExtensions.x-extra-annotation}}{{{vendorExtensions.x-extra-annotation}}}{{/vendorExtensions.x-extra-annotation}}{{#useSwaggerAnnotations}}
|
||||||
|
@ApiModelProperty({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}value = "{{{description}}}"){{/useSwaggerAnnotations}}{{#useSwaggerV3Annotations}}
|
||||||
|
@Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useSwaggerV3Annotations}}{{#useMicroProfileOpenAPIAnnotations}}
|
||||||
|
@org.eclipse.microprofile.openapi.annotations.media.Schema({{#example}}example = "{{{.}}}", {{/example}}{{#required}}required = {{required}}, {{/required}}description = "{{{description}}}"){{/useMicroProfileOpenAPIAnnotations}}
|
||||||
|
{{#jackson}}@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}"){{/jackson}}
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
public JsonNullable<{{{datatypeWithEnum}}}> {{getter}}() {
|
||||||
|
return {{name}};
|
||||||
|
}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{#useBeanValidation}}{{>beanValidation}}{{/useBeanValidation}}public {{>beanValidatedType}} {{getter}}() {
|
||||||
|
return {{name}};
|
||||||
|
}
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
|
||||||
|
{{#jackson}}
|
||||||
|
@JsonProperty({{#required}}required = {{required}}, value = {{/required}}"{{baseName}}")
|
||||||
|
{{/jackson}}
|
||||||
|
{{#vendorExtensions.x-setter-extra-annotation}}{{{vendorExtensions.x-setter-extra-annotation}}}
|
||||||
|
{{/vendorExtensions.x-setter-extra-annotation}}public void {{setter}}({{{datatypeWithEnum}}} {{name}}) {
|
||||||
|
{{#vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = JsonNullable.<{{{datatypeWithEnum}}}>of({{name}});
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
{{^vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
{{/vendorExtensions.x-is-jackson-optional-nullable}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#isArray}}
|
||||||
|
public {{classname}} add{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new {{#uniqueItems}}LinkedHashSet{{/uniqueItems}}{{^uniqueItems}}ArrayList{{/uniqueItems}}<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.{{name}}.add({{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public {{classname}} remove{{nameInPascalCase}}Item({{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if ({{name}}Item != null && this.{{name}} != null) {
|
||||||
|
this.{{name}}.remove({{name}}Item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isArray}}
|
||||||
|
{{#isMap}}
|
||||||
|
public {{classname}} put{{nameInPascalCase}}Item(String key, {{{items.datatypeWithEnum}}} {{name}}Item) {
|
||||||
|
if (this.{{name}} == null) {
|
||||||
|
this.{{name}} = {{{defaultValue}}}{{^defaultValue}}new HashMap<>(){{/defaultValue}};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.{{name}}.put(key, {{name}}Item);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public {{classname}} remove{{nameInPascalCase}}Item(String key) {
|
||||||
|
if (this.{{name}} != null) {
|
||||||
|
this.{{name}}.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
{{/isMap}}
|
||||||
|
{{/vars}}
|
||||||
|
{{>additional_properties}}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (o == null || getClass() != o.getClass()) {
|
||||||
|
return false;
|
||||||
|
}{{#hasVars}}
|
||||||
|
{{classname}} {{classVarName}} = ({{classname}}) o;
|
||||||
|
return {{#vars}}{{#isByteArray}}Arrays{{/isByteArray}}{{^isByteArray}}Objects{{/isByteArray}}.equals(this.{{name}}, {{classVarName}}.{{name}}){{^-last}} &&
|
||||||
|
{{/-last}}{{/vars}}{{#parent}} &&
|
||||||
|
super.equals(o){{/parent}};{{/hasVars}}{{^hasVars}}
|
||||||
|
return {{#parent}}super.equals(o){{/parent}}{{^parent}}true{{/parent}};{{/hasVars}}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash({{#vars}}{{^isByteArray}}{{name}}{{/isByteArray}}{{#isByteArray}}Arrays.hashCode({{name}}){{/isByteArray}}{{^-last}}, {{/-last}}{{/vars}}{{#parent}}{{#hasVars}}, {{/hasVars}}super.hashCode(){{/parent}});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("class {{classname}} {\n");
|
||||||
|
{{#parent}}sb.append(" ").append(toIndentedString(super.toString())).append("\n");{{/parent}}
|
||||||
|
{{#vars}}sb.append(" {{name}}: ").append({{#isPassword}}"*"{{/isPassword}}{{^isPassword}}toIndentedString({{name}}){{/isPassword}}).append("\n");
|
||||||
|
{{/vars}}sb.append("}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given object to string with each line indented by 4 spaces
|
||||||
|
* (except the first line).
|
||||||
|
*/
|
||||||
|
private String toIndentedString(Object o) {
|
||||||
|
if (o == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return o.toString().replace("\n", "\n ");
|
||||||
|
}
|
||||||
|
|
||||||
|
{{#generateBuilders}}{{^additionalProperties}}
|
||||||
|
public static {{classname}}Builder builder() {
|
||||||
|
return new {{classname}}BuilderImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class {{classname}}BuilderImpl extends {{classname}}Builder<{{classname}}, {{classname}}BuilderImpl> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected {{classname}}BuilderImpl self() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public {{classname}} build() {
|
||||||
|
return new {{classname}}(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static abstract class {{classname}}Builder > {{#parent}}extends {{{.}}}Builder {{/parent}} {
|
||||||
|
{{#vars}}
|
||||||
|
private {{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}{{#defaultValue}} = {{{.}}}{{/defaultValue}};
|
||||||
|
{{/vars}}
|
||||||
|
{{^parent}}
|
||||||
|
protected abstract B self();
|
||||||
|
|
||||||
|
public abstract C build();
|
||||||
|
{{/parent}}
|
||||||
|
|
||||||
|
{{#vars}}
|
||||||
|
public B {{name}}({{#removeAnnotations}}{{{datatypeWithEnum}}}{{/removeAnnotations}} {{name}}) {
|
||||||
|
this.{{name}} = {{name}};
|
||||||
|
return self();
|
||||||
|
}
|
||||||
|
{{/vars}}
|
||||||
|
}{{/additionalProperties}}{{/generateBuilders}}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{{#jackson}}
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "{{{discriminator.propertyBaseName}}}", visible = true)
|
||||||
|
@JsonSubTypes({
|
||||||
|
{{#discriminator.mappedModels}}
|
||||||
|
@JsonSubTypes.Type(value = {{modelName}}.class, name = "{{^vendorExtensions.x-discriminator-value}}{{mappingName}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}"),
|
||||||
|
{{/discriminator.mappedModels}}
|
||||||
|
})
|
||||||
|
{{/jackson}}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{{#withXml}}
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
{{#hasVars}} @XmlType(name = "{{classname}}", propOrder =
|
||||||
|
{ {{#vars}}"{{name}}"{{^-last}}, {{/-last}}{{/vars}}
|
||||||
|
}){{/hasVars}}
|
||||||
|
{{^hasVars}}@XmlType(name = "{{classname}}"){{/hasVars}}
|
||||||
|
{{^parent}}@XmlRootElement(name="{{classname}}"){{/parent}}
|
||||||
|
{{/withXml}}
|
||||||
12
src/main/web/index.html
Normal file
12
src/main/web/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RolePlay</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2624
src/main/web/package-lock.json
generated
Normal file
2624
src/main/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "roleplay-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.13.5",
|
||||||
|
"@emotion/styled": "^11.13.5",
|
||||||
|
"@mui/icons-material": "^6.4.4",
|
||||||
|
"@mui/material": "^6.4.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import AppLayout from './components/AppLayout'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import StartPage from './pages/StartPage'
|
||||||
|
import SessionPage from './pages/SessionPage'
|
||||||
|
import ScenariosPage from './pages/ScenariosPage'
|
||||||
|
import CharactersPage from './pages/CharactersPage'
|
||||||
|
import UsersPage from './pages/UsersPage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root application component. Sets up client-side routing with app shell:
|
||||||
|
* Login (public), then Home, Scenarios, Characters, Session (protected).
|
||||||
|
*/
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<StartPage />} />
|
||||||
|
<Route path="/scenarios" element={<ScenariosPage />} />
|
||||||
|
<Route path="/characters" element={<CharactersPage />} />
|
||||||
|
<Route path="/admin/users" element={<UsersPage />} />
|
||||||
|
<Route path="/session/:sessionId" element={<SessionPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Central re-export of the generated OpenAPI client and its models.
|
||||||
|
*
|
||||||
|
* The actual sources under ./generated/ are produced by
|
||||||
|
* openapi-generator-maven-plugin (typescript-fetch generator) during
|
||||||
|
* `mvn generate-sources`. Run `mvn generate-sources` once before opening
|
||||||
|
* the project in an IDE to make these imports resolve.
|
||||||
|
*/
|
||||||
|
export * from './generated/index'
|
||||||
|
import { Configuration } from './generated/runtime'
|
||||||
|
|
||||||
|
/** Base URL used by the generated fetch client. Always points to /api/v1. */
|
||||||
|
export const API_BASE = '/api/v1'
|
||||||
|
|
||||||
|
/** Default configuration for API clients; sends session cookie (credentials: 'include'). */
|
||||||
|
export const apiConfiguration = new Configuration({
|
||||||
|
basePath: API_BASE,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
|
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
|
||||||
|
import DirectionsRunIcon from '@mui/icons-material/DirectionsRun'
|
||||||
|
import SendIcon from '@mui/icons-material/Send'
|
||||||
|
import type {RecommendationRequest, TurnRequest} from '../api/generated/index'
|
||||||
|
import {UserActionRequestTypeEnum,} from '../api/generated/index'
|
||||||
|
|
||||||
|
/***** Props for the ActionInput component. */
|
||||||
|
interface ActionInputProps {
|
||||||
|
/** Called when the user submits an action. */
|
||||||
|
onSubmit: (request: TurnRequest) => void
|
||||||
|
/** Whether the submit button should be disabled (e.g. waiting for response). */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = 'speech' | 'action'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the user interface for composing and submitting a turn action.
|
||||||
|
* Includes a type toggle (speech / action), a free-text input, and an
|
||||||
|
* optional collapsible section for narrative recommendations.
|
||||||
|
*/
|
||||||
|
export default function ActionInput({onSubmit, disabled}: ActionInputProps) {
|
||||||
|
const [actionType, setActionType] = useState<ActionType>('speech')
|
||||||
|
const [content, setContent] = useState<string>('')
|
||||||
|
const [desiredTone, setDesiredTone] = useState<string>('')
|
||||||
|
const [preferredDirection, setPreferredDirection] = useState<string>('')
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!content.trim()) return
|
||||||
|
|
||||||
|
const recommendation: RecommendationRequest | undefined =
|
||||||
|
desiredTone.trim() || preferredDirection.trim()
|
||||||
|
? {
|
||||||
|
desiredTone: desiredTone.trim() || undefined,
|
||||||
|
preferredDirection: preferredDirection.trim() || undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const typeEnum =
|
||||||
|
actionType === 'speech'
|
||||||
|
? UserActionRequestTypeEnum.speech
|
||||||
|
: UserActionRequestTypeEnum.action
|
||||||
|
|
||||||
|
const request: TurnRequest = {
|
||||||
|
userAction: {
|
||||||
|
type: typeEnum,
|
||||||
|
content: content.trim(),
|
||||||
|
},
|
||||||
|
recommendation,
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(request)
|
||||||
|
setContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={actionType}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, v: ActionType | null) => v && setActionType(v)}
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ToggleButton value="speech" aria-label="speech">
|
||||||
|
<RecordVoiceOverIcon sx={{mr: 0.5, fontSize: 18}}/>
|
||||||
|
Speech
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="action" aria-label="action">
|
||||||
|
<DirectionsRunIcon sx={{mr: 0.5, fontSize: 18}}/>
|
||||||
|
Action
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={6}
|
||||||
|
fullWidth
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
actionType === 'speech'
|
||||||
|
? 'What does your character say?'
|
||||||
|
: 'What does your character do?'
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
helperText="Ctrl+Enter to submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Accordion disableGutters elevation={0} sx={{border: '1px solid', borderColor: 'divider', borderRadius: 1}}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Narrative recommendation (optional)
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
|
<TextField
|
||||||
|
label="Desired tone"
|
||||||
|
value={desiredTone}
|
||||||
|
onChange={(e) => setDesiredTone(e.target.value)}
|
||||||
|
helperText='e.g. "tense", "humorous", "mysterious"'
|
||||||
|
disabled={disabled}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Preferred direction"
|
||||||
|
value={preferredDirection}
|
||||||
|
onChange={(e) => setPreferredDirection(e.target.value)}
|
||||||
|
helperText="Free-text hint for the narrator"
|
||||||
|
disabled={disabled}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
multiline
|
||||||
|
maxRows={3}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
endIcon={disabled ? <CircularProgress size={16} color="inherit"/> : <SendIcon/>}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!content.trim() || disabled}
|
||||||
|
>
|
||||||
|
{disabled ? 'Processing…' : 'Submit'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/main/web/src/components/AppLayout.tsx
Normal file
227
src/main/web/src/components/AppLayout.tsx
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Link,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
|
||||||
|
import { Link as RouterLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { AuthApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const authApi = new AuthApi(apiConfiguration)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
|
||||||
|
* When authenticated, shows a user menu (username click) with Change Password, Edit users (admin only), and Logout.
|
||||||
|
*/
|
||||||
|
export default function AppLayout() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null)
|
||||||
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [changePasswordError, setChangePasswordError] = useState<string | null>(null)
|
||||||
|
const [changePasswordSubmitting, setChangePasswordSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', label: 'Home' },
|
||||||
|
{ to: '/scenarios', label: 'Scenarios' },
|
||||||
|
{ to: '/characters', label: 'Characters' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isAdmin = user?.roles?.includes('admin') ?? false
|
||||||
|
|
||||||
|
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setMenuAnchor(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUserMenuClose = () => {
|
||||||
|
setMenuAnchor(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
setChangePasswordError(null)
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setChangePasswordOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordClose = () => {
|
||||||
|
setChangePasswordOpen(false)
|
||||||
|
setChangePasswordError(null)
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangePasswordSubmit = async () => {
|
||||||
|
setChangePasswordError(null)
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setChangePasswordError('New password and confirmation do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.length < 1) {
|
||||||
|
setChangePasswordError('New password is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setChangePasswordSubmitting(true)
|
||||||
|
try {
|
||||||
|
await authApi.changePassword({
|
||||||
|
changePasswordRequest: {
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
handleChangePasswordClose()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to change password'
|
||||||
|
setChangePasswordError(msg)
|
||||||
|
} finally {
|
||||||
|
setChangePasswordSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUsersClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
navigate('/admin/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogoutClick = () => {
|
||||||
|
handleUserMenuClose()
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<AppBar position="static" elevation={0}>
|
||||||
|
<Toolbar variant="dense">
|
||||||
|
<Typography
|
||||||
|
component={RouterLink}
|
||||||
|
to="/"
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
RolePlay
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
{navItems.map(({ to, label }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
component={RouterLink}
|
||||||
|
to={to}
|
||||||
|
underline={location.pathname === to ? 'always' : 'hover'}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ px: 1.5, py: 0.5, borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
onClick={handleUserMenuOpen}
|
||||||
|
endIcon={<KeyboardArrowDownIcon />}
|
||||||
|
aria-label="User menu"
|
||||||
|
aria-controls={menuAnchor ? 'user-menu' : undefined}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={menuAnchor ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
id="user-menu"
|
||||||
|
anchorEl={menuAnchor}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={handleUserMenuClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
MenuListProps={{ 'aria-labelledby': 'user-menu-button' }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleChangePasswordClick}>Change Password</MenuItem>
|
||||||
|
{isAdmin && <MenuItem onClick={handleEditUsersClick}>Edit users</MenuItem>}
|
||||||
|
<MenuItem onClick={handleLogoutClick}>Logout</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, p: 2 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={changePasswordOpen} onClose={handleChangePasswordClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Change Password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
id="current-password"
|
||||||
|
label="Current password"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="new-password"
|
||||||
|
label="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="confirm-password"
|
||||||
|
label="Confirm new password"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{changePasswordError && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{changePasswordError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleChangePasswordClose}>Cancel</Button>
|
||||||
|
<Button onClick={handleChangePasswordSubmit} variant="contained" disabled={changePasswordSubmitting}>
|
||||||
|
{changePasswordSubmitting ? 'Changing…' : 'Change password'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import {Divider, Paper, Typography} from '@mui/material'
|
||||||
|
|
||||||
|
/** Props for the NarrativeView component. */
|
||||||
|
interface NarrativeViewProps {
|
||||||
|
/** The narrative text to display. */
|
||||||
|
narrative: string
|
||||||
|
/** Current turn number, displayed as subtitle. */
|
||||||
|
turnNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the AI-generated narrative text for the current turn inside a
|
||||||
|
* styled MUI Paper card with atmospheric typography.
|
||||||
|
*/
|
||||||
|
export default function NarrativeView({narrative, turnNumber}: NarrativeViewProps) {
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{p: 3, borderRadius: 2}}>
|
||||||
|
<Typography variant="overline" color="text.secondary">
|
||||||
|
{turnNumber === 0 ? 'Opening Scene' : `Turn ${turnNumber}`}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{my: 1}}/>
|
||||||
|
{narrative.split('\n').filter(Boolean).map((paragraph, i) => (
|
||||||
|
<Typography
|
||||||
|
key={i}
|
||||||
|
variant="body1"
|
||||||
|
paragraph
|
||||||
|
sx={{lineHeight: 1.8, fontStyle: 'italic', mb: 1.5}}
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
41
src/main/web/src/components/ProtectedRoute.tsx
Normal file
41
src/main/web/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps content that requires authentication. If not logged in, redirects to /login.
|
||||||
|
* Shows a loading spinner while auth state is being determined.
|
||||||
|
*/
|
||||||
|
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '40vh',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography color="text.secondary">Checking authentication…</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import {Box, Chip, Paper, Typography} from '@mui/material'
|
||||||
|
import type {Suggestion} from '../api/generated/index'
|
||||||
|
|
||||||
|
/** Props for the SuggestionList component. */
|
||||||
|
interface SuggestionListProps {
|
||||||
|
/** Suggestions to display. */
|
||||||
|
suggestions: Suggestion[]
|
||||||
|
/** Called when the user selects a suggestion. */
|
||||||
|
onSelect: (suggestion: Suggestion) => void
|
||||||
|
/** Whether interaction is currently disabled (e.g. while submitting). */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour map from risk level to MUI chip colour. */
|
||||||
|
const riskColour: Record<string, 'success' | 'warning' | 'error'> = {
|
||||||
|
low: 'success',
|
||||||
|
medium: 'warning',
|
||||||
|
high: 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon map from suggestion type to emoji label. */
|
||||||
|
const typeIcon: Record<string, string> = {
|
||||||
|
player_action: '🗡️',
|
||||||
|
world_event: '🌍',
|
||||||
|
npc_action: '🧑',
|
||||||
|
twist: '🌀',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a grid of suggestion cards. Each card can be clicked to select
|
||||||
|
* that suggestion as the user's next action (type = "choice").
|
||||||
|
*/
|
||||||
|
export default function SuggestionList({suggestions, onSelect, disabled}: SuggestionListProps) {
|
||||||
|
if (suggestions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline" color="text.secondary" display="block" mb={1}>
|
||||||
|
What could happen next?
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
gridTemplateColumns="repeat(auto-fill, minmax(220px, 1fr))"
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<Paper
|
||||||
|
key={s.id}
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
border: '1px solid transparent',
|
||||||
|
transition: 'border-color 0.2s, transform 0.15s',
|
||||||
|
'&:hover': disabled ? {} : {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={() => !disabled && onSelect(s)}
|
||||||
|
>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={1}>
|
||||||
|
<Typography variant="body2" fontWeight={700} sx={{flex: 1, pr: 1}}>
|
||||||
|
{typeIcon[s.type] ?? '•'} {s.title}
|
||||||
|
</Typography>
|
||||||
|
{s.riskLevel && (
|
||||||
|
<Chip
|
||||||
|
label={s.riskLevel}
|
||||||
|
color={riskColour[s.riskLevel] ?? 'default'}
|
||||||
|
size="small"
|
||||||
|
sx={{fontSize: '0.65rem', height: 20}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{s.description}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
106
src/main/web/src/contexts/AuthContext.tsx
Normal file
106
src/main/web/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const value = useContext(AuthContext)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides auth state (current user, loading, error) and logout.
|
||||||
|
* Fetches GET /api/v1/auth/me with credentials so the session cookie is sent.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const fetchMe = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUser({
|
||||||
|
username: data.username ?? '',
|
||||||
|
roles: Array.isArray(data.roles) ? data.roles : [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError(false)
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe()
|
||||||
|
}, [fetchMe])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
logout,
|
||||||
|
refresh: fetchMe,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import {StrictMode} from 'react'
|
||||||
|
import {createRoot} from 'react-dom/client'
|
||||||
|
import {CssBaseline, ThemeProvider, createTheme} from '@mui/material'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
/** Dark-toned MUI theme that fits the atmospheric role-play genre. */
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: '#b39ddb',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#80cbc4',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#1a1a2e',
|
||||||
|
paper: '#16213e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Georgia", "Palatino", serif',
|
||||||
|
h4: {fontWeight: 700},
|
||||||
|
h5: {fontWeight: 600},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline/>
|
||||||
|
<App/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
308
src/main/web/src/pages/CharactersPage.tsx
Normal file
308
src/main/web/src/pages/CharactersPage.tsx
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index'
|
||||||
|
import { CharactersApi } 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
src/main/web/src/pages/LoginPage.tsx
Normal file
106
src/main/web/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login page. POSTs credentials to /j_security_check (form-based auth).
|
||||||
|
* On success refreshes auth state and redirects to home or the previously attempted URL.
|
||||||
|
*/
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { refresh } = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('j_username', username)
|
||||||
|
formData.append('j_password', password)
|
||||||
|
const res = await fetch('/j_security_check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: formData.toString(),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} else {
|
||||||
|
setError('Invalid credentials')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Login failed')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box sx={{ pt: 4 }}>
|
||||||
|
<Paper elevation={1} sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" component="h1" gutterBottom>
|
||||||
|
Sign in
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Default: admin / changeme
|
||||||
|
</Typography>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={submitting}
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Signing in…' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
356
src/main/web/src/pages/ScenariosPage.tsx
Normal file
356
src/main/web/src/pages/ScenariosPage.tsx
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
|
import {useNavigate} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
|
||||||
|
import type {
|
||||||
|
CharacterDefinition,
|
||||||
|
CreateScenarioRequest,
|
||||||
|
ScenarioSetup,
|
||||||
|
ScenarioSummary,
|
||||||
|
} from '../api/generated/index'
|
||||||
|
import { CharactersApi, 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,401 @@
|
|||||||
|
import type {ChangeEvent} from 'react'
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import {useNavigate, useParams} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
TextField,
|
||||||
|
Toolbar,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
|
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import PersonIcon from '@mui/icons-material/Person'
|
||||||
|
import PlaceIcon from '@mui/icons-material/Place'
|
||||||
|
import type {
|
||||||
|
CharacterState,
|
||||||
|
SessionResponse,
|
||||||
|
SituationState,
|
||||||
|
Suggestion,
|
||||||
|
TurnRequest,
|
||||||
|
UpdateSessionRequest,
|
||||||
|
} from '../api/generated/index'
|
||||||
|
import { SessionsApi, TurnsApi, UserActionRequestTypeEnum } from '../api/generated/index'
|
||||||
|
import NarrativeView from '../components/NarrativeView'
|
||||||
|
import SuggestionList from '../components/SuggestionList'
|
||||||
|
import ActionInput from '../components/ActionInput'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
|
const sessionsApi = new SessionsApi(apiConfiguration)
|
||||||
|
const turnsApi = new TurnsApi(apiConfiguration)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active role-play session page. Loads the session state, displays the
|
||||||
|
* current narrative, shows suggestions, and accepts user input for the next
|
||||||
|
* turn.
|
||||||
|
*/
|
||||||
|
export default function SessionPage() {
|
||||||
|
const {sessionId} = useParams<{ sessionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [session, setSession] = useState<SessionResponse | null>(null)
|
||||||
|
const [narrative, setNarrative] = useState<string>('')
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
|
const [turnNumber, setTurnNumber] = useState<number>(0)
|
||||||
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
|
const [submitting, setSubmitting] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sceneDialogOpen, setSceneDialogOpen] = useState<boolean>(false)
|
||||||
|
const [sceneDraft, setSceneDraft] = useState<SituationState>({})
|
||||||
|
const [charactersDialogOpen, setCharactersDialogOpen] = useState<boolean>(false)
|
||||||
|
const [charactersDraft, setCharactersDraft] = useState<CharacterState[]>([])
|
||||||
|
|
||||||
|
/** Load existing session on mount. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return
|
||||||
|
sessionsApi.getSession({sessionId})
|
||||||
|
.then((s) => {
|
||||||
|
setSession(s)
|
||||||
|
setNarrative(s.narrative ?? '')
|
||||||
|
setSuggestions(s.suggestions ?? [])
|
||||||
|
setTurnNumber(s.turnNumber)
|
||||||
|
})
|
||||||
|
.catch(() => setError('Session not found or server unavailable.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
/** Submit a user action turn. */
|
||||||
|
const handleTurnSubmit = async (request: TurnRequest) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const turn = await turnsApi.submitTurn({sessionId, turnRequest: request})
|
||||||
|
setNarrative(turn.narrative)
|
||||||
|
setSuggestions(turn.suggestions ?? [])
|
||||||
|
setTurnNumber(turn.turnNumber)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to submit turn. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a suggestion click – submit it as a "choice" action. */
|
||||||
|
const handleSuggestionSelect = (suggestion: Suggestion) => {
|
||||||
|
const request: TurnRequest = {
|
||||||
|
userAction: {
|
||||||
|
type: UserActionRequestTypeEnum.choice,
|
||||||
|
content: suggestion.title,
|
||||||
|
selectedSuggestionId: suggestion.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
void handleTurnSubmit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open scene edit dialog with current situation. */
|
||||||
|
const openSceneDialog = () => {
|
||||||
|
setSceneDraft({
|
||||||
|
setting: session?.situation?.setting ?? '',
|
||||||
|
currentScene: session?.situation?.currentScene ?? '',
|
||||||
|
})
|
||||||
|
setSceneDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save scene and PATCH session. */
|
||||||
|
const saveScene = async () => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const request: UpdateSessionRequest = { situation: sceneDraft }
|
||||||
|
const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request })
|
||||||
|
setSession(updated)
|
||||||
|
setNarrative(updated.narrative ?? '')
|
||||||
|
setSuggestions(updated.suggestions ?? [])
|
||||||
|
setTurnNumber(updated.turnNumber)
|
||||||
|
setSceneDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to update scene.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open characters edit dialog with current characters. */
|
||||||
|
const openCharactersDialog = () => {
|
||||||
|
setCharactersDraft(session?.characters ? [...session.characters] : [])
|
||||||
|
setCharactersDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save characters and PATCH session. */
|
||||||
|
const saveCharacters = async () => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const request: UpdateSessionRequest = { characters: charactersDraft }
|
||||||
|
const updated = await sessionsApi.updateSession({ sessionId, updateSessionRequest: request })
|
||||||
|
setSession(updated)
|
||||||
|
setNarrative(updated.narrative ?? '')
|
||||||
|
setSuggestions(updated.suggestions ?? [])
|
||||||
|
setTurnNumber(updated.turnNumber)
|
||||||
|
setCharactersDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to update characters.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a new character to the draft list. */
|
||||||
|
const addCharacterDraft = () => {
|
||||||
|
setCharactersDraft((prev) => [...prev, { id: '', name: '', role: '', isUserCharacter: false }])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a character in the draft list. */
|
||||||
|
const updateCharacterDraft = (index: number, patch: Partial<CharacterState>) => {
|
||||||
|
setCharactersDraft((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[index] = { ...next[index], ...patch }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a character from the draft list. */
|
||||||
|
const removeCharacterDraft = (index: number) => {
|
||||||
|
setCharactersDraft((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display="flex" flexDirection="column" minHeight="100vh">
|
||||||
|
<AppBar position="static" elevation={0} color="transparent"
|
||||||
|
sx={{borderBottom: '1px solid', borderColor: 'divider'}}>
|
||||||
|
<Toolbar>
|
||||||
|
<Tooltip title="Back to model selection">
|
||||||
|
<IconButton edge="start" onClick={() => navigate('/')} sx={{mr: 1}}>
|
||||||
|
<ArrowBackIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<AutoStoriesIcon sx={{mr: 1, color: 'primary.main'}}/>
|
||||||
|
<Typography variant="h6" sx={{flex: 1}}>
|
||||||
|
RolePlay
|
||||||
|
</Typography>
|
||||||
|
{session && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{session.model} · {session.language}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Container maxWidth="md" sx={{flex: 1, py: 4}}>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{mb: 3}}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={8}>
|
||||||
|
<CircularProgress/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box display="flex" flexDirection="column" gap={3}>
|
||||||
|
{narrative && (
|
||||||
|
<NarrativeView narrative={narrative} turnNumber={turnNumber}/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(session?.situation?.setting || session?.situation?.currentScene) && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{'&:last-child': {pb: 2}}}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<PlaceIcon color="action"/>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">Scene</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button size="small" startIcon={<EditIcon/>} onClick={openSceneDialog}>
|
||||||
|
Edit scene
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{session.situation.setting && (
|
||||||
|
<Typography variant="body2" sx={{mt: 1}}>
|
||||||
|
<strong>Setting:</strong> {session.situation.setting}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{session.situation.currentScene && (
|
||||||
|
<Typography variant="body2" sx={{mt: 0.5}}>
|
||||||
|
<strong>Current:</strong> {session.situation.currentScene}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.characters && session.characters.length > 0 && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent sx={{'&:last-child': {pb: 2}}}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<PersonIcon color="action"/>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">Characters</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button size="small" startIcon={<EditIcon/>} onClick={openCharactersDialog}>
|
||||||
|
Edit characters
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<List dense disablePadding sx={{mt: 1}}>
|
||||||
|
{session.characters.map((c, i) => (
|
||||||
|
<ListItem key={c.id ?? i} dense disablePadding>
|
||||||
|
<ListItemText
|
||||||
|
primary={c.name}
|
||||||
|
secondary={c.role ? `${c.role}${c.isUserCharacter ? ' (you)' : ''}` : c.isUserCharacter ? '(you)' : undefined}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider/>
|
||||||
|
<SuggestionList
|
||||||
|
suggestions={suggestions}
|
||||||
|
onSelect={handleSuggestionSelect}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider/>
|
||||||
|
|
||||||
|
<ActionInput onSubmit={handleTurnSubmit} disabled={submitting}/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Dialog open={sceneDialogOpen} onClose={() => setSceneDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Edit scene</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Setting (place, time, atmosphere)
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Setting"
|
||||||
|
value={sceneDraft.setting ?? ''}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, setting: e.target.value }))}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Current scene (what is in focus)
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Current scene"
|
||||||
|
value={sceneDraft.currentScene ?? ''}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSceneDraft((prev) => ({ ...prev, currentScene: e.target.value }))}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSceneDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={saveScene}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={charactersDialogOpen} onClose={() => setCharactersDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Edit characters</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{charactersDraft.map((c, i) => (
|
||||||
|
<ListItem
|
||||||
|
key={c.id ? `${c.id}-${i}` : i}
|
||||||
|
sx={{flexWrap: 'wrap', alignItems: 'flex-start', gap: 1}}
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton size="small" onClick={() => removeCharacterDraft(i)} aria-label="Remove character">
|
||||||
|
<DeleteIcon/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="column" gap={0.5} sx={{flex: 1, minWidth: 0}}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="ID"
|
||||||
|
value={c.id}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { id: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Name"
|
||||||
|
value={c.name}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { name: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Role"
|
||||||
|
value={c.role ?? ''}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateCharacterDraft(i, { role: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
<Typography variant="caption">User character</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={c.isUserCharacter ? 'contained' : 'outlined'}
|
||||||
|
onClick={() => updateCharacterDraft(i, { isUserCharacter: true })}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={!c.isUserCharacter ? 'contained' : 'outlined'}
|
||||||
|
onClick={() => updateCharacterDraft(i, { isUserCharacter: false })}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Button size="small" startIcon={<PersonIcon/>} onClick={addCharacterDraft}>
|
||||||
|
Add character
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCharactersDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={saveCharacters}
|
||||||
|
disabled={charactersDraft.some((c) => !c.id?.trim() || !c.name?.trim())}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,522 @@
|
|||||||
|
import {useEffect, useRef, useState} from 'react'
|
||||||
|
import {useLocation, useNavigate} from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
InputLabel,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
|
import PersonAddIcon from '@mui/icons-material/PersonAdd'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import type {
|
||||||
|
CharacterDefinition,
|
||||||
|
CreateSessionRequest,
|
||||||
|
ModelInfo,
|
||||||
|
ScenarioSetup,
|
||||||
|
ScenarioSummary,
|
||||||
|
} from '../api/generated/index'
|
||||||
|
import {
|
||||||
|
CreateSessionRequestSafetyLevelEnum,
|
||||||
|
ModelsApi,
|
||||||
|
ScenariosApi,
|
||||||
|
SessionsApi,
|
||||||
|
} from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
|
const modelsApi = new ModelsApi(apiConfiguration)
|
||||||
|
const sessionsApi = new SessionsApi(apiConfiguration)
|
||||||
|
const scenariosApi = new ScenariosApi(apiConfiguration)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page where the user selects an Ollama model and optional settings
|
||||||
|
* before starting a new role-play session.
|
||||||
|
*/
|
||||||
|
export default function StartPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const deepLinkApplied = useRef(false)
|
||||||
|
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||||
|
const [language, setLanguage] = useState<string>('en')
|
||||||
|
const [loading, setLoading] = useState<boolean>(true)
|
||||||
|
const [starting, setStarting] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/** Saved scenarios from API; selectedScenarioId is '' for custom or a UUID. */
|
||||||
|
const [scenarios, setScenarios] = useState<ScenarioSummary[]>([])
|
||||||
|
const [selectedScenarioId, setSelectedScenarioId] = useState<string>('')
|
||||||
|
/** Optional scenario (collapsible). */
|
||||||
|
const [scenarioExpanded, setScenarioExpanded] = useState<boolean>(false)
|
||||||
|
const [setting, setSetting] = useState<string>('')
|
||||||
|
const [initialConflict, setInitialConflict] = useState<string>('')
|
||||||
|
const [userCharacter, setUserCharacter] = useState<CharacterDefinition | null>(null)
|
||||||
|
const [aiCharacters, setAiCharacters] = useState<CharacterDefinition[]>([])
|
||||||
|
/** Dialog for add/edit character: 'user' | 'ai', and index (-1 = add). */
|
||||||
|
const [characterDialog, setCharacterDialog] = useState<{
|
||||||
|
mode: 'user' | 'ai'
|
||||||
|
index: number
|
||||||
|
draft: CharacterDefinition
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
/** Load available models and saved scenarios on mount. */
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
modelsApi.listModels(),
|
||||||
|
scenariosApi.listScenarios().catch(() => ({scenarios: []})),
|
||||||
|
])
|
||||||
|
.then(([modelsResp, scenariosResp]) => {
|
||||||
|
setModels(modelsResp.models ?? [])
|
||||||
|
if (modelsResp.models && modelsResp.models.length > 0) {
|
||||||
|
setSelectedModel(modelsResp.models[0].name)
|
||||||
|
}
|
||||||
|
setScenarios(scenariosResp.scenarios ?? [])
|
||||||
|
})
|
||||||
|
.catch(() => setError('Could not load models. Is the Quarkus server running?'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** When arriving from Scenarios "Start" with state.scenarioId, pre-select that scenario and load its setup. */
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { scenarioId?: string } | null
|
||||||
|
const id = state?.scenarioId
|
||||||
|
if (!id || deepLinkApplied.current || scenarios.length === 0) return
|
||||||
|
if (!scenarios.some((s) => s.id === id)) return
|
||||||
|
deepLinkApplied.current = true
|
||||||
|
setSelectedScenarioId(id)
|
||||||
|
scenariosApi
|
||||||
|
.getScenario({scenarioId: id})
|
||||||
|
.then((setup) => {
|
||||||
|
setSetting(setup.setting ?? '')
|
||||||
|
setInitialConflict(setup.initialConflict ?? '')
|
||||||
|
setUserCharacter(setup.userCharacter ?? null)
|
||||||
|
setAiCharacters(setup.aiCharacters ?? [])
|
||||||
|
})
|
||||||
|
.catch(() => setError('Could not load scenario.'))
|
||||||
|
}, [scenarios, location.state])
|
||||||
|
|
||||||
|
const handleModelChange = (event: SelectChangeEvent) => {
|
||||||
|
setSelectedModel(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When user selects a saved scenario, load its full setup and prefill the form. */
|
||||||
|
const handleScenarioSelect = async (event: SelectChangeEvent<string>) => {
|
||||||
|
const id = event.target.value
|
||||||
|
setSelectedScenarioId(id)
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const setup = await scenariosApi.getScenario({scenarioId: id})
|
||||||
|
setSetting(setup.setting ?? '')
|
||||||
|
setInitialConflict(setup.initialConflict ?? '')
|
||||||
|
setUserCharacter(setup.userCharacter ?? null)
|
||||||
|
setAiCharacters(setup.aiCharacters ?? [])
|
||||||
|
} catch {
|
||||||
|
setError('Could not load scenario.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build scenario from form state if any field is filled. */
|
||||||
|
const buildScenario = (): ScenarioSetup | undefined => {
|
||||||
|
const hasSetting = setting.trim() !== ''
|
||||||
|
const hasConflict = initialConflict.trim() !== ''
|
||||||
|
const hasUser = userCharacter && userCharacter.id.trim() && userCharacter.name.trim() && userCharacter.role.trim()
|
||||||
|
const hasAi = aiCharacters.length > 0 && aiCharacters.every(
|
||||||
|
(c) => c.id.trim() !== '' && c.name.trim() !== '' && c.role.trim() !== ''
|
||||||
|
)
|
||||||
|
if (!hasSetting && !hasConflict && !hasUser && !hasAi) return undefined
|
||||||
|
return {
|
||||||
|
setting: hasSetting ? setting.trim() : undefined,
|
||||||
|
initialConflict: hasConflict ? initialConflict.trim() : undefined,
|
||||||
|
userCharacter: hasUser ? userCharacter : undefined,
|
||||||
|
aiCharacters: hasAi ? aiCharacters : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open dialog to add or edit user character. */
|
||||||
|
const openUserCharacterDialog = () => {
|
||||||
|
setCharacterDialog({
|
||||||
|
mode: 'user',
|
||||||
|
index: -1,
|
||||||
|
draft: userCharacter ?? { id: 'player', name: '', role: '' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open dialog to add or edit an AI character. */
|
||||||
|
const openAiCharacterDialog = (index: number) => {
|
||||||
|
const list = index >= 0 ? aiCharacters[index] : { id: '', name: '', role: '' }
|
||||||
|
setCharacterDialog({
|
||||||
|
mode: 'ai',
|
||||||
|
index,
|
||||||
|
draft: { ...list, personalityTraits: list.personalityTraits ?? [], goals: list.goals ?? [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save character from dialog and close. */
|
||||||
|
const saveCharacterDialog = () => {
|
||||||
|
if (!characterDialog) return
|
||||||
|
const { draft } = characterDialog
|
||||||
|
if (!draft.id?.trim() || !draft.name?.trim() || !draft.role?.trim()) return
|
||||||
|
if (characterDialog.mode === 'user') {
|
||||||
|
setUserCharacter({ ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() })
|
||||||
|
} else {
|
||||||
|
const next = [...aiCharacters]
|
||||||
|
const char = { ...draft, id: draft.id.trim(), name: draft.name.trim(), role: draft.role.trim() }
|
||||||
|
const idx = characterDialog.index
|
||||||
|
if (idx !== undefined && idx >= 0) {
|
||||||
|
next[idx] = char
|
||||||
|
} else {
|
||||||
|
next.push(char)
|
||||||
|
}
|
||||||
|
setAiCharacters(next)
|
||||||
|
}
|
||||||
|
setCharacterDialog(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove an AI character. */
|
||||||
|
const removeAiCharacter = (index: number) => {
|
||||||
|
setAiCharacters((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new session and navigate to the session page. */
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!selectedModel) return
|
||||||
|
setStarting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const request: CreateSessionRequest = {
|
||||||
|
model: selectedModel,
|
||||||
|
language,
|
||||||
|
safetyLevel: CreateSessionRequestSafetyLevelEnum.standard,
|
||||||
|
scenarioId: selectedScenarioId || undefined,
|
||||||
|
scenario: selectedScenarioId ? undefined : buildScenario(),
|
||||||
|
}
|
||||||
|
const session = await sessionsApi.createSession({createSessionRequest: request})
|
||||||
|
navigate(`/session/${session.sessionId}`)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to create session. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm" sx={{mt: 8}}>
|
||||||
|
<Paper elevation={4} sx={{p: 5, borderRadius: 3}}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} mb={4}>
|
||||||
|
<AutoStoriesIcon sx={{fontSize: 48, color: 'primary.main'}}/>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
RolePlay
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" mb={4}>
|
||||||
|
Choose an AI model and start your story.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{mb: 3}}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box display="flex" justifyContent="center" py={4}>
|
||||||
|
<CircularProgress/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box display="flex" flexDirection="column" gap={3}>
|
||||||
|
<FormControl fullWidth disabled={starting}>
|
||||||
|
<InputLabel id="model-label">AI Model</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="model-label"
|
||||||
|
value={selectedModel}
|
||||||
|
label="AI Model"
|
||||||
|
onChange={handleModelChange}
|
||||||
|
>
|
||||||
|
{models.map((m) => (
|
||||||
|
<MenuItem key={m.name} value={m.name}>
|
||||||
|
{m.displayName ?? m.name}
|
||||||
|
{m.family ? ` · ${m.family}` : ''}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Language"
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value)}
|
||||||
|
helperText="BCP-47 language tag, e.g. en, de, fr"
|
||||||
|
disabled={starting}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth disabled={starting} sx={{mb: 1}}>
|
||||||
|
<InputLabel id="scenario-label">Saved scenario</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="scenario-label"
|
||||||
|
value={selectedScenarioId}
|
||||||
|
label="Saved scenario"
|
||||||
|
onChange={handleScenarioSelect}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Custom (none)</em>
|
||||||
|
</MenuItem>
|
||||||
|
{scenarios.map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Accordion
|
||||||
|
expanded={scenarioExpanded}
|
||||||
|
onChange={() => setScenarioExpanded((b) => !b)}
|
||||||
|
disabled={starting}
|
||||||
|
sx={{width: '100%'}}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon/>}>
|
||||||
|
<Typography>Scenario (optional)</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box display="flex" flexDirection="column" gap={2}>
|
||||||
|
<TextField
|
||||||
|
label="Setting"
|
||||||
|
value={setting}
|
||||||
|
onChange={(e) => setSetting(e.target.value)}
|
||||||
|
placeholder="Place, time, and atmosphere"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Initial conflict"
|
||||||
|
value={initialConflict}
|
||||||
|
onChange={(e) => setInitialConflict(e.target.value)}
|
||||||
|
placeholder="The hook or starting conflict"
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
Your character
|
||||||
|
</Typography>
|
||||||
|
{userCharacter ? (
|
||||||
|
<ListItem
|
||||||
|
dense
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton edge="end" size="small" onClick={openUserCharacterDialog}>
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={userCharacter.name}
|
||||||
|
secondary={`${userCharacter.role} (id: ${userCharacter.id})`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
<Button size="small" startIcon={<PersonAddIcon/>} onClick={openUserCharacterDialog}>
|
||||||
|
Add your character
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||||
|
AI characters
|
||||||
|
</Typography>
|
||||||
|
<List dense disablePadding>
|
||||||
|
{aiCharacters.map((c, i) => (
|
||||||
|
<ListItem
|
||||||
|
key={c.id + i}
|
||||||
|
dense
|
||||||
|
secondaryAction={
|
||||||
|
<Box component="span">
|
||||||
|
<IconButton size="small" onClick={() => openAiCharacterDialog(i)}>
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => removeAiCharacter(i)}>
|
||||||
|
<DeleteIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText primary={c.name} secondary={c.role}/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Button size="small" startIcon={<PersonAddIcon/>} onClick={() => openAiCharacterDialog(-1)}>
|
||||||
|
Add AI character
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={!selectedModel || starting || loading}
|
||||||
|
startIcon={starting ? <CircularProgress size={18} color="inherit"/> : <AutoStoriesIcon/>}
|
||||||
|
>
|
||||||
|
{starting ? 'Starting…' : 'Begin Story'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Dialog open={characterDialog !== null} onClose={() => setCharacterDialog(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>
|
||||||
|
{characterDialog?.mode === 'user' ? 'Your character' : (characterDialog?.index ?? -1) >= 0 ? 'Edit AI character' : 'Add AI character'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{characterDialog && (
|
||||||
|
<Box display="flex" flexDirection="column" gap={2} sx={{pt: 1}}>
|
||||||
|
<TextField
|
||||||
|
label="ID"
|
||||||
|
value={characterDialog.draft.id}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, id: e.target.value}})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Unique identifier, e.g. captain_morgan"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={characterDialog.draft.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, name: e.target.value}})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Role"
|
||||||
|
value={characterDialog.draft.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, role: e.target.value}})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{characterDialog.mode === 'ai' && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Backstory"
|
||||||
|
value={characterDialog.draft.backstory ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
|
||||||
|
}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Speaking style"
|
||||||
|
value={characterDialog.draft.speakingStyle ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Personality traits"
|
||||||
|
value={Array.isArray(characterDialog.draft.personalityTraits) ? characterDialog.draft.personalityTraits.join(', ') : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({
|
||||||
|
...characterDialog,
|
||||||
|
draft: {
|
||||||
|
...characterDialog.draft,
|
||||||
|
personalityTraits: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Comma-separated"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Goals"
|
||||||
|
value={Array.isArray(characterDialog.draft.goals) ? characterDialog.draft.goals.join(', ') : ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({
|
||||||
|
...characterDialog,
|
||||||
|
draft: {
|
||||||
|
...characterDialog.draft,
|
||||||
|
goals: e.target.value ? e.target.value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Comma-separated"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{characterDialog.mode === 'user' && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Backstory"
|
||||||
|
value={characterDialog.draft.backstory ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, backstory: e.target.value || undefined}})
|
||||||
|
}
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Speaking style"
|
||||||
|
value={characterDialog.draft.speakingStyle ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCharacterDialog({...characterDialog, draft: {...characterDialog.draft, speakingStyle: e.target.value || undefined}})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCharacterDialog(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={saveCharacterDialog} disabled={!characterDialog?.draft.id?.trim() || !characterDialog?.draft.name?.trim() || !characterDialog?.draft.role?.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
280
src/main/web/src/pages/UsersPage.tsx
Normal file
280
src/main/web/src/pages/UsersPage.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
import type { CreateUserRequest, UpdateUserRequest, UserSummary } from '../api/generated/index'
|
||||||
|
import { UsersApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const usersApi = new UsersApi(apiConfiguration)
|
||||||
|
|
||||||
|
const ROLES = ['user', 'admin'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin users management page. Lists users with Add/Edit/Delete. Admin only (backend enforces 403).
|
||||||
|
*/
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const [users, setUsers] = useState<UserSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [formUsername, setFormUsername] = useState('')
|
||||||
|
const [formPassword, setFormPassword] = useState('')
|
||||||
|
const [formRole, setFormRole] = useState<string>('user')
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const list = await usersApi.listUsers()
|
||||||
|
setUsers(list)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load users')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [loadUsers])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setFormUsername('')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormRole('user')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (u: UserSummary) => {
|
||||||
|
setEditingId(u.id)
|
||||||
|
setFormUsername(u.username ?? '')
|
||||||
|
setFormPassword('')
|
||||||
|
setFormRole(u.role ?? 'user')
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formUsername.trim() || !formRole) return
|
||||||
|
if (!editingId && !formPassword.trim()) {
|
||||||
|
setError('Password is required for new user')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
const body: UpdateUserRequest = {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
role: formRole as UpdateUserRequest['role'],
|
||||||
|
}
|
||||||
|
if (formPassword.trim()) {
|
||||||
|
body.password = formPassword
|
||||||
|
}
|
||||||
|
await usersApi.updateUser({ userId: editingId, updateUserRequest: body })
|
||||||
|
} else {
|
||||||
|
const body: CreateUserRequest = {
|
||||||
|
username: formUsername.trim(),
|
||||||
|
password: formPassword,
|
||||||
|
role: formRole as CreateUserRequest['role'],
|
||||||
|
}
|
||||||
|
await usersApi.createUser({ createUserRequest: body })
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to save user'
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await usersApi.deleteUser({ userId: id })
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
e && typeof e === 'object' && 'body' in e && e.body && typeof (e.body as { message?: string }).message === 'string'
|
||||||
|
? (e.body as { message: string }).message
|
||||||
|
: 'Failed to delete user'
|
||||||
|
setError(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentUser = (u: UserSummary) => currentUser?.username === u.username
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Typography variant="h4">Users</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Alert severity="info">No users.</Alert>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell>Role</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell>{u.username}</TableCell>
|
||||||
|
<TableCell>{u.role}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => openEdit(u)}
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => setDeleteConfirmId(u.id)}
|
||||||
|
disabled={isCurrentUser(u)}
|
||||||
|
title={isCurrentUser(u) ? 'Cannot delete your own account' : undefined}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{editingId ? 'Edit user' : 'Add user'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={formUsername}
|
||||||
|
onChange={(e) => setFormUsername(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={editingId ? 'New password (leave blank to keep current)' : 'Password'}
|
||||||
|
type="password"
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
required={!editingId}
|
||||||
|
autoComplete={editingId ? 'new-password' : 'new-password'}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Role</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formRole}
|
||||||
|
label="Role"
|
||||||
|
onChange={(e) => setFormRole(e.target.value)}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<MenuItem key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={closeDialog}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="contained"
|
||||||
|
disabled={saving || !formUsername.trim() || (!editingId && !formPassword.trim())}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(deleteConfirmId)} onClose={() => setDeleteConfirmId(null)}>
|
||||||
|
<DialogTitle>Delete user?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
This cannot be undone. You cannot delete your own account.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteConfirmId(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/main/web/tsconfig.app.json
Normal file
30
src/main/web/tsconfig.app.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
src/main/web/tsconfig.json
Normal file
7
src/main/web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
src/main/web/tsconfig.node.json
Normal file
24
src/main/web/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2023",
|
||||||
|
"DOM"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/',
|
||||||
|
build: {
|
||||||
|
outDir: '../resources/META-INF/resources',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// Form-based login; must reach Quarkus in dev
|
||||||
|
'/j_security_check': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
217
src/target/web-dist/assets/index-PUayaP4F.js
Normal file
217
src/target/web-dist/assets/index-PUayaP4F.js
Normal file
File diff suppressed because one or more lines are too long
12
src/target/web-dist/index.html
Normal file
12
src/target/web-dist/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>RolePlay</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-PUayaP4F.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates BCrypt hash for the default admin password for use in Liquibase seed.
|
||||||
|
* Run: mvn test -Dtest=BcryptHashGenerator#generateChangemeHash
|
||||||
|
* Then paste the printed hash into v002__users_and_roles.xml seed changeset.
|
||||||
|
*/
|
||||||
|
final class BcryptHashGenerator {
|
||||||
|
|
||||||
|
/** BCrypt hash stored in v002__users_and_roles.xml for admin user (password: changeme). */
|
||||||
|
private static final String SEEDED_ADMIN_PASSWORD_HASH =
|
||||||
|
"$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateChangemeHash() {
|
||||||
|
String hash = BcryptUtil.bcryptHash("changeme");
|
||||||
|
System.out.println("BCrypt hash for 'changeme': " + hash);
|
||||||
|
assert hash != null && hash.startsWith("$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the seeded admin password hash in the migration matches "changeme".
|
||||||
|
* If this fails, run generateChangemeHash() and update v002__users_and_roles.xml with the new hash.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void seededAdminHashMatchesChangeme() {
|
||||||
|
boolean matches = BcryptUtil.matches("changeme", SEEDED_ADMIN_PASSWORD_HASH);
|
||||||
|
assertTrue(matches, "Seeded admin password hash in v002__users_and_roles.xml must match 'changeme'. "
|
||||||
|
+ "Run generateChangemeHash() and update the migration with the printed hash.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.CharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateCharacterRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link CharacterService}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CharacterServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
|
private CharacterService characterService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
characterService = new CharacterService(characterRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPersistsWithGeneratedIdAndReturnsDefinition() {
|
||||||
|
CreateCharacterRequest request = new CreateCharacterRequest("Captain Morgan", "captain");
|
||||||
|
request.setBackstory("Sea veteran");
|
||||||
|
request.setSpeakingStyle("Gruff");
|
||||||
|
request.setPersonalityTraits(List.of("brave", "stern"));
|
||||||
|
request.setGoals(List.of("Find the treasure"));
|
||||||
|
|
||||||
|
CharacterDefinition result = characterService.create(request);
|
||||||
|
|
||||||
|
assertNotNull(result.getId());
|
||||||
|
assertEquals("Captain Morgan", result.getName());
|
||||||
|
assertEquals("captain", result.getRole());
|
||||||
|
assertEquals("Sea veteran", result.getBackstory());
|
||||||
|
assertEquals("Gruff", result.getSpeakingStyle());
|
||||||
|
assertEquals(List.of("brave", "stern"), result.getPersonalityTraits());
|
||||||
|
assertEquals(List.of("Find the treasure"), result.getGoals());
|
||||||
|
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
|
||||||
|
verify(characterRepository).persist(captor.capture());
|
||||||
|
assertEquals("Captain Morgan", captor.getValue().getName());
|
||||||
|
assertEquals("captain", captor.getValue().getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUsesRequestIdWhenProvided() {
|
||||||
|
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||||
|
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
|
||||||
|
request.setId(id);
|
||||||
|
|
||||||
|
CharacterDefinition result = characterService.create(request);
|
||||||
|
|
||||||
|
assertEquals(id.toString(), result.getId());
|
||||||
|
ArgumentCaptor<CharacterEntity> captor = ArgumentCaptor.forClass(CharacterEntity.class);
|
||||||
|
verify(characterRepository).persist(captor.capture());
|
||||||
|
assertEquals(id, captor.getValue().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateReplacesAndPersists() {
|
||||||
|
UUID id = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||||
|
CharacterEntity existing = new CharacterEntity();
|
||||||
|
existing.setId(id);
|
||||||
|
existing.setName("Old");
|
||||||
|
existing.setRole("oldRole");
|
||||||
|
when(characterRepository.findByIdOptional(id)).thenReturn(existing);
|
||||||
|
|
||||||
|
CreateCharacterRequest request = new CreateCharacterRequest("New Name", "newRole");
|
||||||
|
request.setBackstory("Updated backstory");
|
||||||
|
|
||||||
|
CharacterDefinition result = characterService.update(id, request);
|
||||||
|
|
||||||
|
assertEquals(id.toString(), result.getId());
|
||||||
|
assertEquals("New Name", result.getName());
|
||||||
|
assertEquals("newRole", result.getRole());
|
||||||
|
assertEquals("Updated backstory", result.getBackstory());
|
||||||
|
verify(characterRepository).persist(existing);
|
||||||
|
assertEquals("New Name", existing.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateThrowsWhenCharacterNotFound() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(characterRepository.findByIdOptional(id)).thenReturn(null);
|
||||||
|
CreateCharacterRequest request = new CreateCharacterRequest("Name", "role");
|
||||||
|
|
||||||
|
assertThrows(java.util.NoSuchElementException.class,
|
||||||
|
() -> characterService.update(id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteReturnsTrueAndDeletesWhenFound() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
CharacterEntity entity = new CharacterEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
when(characterRepository.findByIdOptional(id)).thenReturn(entity);
|
||||||
|
|
||||||
|
boolean result = characterService.delete(id);
|
||||||
|
|
||||||
|
assertTrue(result);
|
||||||
|
verify(characterRepository).delete(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteReturnsFalseWhenNotFound() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(characterRepository.findByIdOptional(id)).thenReturn(null);
|
||||||
|
|
||||||
|
boolean result = characterService.delete(id);
|
||||||
|
|
||||||
|
assertTrue(!result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,36 +1,70 @@
|
|||||||
package de.neitzel.roleplay.business;
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import de.neitzel.roleplay.common.StateUpdateResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.OllamaClient;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterDefinition;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CharacterState;
|
||||||
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
import de.neitzel.roleplay.fascade.model.SessionResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.SituationState;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
import de.neitzel.roleplay.fascade.model.TurnRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
import de.neitzel.roleplay.fascade.model.TurnResponse;
|
||||||
|
import de.neitzel.roleplay.fascade.model.UpdateSessionRequest;
|
||||||
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
import de.neitzel.roleplay.fascade.model.UserActionRequest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link InMemorySessionService}.
|
* Unit tests for {@link InMemorySessionService}.
|
||||||
*/
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
class InMemorySessionServiceTest {
|
class InMemorySessionServiceTest {
|
||||||
|
|
||||||
/**
|
@Mock
|
||||||
* Instance under test – no CDI dependencies to mock.
|
private OllamaClient ollamaClient;
|
||||||
*/
|
|
||||||
|
@Mock
|
||||||
|
private ScenarioService scenarioService;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
private InMemorySessionService sessionService;
|
private InMemorySessionService sessionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a fresh service instance before each test to ensure isolation.
|
* Creates a fresh service instance with mocked Ollama client and scenario service before each test.
|
||||||
|
* By default, Ollama is stubbed to return a short narrative and an empty
|
||||||
|
* state update so that createSession (with scenario) and submitTurn complete.
|
||||||
*/
|
*/
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
sessionService = new InMemorySessionService();
|
sessionService = new InMemorySessionService(ollamaClient, objectMapper, scenarioService);
|
||||||
|
StateUpdateResponse emptyUpdate = StateUpdateResponse.builder()
|
||||||
|
.responses(null)
|
||||||
|
.updatedSituation(null)
|
||||||
|
.updatedCharacters(null)
|
||||||
|
.suggestions(null)
|
||||||
|
.build();
|
||||||
|
lenient().when(ollamaClient.generateNarrative(anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn("A short narrative.");
|
||||||
|
lenient().when(ollamaClient.generateStateUpdate(anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(emptyUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -164,5 +198,132 @@ class InMemorySessionServiceTest {
|
|||||||
// Assert
|
// Assert
|
||||||
assertEquals("en", response.getLanguage());
|
assertEquals("en", response.getLanguage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that creating a session with a scenario populates situation and characters.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createSessionWithScenarioPopulatesSituationAndCharacters() {
|
||||||
|
// Arrange
|
||||||
|
CharacterDefinition userChar = new CharacterDefinition("hero", "The Hero", "protagonist");
|
||||||
|
CharacterDefinition aiChar = new CharacterDefinition("mentor", "Old Sage", "wise guide");
|
||||||
|
ScenarioSetup scenario = new ScenarioSetup();
|
||||||
|
scenario.setSetting("A fog-covered harbour at dawn, 1923");
|
||||||
|
scenario.setInitialConflict("Strange noises from the cargo hold");
|
||||||
|
scenario.setUserCharacter(userChar);
|
||||||
|
scenario.setAiCharacters(List.of(aiChar));
|
||||||
|
|
||||||
|
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
|
||||||
|
request.setScenario(scenario);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
SessionResponse response = sessionService.createSession(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertNotNull(response.getSituation());
|
||||||
|
assertEquals("A fog-covered harbour at dawn, 1923", response.getSituation().getSetting());
|
||||||
|
assertNotNull(response.getSituation().getCurrentScene());
|
||||||
|
assertTrue(response.getSituation().getCurrentScene()
|
||||||
|
.contains("A fog-covered harbour at dawn, 1923"));
|
||||||
|
assertTrue(response.getSituation().getCurrentScene().contains("Strange noises"));
|
||||||
|
|
||||||
|
assertNotNull(response.getCharacters());
|
||||||
|
assertEquals(2, response.getCharacters().size());
|
||||||
|
CharacterState userState = response.getCharacters().stream()
|
||||||
|
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals("hero", userState.getId());
|
||||||
|
assertEquals("The Hero", userState.getName());
|
||||||
|
assertEquals("protagonist", userState.getRole());
|
||||||
|
CharacterState aiState = response.getCharacters().stream()
|
||||||
|
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals("mentor", aiState.getId());
|
||||||
|
assertEquals("Old Sage", aiState.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that creating a session with scenarioId uses the scenario loaded from the database.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void createSessionWithScenarioIdUsesScenarioFromDatabase() {
|
||||||
|
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
|
||||||
|
CharacterDefinition userChar = new CharacterDefinition("db_user", "DB User", "protagonist");
|
||||||
|
CharacterDefinition aiChar = new CharacterDefinition("db_ai", "DB NPC", "antagonist");
|
||||||
|
ScenarioSetup loadedScenario = new ScenarioSetup();
|
||||||
|
loadedScenario.setSetting("Database setting");
|
||||||
|
loadedScenario.setInitialConflict("Database conflict");
|
||||||
|
loadedScenario.setUserCharacter(userChar);
|
||||||
|
loadedScenario.setAiCharacters(List.of(aiChar));
|
||||||
|
when(scenarioService.getScenarioAsSetup(scenarioId)).thenReturn(Optional.of(loadedScenario));
|
||||||
|
|
||||||
|
CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
|
||||||
|
request.setScenarioId(scenarioId);
|
||||||
|
request.setScenario(null);
|
||||||
|
|
||||||
|
SessionResponse response = sessionService.createSession(request);
|
||||||
|
|
||||||
|
assertNotNull(response.getSituation());
|
||||||
|
assertEquals("Database setting", response.getSituation().getSetting());
|
||||||
|
assertNotNull(response.getCharacters());
|
||||||
|
assertEquals(2, response.getCharacters().size());
|
||||||
|
CharacterState userState = response.getCharacters().stream()
|
||||||
|
.filter(c -> Boolean.TRUE.equals(c.getIsUserCharacter()))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals("db_user", userState.getId());
|
||||||
|
assertEquals("DB User", userState.getName());
|
||||||
|
CharacterState aiState = response.getCharacters().stream()
|
||||||
|
.filter(c -> Boolean.FALSE.equals(c.getIsUserCharacter()))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
assertEquals("db_ai", aiState.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that updateSession updates situation and characters when provided.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void updateSessionUpdatesSituationAndCharacters() {
|
||||||
|
// Arrange
|
||||||
|
CreateSessionRequest createRequest = new CreateSessionRequest("llama3:latest");
|
||||||
|
SessionResponse session = sessionService.createSession(createRequest);
|
||||||
|
String sessionId = session.getSessionId();
|
||||||
|
|
||||||
|
SituationState newSituation = new SituationState();
|
||||||
|
newSituation.setSetting("New setting");
|
||||||
|
newSituation.setCurrentScene("New scene focus");
|
||||||
|
CharacterState newChar = new CharacterState("npc1", "First NPC", false);
|
||||||
|
newChar.setRole("supporting");
|
||||||
|
UpdateSessionRequest updateRequest = new UpdateSessionRequest();
|
||||||
|
updateRequest.setSituation(newSituation);
|
||||||
|
updateRequest.setCharacters(List.of(newChar));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
Optional<SessionResponse> updated = sessionService.updateSession(sessionId, updateRequest);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertTrue(updated.isPresent());
|
||||||
|
assertEquals("New setting", updated.get().getSituation().getSetting());
|
||||||
|
assertEquals("New scene focus", updated.get().getSituation().getCurrentScene());
|
||||||
|
assertEquals(1, updated.get().getCharacters().size());
|
||||||
|
assertEquals("npc1", updated.get().getCharacters().get(0).getId());
|
||||||
|
|
||||||
|
Optional<SessionResponse> got = sessionService.getSession(sessionId);
|
||||||
|
assertTrue(got.isPresent());
|
||||||
|
assertEquals("New setting", got.get().getSituation().getSetting());
|
||||||
|
assertEquals(1, got.get().getCharacters().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that updateSession returns empty for unknown session.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void updateSessionReturnsEmptyForUnknownSession() {
|
||||||
|
UpdateSessionRequest request = new UpdateSessionRequest();
|
||||||
|
request.setSituation(new SituationState());
|
||||||
|
|
||||||
|
Optional<SessionResponse> result = sessionService.updateSession("unknown-id", request);
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,242 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.CharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.CharacterRepository;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioCharacterEntity;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioEntity;
|
||||||
|
import de.neitzel.roleplay.data.ScenarioRepository;
|
||||||
|
import de.neitzel.roleplay.fascade.model.CreateScenarioRequest;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioCharacterSlot;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSetup;
|
||||||
|
import de.neitzel.roleplay.fascade.model.ScenarioSummary;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link ScenarioService}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ScenarioServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ScenarioRepository scenarioRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
private ScenarioService scenarioService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
scenarioService = new ScenarioService(scenarioRepository, characterRepository, entityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listScenariosReturnsMappedSummaries() {
|
||||||
|
ScenarioEntity entity = new ScenarioEntity();
|
||||||
|
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222201"));
|
||||||
|
entity.setName("Harbour mystery");
|
||||||
|
entity.setSetting("A fog-covered harbour");
|
||||||
|
entity.setInitialConflict("Strange noises");
|
||||||
|
when(scenarioRepository.listAll()).thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
List<ScenarioSummary> result = scenarioService.listScenarios();
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
ScenarioSummary summary = result.get(0);
|
||||||
|
assertEquals(UUID.fromString("22222222-2222-2222-2222-222222222201"), summary.getId());
|
||||||
|
assertEquals("Harbour mystery", summary.getName());
|
||||||
|
assertEquals("A fog-covered harbour", summary.getSetting());
|
||||||
|
assertEquals("Strange noises", summary.getInitialConflict());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listScenariosReturnsEmptyWhenNoneStored() {
|
||||||
|
when(scenarioRepository.listAll()).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<ScenarioSummary> result = scenarioService.listScenarios();
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getScenarioAsSetupReturnsEmptyWhenNotFound() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(id)).thenReturn(null);
|
||||||
|
|
||||||
|
Optional<ScenarioSetup> result = scenarioService.getScenarioAsSetup(id);
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getScenarioAsSetupReturnsSetupWithUserAndAiCharacters() {
|
||||||
|
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
|
||||||
|
UUID userCharId = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||||
|
UUID aiCharId = UUID.fromString("11111111-1111-1111-1111-111111111102");
|
||||||
|
|
||||||
|
CharacterEntity userCharEntity = new CharacterEntity();
|
||||||
|
userCharEntity.setId(userCharId);
|
||||||
|
userCharEntity.setName("The Detective");
|
||||||
|
userCharEntity.setRole("detective");
|
||||||
|
|
||||||
|
CharacterEntity aiCharEntity = new CharacterEntity();
|
||||||
|
aiCharEntity.setId(aiCharId);
|
||||||
|
aiCharEntity.setName("Captain Morgan");
|
||||||
|
aiCharEntity.setRole("captain");
|
||||||
|
|
||||||
|
ScenarioCharacterEntity userLink = new ScenarioCharacterEntity();
|
||||||
|
userLink.setUserCharacter(true);
|
||||||
|
userLink.setPosition(0);
|
||||||
|
userLink.setCharacter(userCharEntity);
|
||||||
|
|
||||||
|
ScenarioCharacterEntity aiLink = new ScenarioCharacterEntity();
|
||||||
|
aiLink.setUserCharacter(false);
|
||||||
|
aiLink.setPosition(1);
|
||||||
|
aiLink.setCharacter(aiCharEntity);
|
||||||
|
|
||||||
|
ScenarioEntity scenarioEntity = new ScenarioEntity();
|
||||||
|
scenarioEntity.setId(scenarioId);
|
||||||
|
scenarioEntity.setName("Harbour mystery");
|
||||||
|
scenarioEntity.setSetting("A fog-covered harbour at dawn");
|
||||||
|
scenarioEntity.setInitialConflict("Strange noises from the cargo hold");
|
||||||
|
scenarioEntity.setScenarioCharacters(List.of(userLink, aiLink));
|
||||||
|
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(scenarioEntity);
|
||||||
|
|
||||||
|
Optional<ScenarioSetup> result = scenarioService.getScenarioAsSetup(scenarioId);
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
ScenarioSetup setup = result.get();
|
||||||
|
assertEquals("A fog-covered harbour at dawn", setup.getSetting());
|
||||||
|
assertEquals("Strange noises from the cargo hold", setup.getInitialConflict());
|
||||||
|
assertNotNull(setup.getUserCharacter());
|
||||||
|
assertEquals("The Detective", setup.getUserCharacter().getName());
|
||||||
|
assertEquals("detective", setup.getUserCharacter().getRole());
|
||||||
|
assertNotNull(setup.getAiCharacters());
|
||||||
|
assertEquals(1, setup.getAiCharacters().size());
|
||||||
|
assertEquals("Captain Morgan", setup.getAiCharacters().get(0).getName());
|
||||||
|
assertEquals("captain", setup.getAiCharacters().get(0).getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createScenarioPersistsAndReturnsSummary() {
|
||||||
|
UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||||
|
CharacterEntity character = new CharacterEntity();
|
||||||
|
character.setId(charId);
|
||||||
|
character.setName("Detective");
|
||||||
|
character.setRole("detective");
|
||||||
|
when(characterRepository.findByIdOptional(charId)).thenReturn(character);
|
||||||
|
|
||||||
|
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0);
|
||||||
|
CreateScenarioRequest request = new CreateScenarioRequest("Mystery");
|
||||||
|
request.setSetting("A harbour");
|
||||||
|
request.setInitialConflict("Noises");
|
||||||
|
request.setCharacterSlots(List.of(slot));
|
||||||
|
|
||||||
|
ScenarioSummary result = scenarioService.create(request);
|
||||||
|
|
||||||
|
assertNotNull(result.getId());
|
||||||
|
assertEquals("Mystery", result.getName());
|
||||||
|
assertEquals("A harbour", result.getSetting());
|
||||||
|
assertEquals("Noises", result.getInitialConflict());
|
||||||
|
ArgumentCaptor<ScenarioEntity> captor = ArgumentCaptor.forClass(ScenarioEntity.class);
|
||||||
|
verify(scenarioRepository).persist(captor.capture());
|
||||||
|
assertEquals(1, captor.getValue().getScenarioCharacters().size());
|
||||||
|
assertTrue(captor.getValue().getScenarioCharacters().get(0).isUserCharacter());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createScenarioThrowsWhenCharacterNotFound() {
|
||||||
|
UUID charId = UUID.randomUUID();
|
||||||
|
when(characterRepository.findByIdOptional(charId)).thenReturn(null);
|
||||||
|
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, false, 1);
|
||||||
|
CreateScenarioRequest request = new CreateScenarioRequest("Mystery");
|
||||||
|
request.setCharacterSlots(List.of(slot));
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> scenarioService.create(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateScenarioReplacesAndPersists() {
|
||||||
|
UUID scenarioId = UUID.fromString("22222222-2222-2222-2222-222222222201");
|
||||||
|
UUID charId = UUID.fromString("11111111-1111-1111-1111-111111111101");
|
||||||
|
CharacterEntity character = new CharacterEntity();
|
||||||
|
character.setId(charId);
|
||||||
|
character.setName("Detective");
|
||||||
|
character.setRole("detective");
|
||||||
|
ScenarioEntity existing = new ScenarioEntity();
|
||||||
|
existing.setId(scenarioId);
|
||||||
|
existing.setName("Old");
|
||||||
|
existing.setScenarioCharacters(new java.util.ArrayList<>());
|
||||||
|
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(existing);
|
||||||
|
when(characterRepository.findByIdOptional(charId)).thenReturn(character);
|
||||||
|
|
||||||
|
ScenarioCharacterSlot slot = new ScenarioCharacterSlot(charId, true, 0);
|
||||||
|
CreateScenarioRequest request = new CreateScenarioRequest("Updated");
|
||||||
|
request.setSetting("New setting");
|
||||||
|
request.setCharacterSlots(List.of(slot));
|
||||||
|
|
||||||
|
ScenarioSummary result = scenarioService.update(scenarioId, request);
|
||||||
|
|
||||||
|
assertEquals(scenarioId, result.getId());
|
||||||
|
assertEquals("Updated", result.getName());
|
||||||
|
assertEquals("New setting", result.getSetting());
|
||||||
|
verify(scenarioRepository).persist(existing);
|
||||||
|
assertEquals(1, existing.getScenarioCharacters().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateScenarioThrowsWhenScenarioNotFound() {
|
||||||
|
UUID scenarioId = UUID.randomUUID();
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null);
|
||||||
|
CreateScenarioRequest request = new CreateScenarioRequest("Name");
|
||||||
|
|
||||||
|
assertThrows(java.util.NoSuchElementException.class,
|
||||||
|
() -> scenarioService.update(scenarioId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteScenarioReturnsTrueWhenFound() {
|
||||||
|
UUID scenarioId = UUID.randomUUID();
|
||||||
|
ScenarioEntity scenario = new ScenarioEntity();
|
||||||
|
scenario.setId(scenarioId);
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(scenario);
|
||||||
|
|
||||||
|
boolean result = scenarioService.delete(scenarioId);
|
||||||
|
|
||||||
|
assertTrue(result);
|
||||||
|
verify(scenarioRepository).delete(scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteScenarioReturnsFalseWhenNotFound() {
|
||||||
|
UUID scenarioId = UUID.randomUUID();
|
||||||
|
when(scenarioRepository.findByIdWithCharacters(scenarioId)).thenReturn(null);
|
||||||
|
|
||||||
|
boolean result = scenarioService.delete(scenarioId);
|
||||||
|
|
||||||
|
assertTrue(!result);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/test/java/de/neitzel/roleplay/business/UserServiceTest.java
Normal file
196
src/test/java/de/neitzel/roleplay/business/UserServiceTest.java
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UpdateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link UserService}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userService = new UserService(userRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUsersReturnsMappedSummaries() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.listAll()).thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
List<UserSummary> result = userService.listUsers();
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
UserSummary summary = result.get(0);
|
||||||
|
assertEquals(UUID.fromString("11111111-1111-1111-1111-111111111111"), summary.getId());
|
||||||
|
assertEquals("alice", summary.getUsername());
|
||||||
|
assertEquals("user", summary.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsBlankUsername() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("", "secret", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Username is required", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsBlankPassword() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Password is required", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsInvalidRole() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "secret", "superuser");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Role must be 'admin' or 'user'", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsDuplicateUsername() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "secret", "user");
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(new UserEntity());
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Username already exists: alice", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordUpdatesPasswordWhenCurrentMatches() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(entity);
|
||||||
|
|
||||||
|
userService.changePassword("alice", "oldpass", "newpass");
|
||||||
|
|
||||||
|
assertTrue(BcryptUtil.matches("newpass", entity.getPassword()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordThrowsWhenCurrentPasswordWrong() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setPassword(BcryptUtil.bcryptHash("oldpass"));
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(entity);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.changePassword("alice", "wrong", "newpass"));
|
||||||
|
assertEquals("Current password is incorrect", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordThrowsWhenUserNotFound() {
|
||||||
|
when(userRepository.findByUsername("nobody")).thenReturn(null);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.changePassword("nobody", "old", "new"));
|
||||||
|
assertEquals("User not found: nobody", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserSuccessWithoutNewPassword() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.findById(UUID.fromString("22222222-2222-2222-2222-222222222222"))).thenReturn(entity);
|
||||||
|
// Same username as entity: uniqueness check not invoked
|
||||||
|
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("alice", null, "admin");
|
||||||
|
UserSummary result = userService.updateUser(entity.getId(), request, "admin");
|
||||||
|
|
||||||
|
assertEquals("alice", result.getUsername());
|
||||||
|
assertEquals("admin", result.getRole());
|
||||||
|
assertEquals("admin", entity.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserThrowsWhenUserNotFound() {
|
||||||
|
when(userRepository.findById(UUID.fromString("99999999-9999-9999-9999-999999999999"))).thenReturn(null);
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("bob", "secret", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.updateUser(UUID.fromString("99999999-9999-9999-9999-999999999999"), request, "admin"));
|
||||||
|
assertEquals("User not found: 99999999-9999-9999-9999-999999999999", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserThrowsWhenUsernameTakenByOther() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
UserEntity other = new UserEntity();
|
||||||
|
other.setUsername("bob");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
when(userRepository.findByUsername("bob")).thenReturn(other);
|
||||||
|
|
||||||
|
UpdateUserRequest request = new UpdateUserRequest("bob", null, "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.updateUser(entity.getId(), request, "admin"));
|
||||||
|
assertEquals("Username already exists: bob", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserThrowsWhenDeletingSelf() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("admin");
|
||||||
|
entity.setRole("admin");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.deleteUser(entity.getId(), "admin"));
|
||||||
|
assertEquals("Cannot delete your own user account", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserThrowsWhenDeletingLastAdmin() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.randomUUID());
|
||||||
|
entity.setUsername("soleadmin");
|
||||||
|
entity.setRole("admin");
|
||||||
|
when(userRepository.findById(entity.getId())).thenReturn(entity);
|
||||||
|
when(userRepository.count("role = ?1", "admin")).thenReturn(1L);
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> userService.deleteUser(entity.getId(), "user"));
|
||||||
|
assertEquals("Cannot delete the last admin user", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -139,4 +139,34 @@ class StateUpdateResponseDeserializationTest {
|
|||||||
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
|
assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained());
|
||||||
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that combined type "speech|action" from Ollama is accepted and
|
||||||
|
* mapped to {@link ResponseType#SPEECH}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void deserialisesSpeechOrActionAsSpeech() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"responses": [
|
||||||
|
{
|
||||||
|
"character_id": "narrator",
|
||||||
|
"type": "speech|action",
|
||||||
|
"content": "I will do it.",
|
||||||
|
"action": "nods firmly",
|
||||||
|
"mood_after": "determined"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"updated_situation": null,
|
||||||
|
"updated_characters": [],
|
||||||
|
"suggestions": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
StateUpdateResponse result = mapper.readValue(json, StateUpdateResponse.class);
|
||||||
|
assertNotNull(result.getResponses());
|
||||||
|
assertEquals(1, result.getResponses().size());
|
||||||
|
assertEquals(ResponseType.SPEECH, result.getResponses().get(0).getType());
|
||||||
|
assertEquals("I will do it.", result.getResponses().get(0).getContent());
|
||||||
|
assertEquals("nods firmly", result.getResponses().get(0).getAction());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,145 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.restassured.http.Cookies;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
|
import static org.hamcrest.Matchers.hasItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for form-based auth: login, auth/me, and protected API access.
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
class AuthIntegrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedRequestToApiReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/scenarios")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedRequestToAuthMeReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/auth/me")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginThenAuthMeAndScenariosSucceed() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/auth/me")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is("admin"))
|
||||||
|
.body("roles", hasItem("admin"));
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/scenarios")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidLoginReturns401() {
|
||||||
|
given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "wrong")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordSucceedsWithValidCurrentPassword() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "changeme", "newPassword", "newpass123"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
|
||||||
|
// Restore original password so other tests are not affected
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "newpass123", "newPassword", "changeme"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePasswordReturns400WhenCurrentPasswordWrong() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "wrong", "newPassword", "newpass"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedChangePasswordReturns401() {
|
||||||
|
given()
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("currentPassword", "old", "newPassword", "new"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/auth/change-password")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.restassured.http.Cookies;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
|
import static org.hamcrest.CoreMatchers.notNullValue;
|
||||||
|
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for admin user management: list, create, update, delete users.
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
class UsersIntegrationTest {
|
||||||
|
|
||||||
|
private static Cookies loginAsAdmin() {
|
||||||
|
return given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedListUsersReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUsersAsAdminReturns200() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("size()", greaterThanOrEqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserAsAdminReturns201() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "testuser_" + System.currentTimeMillis();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.body("username", is(username))
|
||||||
|
.body("role", is("user"))
|
||||||
|
.body("id", notNullValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserAsAdminReturns200() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "edituser_" + System.currentTimeMillis();
|
||||||
|
String id = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path("id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username + "_updated", "role", "admin"))
|
||||||
|
.when()
|
||||||
|
.put("/api/v1/admin/users/" + id)
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is(username + "_updated"))
|
||||||
|
.body("role", is("admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserAsAdminReturns204() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String username = "deluser_" + System.currentTimeMillis();
|
||||||
|
String id = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("username", username, "password", "secret", "role", "user"))
|
||||||
|
.when()
|
||||||
|
.post("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(201)
|
||||||
|
.extract()
|
||||||
|
.path("id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/" + id)
|
||||||
|
.then()
|
||||||
|
.statusCode(204);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserSelfReturns400() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
String adminId = given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/admin/users")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.path("find { it.username == 'admin' }.id");
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/" + adminId)
|
||||||
|
.then()
|
||||||
|
.statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUserNotFoundReturns404() {
|
||||||
|
Cookies cookies = loginAsAdmin();
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.delete("/api/v1/admin/users/00000000-0000-0000-0000-000000000000")
|
||||||
|
.then()
|
||||||
|
.statusCode(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
tree.txt
Normal file
216
tree.txt
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
[INFO] Scanning for projects...
|
||||||
|
[INFO]
|
||||||
|
[INFO] ------------------------< de.neitzel:roleplay >-------------------------
|
||||||
|
[INFO] Building roleplay 0.1.0-SNAPSHOT
|
||||||
|
[INFO] from pom.xml
|
||||||
|
[INFO] --------------------------------[ jar ]---------------------------------
|
||||||
|
[INFO]
|
||||||
|
[INFO] --- dependency:3.7.0:tree (default-cli) @ roleplay ---
|
||||||
|
[INFO] de.neitzel:roleplay:jar:0.1.0-SNAPSHOT
|
||||||
|
[INFO] +- io.quarkus:quarkus-arc:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus.arc:arc:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- jakarta.enterprise:jakarta.enterprise.cdi-api:jar:4.1.0:compile
|
||||||
|
[INFO] | | | +- jakarta.enterprise:jakarta.enterprise.lang-model:jar:4.1.0:compile
|
||||||
|
[INFO] | | | \- jakarta.interceptor:jakarta.interceptor-api:jar:2.2.0:compile
|
||||||
|
[INFO] | | +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile
|
||||||
|
[INFO] | | +- jakarta.transaction:jakarta.transaction-api:jar:2.0.1:compile
|
||||||
|
[INFO] | | +- io.smallrye.reactive:mutiny:jar:3.1.0:compile
|
||||||
|
[INFO] | | | +- io.smallrye.common:smallrye-common-annotation:jar:2.15.0:compile
|
||||||
|
[INFO] | | | \- org.jctools:jctools-core:jar:4.0.5:compile
|
||||||
|
[INFO] | | \- org.jboss.logging:jboss-logging:jar:3.6.2.Final:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-core:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- jakarta.inject:jakarta.inject-api:jar:2.0.1:compile
|
||||||
|
[INFO] | | +- io.smallrye.common:smallrye-common-os:jar:2.15.0:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-ide-launcher:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-development-mode-spi:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.smallrye.config:smallrye-config:jar:3.15.1:compile
|
||||||
|
[INFO] | | | \- io.smallrye.config:smallrye-config-core:jar:3.15.1:compile
|
||||||
|
[INFO] | | +- org.jboss.logmanager:jboss-logmanager:jar:3.2.0.Final:compile
|
||||||
|
[INFO] | | | +- io.smallrye.common:smallrye-common-cpu:jar:2.15.0:compile
|
||||||
|
[INFO] | | | +- io.smallrye.common:smallrye-common-expression:jar:2.15.0:compile
|
||||||
|
[INFO] | | | +- io.smallrye.common:smallrye-common-net:jar:2.15.0:compile
|
||||||
|
[INFO] | | | +- io.smallrye.common:smallrye-common-ref:jar:2.15.0:compile
|
||||||
|
[INFO] | | | \- jakarta.json:jakarta.json-api:jar:2.1.3:compile
|
||||||
|
[INFO] | | +- org.jboss.threads:jboss-threads:jar:3.9.2:compile
|
||||||
|
[INFO] | | | \- io.smallrye.common:smallrye-common-function:jar:2.15.0:compile
|
||||||
|
[INFO] | | +- org.slf4j:slf4j-api:jar:2.0.17:compile
|
||||||
|
[INFO] | | +- org.jboss.slf4j:slf4j-jboss-logmanager:jar:2.0.2.Final:compile
|
||||||
|
[INFO] | | +- org.wildfly.common:wildfly-common:jar:2.0.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-registry:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-bootstrap-runner:jar:3.31.1:compile
|
||||||
|
[INFO] | | | +- io.quarkus:quarkus-classloader-commons:jar:3.31.1:compile
|
||||||
|
[INFO] | | | \- io.smallrye.common:smallrye-common-io:jar:2.15.0:compile
|
||||||
|
[INFO] | | \- io.quarkus:quarkus-fs-util:jar:1.3.0:compile
|
||||||
|
[INFO] | \- org.eclipse.microprofile.context-propagation:microprofile-context-propagation-api:jar:1.3:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-rest:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-rest-common:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus.resteasy.reactive:resteasy-reactive-common:jar:3.31.1:compile
|
||||||
|
[INFO] | | | +- io.quarkus.resteasy.reactive:resteasy-reactive-common-types:jar:3.31.1:compile
|
||||||
|
[INFO] | | | \- org.reactivestreams:reactive-streams:jar:1.0.4:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-mutiny:jar:3.31.1:compile
|
||||||
|
[INFO] | | | +- io.quarkus:quarkus-smallrye-context-propagation:jar:3.31.1:compile
|
||||||
|
[INFO] | | | | \- io.smallrye:smallrye-context-propagation:jar:2.3.0:compile
|
||||||
|
[INFO] | | | | +- io.smallrye:smallrye-context-propagation-api:jar:2.3.0:compile
|
||||||
|
[INFO] | | | | \- io.smallrye:smallrye-context-propagation-storage:jar:2.3.0:compile
|
||||||
|
[INFO] | | | \- io.smallrye.reactive:mutiny-smallrye-context-propagation:jar:3.1.0:compile
|
||||||
|
[INFO] | | \- io.quarkus:quarkus-vertx:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-netty:jar:3.31.1:compile
|
||||||
|
[INFO] | | | \- io.netty:netty-codec:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | | +- io.netty:netty-codec-haproxy:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-vertx-latebound-mdc-provider:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- io.smallrye:smallrye-fault-tolerance-vertx:jar:6.10.0:compile
|
||||||
|
[INFO] | +- io.quarkus.resteasy.reactive:resteasy-reactive-vertx:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.vertx:vertx-web:jar:4.5.24:compile
|
||||||
|
[INFO] | | | +- io.vertx:vertx-web-common:jar:4.5.24:compile
|
||||||
|
[INFO] | | | +- io.vertx:vertx-auth-common:jar:4.5.24:compile
|
||||||
|
[INFO] | | | \- io.vertx:vertx-bridge-common:jar:4.5.24:compile
|
||||||
|
[INFO] | | +- io.smallrye.reactive:smallrye-mutiny-vertx-core:jar:3.21.4:compile
|
||||||
|
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-runtime:jar:3.21.4:compile
|
||||||
|
[INFO] | | | \- io.smallrye.reactive:vertx-mutiny-generator:jar:3.21.4:compile
|
||||||
|
[INFO] | | | \- io.vertx:vertx-codegen:jar:4.5.24:compile
|
||||||
|
[INFO] | | +- io.quarkus.resteasy.reactive:resteasy-reactive:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus.vertx.utils:quarkus-vertx-utils:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- org.jboss.logging:commons-logging-jboss-logging:jar:1.0.0.Final:compile
|
||||||
|
[INFO] | | \- jakarta.xml.bind:jakarta.xml.bind-api:jar:4.0.4:compile
|
||||||
|
[INFO] | | \- jakarta.activation:jakarta.activation-api:jar:2.1.4:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-vertx-http:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-security-runtime-spi:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-tls-registry:jar:3.31.1:compile
|
||||||
|
[INFO] | | | +- io.quarkus:quarkus-tls-registry-spi:jar:3.31.1:compile
|
||||||
|
[INFO] | | | \- io.smallrye.certs:smallrye-private-key-pem-parser:jar:0.9.2:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-credentials:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.smallrye.common:smallrye-common-vertx-context:jar:2.15.0:compile
|
||||||
|
[INFO] | | +- io.quarkus.security:quarkus-security:jar:2.3.2:compile
|
||||||
|
[INFO] | | +- io.smallrye.reactive:smallrye-mutiny-vertx-web:jar:3.21.4:compile
|
||||||
|
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-web-common:jar:3.21.4:compile
|
||||||
|
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-auth-common:jar:3.21.4:compile
|
||||||
|
[INFO] | | | +- io.smallrye.reactive:smallrye-mutiny-vertx-bridge-common:jar:3.21.4:compile
|
||||||
|
[INFO] | | | \- io.smallrye.reactive:smallrye-mutiny-vertx-uri-template:jar:3.21.4:compile
|
||||||
|
[INFO] | | | \- io.vertx:vertx-uri-template:jar:4.5.24:compile
|
||||||
|
[INFO] | | +- org.crac:crac:jar:1.5.0:compile
|
||||||
|
[INFO] | | \- com.aayushatharva.brotli4j:brotli4j:jar:1.16.0:compile
|
||||||
|
[INFO] | | +- com.aayushatharva.brotli4j:service:jar:1.16.0:compile
|
||||||
|
[INFO] | | \- com.aayushatharva.brotli4j:native-osx-aarch64:jar:1.16.0:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-jsonp:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- org.eclipse.parsson:parsson:jar:1.1.7:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-virtual-threads:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.vertx:vertx-core:jar:4.5.24:compile
|
||||||
|
[INFO] | +- io.netty:netty-common:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-buffer:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-transport:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-handler:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | | \- io.netty:netty-transport-native-unix-common:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-handler-proxy:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | | \- io.netty:netty-codec-socks:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-codec-http:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-codec-http2:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-resolver:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | +- io.netty:netty-resolver-dns:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | | \- io.netty:netty-codec-dns:jar:4.1.130.Final:compile
|
||||||
|
[INFO] | \- com.fasterxml.jackson.core:jackson-core:jar:2.20.1:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-rest-jackson:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-rest-jackson-common:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-jackson:jar:3.31.1:compile
|
||||||
|
[INFO] | +- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.20.1:compile
|
||||||
|
[INFO] | +- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:jar:2.20.1:compile
|
||||||
|
[INFO] | \- com.fasterxml.jackson.module:jackson-module-parameter-names:jar:2.20.1:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-hibernate-validator:jar:3.31.1:compile
|
||||||
|
[INFO] | +- org.hibernate.validator:hibernate-validator:jar:9.1.0.Final:compile
|
||||||
|
[INFO] | | +- jakarta.validation:jakarta.validation-api:jar:3.1.1:compile
|
||||||
|
[INFO] | | \- com.fasterxml:classmate:jar:1.7.1:compile
|
||||||
|
[INFO] | +- org.glassfish.expressly:expressly:jar:6.0.0:compile
|
||||||
|
[INFO] | | \- jakarta.el:jakarta.el-api:jar:6.0.1:compile
|
||||||
|
[INFO] | +- io.smallrye.config:smallrye-config-validator:jar:3.15.1:compile
|
||||||
|
[INFO] | \- jakarta.ws.rs:jakarta.ws.rs-api:jar:3.1.0:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-config-yaml:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.smallrye.config:smallrye-config-source-yaml:jar:3.15.1:compile
|
||||||
|
[INFO] | | +- io.smallrye.config:smallrye-config-common:jar:3.15.1:compile
|
||||||
|
[INFO] | | | \- io.smallrye.common:smallrye-common-classloader:jar:2.15.0:compile
|
||||||
|
[INFO] | | \- io.smallrye.common:smallrye-common-constraint:jar:2.15.0:compile
|
||||||
|
[INFO] | \- org.eclipse.microprofile.config:microprofile-config-api:jar:3.1:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-jdbc-h2:jar:3.31.1:compile
|
||||||
|
[INFO] | +- com.h2database:h2:jar:2.4.240:compile
|
||||||
|
[INFO] | \- org.locationtech.jts:jts-core:jar:1.19.0:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-liquibase:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-liquibase-common:jar:3.31.1:compile
|
||||||
|
[INFO] | +- org.liquibase:liquibase-core:jar:4.33.0:compile
|
||||||
|
[INFO] | | +- com.opencsv:opencsv:jar:5.11.2:compile
|
||||||
|
[INFO] | | +- org.apache.commons:commons-collections4:jar:4.5.0:compile
|
||||||
|
[INFO] | | +- org.apache.commons:commons-text:jar:1.15.0:compile
|
||||||
|
[INFO] | | +- org.apache.commons:commons-lang3:jar:3.20.0:compile
|
||||||
|
[INFO] | | \- commons-io:commons-io:jar:2.21.0:compile
|
||||||
|
[INFO] | +- org.osgi:osgi.core:jar:6.0.0:compile
|
||||||
|
[INFO] | +- org.yaml:snakeyaml:jar:2.5:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-jaxb:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-jaxp:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- org.glassfish.jaxb:jaxb-runtime:jar:4.0.6:compile
|
||||||
|
[INFO] | | \- org.glassfish.jaxb:jaxb-core:jar:4.0.6:compile
|
||||||
|
[INFO] | | +- org.eclipse.angus:angus-activation:jar:2.0.3:runtime
|
||||||
|
[INFO] | | +- org.glassfish.jaxb:txw2:jar:4.0.6:compile
|
||||||
|
[INFO] | | \- com.sun.istack:istack-commons-runtime:jar:4.1.2:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-agroal:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.quarkus:quarkus-datasource:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- io.agroal:agroal-api:jar:2.8:compile
|
||||||
|
[INFO] | | +- io.agroal:agroal-narayana:jar:2.8:compile
|
||||||
|
[INFO] | | | \- org.jboss:jboss-transaction-spi:jar:8.0.0.Final:compile
|
||||||
|
[INFO] | | \- io.agroal:agroal-pool:jar:2.8:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-datasource-common:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-narayana-jta:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-transaction-annotations:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.smallrye:smallrye-context-propagation-jta:jar:2.3.0:compile
|
||||||
|
[INFO] | +- io.smallrye.reactive:smallrye-reactive-converter-api:jar:3.0.3:compile
|
||||||
|
[INFO] | +- io.smallrye.reactive:smallrye-reactive-converter-mutiny:jar:3.0.3:compile
|
||||||
|
[INFO] | +- io.smallrye.reactive:mutiny-zero-flow-adapters:jar:1.1.1:compile
|
||||||
|
[INFO] | +- org.jboss.narayana.jta:narayana-jta:jar:7.3.3.Final:compile
|
||||||
|
[INFO] | | +- jakarta.resource:jakarta.resource-api:jar:2.1.0:compile
|
||||||
|
[INFO] | | +- org.jboss.invocation:jboss-invocation:jar:2.0.0.Final:compile
|
||||||
|
[INFO] | | \- org.eclipse.microprofile.reactive-streams-operators:microprofile-reactive-streams-operators-api:jar:3.0.1:compile
|
||||||
|
[INFO] | \- org.jboss.narayana.jts:narayana-jts-integration:jar:7.3.3.Final:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-hibernate-orm-panache:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-hibernate-orm:jar:3.31.1:compile
|
||||||
|
[INFO] | | +- org.hibernate.orm:hibernate-core:jar:7.2.1.Final:compile
|
||||||
|
[INFO] | | | +- org.hibernate.models:hibernate-models:jar:1.0.1:runtime
|
||||||
|
[INFO] | | | \- org.antlr:antlr4-runtime:jar:4.13.2:compile
|
||||||
|
[INFO] | | +- net.bytebuddy:byte-buddy:jar:1.17.8:compile
|
||||||
|
[INFO] | | +- org.hibernate.orm:hibernate-graalvm:jar:7.2.1.Final:compile
|
||||||
|
[INFO] | | +- jakarta.persistence:jakarta.persistence-api:jar:3.2.0:compile
|
||||||
|
[INFO] | | +- org.hibernate.local-cache:quarkus-local-cache:jar:0.5.0:compile
|
||||||
|
[INFO] | | \- io.quarkus:quarkus-caffeine:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- com.github.ben-manes.caffeine:caffeine:jar:3.2.3:compile
|
||||||
|
[INFO] | | \- com.google.errorprone:error_prone_annotations:jar:2.46.0:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-hibernate-orm-panache-common:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- io.quarkus:quarkus-panache-hibernate-common:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-panache-common:jar:3.31.1:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-rest-client-config:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus:quarkus-proxy-registry:jar:3.31.1:compile
|
||||||
|
[INFO] | +- org.eclipse.microprofile.rest.client:microprofile-rest-client-api:jar:4.0:compile
|
||||||
|
[INFO] | \- io.smallrye:jandex:jar:3.5.3:compile
|
||||||
|
[INFO] +- io.quarkus:quarkus-rest-client-jackson:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.quarkus.resteasy.reactive:resteasy-reactive-jackson:jar:3.31.1:compile
|
||||||
|
[INFO] | | \- com.fasterxml.jackson.core:jackson-databind:jar:2.20.1:compile
|
||||||
|
[INFO] | | \- com.fasterxml.jackson.core:jackson-annotations:jar:2.20:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-rest-client:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus:quarkus-rest-client-jaxrs:jar:3.31.1:compile
|
||||||
|
[INFO] | \- io.quarkus.resteasy.reactive:resteasy-reactive-client:jar:3.31.1:compile
|
||||||
|
[INFO] | +- io.smallrye.stork:stork-api:jar:2.7.3:compile
|
||||||
|
[INFO] | \- io.vertx:vertx-web-client:jar:4.5.24:compile
|
||||||
|
[INFO] +- org.projectlombok:lombok:jar:1.18.42:provided
|
||||||
|
[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.10.3:test
|
||||||
|
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:6.0.2:test
|
||||||
|
[INFO] | | +- org.opentest4j:opentest4j:jar:1.3.0:test
|
||||||
|
[INFO] | | +- org.junit.platform:junit-platform-commons:jar:6.0.2:test
|
||||||
|
[INFO] | | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
|
||||||
|
[INFO] | | \- org.jspecify:jspecify:jar:1.0.0:compile
|
||||||
|
[INFO] | +- org.junit.jupiter:junit-jupiter-params:jar:6.0.2:test
|
||||||
|
[INFO] | \- org.junit.jupiter:junit-jupiter-engine:jar:6.0.2:test
|
||||||
|
[INFO] | \- org.junit.platform:junit-platform-engine:jar:6.0.2:test
|
||||||
|
[INFO] \- org.mockito:mockito-junit-jupiter:jar:5.12.0:test
|
||||||
|
[INFO] \- org.mockito:mockito-core:jar:5.21.0:test
|
||||||
|
[INFO] +- net.bytebuddy:byte-buddy-agent:jar:1.17.8:test
|
||||||
|
[INFO] \- org.objenesis:objenesis:jar:3.3:test
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
|
[INFO] Total time: 1.062 s
|
||||||
|
[INFO] Finished at: 2026-02-21T19:02:53+01:00
|
||||||
|
[INFO] ------------------------------------------------------------------------
|
||||||
Loading…
x
Reference in New Issue
Block a user