Turn orchestration (the two-call Ollama pattern) is not yet wired; the
+ * methods return stub responses so the REST layer can be exercised end-to-end.
+ * The {@code TODO} markers indicate where the Ollama integration must be added.
+ */
+@ApplicationScoped
+public class InMemorySessionService implements SessionService {
+
+ /**
+ * In-memory store mapping session IDs to their current state.
+ */
+ private final Map Generates a new UUID as the session ID, populates default session
+ * metadata, and stores the session. The Ollama two-call pattern is not
+ * yet invoked; a placeholder narrative is returned instead.
+ */
+ @Override
+ public SessionResponse createSession(final CreateSessionRequest request) {
+ String sessionId = UUID.randomUUID().toString();
+
+ SessionResponse session = new SessionResponse(
+ sessionId,
+ request.getModel(),
+ request.getLanguage() != null ? request.getLanguage() : "en",
+ request.getSafetyLevel() != null
+ ? request.getSafetyLevel().value()
+ : CreateSessionRequest.SafetyLevelEnum.STANDARD.value(),
+ 0
+ );
+
+ // TODO: Invoke OllamaClient two-call pattern (narrative + state extraction)
+ // to produce a real opening scene and initial state.
+ session.setNarrative("Session initialised. Ollama integration pending.");
+
+ sessions.put(sessionId, session);
+ return session;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Optional Increments the turn counter and returns a stub {@link TurnResponse}.
+ * The Ollama two-call pattern is not yet invoked.
+ */
+ @Override
+ public Optional Delegates to {@link ModelService#listModels()} and returns the result
+ * directly.
+ */
+ @Override
+ public ModelListResponse listModels() {
+ return modelService.listModels();
+ }
+}
+
diff --git a/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java b/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java
new file mode 100644
index 0000000..14367ea
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/fascade/NotFoundExceptionMapper.java
@@ -0,0 +1,33 @@
+package de.neitzel.roleplay.fascade;
+
+import de.neitzel.roleplay.fascade.model.ErrorResponse;
+import jakarta.ws.rs.NotFoundException;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * JAX-RS {@link ExceptionMapper} that converts a {@link NotFoundException}
+ * into a well-formed JSON {@link ErrorResponse} with HTTP status 404.
+ */
+@Provider
+public class NotFoundExceptionMapper implements ExceptionMapper Delegates to {@link SessionService#createSession(CreateSessionRequest)}.
+ * The HTTP 201 status is set by annotating with {@link jakarta.ws.rs.core.Response}
+ * in the generated interface; the resource simply returns the body.
+ */
+ @Override
+ public SessionResponse createSession(final CreateSessionRequest createSessionRequest) {
+ return sessionService.createSession(createSessionRequest);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Returns 404 if the session is not found.
+ */
+ @Override
+ public SessionResponse getSession(final String sessionId) {
+ return sessionService.getSession(sessionId)
+ .orElseThrow(() -> new NotFoundException(
+ "No session found with id: " + sessionId));
+ }
+}
+
diff --git a/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java b/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java
new file mode 100644
index 0000000..342b533
--- /dev/null
+++ b/src/main/java/de/neitzel/roleplay/fascade/TurnResource.java
@@ -0,0 +1,46 @@
+package de.neitzel.roleplay.fascade;
+
+import de.neitzel.roleplay.business.SessionService;
+import de.neitzel.roleplay.fascade.api.TurnsApi;
+import de.neitzel.roleplay.fascade.model.TurnRequest;
+import de.neitzel.roleplay.fascade.model.TurnResponse;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.NotFoundException;
+
+/**
+ * JAX-RS resource that implements the {@link TurnsApi} interface generated
+ * from the OpenAPI specification. Delegates all business logic to
+ * {@link SessionService}.
+ */
+@ApplicationScoped
+public class TurnResource implements TurnsApi {
+
+ /**
+ * Business service providing turn submission and processing.
+ */
+ private final SessionService sessionService;
+
+ /**
+ * Creates a new {@code TurnResource}.
+ *
+ * @param sessionService the session management service
+ */
+ @Inject
+ public TurnResource(final SessionService sessionService) {
+ this.sessionService = sessionService;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Returns 404 if the session is not found.
+ */
+ @Override
+ public TurnResponse submitTurn(final String sessionId, final TurnRequest turnRequest) {
+ return sessionService.submitTurn(sessionId, turnRequest)
+ .orElseThrow(() -> new NotFoundException(
+ "No session found with id: " + sessionId));
+ }
+}
+
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 9275b82..e35eb86 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -9,4 +9,5 @@ quarkus:
url: http://debian:11434
connect-timeout: 5000
read-timeout: 120000
-
+ http:
+ root-path: /api/v1
diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml
new file mode 100644
index 0000000..c2f8158
--- /dev/null
+++ b/src/main/resources/openapi-roleplay-public-v1.yml
@@ -0,0 +1,623 @@
+openapi: 3.0.3
+info:
+ title: RolePlay API
+ description: |
+ Public REST API for the RolePlay service. Provides endpoints to discover
+ available AI models, manage role-play sessions, and submit user turns.
+ version: 1.0.0
+ contact:
+ name: RolePlay Project
+
+servers:
+ - url: /api/v1
+ description: Default server base path
+
+tags:
+ - name: models
+ description: Discover available Ollama models
+ - name: sessions
+ description: Manage role-play sessions
+ - name: turns
+ description: Submit user actions within a session
+
+paths:
+
+ /models:
+ get:
+ operationId: listModels
+ summary: List available AI models
+ description: Returns all Ollama models currently installed on the backend server.
+ tags:
+ - models
+ responses:
+ "200":
+ description: Successful response with a list of available models.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ModelListResponse'
+ "500":
+ description: Server error while contacting Ollama.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /sessions:
+ post:
+ operationId: createSession
+ summary: Start a new role-play session
+ description: |
+ Initialises a new session with the given model and optional scenario
+ setup. The backend performs the two-call Ollama pattern (narrative +
+ state extraction) and returns the opening scene.
+ tags:
+ - sessions
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateSessionRequest'
+ responses:
+ "201":
+ description: Session created; opening narrative and initial state returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionResponse'
+ "400":
+ description: Invalid request body.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ "500":
+ description: Server error during session initialisation.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /sessions/{sessionId}:
+ get:
+ operationId: getSession
+ summary: Retrieve a session
+ description: Returns the full current state of an existing role-play session.
+ tags:
+ - sessions
+ parameters:
+ - $ref: '#/components/parameters/SessionId'
+ responses:
+ "200":
+ description: Session found and returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionResponse'
+ "404":
+ description: Session not found.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /sessions/{sessionId}/turns:
+ post:
+ operationId: submitTurn
+ summary: Submit a user action for the next turn
+ description: |
+ Submits the user's action (speech, physical action, or a chosen
+ suggestion) and an optional narrative recommendation. The backend
+ executes the two-call Ollama pattern and returns the resulting
+ narrative and updated suggestions.
+ tags:
+ - turns
+ parameters:
+ - $ref: '#/components/parameters/SessionId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TurnRequest'
+ responses:
+ "200":
+ description: Turn processed; narrative and new suggestions returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TurnResponse'
+ "400":
+ description: Invalid request body.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ "404":
+ description: Session not found.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ "500":
+ description: Server error during turn processing.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+components:
+
+ parameters:
+ SessionId:
+ name: sessionId
+ in: path
+ required: true
+ description: Unique identifier of the role-play session.
+ schema:
+ type: string
+
+ schemas:
+
+ # ─── Model discovery ──────────────────────────────────────────────────────
+
+ ModelInfo:
+ type: object
+ description: Metadata about a single Ollama model.
+ properties:
+ name:
+ type: string
+ description: Technical model identifier (e.g. "llama3:latest").
+ example: llama3:latest
+ displayName:
+ type: string
+ description: Human-readable model name derived from the identifier.
+ example: Llama 3 (latest)
+ size:
+ type: integer
+ format: int64
+ description: Model size in bytes.
+ example: 4000000000
+ family:
+ type: string
+ description: Model family (e.g. "llama").
+ example: llama
+ required:
+ - name
+
+ ModelListResponse:
+ type: object
+ description: Response containing all available models.
+ properties:
+ models:
+ type: array
+ items:
+ $ref: '#/components/schemas/ModelInfo'
+ required:
+ - models
+
+ # ─── Session ──────────────────────────────────────────────────────────────
+
+ CreateSessionRequest:
+ type: object
+ description: Request body for creating a new role-play session.
+ properties:
+ model:
+ type: string
+ description: The Ollama model name to use for this session.
+ example: llama3:latest
+ language:
+ type: string
+ description: BCP-47 language tag for the session (default "en").
+ example: en
+ default: en
+ safetyLevel:
+ type: string
+ description: Content safety level.
+ enum:
+ - standard
+ - strict
+ default: standard
+ scenario:
+ $ref: '#/components/schemas/ScenarioSetup'
+ required:
+ - model
+
+ ScenarioSetup:
+ type: object
+ description: |
+ Optional initial scenario definition. If omitted the backend uses
+ built-in defaults.
+ properties:
+ setting:
+ type: string
+ description: Place, time, and atmosphere description.
+ example: "A fog-covered harbour at dawn, 1923"
+ initialConflict:
+ type: string
+ description: The hook or starting conflict.
+ example: "Strange noises from the cargo hold"
+ userCharacter:
+ $ref: '#/components/schemas/CharacterDefinition'
+ aiCharacters:
+ type: array
+ items:
+ $ref: '#/components/schemas/CharacterDefinition'
+
+ CharacterDefinition:
+ type: object
+ description: Definition of a character for session initialisation.
+ properties:
+ id:
+ type: string
+ description: Unique character identifier.
+ example: captain_morgan
+ name:
+ type: string
+ description: Display name.
+ example: Captain Morgan
+ role:
+ type: string
+ description: Narrative role.
+ example: ship captain
+ backstory:
+ type: string
+ description: Character backstory (AI characters only).
+ personalityTraits:
+ type: array
+ items:
+ type: string
+ description: Personality descriptors (AI characters only).
+ speakingStyle:
+ type: string
+ description: Tone and speech quirks (AI characters only).
+ goals:
+ type: array
+ items:
+ type: string
+ description: Short- and long-term goals (AI characters only).
+ required:
+ - id
+ - name
+ - role
+
+ SessionResponse:
+ type: object
+ description: Current state of a role-play session.
+ properties:
+ sessionId:
+ type: string
+ description: Unique session identifier.
+ example: "550e8400-e29b-41d4-a716-446655440000"
+ model:
+ type: string
+ description: Ollama model in use.
+ example: llama3:latest
+ language:
+ type: string
+ description: Session language.
+ example: en
+ safetyLevel:
+ type: string
+ description: Content safety level.
+ example: standard
+ narrative:
+ type: string
+ description: Most recent narrative text (opening scene or latest turn).
+ situation:
+ $ref: '#/components/schemas/SituationState'
+ characters:
+ type: array
+ items:
+ $ref: '#/components/schemas/CharacterState'
+ suggestions:
+ type: array
+ items:
+ $ref: '#/components/schemas/Suggestion'
+ turnNumber:
+ type: integer
+ description: Current turn counter (0 = just initialised).
+ example: 0
+ required:
+ - sessionId
+ - model
+ - language
+ - safetyLevel
+ - turnNumber
+
+ SituationState:
+ type: object
+ description: Current world and scene state within a session.
+ properties:
+ setting:
+ type: string
+ description: Place, time, and atmosphere.
+ currentScene:
+ type: string
+ description: What is currently in focus for the scene.
+ timeline:
+ type: array
+ items:
+ type: string
+ description: Ordered list of important events so far.
+ openThreads:
+ type: array
+ items:
+ type: string
+ description: Unresolved plot lines, mysteries, or quests.
+ externalPressures:
+ type: array
+ items:
+ type: string
+ description: Time-sensitive pressures such as deadlines or approaching threats.
+ worldStateFlags:
+ type: object
+ additionalProperties: true
+ description: Boolean or tag-style flags representing key world states.
+
+ CharacterState:
+ type: object
+ description: Current dynamic state of a single character within a session.
+ properties:
+ id:
+ type: string
+ description: Unique character identifier.
+ name:
+ type: string
+ description: Display name.
+ role:
+ type: string
+ description: Narrative role.
+ isUserCharacter:
+ type: boolean
+ description: Whether this is the user-controlled character.
+ currentMood:
+ type: string
+ description: Current emotional state.
+ status:
+ type: string
+ description: Physical or narrative status.
+ knowledge:
+ type: array
+ items:
+ type: string
+ description: Facts the character has learned.
+ relationships:
+ type: object
+ additionalProperties:
+ type: string
+ description: Map from other character IDs to relationship descriptions.
+ recentActionsSummary:
+ type: array
+ items:
+ type: string
+ description: Brief summary of recent behaviour.
+ required:
+ - id
+ - name
+ - isUserCharacter
+
+ # ─── Turn ─────────────────────────────────────────────────────────────────
+
+ TurnRequest:
+ type: object
+ description: User action submitted for the next story turn.
+ properties:
+ userAction:
+ $ref: '#/components/schemas/UserActionRequest'
+ recommendation:
+ $ref: '#/components/schemas/RecommendationRequest'
+ required:
+ - userAction
+
+ UserActionRequest:
+ type: object
+ description: The action the user performed this turn.
+ properties:
+ type:
+ type: string
+ description: Type of user action.
+ enum:
+ - speech
+ - action
+ - choice
+ content:
+ type: string
+ description: Free-text content of the action or dialogue.
+ selectedSuggestionId:
+ type: string
+ description: If the user chose a suggestion, its ID; otherwise omitted.
+ required:
+ - type
+
+ RecommendationRequest:
+ type: object
+ description: |
+ Optional narrative guidance the user attaches to a turn. Treated as a
+ soft hint by the model.
+ properties:
+ desiredTone:
+ type: string
+ description: Desired mood for the next beat (e.g. "tense", "humorous").
+ example: tense
+ preferredDirection:
+ type: string
+ description: Free-text direction hint.
+ example: "The captain should reveal a partial truth"
+ focusCharacters:
+ type: array
+ items:
+ type: string
+ description: Character IDs that should play a prominent role.
+
+ TurnResponse:
+ type: object
+ description: Result of processing a user turn.
+ properties:
+ turnNumber:
+ type: integer
+ description: The turn number that was just completed.
+ example: 1
+ narrative:
+ type: string
+ description: Free-form narrative text produced by the model for this turn.
+ characterResponses:
+ type: array
+ items:
+ $ref: '#/components/schemas/CharacterResponseItem'
+ description: Per-character breakdown of what each AI character said or did.
+ updatedSituation:
+ $ref: '#/components/schemas/SituationUpdate'
+ updatedCharacters:
+ type: array
+ items:
+ $ref: '#/components/schemas/CharacterUpdate'
+ suggestions:
+ type: array
+ items:
+ $ref: '#/components/schemas/Suggestion'
+ required:
+ - turnNumber
+ - narrative
+
+ CharacterResponseItem:
+ type: object
+ description: What a single AI character said or did during a turn.
+ properties:
+ characterId:
+ type: string
+ description: ID of the character who acted.
+ type:
+ type: string
+ description: Type of response.
+ enum:
+ - speech
+ - action
+ - reaction
+ content:
+ type: string
+ description: Quoted dialogue if type is "speech", otherwise null.
+ action:
+ type: string
+ description: Description of physical action, or null if none.
+ moodAfter:
+ type: string
+ description: The character's mood after this turn.
+ required:
+ - characterId
+ - type
+
+ SituationUpdate:
+ type: object
+ description: Diff-style situation changes for a single turn.
+ properties:
+ currentScene:
+ type: string
+ description: Updated scene description.
+ newTimelineEntries:
+ type: array
+ items:
+ type: string
+ description: New events to append to the timeline.
+ openThreadsChanges:
+ $ref: '#/components/schemas/OpenThreadsChanges'
+ worldStateFlags:
+ type: object
+ additionalProperties: true
+ description: Current world state flags after this turn.
+
+ OpenThreadsChanges:
+ type: object
+ description: Diff-style changes to open narrative threads.
+ properties:
+ added:
+ type: array
+ items:
+ type: string
+ description: New threads introduced this turn.
+ resolved:
+ type: array
+ items:
+ type: string
+ description: Threads that were resolved or closed this turn.
+
+ CharacterUpdate:
+ type: object
+ description: Diff-style character state changes for a single character.
+ properties:
+ characterId:
+ type: string
+ description: ID of the character whose state changed.
+ currentMood:
+ type: string
+ description: The character's mood after this turn.
+ knowledgeGained:
+ type: array
+ items:
+ type: string
+ description: New facts the character learned this turn.
+ relationshipChanges:
+ type: object
+ additionalProperties:
+ type: string
+ description: Changed relationships (character ID to new descriptor).
+ required:
+ - characterId
+
+ Suggestion:
+ type: object
+ description: A suggestion for what could happen next in the story.
+ properties:
+ id:
+ type: string
+ description: Unique suggestion identifier.
+ type:
+ type: string
+ description: Category of the suggestion.
+ enum:
+ - player_action
+ - world_event
+ - npc_action
+ - twist
+ title:
+ type: string
+ description: Short human-readable label.
+ description:
+ type: string
+ description: One- or two-sentence explanation.
+ consequences:
+ type: array
+ items:
+ type: string
+ description: Brief list of possible outcomes.
+ riskLevel:
+ type: string
+ description: How risky this option is for the player.
+ enum:
+ - low
+ - medium
+ - high
+ required:
+ - id
+ - type
+ - title
+
+ # ─── Error ────────────────────────────────────────────────────────────────
+
+ ErrorResponse:
+ type: object
+ description: Standard error response body.
+ properties:
+ code:
+ type: string
+ description: Machine-readable error code.
+ example: SESSION_NOT_FOUND
+ message:
+ type: string
+ description: Human-readable error description.
+ example: "No session found with the given ID"
+ required:
+ - code
+ - message
+
diff --git a/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java
new file mode 100644
index 0000000..e1447f4
--- /dev/null
+++ b/src/test/java/de/neitzel/roleplay/business/InMemorySessionServiceTest.java
@@ -0,0 +1,168 @@
+package de.neitzel.roleplay.business;
+
+import de.neitzel.roleplay.fascade.model.CreateSessionRequest;
+import de.neitzel.roleplay.fascade.model.SessionResponse;
+import de.neitzel.roleplay.fascade.model.TurnRequest;
+import de.neitzel.roleplay.fascade.model.TurnResponse;
+import de.neitzel.roleplay.fascade.model.UserActionRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link InMemorySessionService}.
+ */
+class InMemorySessionServiceTest {
+
+ /**
+ * Instance under test – no CDI dependencies to mock.
+ */
+ private InMemorySessionService sessionService;
+
+ /**
+ * Creates a fresh service instance before each test to ensure isolation.
+ */
+ @BeforeEach
+ void setUp() {
+ sessionService = new InMemorySessionService();
+ }
+
+ /**
+ * Verifies that creating a session returns a response with a non-null ID,
+ * the correct model name, and turn number 0.
+ */
+ @Test
+ void createSessionReturnsSessionWithCorrectMetadata() {
+ // Arrange
+ CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
+ request.setLanguage("de");
+
+ // Act
+ SessionResponse response = sessionService.createSession(request);
+
+ // Assert
+ assertNotNull(response.getSessionId());
+ assertFalse(response.getSessionId().isBlank());
+ assertEquals("llama3:latest", response.getModel());
+ assertEquals("de", response.getLanguage());
+ assertEquals(0, response.getTurnNumber());
+ }
+
+ /**
+ * Verifies that a created session can be retrieved by its ID.
+ */
+ @Test
+ void getSessionReturnsPreviouslyCreatedSession() {
+ // Arrange
+ CreateSessionRequest request = new CreateSessionRequest("llama3:latest");
+ SessionResponse created = sessionService.createSession(request);
+
+ // Act
+ Optional