commit eed9d1da662e90477cf7da0ec9469a40922bef6c Author: Konrad Neitzel Date: Fri Feb 20 13:41:24 2026 +0100 Add initial project structure and configuration for RolePlay service - Create .gitignore to exclude target and IDE files - Add application.yml for Quarkus configuration - Implement package-info.java for business logic, facade, data, and common packages - Define core classes for handling user actions, character states, and narrative suggestions - Set up Ollama API client for narrative generation and state updates - Include unit tests for greeting service and serialization of context diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3089b0e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,70 @@ +# GitHub Copilot Custom 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 base package. +- use liquidbase for database migrations, with migration scripts in `src/main/resources/db/migration`. + +## Architecture and package rules + +- Keep startup/bootstrap classes in `de.neitzel.roleplay`. +- Place business logic in `de.neitzel.roleplay.business`. +- Place shared utilities and cross-cutting types in `de.neitzel.roleplay.common`. +- Place persistence/data access in `de.neitzel.roleplay.data`. +- Place external-facing facades in `de.neitzel.roleplay.fascade`. +- Prefer clear package boundaries and avoid circular dependencies between packages. + +## Coding standards + +- Use Lombok to reduce boilerplate where appropriate. +- Favor immutability; prefer `final` fields and constructor injection. +- Use Quarkus idioms and avoid heavyweight frameworks. +- Keep methods small and focused; one responsibility per class. +- Use Java 21 language features when they improve clarity. +- Add concise comments only for non-obvious logic. +- All classes, fields, and methods (even private) should have JavaDoc comments, even if brief, to explain their purpose + and usage. + +## Testing requirements + +- Create unit tests for new or changed logic. +- Use JUnit 5 and Mockito for unit tests. +- Follow Arrange-Act-Assert style and keep tests deterministic. +- Mock external dependencies; avoid integration tests unless asked. + +## Maven and dependencies + +- Target Java 21 toolchain in Maven. +- Use the latest stable Quarkus platform BOM. +- Keep dependencies minimal; do not add libraries without necessity. +- Keep versions of dependencies up to date, especially for security patches. +- Versions of plugins and dependencies should be stored inside the `properties` section of the `pom.xml` for easy + management. +- Use the `quarkus-maven-plugin` for building and running the application, and ensure it is configured correctly in the + `pom.xml`. +- Use the `maven-surefire-plugin` for running unit tests, and ensure it is configured to use JUnit 5. +- Use the `maven-compiler-plugin` to set the Java source and target versions to 21, and ensure it is configured + correctly in the `pom.xml`. + +## Output expectations for Copilot + +- Provide complete, compilable code snippets. +- Include package declarations and imports. +- Prefer Quarkus-friendly APIs and annotations. +- When adding code, update or add unit tests. + +## Do and don’t + +- Do: keep code in the correct package. +- Do: add tests for business logic. +- Don’t: put startup code outside the base package. +- Don’t: introduce unnecessary abstraction layers. + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bba7b53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target/ +/.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..28b46b6 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# RolePlay + +Minimal Quarkus (Java 21) project scaffold for the RolePlay service. + +## Structure + +- `src/main/java/de/neitzel/roleplay`: startup and base package +- `src/main/java/de/neitzel/roleplay/business`: business logic +- `src/main/java/de/neitzel/roleplay/common`: shared utilities +- `src/main/java/de/neitzel/roleplay/data`: data access +- `src/main/java/de/neitzel/roleplay/fascade`: external facades +- `src/main/resources/db/migration`: Liquibase changelog location + +## Build and test + +```zsh +mvn test +``` + diff --git a/ROLEPLAY_CONCEPT.md b/ROLEPLAY_CONCEPT.md new file mode 100644 index 0000000..1d8e1da --- /dev/null +++ b/ROLEPLAY_CONCEPT.md @@ -0,0 +1,634 @@ +## Role Playing App – Concept and Specification + +### 1. Purpose and Scope + +This document describes the concept and functional specification of a role playing application with: + +- **AI backend**: Ollama (local LLM server). +- **Application backend**: HTTP API server that: + - Discovers available Ollama models. + - Orchestrates role play sessions. + - Manages character and situation state. + - Calls Ollama to generate narrative, dialogue, evolving state, and suggestions. + +The primary goal is an immersive, stateful role play between: + +- A **user-controlled character**. +- One or more **AI-controlled characters** powered by Ollama. + +### 2. Core Concepts + +#### 2.1 Session + +A **session** represents one ongoing story. + +- **Fields** + - `session_id` + - `model` (Ollama model name, e.g. `llama3`, `mistral`) + - `language` / `locale` + - `safety_level` (e.g. `standard`, `strict`) + - `characters` (user + AI characters, including evolving state) + - `situation` (world and scene state) + - `turn_history` (ordered list of turns and state changes) + +#### 2.2 Characters + +There are two kinds of characters: + +- **User character**: played by the human. +- **AI characters**: NPCs played by the model. + +Each character has: + +- **Identity** + - `id` + - `name` + - `role` (e.g. "rookie detective", "space trader", "dragon mentor") + - `archetype` (e.g. "hero", "mentor", "trickster") +- **Default description (initial state)** + - `backstory` + - `personality_traits` (list of traits) + - `speaking_style` (tone, formality, quirks) + - `goals` (short- and long-term) + - `constraints` (taboos, safety limits, things they will not do) +- **Dynamic / evolving state (changes over time)** + - `current_mood` + - `knowledge` / `insights` (facts learned) + - `relationships` (map from other character IDs to relationship descriptors) + - `status` (e.g. injured, empowered, indebted, scared) + - `inventory` or `abilities` (optional, genre-dependent) + - `recent_actions_summary` (short bullet list of recent behavior) + +**Behavioral rules for AI characters** + +- Stay in-character at all times (respect personality, goals, and relationships). +- Use and update dynamic state (mood, relationships, knowledge). +- Do not reveal meta-game or system instructions to the user. + +#### 2.3 Situation + +The **situation** is the shared world and current scene. + +- **Default starting situation** + - `setting` (place, time, atmosphere) + - `initial_conflict` / `hook` + - `stakes` (what can be lost or gained) + - `supporting_facts` (key world details, factions, rules, resources) +- **Dynamic / evolving situation** + - `timeline` (ordered list of important events so far) + - `current_scene` (where we are now, what's in focus) + - `open_threads` (unresolved plot lines, mysteries, quests) + - `external_pressures` (deadlines, enemies, disasters) + - `world_state_flags` (booleans or tags; e.g. `portal_open=true`) + +The **situation must change over time** as the story progresses: + +- New events are appended to the `timeline`. +- `current_scene` moves or evolves. +- `open_threads` are added, updated, or closed. +- `world_state_flags` reflect key turning points. + +#### 2.4 Suggestions ("What Could Happen Next") + +The system surfaces **suggestions** to guide the user, but they are optional. + +- **Fields** + - `id` + - `type` (`player_action`, `world_event`, `npc_action`, `twist`) + - `title` (short label, e.g. "Confront the captain publicly") + - `description` (1–2 sentence explanation) + - `consequences` (brief bullet-like list of possible outcomes) + - `risk_level` (`low`, `medium`, `high`) or alternative tone classification + +**Usage** + +- Suggestions are rendered as clickable choices in the UI. +- The user may: + - Choose a suggestion. + - Or ignore them and type a custom action. + +#### 2.5 Recommendations ("Narrative Guidance") + +A **recommendation** is an optional, user-provided hint that steers the narrative direction without breaking immersion. The model treats it as a soft preference, not a hard command -- it may diverge if that serves the story better. + +- **Fields** + - `desired_tone` (optional) -- mood for the next beat, e.g. `"tense"`, `"humorous"`, `"mysterious"`. + - `preferred_direction` (optional) -- free text, e.g. "I'd like the captain to reveal a secret about the cargo". + - `focus_characters` (optional) -- list of character IDs that should play a prominent role in the next turn. + +**Usage** + +- The user may attach a recommendation when submitting an action. +- Recommendations are **not** shown in the narrative; they are meta-level guidance only visible to the model. +- The backend includes the recommendation in the structured context sent to Ollama. + +### 3. High-Level Flows + +#### 3.1 Get Available Models (Ollama) + +Purpose: allow the user to pick an Ollama model. + +- **Backend -> Ollama** + - Method: `GET` + - URL: `/api/tags` +- **Backend behavior** + - Map Ollama's response into an internal list of models: + - `name` + - `display_name` + - relevant metadata (size, family, etc., if needed) + - Expose to frontend via e.g. `/models` endpoint. + +#### 3.2 Start a New Role Play Session + +Flow: + +1. User selects model and scenario template (or accepts defaults). +2. Backend initializes **default characters** and **default situation**. +3. Backend builds a structured JSON context from the defaults. +4. **Call 1 -- Narrative**: Backend sends the context to Ollama without a format constraint. Ollama returns a free-form opening scene (narrative + dialogue). +5. **Call 2 -- State Update**: Backend sends the same context plus the narrative from Call 1 to Ollama with `"format": "json"`. Ollama returns structured JSON with enriched characters, initial situation state, and suggestions. +6. Backend stores the returned state and suggestions as the first turn. + +#### 3.3 Play a Turn + +Flow: + +1. User performs an action: + - Types in-character text (speech or action). + - Or clicks a suggestion. + - Optionally attaches a **recommendation** (desired tone, preferred direction, focus characters). +2. Backend builds a structured JSON context object containing: + - Current situation (scene, timeline, open threads, world state flags). + - Current characters (identity + dynamic state + memories). + - User's latest action (typed text or selected suggestion, with action type). + - User's recommendation (if provided). + - Recent history summary (last N turns or condensed summary). +3. **Call 1 -- Narrative**: Backend sends the structured context to Ollama (no format constraint). Ollama returns free-form narrative and dialogue (2-5 paragraphs). +4. **Call 2 -- State Update**: Backend sends the same structured context plus the narrative from Call 1 to Ollama with `"format": "json"`. Ollama returns a JSON object containing: + - Per-character responses (who said/did what). + - Updated situation (scene changes, new timeline entries, thread changes). + - Updated character states (mood, knowledge, relationships). + - 3-5 new suggestions. +5. Backend merges the state updates into the session, stores the turn, and returns the narrative and suggestions to the user. + +### 4. Ollama Interaction Design + +The application backend communicates with Ollama via HTTP. The Ollama server is reachable at its **default slot** (`http://debian:11434`). Every turn uses **two sequential calls** to separate creative writing from structured data extraction. + +The main operations are: + +1. **Model discovery**. +2. **Session initialization** (two calls: narrative + state). +3. **Turn-based continuation** (two calls: narrative + state). + +#### 4.1 Get Available Models + +- **Request** + - Method: `GET` + - URL: `/api/tags` +- **Response** + - Ollama returns list of installed models with names and metadata. +- **Usage** + - Backend converts this into a frontend-friendly list to let the user select a model. + +#### 4.2 Structured Input Schema + +Both Ollama calls in a turn receive a **structured JSON context** as the user message content. This ensures clear separation of concerns and makes the input machine-parseable. + +**Turn context (used for both Call 1 and Call 2):** + +```json +{ + "current_situation": { + "setting": "A fog-covered harbor at dawn, 1923", + "current_scene": "The captain's quarters aboard the Redwind", + "timeline": [ + "Day 1: The player boarded the Redwind as a deckhand.", + "Day 2: Strange noises heard from the cargo hold." + ], + "open_threads": [ + "What is the secret cargo?", + "Why did the previous deckhand disappear?" + ], + "external_pressures": ["Storm approaching within 24 hours"], + "world_state_flags": { + "cargo_hold_locked": true, + "captain_suspicious": false + } + }, + "characters": { + "user_character": { + "id": "player", + "name": "Sam Harlow", + "role": "rookie deckhand", + "current_mood": "nervous", + "knowledge": ["Heard noises from cargo hold", "First mate avoids eye contact"], + "relationships": { + "captain_morgan": "wary respect", + "first_mate_jones": "distrust" + }, + "status": "healthy", + "recent_actions_summary": ["Asked the cook about the previous deckhand"] + }, + "ai_characters": [ + { + "id": "captain_morgan", + "name": "Captain Morgan", + "role": "ship captain", + "personality_traits": ["authoritative", "secretive", "pragmatic"], + "speaking_style": "terse, clipped sentences, avoids direct answers", + "goals": ["Deliver the cargo on time", "Keep the crew in line"], + "current_mood": "guarded", + "knowledge": ["Knows what the cargo is", "Knows about the storm"], + "relationships": { + "player": "neutral, watching closely" + } + } + ] + }, + "user_action": { + "type": "action", + "content": "I walk up to the captain and demand answers about the cargo.", + "selected_suggestion_id": null + }, + "recommendation": { + "desired_tone": "tense", + "preferred_direction": "The captain reveals a partial truth but raises more questions", + "focus_characters": ["captain_morgan"] + }, + "recent_history_summary": "Turn 1: Sam boarded the ship and met the crew. Turn 2: Strange noises at night; the cook hinted the previous deckhand 'asked too many questions'." +} +``` + +**Session initialization context** uses the same structure but with default/initial values for all fields, `user_action` set to `null`, and `recommendation` set to `null`. + +**Notes:** +- `user_action.type` distinguishes between `"speech"` (dialogue), `"action"` (physical action), and `"choice"` (selected a suggestion). +- `recommendation` is entirely optional; the field may be `null` or absent. +- `recent_history_summary` is a backend-managed condensation of `turn_history` to control context window size. + +#### 4.3 Session Initialization (Two Calls) + +**Goal:** from default character and situation descriptions, produce an opening scene and initial state. + +##### Call 1 -- Opening Narrative + +- **Request** + - Method: `POST` + - URL: `/api/chat` + - Body: + +```json +{ + "model": "", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are a role playing game narrator. Your task is to write an immersive opening scene for a new story.\n\nRules:\n1. Write from the user character's perspective.\n2. Introduce the setting, atmosphere, and at least one AI character.\n3. At least one AI character must speak.\n4. Write 2-4 paragraphs of vivid, immersive prose.\n5. Do NOT include any JSON, metadata, or out-of-character commentary." + }, + { + "role": "user", + "content": "{{STRUCTURED_CONTEXT_JSON}}" + } + ] +} +``` + +- **Response**: Free-form text (the opening narrative). + +##### Call 2 -- Initial State Extraction + +- **Request** + - Method: `POST` + - URL: `/api/chat` + - Body: + +```json +{ + "model": "", + "stream": false, + "format": "json", + "messages": [ + { + "role": "system", + "content": "You are a role playing game engine. Given the story context and a narrative scene, extract structured state updates as JSON.\n\nYou must return a JSON object matching the schema described below. Do NOT include any text outside the JSON object.\n\nSchema:\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}" + }, + { + "role": "user", + "content": "Story context:\n{{STRUCTURED_CONTEXT_JSON}}\n\nNarrative that was just generated:\n{{NARRATIVE_FROM_CALL_1}}\n\nBased on the narrative above, produce the JSON state update. Include 3-5 suggestions for what the user could do next." + } + ] +} +``` + +- **Response**: JSON object matching the state update schema (see Section 4.5). + +- **Backend behavior** + - Store the narrative from Call 1 as the display text. + - Deserialize the JSON from Call 2 directly (no tag parsing needed). + - Store `updated_characters`, `updated_situation`, and `suggestions` as the initial session state. + +#### 4.4 Turn Continuation (Two Calls) + +**Goal:** continue the story one turn at a time, producing narrative and updating state. + +##### Call 1 -- Narrative Continuation + +- **Request** + - Method: `POST` + - URL: `/api/chat` + - Body: + +```json +{ + "model": "", + "stream": false, + "messages": [ + { + "role": "system", + "content": "You are a role playing game narrator continuing an ongoing story.\n\nRules:\n1. Stay strictly in-character for all AI-controlled characters.\n2. Respect and build on the provided character and situation state.\n3. Do NOT contradict established facts unless the context explicitly says reality has changed.\n4. Keep the text immersive and avoid meta commentary.\n5. At least one AI character must speak or act.\n6. If a recommendation is provided, treat it as a soft guideline for the scene's direction.\n\nStyle:\n- Mix short descriptive narration with dialogue.\n- Keep responses roughly 2-5 paragraphs." + }, + { + "role": "user", + "content": "{{STRUCTURED_CONTEXT_JSON}}" + } + ] +} +``` + +- **Response**: Free-form text (narrative continuation with dialogue). + +##### Call 2 -- State Update + +- **Request** + - Method: `POST` + - URL: `/api/chat` + - Body: + +```json +{ + "model": "", + "stream": false, + "format": "json", + "messages": [ + { + "role": "system", + "content": "You are a role playing game engine. Given the story context and the narrative that just occurred, extract structured state updates as JSON.\n\nYou must return a JSON object matching the schema described below. Do NOT include any text outside the JSON object.\n\nSchema:\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}" + }, + { + "role": "user", + "content": "Story context:\n{{STRUCTURED_CONTEXT_JSON}}\n\nNarrative that was just generated:\n{{NARRATIVE_FROM_CALL_1}}\n\nBased on the narrative above, produce the JSON state update. Include 3-5 suggestions for what the user could do or what might happen next." + } + ] +} +``` + +- **Response**: JSON object matching the state update schema (see Section 4.5). + +- **Backend behavior** + - Display the narrative from Call 1 to the user. + - Deserialize the JSON from Call 2 directly via Jackson (no tag parsing). + - Apply diff-style updates from `updated_characters` and `updated_situation` to the stored session state. + - Replace the session's current `suggestions` with the new ones. + - Append the turn to `turn_history`. + +#### 4.5 State Update Output Schema + +Both Call 2 variants (initialization and continuation) return the same JSON structure. The `"format": "json"` parameter on the Ollama request guarantees the response is valid JSON. + +```json +{ + "responses": [ + { + "character_id": "captain_morgan", + "type": "speech", + "content": "You want answers? Fine. But you won't like what you hear.", + "action": "slams fist on the table", + "mood_after": "angry" + }, + { + "character_id": "first_mate_jones", + "type": "action", + "content": null, + "action": "quietly steps between the player and the door", + "mood_after": "cautious" + } + ], + "updated_situation": { + "current_scene": "Captain's quarters, tension rising. The storm outside mirrors the confrontation within.", + "new_timeline_entries": [ + "Sam confronted the captain about the cargo.", + "The captain admitted to carrying something dangerous." + ], + "open_threads_changes": { + "added": ["What exactly is the dangerous cargo?"], + "resolved": [] + }, + "world_state_flags": { + "captain_suspicious": true, + "cargo_hold_locked": true + } + }, + "updated_characters": [ + { + "character_id": "captain_morgan", + "current_mood": "angry", + "knowledge_gained": ["Player is not going to stay quiet"], + "relationship_changes": { + "player": "hostile" + } + }, + { + "character_id": "first_mate_jones", + "current_mood": "cautious", + "knowledge_gained": [], + "relationship_changes": { + "player": "wary" + } + } + ], + "suggestions": [ + { + "id": "s1", + "type": "player_action", + "title": "Press the captain harder", + "description": "Demand to see the cargo hold yourself, refusing to back down.", + "consequences": ["May provoke violence", "Could earn grudging respect"], + "risk_level": "high" + }, + { + "id": "s2", + "type": "player_action", + "title": "Back down and regroup", + "description": "Apologize and leave, but plan to sneak into the cargo hold at night.", + "consequences": ["Captain lowers guard", "Risk of being caught later"], + "risk_level": "medium" + }, + { + "id": "s3", + "type": "npc_action", + "title": "The first mate follows you", + "description": "After you leave, the first mate corners you with a warning -- or an offer.", + "consequences": ["New information", "Possible ally or deeper trap"], + "risk_level": "medium" + }, + { + "id": "s4", + "type": "world_event", + "title": "The storm hits early", + "description": "A sudden squall forces all hands on deck, interrupting the confrontation.", + "consequences": ["Chaos creates opportunity", "Danger on deck"], + "risk_level": "high" + } + ] +} +``` + +**Field definitions:** + +| Field | Description | +|---|---| +| `responses[]` | Per-character breakdown of what each AI character said or did in the narrative. | +| `responses[].type` | `"speech"` (dialogue), `"action"` (physical), or `"reaction"` (emotional/internal). | +| `responses[].content` | Quoted dialogue if type is `"speech"`, otherwise `null`. | +| `responses[].action` | Description of physical action, or `null` if none. | +| `responses[].mood_after` | The character's mood after this turn. | +| `updated_situation` | Diff-style situation changes (not a full replacement). | +| `updated_situation.new_timeline_entries` | Events to append to the timeline. | +| `updated_situation.open_threads_changes` | Threads added or resolved this turn. | +| `updated_characters[]` | Diff-style character changes (only fields that changed). | +| `suggestions[]` | 3-5 proposals for what could happen next. | + +### 5. Non-Goals and Constraints + +- The backend is responsible for: + - Session persistence and identity. + - Safety controls and filtering (not delegated fully to Ollama). + - Enforcing maximum context window by summarizing history when needed. +- Ollama is stateless from the application's perspective: + - All relevant state is passed in the prompt at each call. + - The application, not Ollama, is the source of truth for story state. +- Recommendations are **soft hints**, not hard directives: + - The model may diverge from a recommendation if it produces a better story. + - The backend does not enforce recommendation compliance. + +### 6. Implementation Notes (Informative) + +#### 6.1 Backend Technology Stack + +- **Language & runtime** + - Java 21 (LTS). +- **Framework** + - Latest stable Spring Boot (3.x line at time of implementation). +- **Build & dependency management** + - Maven with: + - Spring Boot starter parent (for dependency management). + - OpenAPI / Swagger codegen Maven plugin. + - Lombok. + - JUnit 5 and Mockito for testing. + +#### 6.2 Project Structure (Suggested) + +- `roleplay-backend` (Spring Boot application) + - `src/main/java/.../api` -- generated API interfaces and DTOs (from OpenAPI). + - `src/main/java/.../controller` -- thin controllers implementing generated interfaces. + - `src/main/java/.../service` -- business logic (sessions, turns, prompts). + - `src/main/java/.../model` -- domain model (session, characters, situation, suggestions). + - `src/main/java/.../ollama` -- Ollama client (HTTP integration). + - `src/main/java/.../config` -- configuration (e.g. Ollama base URL, model defaults). + - `src/test/java/...` -- unit and integration tests. + +#### 6.3 REST API and OpenAPI + +- The REST API of the backend is **defined via an OpenAPI specification** (e.g. `roleplay-public-v1.yml`). + - Contains endpoints such as: + - `GET /models` -- list available Ollama models. + - `POST /sessions` -- create a new role play session. + - `GET /sessions/{id}` -- fetch session state. + - `POST /sessions/{id}/turns` -- submit a user action and get the next turn. + - Request schemas: + - `TurnRequest`: contains `user_action` (type + content + optional suggestion ID) and optional `recommendation` (desired_tone, preferred_direction, focus_characters). + - Response schemas: + - `TurnResponse`: contains `narrative` (string from Call 1), `responses` (per-character actions from Call 2), `suggestions` (from Call 2), and `updated_state_summary` (optional, for debugging/transparency). + - Other domain DTOs: `Session`, `Character`, `Situation`, `Suggestion`, `Recommendation`. +- **Code generation** + - Maven OpenAPI codegen plugin generates: + - Java interfaces for controllers (e.g. `SessionsApi`). + - DTO classes mirroring OpenAPI schemas. + - Application code: + - Implements generated interfaces in Spring `@RestController` classes. + - Maps DTOs <-> domain model where needed. + +#### 6.4 Spring Boot Application Design + +- **Application entry point** + - `@SpringBootApplication` main class starting the HTTP server. +- **Controllers** + - Implement generated API interfaces. + - Are kept thin and delegate to services: + - `ModelService` (wrapping Ollama `/api/tags`). + - `SessionService` (create/load/update sessions). + - `TurnService` (execute a role play turn via the two-call pattern). +- **Services** + - `PromptBuilderService`: + - Builds the structured JSON context object from session state, user action, and recommendation. + - Produces two system prompts per turn: one for Call 1 (narrative) and one for Call 2 (state extraction). + - `TurnService`: + - Orchestrates the two-call flow: Call 1 (narrative) -> Call 2 (state update) -> merge -> store. + - Handles errors (e.g. invalid JSON from Call 2: retry or fall back). + - `StateMergerService`: + - Applies diff-style updates from the Call 2 response to the stored session state. + - Appends `new_timeline_entries`, merges `open_threads_changes`, updates character fields. +- **Ollama client** + - Implemented as a Spring `@Service` or component using: + - `WebClient` (preferred) or `RestTemplate`. + - Configurable base URL (default `http://localhost:11434`), model default, and timeouts. + - Supports an optional `format` parameter on `/api/chat` calls (`null` for Call 1, `"json"` for Call 2). +- **Response handling** + - Call 1: response body is used as-is (plain text narrative). + - Call 2: response body is deserialized directly into a `StateUpdateResponse` POJO via Jackson. No regex or tag parsing required. + +#### 6.5 Lombok Usage + +- Lombok is used to reduce boilerplate in domain and DTO classes: + - `@Data` or `@Getter`/`@Setter` for simple POJOs. + - `@Builder` for objects that are convenient to construct fluently (e.g. prompts). + - `@AllArgsConstructor` / `@NoArgsConstructor` where needed (e.g. for Jackson). +- Guidelines: + - Keep domain models immutable where reasonable (`@Builder` + `@Getter` + `final` fields). + - Use explicit methods for logic; use Lombok only for structural boilerplate. + +#### 6.6 Testing Strategy (JUnit 5, Mockito) + +- **Unit tests** + - Written with JUnit 5 (Jupiter). + - Mockito for mocking dependencies (e.g. Ollama client, repositories). + - Test coverage focus: + - `PromptBuilderService`: ensure structured JSON context is correctly assembled. + - `StateMergerService`: ensure diff-style updates are applied correctly. + - `TurnService`: ensure two-call orchestration and error handling work. + - Business rules (e.g. suggestion mapping, state transitions). +- **Integration tests** + - Spring Boot test slices (e.g. `@SpringBootTest`). + - Mock Ollama using: + - MockWebServer (or WireMock) or a simple test double for the Ollama client. + - Validate REST endpoints from OpenAPI behave as specified (inputs, outputs, HTTP codes). + +#### 6.7 State Storage and Frontend (Informative) + +- **State storage** + - Can be in-memory (for early prototypes) or persisted in a database. + - `turn_history` may store: + - Raw user input (action + recommendation). + - Model outputs (narrative from Call 1, JSON state from Call 2). + - A derived summary to control context size. +- **Frontend** + - Renders: + - Chat-style view (user/AI messages). + - Rich narrative layout if desired. + - Character cards (with current mood, relationships, etc.). + - Suggestions as buttons/chips. + - Optional recommendation input (tone selector, free-text direction, character focus picker). +- **Extensibility** + - Additional features (experience points, branching save points, etc.) can be built on top of this core structure without changing the Ollama interaction pattern. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7117c18 --- /dev/null +++ b/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + de.neitzel + roleplay + 0.1.0-SNAPSHOT + + + 21 + 3.12.1 + 3.2.5 + io.quarkus.platform + quarkus-bom + 3.15.3 + ${quarkus.platform.version} + 1.18.42 + 5.10.3 + 5.12.0 + UTF-8 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-liquibase + + + io.quarkus + quarkus-rest-client-jackson + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.plugin.version} + true + + + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${java.version} + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + maven-surefire-plugin + ${maven.surefire.plugin.version} + + false + -Dnet.bytebuddy.experimental=true + + + + + + diff --git a/src/main/java/de/neitzel/roleplay/RolePlayApplication.java b/src/main/java/de/neitzel/roleplay/RolePlayApplication.java new file mode 100644 index 0000000..4d78336 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/RolePlayApplication.java @@ -0,0 +1,33 @@ +package de.neitzel.roleplay; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.annotations.QuarkusMain; + +/** + * Application entry point for the RolePlay service. + */ +@QuarkusMain +public final class RolePlayApplication implements QuarkusApplication { + + /** + * Starts the Quarkus application. + * + * @param args command-line arguments + */ + public static void main(final String[] args) { + Quarkus.run(RolePlayApplication.class, args); + } + + /** + * Runs after the Quarkus runtime starts. + * + * @param args command-line arguments + * @return the process exit code + */ + @Override + public int run(final String... args) { + return 0; + } +} + diff --git a/src/main/java/de/neitzel/roleplay/business/GreetingService.java b/src/main/java/de/neitzel/roleplay/business/GreetingService.java new file mode 100644 index 0000000..5cb968f --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/GreetingService.java @@ -0,0 +1,35 @@ +package de.neitzel.roleplay.business; + +import de.neitzel.roleplay.common.GreetingFormatter; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Business service that assembles greeting messages. + */ +@ApplicationScoped +public class GreetingService { + + private final GreetingFormatter greetingFormatter; + + /** + * Creates a new service with the required formatter. + * + * @param greetingFormatter formatter for greeting messages + */ + @Inject + public GreetingService(final GreetingFormatter greetingFormatter) { + this.greetingFormatter = greetingFormatter; + } + + /** + * Builds a greeting for the supplied name. + * + * @param name the name to greet + * @return the formatted greeting message + */ + public String greet(final String name) { + return greetingFormatter.format(name); + } +} + diff --git a/src/main/java/de/neitzel/roleplay/business/package-info.java b/src/main/java/de/neitzel/roleplay/business/package-info.java new file mode 100644 index 0000000..df93dbf --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/package-info.java @@ -0,0 +1,5 @@ +/** + * Business logic for the RolePlay domain. + */ +package de.neitzel.roleplay.business; + diff --git a/src/main/java/de/neitzel/roleplay/common/ActionType.java b/src/main/java/de/neitzel/roleplay/common/ActionType.java new file mode 100644 index 0000000..abab322 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/ActionType.java @@ -0,0 +1,21 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Distinguishes the kind of action the user performed. + */ +public enum ActionType { + + /** In-character dialogue. */ + @JsonProperty("speech") + SPEECH, + + /** Physical or environmental action. */ + @JsonProperty("action") + ACTION, + + /** User selected a suggestion. */ + @JsonProperty("choice") + CHOICE +} diff --git a/src/main/java/de/neitzel/roleplay/common/CharacterResponse.java b/src/main/java/de/neitzel/roleplay/common/CharacterResponse.java new file mode 100644 index 0000000..033ae6d --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/CharacterResponse.java @@ -0,0 +1,33 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Describes what a single AI character said or did during a turn, as extracted + * by the state-update call. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CharacterResponse { + + /** ID of the character who acted. */ + String characterId; + + /** Whether this was speech, a physical action, or an internal reaction. */ + ResponseType type; + + /** Quoted dialogue if {@link #type} is {@code SPEECH}, otherwise {@code null}. */ + String content; + + /** Description of a physical action, or {@code null} if none. */ + String action; + + /** The character's mood after this turn. */ + String moodAfter; +} diff --git a/src/main/java/de/neitzel/roleplay/common/CharacterSet.java b/src/main/java/de/neitzel/roleplay/common/CharacterSet.java new file mode 100644 index 0000000..88dcde4 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/CharacterSet.java @@ -0,0 +1,26 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Container holding the user-controlled character and all AI-controlled + * characters for a turn context. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CharacterSet { + + /** The human player's character. */ + CharacterSnapshot userCharacter; + + /** AI-controlled NPCs. */ + List aiCharacters; +} diff --git a/src/main/java/de/neitzel/roleplay/common/CharacterSnapshot.java b/src/main/java/de/neitzel/roleplay/common/CharacterSnapshot.java new file mode 100644 index 0000000..88e78be --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/CharacterSnapshot.java @@ -0,0 +1,54 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; +import java.util.Map; + +/** + * Snapshot of a single character's identity and dynamic state, included in the + * structured context sent to Ollama. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CharacterSnapshot { + + /** Unique character identifier. */ + String id; + + /** Display name. */ + String name; + + /** Narrative role (e.g. "rookie detective"). */ + String role; + + /** Personality descriptors (AI characters only). */ + List personalityTraits; + + /** Tone and speech quirks (AI characters only). */ + String speakingStyle; + + /** Short- and long-term goals (AI characters only). */ + List goals; + + /** Current emotional state. */ + String currentMood; + + /** Facts the character has learned. */ + List knowledge; + + /** Map from other character IDs to relationship descriptions. */ + Map relationships; + + /** Physical or narrative status (e.g. "healthy", "injured"). */ + String status; + + /** Brief summary of recent behaviour. */ + List recentActionsSummary; +} diff --git a/src/main/java/de/neitzel/roleplay/common/CharacterUpdate.java b/src/main/java/de/neitzel/roleplay/common/CharacterUpdate.java new file mode 100644 index 0000000..bce88c9 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/CharacterUpdate.java @@ -0,0 +1,33 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; +import java.util.Map; + +/** + * Diff-style character state changes for a single AI character, produced by + * the state-update call. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class CharacterUpdate { + + /** ID of the character whose state changed. */ + String characterId; + + /** The character's mood after this turn. */ + String currentMood; + + /** New facts the character learned this turn. */ + List knowledgeGained; + + /** Changed relationships (character ID to new descriptor). */ + Map relationshipChanges; +} diff --git a/src/main/java/de/neitzel/roleplay/common/GreetingFormatter.java b/src/main/java/de/neitzel/roleplay/common/GreetingFormatter.java new file mode 100644 index 0000000..c6cff82 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/GreetingFormatter.java @@ -0,0 +1,21 @@ +package de.neitzel.roleplay.common; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * Formats greeting messages for display. + */ +@ApplicationScoped +public class GreetingFormatter { + + /** + * Formats a greeting for the provided name. + * + * @param name the name to greet + * @return a formatted greeting message + */ + public String format(final String name) { + return "Hello, " + name + "!"; + } +} + diff --git a/src/main/java/de/neitzel/roleplay/common/OpenThreadsChanges.java b/src/main/java/de/neitzel/roleplay/common/OpenThreadsChanges.java new file mode 100644 index 0000000..b43df66 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/OpenThreadsChanges.java @@ -0,0 +1,25 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Diff-style changes to the open narrative threads for a single turn. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class OpenThreadsChanges { + + /** New threads introduced this turn. */ + List added; + + /** Threads that were resolved or closed this turn. */ + List resolved; +} diff --git a/src/main/java/de/neitzel/roleplay/common/Recommendation.java b/src/main/java/de/neitzel/roleplay/common/Recommendation.java new file mode 100644 index 0000000..4475ef5 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/Recommendation.java @@ -0,0 +1,29 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Optional narrative guidance the user attaches to a turn. The model treats + * this as a soft hint, not a hard directive. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class Recommendation { + + /** Desired mood for the next beat (e.g. "tense", "humorous"). */ + String desiredTone; + + /** Free-text direction hint (e.g. "I'd like the captain to reveal a secret"). */ + String preferredDirection; + + /** Character IDs that should play a prominent role. */ + List focusCharacters; +} diff --git a/src/main/java/de/neitzel/roleplay/common/ResponseType.java b/src/main/java/de/neitzel/roleplay/common/ResponseType.java new file mode 100644 index 0000000..c49e4bb --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/ResponseType.java @@ -0,0 +1,21 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Classifies an AI character's response within a turn. + */ +public enum ResponseType { + + /** Spoken dialogue. */ + @JsonProperty("speech") + SPEECH, + + /** Physical or environmental action. */ + @JsonProperty("action") + ACTION, + + /** Emotional or internal reaction. */ + @JsonProperty("reaction") + REACTION +} diff --git a/src/main/java/de/neitzel/roleplay/common/RiskLevel.java b/src/main/java/de/neitzel/roleplay/common/RiskLevel.java new file mode 100644 index 0000000..921a4bd --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/RiskLevel.java @@ -0,0 +1,21 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Indicates how risky a suggestion is for the player. + */ +public enum RiskLevel { + + /** Minimal danger or consequence. */ + @JsonProperty("low") + LOW, + + /** Moderate danger or uncertain outcome. */ + @JsonProperty("medium") + MEDIUM, + + /** High stakes with potentially severe consequences. */ + @JsonProperty("high") + HIGH +} diff --git a/src/main/java/de/neitzel/roleplay/common/SituationSnapshot.java b/src/main/java/de/neitzel/roleplay/common/SituationSnapshot.java new file mode 100644 index 0000000..aad88a7 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/SituationSnapshot.java @@ -0,0 +1,39 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; +import java.util.Map; + +/** + * Snapshot of the current world and scene state sent to Ollama as part of the + * structured context. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class SituationSnapshot { + + /** Place, time, and atmosphere description. */ + String setting; + + /** What is currently in focus for the scene. */ + String currentScene; + + /** Ordered list of important events so far. */ + List timeline; + + /** Unresolved plot lines, mysteries, or quests. */ + List openThreads; + + /** Time-sensitive pressures such as deadlines or approaching threats. */ + List externalPressures; + + /** Boolean or tag-style flags representing key world states. */ + Map worldStateFlags; +} diff --git a/src/main/java/de/neitzel/roleplay/common/SituationUpdate.java b/src/main/java/de/neitzel/roleplay/common/SituationUpdate.java new file mode 100644 index 0000000..a5f75d7 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/SituationUpdate.java @@ -0,0 +1,34 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; +import java.util.Map; + +/** + * Diff-style situation changes produced by the state-update call. Fields + * represent additions or modifications, not a full replacement of the + * situation. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class SituationUpdate { + + /** Updated scene description. */ + String currentScene; + + /** New events to append to the timeline. */ + List newTimelineEntries; + + /** Threads added or resolved this turn. */ + OpenThreadsChanges openThreadsChanges; + + /** Current world state flags after this turn. */ + Map worldStateFlags; +} diff --git a/src/main/java/de/neitzel/roleplay/common/StateUpdateResponse.java b/src/main/java/de/neitzel/roleplay/common/StateUpdateResponse.java new file mode 100644 index 0000000..298f9b5 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/StateUpdateResponse.java @@ -0,0 +1,33 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Top-level structured response returned by the Ollama state-update call + * (Call 2). Contains per-character responses, situation and character diffs, + * and suggestions for the next turn. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class StateUpdateResponse { + + /** Per-character breakdown of what each AI character said or did. */ + List responses; + + /** Diff-style situation changes. */ + SituationUpdate updatedSituation; + + /** Diff-style character state changes. */ + List updatedCharacters; + + /** 3-5 proposals for what could happen next. */ + List suggestions; +} diff --git a/src/main/java/de/neitzel/roleplay/common/Suggestion.java b/src/main/java/de/neitzel/roleplay/common/Suggestion.java new file mode 100644 index 0000000..63fbc91 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/Suggestion.java @@ -0,0 +1,37 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * A single suggestion for what could happen next in the story. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class Suggestion { + + /** Unique suggestion identifier. */ + String id; + + /** Category of the suggestion. */ + SuggestionType type; + + /** Short human-readable label. */ + String title; + + /** One- or two-sentence explanation. */ + String description; + + /** Brief list of possible outcomes. */ + List consequences; + + /** How risky this option is for the player. */ + RiskLevel riskLevel; +} diff --git a/src/main/java/de/neitzel/roleplay/common/SuggestionType.java b/src/main/java/de/neitzel/roleplay/common/SuggestionType.java new file mode 100644 index 0000000..9917172 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/SuggestionType.java @@ -0,0 +1,25 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Categorises a suggestion by its narrative origin. + */ +public enum SuggestionType { + + /** An action the player character could take. */ + @JsonProperty("player_action") + PLAYER_ACTION, + + /** An external event that changes the world. */ + @JsonProperty("world_event") + WORLD_EVENT, + + /** An action initiated by an AI-controlled character. */ + @JsonProperty("npc_action") + NPC_ACTION, + + /** An unexpected plot twist. */ + @JsonProperty("twist") + TWIST +} diff --git a/src/main/java/de/neitzel/roleplay/common/TurnContext.java b/src/main/java/de/neitzel/roleplay/common/TurnContext.java new file mode 100644 index 0000000..cd30ea8 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/TurnContext.java @@ -0,0 +1,34 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Top-level structured context sent to Ollama for every call. Contains the + * current situation, characters, the user's action, optional narrative + * guidance, and a condensed history. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class TurnContext { + + /** Current world and scene state. */ + SituationSnapshot currentSituation; + + /** User character and AI characters with their dynamic state. */ + CharacterSet characters; + + /** The action the user performed this turn ({@code null} during session init). */ + UserAction userAction; + + /** Optional narrative guidance from the user ({@code null} if not provided). */ + Recommendation recommendation; + + /** Backend-managed condensation of recent turn history. */ + String recentHistorySummary; +} diff --git a/src/main/java/de/neitzel/roleplay/common/UserAction.java b/src/main/java/de/neitzel/roleplay/common/UserAction.java new file mode 100644 index 0000000..b908271 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/UserAction.java @@ -0,0 +1,26 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Describes the action the user took this turn. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class UserAction { + + /** Whether this is speech, a physical action, or a suggestion choice. */ + ActionType type; + + /** Free-text content of the action or dialogue. */ + String content; + + /** If the user clicked a suggestion, its ID; otherwise {@code null}. */ + String selectedSuggestionId; +} diff --git a/src/main/java/de/neitzel/roleplay/common/package-info.java b/src/main/java/de/neitzel/roleplay/common/package-info.java new file mode 100644 index 0000000..eb524a7 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/package-info.java @@ -0,0 +1,5 @@ +/** + * Shared utilities and cross-cutting types for the RolePlay service. + */ +package de.neitzel.roleplay.common; + diff --git a/src/main/java/de/neitzel/roleplay/data/package-info.java b/src/main/java/de/neitzel/roleplay/data/package-info.java new file mode 100644 index 0000000..d14f6a8 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/data/package-info.java @@ -0,0 +1,5 @@ +/** + * Persistence and data access components for the RolePlay service. + */ +package de.neitzel.roleplay.data; + diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java new file mode 100644 index 0000000..f269849 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaApi.java @@ -0,0 +1,35 @@ +package de.neitzel.roleplay.fascade; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +/** + * Quarkus declarative REST client for the Ollama HTTP API. + * Configuration (base URL, timeouts) is read from + * {@code quarkus.rest-client.ollama-api.*} in {@code application.yml}. + */ +@RegisterRestClient(configKey = "ollama-api") +@Path("/api") +public interface OllamaApi { + + /** + * Lists all locally installed models. + * + * @return the tags response containing model metadata + */ + @GET + @Path("/tags") + OllamaTagsResponse getTags(); + + /** + * Sends a chat completion request. + * + * @param request the chat request including model, messages, and optional format + * @return the chat response with the assistant's reply + */ + @POST + @Path("/chat") + OllamaChatResponse chat(OllamaChatRequest request); +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaChatMessage.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatMessage.java new file mode 100644 index 0000000..ca1eadd --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatMessage.java @@ -0,0 +1,20 @@ +package de.neitzel.roleplay.fascade; + +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * A single message within an Ollama {@code /api/chat} request or response. + */ +@Value +@Builder +@Jacksonized +public class OllamaChatMessage { + + /** Message role ({@code "system"}, {@code "user"}, or {@code "assistant"}). */ + String role; + + /** Text content of the message. */ + String content; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaChatRequest.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatRequest.java new file mode 100644 index 0000000..9ddce0d --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatRequest.java @@ -0,0 +1,34 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Request body for the Ollama {@code POST /api/chat} endpoint. + */ +@Value +@Builder +@Jacksonized +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OllamaChatRequest { + + /** Name of the Ollama model to use (e.g. {@code "llama3"}). */ + String model; + + /** Whether to stream the response; always {@code false} for this application. */ + @Builder.Default + boolean stream = false; + + /** + * Optional output format constraint. Set to {@code "json"} to force valid + * JSON output (Call 2). Leave {@code null} for free-form text (Call 1). + */ + String format; + + /** Ordered list of system and user messages. */ + List messages; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaChatResponse.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatResponse.java new file mode 100644 index 0000000..da97a40 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaChatResponse.java @@ -0,0 +1,33 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Response body from the Ollama {@code POST /api/chat} endpoint. + * Only the fields relevant to this application are mapped; additional + * Ollama metadata is ignored. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaChatResponse { + + /** Name of the model that generated the response. */ + String model; + + /** The assistant's reply. */ + OllamaResponseMessage message; + + /** Whether generation is complete. */ + boolean done; + + /** Total generation duration in nanoseconds. */ + Long totalDuration; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java new file mode 100644 index 0000000..90fa5e5 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaClient.java @@ -0,0 +1,122 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.neitzel.roleplay.common.StateUpdateResponse; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import java.util.Collections; +import java.util.List; + +/** + * Application-facing service that wraps the low-level {@link OllamaApi} REST + * client and exposes domain-oriented methods for narrative generation and + * state extraction. + */ +@ApplicationScoped +public class OllamaClient { + + /** Low-level REST client for the Ollama HTTP API. */ + private final OllamaApi ollamaApi; + + /** Jackson mapper used to deserialise the JSON content of state-update responses. */ + private final ObjectMapper objectMapper; + + /** + * Creates a new client with the required dependencies. + * + * @param ollamaApi the REST client proxy + * @param objectMapper the Jackson object mapper + */ + @Inject + public OllamaClient(@RestClient final OllamaApi ollamaApi, + final ObjectMapper objectMapper) { + this.ollamaApi = ollamaApi; + this.objectMapper = objectMapper; + } + + /** + * Lists all models installed on the Ollama server. + * + * @return model metadata, or an empty list if none are installed + */ + public List listModels() { + OllamaTagsResponse response = ollamaApi.getTags(); + if (response == null || response.getModels() == null) { + return Collections.emptyList(); + } + return response.getModels(); + } + + /** + * Generates a free-form narrative by sending a chat request without + * a format constraint (Call 1 in the two-call pattern). + * + * @param model the Ollama model name + * @param systemPrompt the system-level instructions + * @param userContent the structured context JSON (serialised by the caller) + * @return the plain-text narrative produced by the model + */ + public String generateNarrative(final String model, + final String systemPrompt, + final String userContent) { + OllamaChatRequest request = OllamaChatRequest.builder() + .model(model) + .messages(List.of( + OllamaChatMessage.builder() + .role("system") + .content(systemPrompt) + .build(), + OllamaChatMessage.builder() + .role("user") + .content(userContent) + .build() + )) + .build(); + + OllamaChatResponse response = ollamaApi.chat(request); + return response.getMessage().getContent(); + } + + /** + * Generates a structured state update by sending a chat request with + * {@code format = "json"} (Call 2 in the two-call pattern). The JSON + * content string is deserialised into a {@link StateUpdateResponse}. + * + * @param model the Ollama model name + * @param systemPrompt the system-level instructions for state extraction + * @param userContent the structured context JSON plus the narrative from Call 1 + * @return the parsed state update + * @throws OllamaParseException if the model's response is not valid JSON + */ + public StateUpdateResponse generateStateUpdate(final String model, + final String systemPrompt, + final String userContent) { + OllamaChatRequest request = OllamaChatRequest.builder() + .model(model) + .format("json") + .messages(List.of( + OllamaChatMessage.builder() + .role("system") + .content(systemPrompt) + .build(), + OllamaChatMessage.builder() + .role("user") + .content(userContent) + .build() + )) + .build(); + + OllamaChatResponse response = ollamaApi.chat(request); + String json = response.getMessage().getContent(); + + try { + return objectMapper.readValue(json, StateUpdateResponse.class); + } catch (JsonProcessingException e) { + throw new OllamaParseException( + "Failed to parse state update JSON from Ollama response", e); + } + } +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaModelDetails.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaModelDetails.java new file mode 100644 index 0000000..fd1e900 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaModelDetails.java @@ -0,0 +1,36 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Technical details of an Ollama model returned by {@code /api/tags}. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaModelDetails { + + /** Model format (e.g. {@code "gguf"}). */ + String format; + + /** Primary model family (e.g. {@code "llama"}). */ + String family; + + /** All model families. */ + List families; + + /** Human-readable parameter count (e.g. {@code "8B"}). */ + String parameterSize; + + /** Quantisation level (e.g. {@code "Q4_0"}). */ + String quantizationLevel; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaModelInfo.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaModelInfo.java new file mode 100644 index 0000000..d29e5f8 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaModelInfo.java @@ -0,0 +1,35 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * Metadata for a single model returned by the Ollama {@code /api/tags} + * endpoint. + */ +@Value +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaModelInfo { + + /** Full model name including tag (e.g. {@code "llama3:latest"}). */ + String name; + + /** Model identifier. */ + String model; + + /** Size in bytes. */ + Long size; + + /** Content digest. */ + String digest; + + /** Technical details (family, parameters, quantisation). */ + OllamaModelDetails details; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaParseException.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaParseException.java new file mode 100644 index 0000000..b6b2a41 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaParseException.java @@ -0,0 +1,18 @@ +package de.neitzel.roleplay.fascade; + +/** + * Thrown when an Ollama response cannot be parsed into the expected domain + * structure (e.g. invalid JSON from a state-update call). + */ +public class OllamaParseException extends RuntimeException { + + /** + * Creates a new parse exception. + * + * @param message description of the parsing failure + * @param cause the underlying cause + */ + public OllamaParseException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaResponseMessage.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaResponseMessage.java new file mode 100644 index 0000000..10e3644 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaResponseMessage.java @@ -0,0 +1,20 @@ +package de.neitzel.roleplay.fascade; + +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +/** + * The assistant message returned inside an Ollama chat response. + */ +@Value +@Builder +@Jacksonized +public class OllamaResponseMessage { + + /** Always {@code "assistant"} for chat completions. */ + String role; + + /** The generated text content. */ + String content; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/OllamaTagsResponse.java b/src/main/java/de/neitzel/roleplay/fascade/OllamaTagsResponse.java new file mode 100644 index 0000000..55da9f7 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/OllamaTagsResponse.java @@ -0,0 +1,21 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Builder; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +/** + * Response body from the Ollama {@code GET /api/tags} endpoint. + */ +@Value +@Builder +@Jacksonized +@JsonIgnoreProperties(ignoreUnknown = true) +public class OllamaTagsResponse { + + /** List of installed models. */ + List models; +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/package-info.java b/src/main/java/de/neitzel/roleplay/fascade/package-info.java new file mode 100644 index 0000000..570435f --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/package-info.java @@ -0,0 +1,5 @@ +/** + * External-facing facades for the RolePlay service. + */ +package de.neitzel.roleplay.fascade; + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9275b82 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +quarkus: + application: + name: roleplay + liquibase: + change-log: db/migration/changelog.xml + migrate-at-start: false + rest-client: + ollama-api: + url: http://debian:11434 + connect-timeout: 5000 + read-timeout: 120000 + diff --git a/src/main/resources/db/migration/changelog.xml b/src/main/resources/db/migration/changelog.xml new file mode 100644 index 0000000..342d5a5 --- /dev/null +++ b/src/main/resources/db/migration/changelog.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/test/java/de/neitzel/roleplay/business/GreetingServiceTest.java b/src/test/java/de/neitzel/roleplay/business/GreetingServiceTest.java new file mode 100644 index 0000000..e9d2af5 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/GreetingServiceTest.java @@ -0,0 +1,39 @@ +package de.neitzel.roleplay.business; + +import de.neitzel.roleplay.common.GreetingFormatter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link GreetingService}. + */ +@ExtendWith(MockitoExtension.class) +class GreetingServiceTest { + + @Mock + private GreetingFormatter greetingFormatter; + + @InjectMocks + private GreetingService greetingService; + + /** + * Verifies that the service delegates to the formatter. + */ + @Test + void greetReturnsFormattedMessage() { + // Arrange + when(greetingFormatter.format("Ada")).thenReturn("Hello, Ada!"); + + // Act + String result = greetingService.greet("Ada"); + + // Assert + assertEquals("Hello, Ada!", result); + } +} diff --git a/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java b/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java new file mode 100644 index 0000000..48e1811 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/common/StateUpdateResponseDeserializationTest.java @@ -0,0 +1,142 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates that a JSON string matching the Section 4.5 schema can be + * correctly deserialised into a {@link StateUpdateResponse} and its nested + * types. + */ +class StateUpdateResponseDeserializationTest { + + /** Shared mapper instance configured the same way Quarkus would. */ + private final ObjectMapper mapper = new ObjectMapper(); + + /** Sample JSON matching the state-update output schema. */ + private static final String SAMPLE_JSON = """ + { + "responses": [ + { + "character_id": "captain_morgan", + "type": "speech", + "content": "You want answers? Fine.", + "action": "slams fist on the table", + "mood_after": "angry" + }, + { + "character_id": "first_mate_jones", + "type": "action", + "content": null, + "action": "steps between the player and the door", + "mood_after": "cautious" + } + ], + "updated_situation": { + "current_scene": "Captain's quarters, tension rising.", + "new_timeline_entries": [ + "Sam confronted the captain." + ], + "open_threads_changes": { + "added": ["What is the dangerous cargo?"], + "resolved": [] + }, + "world_state_flags": { + "captain_suspicious": true + } + }, + "updated_characters": [ + { + "character_id": "captain_morgan", + "current_mood": "angry", + "knowledge_gained": ["Player is not going to stay quiet"], + "relationship_changes": { + "player": "hostile" + } + } + ], + "suggestions": [ + { + "id": "s1", + "type": "player_action", + "title": "Press harder", + "description": "Demand to see the cargo.", + "consequences": ["May provoke violence"], + "risk_level": "high" + } + ] + } + """; + + /** + * Verifies that the full JSON structure maps to the correct Java types + * including all nested objects. + */ + @Test + void deserialisesFullSchemaCorrectly() throws Exception { + // Act + StateUpdateResponse result = mapper.readValue(SAMPLE_JSON, StateUpdateResponse.class); + + // Assert - responses + assertNotNull(result.getResponses()); + assertEquals(2, result.getResponses().size()); + + CharacterResponse speech = result.getResponses().get(0); + assertEquals("captain_morgan", speech.getCharacterId()); + assertEquals(ResponseType.SPEECH, speech.getType()); + assertEquals("You want answers? Fine.", speech.getContent()); + assertEquals("slams fist on the table", speech.getAction()); + assertEquals("angry", speech.getMoodAfter()); + + CharacterResponse action = result.getResponses().get(1); + assertEquals("first_mate_jones", action.getCharacterId()); + assertEquals(ResponseType.ACTION, action.getType()); + assertNull(action.getContent()); + + // Assert - situation + SituationUpdate situation = result.getUpdatedSituation(); + assertEquals("Captain's quarters, tension rising.", situation.getCurrentScene()); + assertEquals(1, situation.getNewTimelineEntries().size()); + assertEquals(1, situation.getOpenThreadsChanges().getAdded().size()); + assertTrue(situation.getOpenThreadsChanges().getResolved().isEmpty()); + assertEquals(true, situation.getWorldStateFlags().get("captain_suspicious")); + + // Assert - characters + assertEquals(1, result.getUpdatedCharacters().size()); + CharacterUpdate charUpdate = result.getUpdatedCharacters().get(0); + assertEquals("captain_morgan", charUpdate.getCharacterId()); + assertEquals("angry", charUpdate.getCurrentMood()); + assertEquals("hostile", charUpdate.getRelationshipChanges().get("player")); + + // Assert - suggestions + assertEquals(1, result.getSuggestions().size()); + Suggestion suggestion = result.getSuggestions().get(0); + assertEquals("s1", suggestion.getId()); + assertEquals(SuggestionType.PLAYER_ACTION, suggestion.getType()); + assertEquals("Press harder", suggestion.getTitle()); + assertEquals(RiskLevel.HIGH, suggestion.getRiskLevel()); + } + + /** + * Verifies that snake_case JSON keys map to camelCase Java fields. + */ + @Test + void snakeCaseMapsToJavaFields() throws Exception { + // Act + StateUpdateResponse result = mapper.readValue(SAMPLE_JSON, StateUpdateResponse.class); + + // Assert - fields that rely on snake_case -> camelCase mapping + assertNotNull(result.getUpdatedSituation()); + assertNotNull(result.getUpdatedCharacters()); + assertEquals("captain_morgan", + result.getResponses().get(0).getCharacterId()); + assertEquals("angry", + result.getResponses().get(0).getMoodAfter()); + assertNotNull(result.getUpdatedSituation().getNewTimelineEntries()); + assertNotNull(result.getUpdatedSituation().getOpenThreadsChanges()); + assertNotNull(result.getUpdatedCharacters().get(0).getKnowledgeGained()); + assertNotNull(result.getUpdatedCharacters().get(0).getRelationshipChanges()); + } +} diff --git a/src/test/java/de/neitzel/roleplay/common/TurnContextSerializationTest.java b/src/test/java/de/neitzel/roleplay/common/TurnContextSerializationTest.java new file mode 100644 index 0000000..6f7548a --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/common/TurnContextSerializationTest.java @@ -0,0 +1,129 @@ +package de.neitzel.roleplay.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates that a {@link TurnContext} built with the Lombok builder + * serialises to JSON with the expected snake_case property names. + */ +class TurnContextSerializationTest { + + /** Shared mapper instance. */ + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Verifies that a fully populated context serialises with snake_case keys. + */ + @Test + void serialisesWithSnakeCasePropertyNames() throws Exception { + // Arrange + TurnContext context = TurnContext.builder() + .currentSituation(SituationSnapshot.builder() + .setting("A fog-covered harbor") + .currentScene("The captain's quarters") + .timeline(List.of("Day 1: Boarded the ship")) + .openThreads(List.of("What is the cargo?")) + .externalPressures(List.of("Storm approaching")) + .worldStateFlags(Map.of("cargo_hold_locked", true)) + .build()) + .characters(CharacterSet.builder() + .userCharacter(CharacterSnapshot.builder() + .id("player") + .name("Sam") + .role("deckhand") + .currentMood("nervous") + .knowledge(List.of("Heard noises")) + .relationships(Map.of("captain", "wary")) + .status("healthy") + .build()) + .aiCharacters(List.of(CharacterSnapshot.builder() + .id("captain") + .name("Captain Morgan") + .role("ship captain") + .personalityTraits(List.of("secretive")) + .speakingStyle("terse") + .goals(List.of("Deliver cargo")) + .currentMood("guarded") + .build())) + .build()) + .userAction(UserAction.builder() + .type(ActionType.ACTION) + .content("I confront the captain.") + .build()) + .recommendation(Recommendation.builder() + .desiredTone("tense") + .preferredDirection("Captain reveals a secret") + .focusCharacters(List.of("captain")) + .build()) + .recentHistorySummary("Turn 1: Boarded. Turn 2: Noises at night.") + .build(); + + // Act + String json = mapper.writeValueAsString(context); + JsonNode tree = mapper.readTree(json); + + // Assert - top-level keys are snake_case + assertTrue(tree.has("current_situation")); + assertTrue(tree.has("characters")); + assertTrue(tree.has("user_action")); + assertTrue(tree.has("recommendation")); + assertTrue(tree.has("recent_history_summary")); + assertFalse(tree.has("currentSituation")); + assertFalse(tree.has("recentHistorySummary")); + + // Assert - nested keys are snake_case + JsonNode situation = tree.get("current_situation"); + assertTrue(situation.has("current_scene")); + assertTrue(situation.has("open_threads")); + assertTrue(situation.has("external_pressures")); + assertTrue(situation.has("world_state_flags")); + + // Assert - user_action enum serialised as lowercase + assertEquals("action", tree.get("user_action").get("type").asText()); + + // Assert - recommendation nested keys + JsonNode rec = tree.get("recommendation"); + assertTrue(rec.has("desired_tone")); + assertTrue(rec.has("preferred_direction")); + assertTrue(rec.has("focus_characters")); + } + + /** + * Verifies that null {@code userAction} and {@code recommendation} + * fields (session initialisation case) are handled gracefully. + */ + @Test + void serialisesNullOptionalFields() throws Exception { + // Arrange + TurnContext context = TurnContext.builder() + .currentSituation(SituationSnapshot.builder() + .setting("A tavern") + .build()) + .characters(CharacterSet.builder() + .userCharacter(CharacterSnapshot.builder() + .id("player") + .name("Ada") + .role("mage") + .build()) + .aiCharacters(List.of()) + .build()) + .build(); + + // Act + String json = mapper.writeValueAsString(context); + JsonNode tree = mapper.readTree(json); + + // Assert - null fields are present as JSON null + assertTrue(tree.has("user_action")); + assertTrue(tree.get("user_action").isNull()); + assertTrue(tree.has("recommendation")); + assertTrue(tree.get("recommendation").isNull()); + } +} diff --git a/src/test/java/de/neitzel/roleplay/fascade/OllamaClientTest.java b/src/test/java/de/neitzel/roleplay/fascade/OllamaClientTest.java new file mode 100644 index 0000000..12a0233 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/fascade/OllamaClientTest.java @@ -0,0 +1,228 @@ +package de.neitzel.roleplay.fascade; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.neitzel.roleplay.common.StateUpdateResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link OllamaClient}. + */ +@ExtendWith(MockitoExtension.class) +class OllamaClientTest { + + @Mock + private OllamaApi ollamaApi; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OllamaClient ollamaClient; + + @Captor + private ArgumentCaptor requestCaptor; + + /** + * Verifies that {@code listModels} delegates to the API and returns the + * model list. + */ + @Test + void listModelsReturnsMappedModels() { + // Arrange + OllamaModelInfo model = OllamaModelInfo.builder() + .name("llama3:latest") + .model("llama3:latest") + .size(4_000_000_000L) + .build(); + OllamaTagsResponse tagsResponse = OllamaTagsResponse.builder() + .models(List.of(model)) + .build(); + when(ollamaApi.getTags()).thenReturn(tagsResponse); + + // Act + List result = ollamaClient.listModels(); + + // Assert + assertEquals(1, result.size()); + assertEquals("llama3:latest", result.get(0).getName()); + verify(ollamaApi).getTags(); + } + + /** + * Verifies that {@code listModels} returns an empty list when the API + * response is {@code null}. + */ + @Test + void listModelsReturnsEmptyListWhenResponseIsNull() { + // Arrange + when(ollamaApi.getTags()).thenReturn(null); + + // Act + List result = ollamaClient.listModels(); + + // Assert + assertTrue(result.isEmpty()); + } + + /** + * Verifies that {@code generateNarrative} builds a request without the + * {@code format} field and returns the content string. + */ + @Test + void generateNarrativeBuildsRequestWithoutFormat() { + // Arrange + OllamaChatResponse chatResponse = OllamaChatResponse.builder() + .model("llama3") + .message(OllamaResponseMessage.builder() + .role("assistant") + .content("The fog rolled in...") + .build()) + .done(true) + .build(); + when(ollamaApi.chat(any())).thenReturn(chatResponse); + + // Act + String narrative = ollamaClient.generateNarrative( + "llama3", "You are a narrator.", "{\"context\":\"test\"}"); + + // Assert + assertEquals("The fog rolled in...", narrative); + verify(ollamaApi).chat(requestCaptor.capture()); + OllamaChatRequest captured = requestCaptor.getValue(); + assertNull(captured.getFormat()); + assertEquals("llama3", captured.getModel()); + assertFalse(captured.isStream()); + } + + /** + * Verifies that {@code generateNarrative} sends the system and user + * messages with the correct roles and content. + */ + @Test + void generateNarrativeSendsCorrectMessages() { + // Arrange + OllamaChatResponse chatResponse = OllamaChatResponse.builder() + .model("llama3") + .message(OllamaResponseMessage.builder() + .role("assistant") + .content("narrative") + .build()) + .done(true) + .build(); + when(ollamaApi.chat(any())).thenReturn(chatResponse); + + // Act + ollamaClient.generateNarrative("llama3", "system prompt", "user content"); + + // Assert + verify(ollamaApi).chat(requestCaptor.capture()); + List messages = requestCaptor.getValue().getMessages(); + assertEquals(2, messages.size()); + assertEquals("system", messages.get(0).getRole()); + assertEquals("system prompt", messages.get(0).getContent()); + assertEquals("user", messages.get(1).getRole()); + assertEquals("user content", messages.get(1).getContent()); + } + + /** + * Verifies that {@code generateStateUpdate} sets {@code format = "json"} + * on the request. + */ + @Test + void generateStateUpdateBuildsRequestWithJsonFormat() throws JsonProcessingException { + // Arrange + OllamaChatResponse chatResponse = OllamaChatResponse.builder() + .model("llama3") + .message(OllamaResponseMessage.builder() + .role("assistant") + .content("{}") + .build()) + .done(true) + .build(); + when(ollamaApi.chat(any())).thenReturn(chatResponse); + when(objectMapper.readValue(eq("{}"), eq(StateUpdateResponse.class))) + .thenReturn(StateUpdateResponse.builder().build()); + + // Act + ollamaClient.generateStateUpdate("llama3", "system prompt", "user content"); + + // Assert + verify(ollamaApi).chat(requestCaptor.capture()); + assertEquals("json", requestCaptor.getValue().getFormat()); + } + + /** + * Verifies that {@code generateStateUpdate} deserialises the response + * content into a {@link StateUpdateResponse}. + */ + @Test + void generateStateUpdateDeserialisesContent() throws JsonProcessingException { + // Arrange + String jsonContent = "{\"responses\":[],\"suggestions\":[]}"; + StateUpdateResponse expected = StateUpdateResponse.builder() + .responses(List.of()) + .suggestions(List.of()) + .build(); + OllamaChatResponse chatResponse = OllamaChatResponse.builder() + .model("llama3") + .message(OllamaResponseMessage.builder() + .role("assistant") + .content(jsonContent) + .build()) + .done(true) + .build(); + when(ollamaApi.chat(any())).thenReturn(chatResponse); + when(objectMapper.readValue(eq(jsonContent), eq(StateUpdateResponse.class))) + .thenReturn(expected); + + // Act + StateUpdateResponse result = ollamaClient.generateStateUpdate( + "llama3", "system prompt", "user content"); + + // Assert + assertSame(expected, result); + verify(objectMapper).readValue(jsonContent, StateUpdateResponse.class); + } + + /** + * Verifies that {@code generateStateUpdate} wraps a JSON parsing failure + * in an {@link OllamaParseException}. + */ + @Test + void generateStateUpdateThrowsOnInvalidJson() throws JsonProcessingException { + // Arrange + String badJson = "not valid json"; + OllamaChatResponse chatResponse = OllamaChatResponse.builder() + .model("llama3") + .message(OllamaResponseMessage.builder() + .role("assistant") + .content(badJson) + .build()) + .done(true) + .build(); + when(ollamaApi.chat(any())).thenReturn(chatResponse); + when(objectMapper.readValue(eq(badJson), eq(StateUpdateResponse.class))) + .thenThrow(new JsonProcessingException("parse error") {}); + + // Act & Assert + OllamaParseException ex = assertThrows(OllamaParseException.class, + () -> ollamaClient.generateStateUpdate("llama3", "sys", badJson)); + assertTrue(ex.getMessage().contains("Failed to parse")); + assertInstanceOf(JsonProcessingException.class, ex.getCause()); + } +}