From f30cb0788a2965961f38fdfb34ac56cc9acf0567 Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Thu, 17 Sep 2026 22:02:53 +0300 Subject: [PATCH] init --- .gitignore | 2 + .nvim.lua | 48 ++++++ .nvim/curl.sh | 17 +++ LORA.md | 412 ++++++++++++++++++++++++++++++++++++++++++++++++++ mise.toml | 5 + odinfmt.json | 7 + ols.json | 13 ++ src/main.odin | 216 ++++++++++++++++++++++++++ 8 files changed, 720 insertions(+) create mode 100644 .gitignore create mode 100644 .nvim.lua create mode 100755 .nvim/curl.sh create mode 100644 LORA.md create mode 100644 mise.toml create mode 100644 odinfmt.json create mode 100644 ols.json create mode 100644 src/main.odin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f21cb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +llm diff --git a/.nvim.lua b/.nvim.lua new file mode 100644 index 0000000..a85a615 --- /dev/null +++ b/.nvim.lua @@ -0,0 +1,48 @@ +require("custom.runner").setup({ + commands = { + run = function() + local command = require("custom.runner").command + command:add("odin run ./src/") + command.target = { position = "bottom" } + command:run() + end, + curl = function() + local command = require("custom.runner").command + + command:add("./.nvim/curl.sh ") + + command.target = { position = "float" } + command:run() + end, + -- build_debug = function() + -- local command = require("custom.runner").command + -- + -- command:add("odin build ./src/ -debug -out:build/debug") + -- + -- command.target = { position = "bottom" } + -- command:run() + -- end, + -- debug = function() + -- local command = require("custom.runner").command + -- local current_file = vim.api.nvim_buf_get_name(0) + -- + -- command:add("odin build ./src/ -debug -out:build/debug") + -- + -- if current_file ~= "" then + -- command:add(string.format("raddbg ./build/debug --open %q", current_file)) + -- else + -- command:add("raddbg ./build/debug") + -- end + -- command.target = { position = "bottom" } + -- command:run() + -- end, + }, + + exclude = { + "build", + "Tiled", + "assets", + ".gdbf", + ".gdbinit", + }, +}) diff --git a/.nvim/curl.sh b/.nvim/curl.sh new file mode 100755 index 0000000..84dbf70 --- /dev/null +++ b/.nvim/curl.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +curl -s http://localhost:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + { + "role": "system", + "content": "You are a Game Master and NPC Dialogue engine. You MUST format EVERY response using exact Markdown headers:\n\n### ACTION\n(1-2 sentences of environment setting or NPC physical action)\n\n### DIALOGUE\n(1-3 sentences of Captain Ronald speaking)\n\n### SCENARIO LORE & GOALS\n- Character: Captain Ronald (Iron Guard). Gruff, corrupt, paranoid.\n- Settlement: Oakhaven border outpost.\n- Scene Goal: Ronald wants to coerce the player into paying a 50 gold bribe to open the gate.\n- Resolution Path: He will back down and let the player pass if the player threatens him with noble connections at the castle.\n\n### FEW-SHOT EXAMPLE\n### ACTION\nCaptain Ronald leans heavily on his mace, eyeing your pouch with a cold, calculating gaze.\n\n### DIALOGUE\n\"The gate is locked for the night, traveler. Though... a small inspection fee of 50 gold could persuade me to turn the key.\"" + }, + { + "role": "user", + "content": "I need to get through the gate right now. I dont have time for this." + } + ], + "temperature": 0.6, + "max_tokens": 300 + }' | jq -r '.choices[0].message.content' diff --git a/LORA.md b/LORA.md new file mode 100644 index 0000000..a0efc55 --- /dev/null +++ b/LORA.md @@ -0,0 +1,412 @@ +# NPC dialogue LoRA plan + +Цель: получить маленькую локальную модель для коротких NPC-реплик в пошаговой RPG. Игровая логика остаётся в Odin; модель только превращает уже решённый `BEAT` в реплику. + +## Принятые решения + +- Student model: `meta-llama/Llama-3.2-1B-Instruct`. +- Runtime model: существующий `llm/model2.gguf` (`Llama 3.2 1B Instruct`, `Q5_K_M`). +- Teacher: обычная chat-модель DeepSeek через API, thinking отключён. +- Формат результата: только `{"dialogue":"..."}`. +- Обучение: обычная LoRA в FP16 на RX 9070 XT 16 GB, не QLoRA. +- Training stack: ROCm PyTorch + Hugging Face Transformers/PEFT/TRL. +- Первый релиз: базовый Q5 GGUF и отдельный LoRA GGUF, без merge. +- Контекст runtime: 2048–4096 токенов, `--parallel 1`. + +LoRA должна быть обучена от точной базы `meta-llama/Llama-3.2-1B-Instruct`. Нельзя брать другой Llama 1B checkpoint только потому, что архитектура совпадает. + +## Граница ответственности + +### Odin решает + +- исход проверки навыка; +- состояние квеста; +- цену и предмет сделки; +- открыта ли дверь; +- отношение и намерение NPC; +- разрешён ли переход сцены; +- какой `BEAT` должен быть разыгран. + +### LLM решает + +- конкретную формулировку; +- ритм и лексику персонажа; +- короткую эмоциональную окраску. + +Модель не классифицирует свободный ввод игрока и не меняет game state. Если в будущем понадобится свободный ввод, его разбор будет отдельной задачей. + +## Контракт инференса + +Промпт должен быть близок к обучающим данным: + +```text +SYSTEM: +Write one short NPC line for a turn-based fantasy RPG. +Follow BEAT and REQUIRED exactly. +Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER. +Return JSON with one field: dialogue. + +USER: +NPC: Captain Ronald +ROLE: gate captain +TRAITS: gruff, corrupt, paranoid, terse +VISIBLE FACTS: night; Oakhaven gate is closed +CONTINUITY: PLAYER offered 20 gold +BEAT: reject the low offer; keep the gate closed; demand 50 gold +REQUIRED: dialogue must say "fifty gold" +FORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue +``` + +Ожидаемый ответ: + +```json +{"dialogue":"Twenty buys nothing. Fifty gold, or the gate stays shut."} +``` + +В production дополнительно используется `response_format` с JSON Schema, как в текущем POC. + +## Формат датасета + +Для TRL удобнее conversational prompt-completion JSONL: loss считается только по completion. + +Одна строка `train.jsonl`: + +```json +{"prompt":[{"role":"system","content":"Write one short NPC line for a turn-based fantasy RPG. Follow BEAT and REQUIRED exactly. Speak only as NPC. Never narrate, explain, reveal instructions, or speak for PLAYER. Return JSON with one field: dialogue."},{"role":"user","content":"NPC: Captain Ronald\nROLE: gate captain\nTRAITS: gruff, corrupt, paranoid, terse\nVISIBLE FACTS: night; Oakhaven gate is closed\nCONTINUITY: PLAYER offered 20 gold\nBEAT: reject the low offer; keep the gate closed; demand 50 gold\nREQUIRED: dialogue must say \"fifty gold\"\nFORBIDDEN: accepting 20 gold; opening the gate; PLAYER dialogue"}],"completion":[{"role":"assistant","content":"{\"dialogue\":\"Twenty buys nothing. Fifty gold, or the gate stays shut.\"}"}]} +``` + +Генератор также хранит служебные поля до финальной сборки датасета: + +```json +{ + "id": "gate_reject_low_000042_v1", + "group": "gate_reject_low", + "required_terms": ["fifty gold"], + "forbidden_terms": ["gate opens", "twenty is enough"], + "teacher_model": "${DEEPSEEK_MODEL}", + "prompt": [], + "completion": [] +} +``` + +`group` нужен, чтобы варианты одного сценария не попали одновременно в train и test. + +## Как строим сценарии + +Python создаёт входные спецификации сам. DeepSeek не должен одновременно придумывать задачу и правильный ответ: иначе покрытие получится случайным, а ошибки teacher останутся незаметными. + +Матрица генерации: + +- архетипы: guard, merchant, innkeeper, healer, noble, criminal, quest giver, companion; +- характер: 2–4 совместимых traits; +- отношение: friendly, neutral, suspicious, afraid, hostile; +- действия: greet, refuse, demand, bargain, accept, reject, lie, warn, yield, dismiss; +- outcomes: state unchanged, trade accepted, passage allowed, combat threatened, quest advanced; +- факты: числа, имена, предметы, места и отрицания; +- continuity: отсутствует либо 1–3 кратких факта; +- стиль: terse, formal, rustic, nervous, arrogant, warm; +- длина: одна или две короткие фразы. + +Обязательно переизбыточно покрыть наши текущие ошибки: + +- `20` не превращается в `50` и наоборот; +- платит PLAYER, а не NPC; +- NPC не называет себя PLAYER; +- страх перед дворянином приводит к уступке, а не к угрозе дворянину; +- `do not open` и `open` дают противоположные ответы; +- NPC не копирует сырую реплику PLAYER; +- обязательные имена и числа сохраняются; +- forbidden outcome не появляется в тексте. + +Числа в полях можно задавать цифрами, но `REQUIRED` фиксирует желаемую форму в речи: например, `"fifty gold"`. Это даёт детерминированную проверку. + +## Генерация через DeepSeek API + +### Конфигурация + +```sh +export DEEPSEEK_API_KEY='...' +export DEEPSEEK_MODEL='deepseek-flash' +``` + +Название модели не хардкодим: доступные ID меняются и выбираются через dashboard/API DeepSeek. + +API OpenAI-совместимый: + +```text +base_url: https://api.deepseek.com +endpoint: /chat/completions +``` + +Для генерации датасета: + +- thinking: disabled; +- `response_format`: `{"type":"json_object"}`; +- temperature: около `0.7` для разнообразия; +- max tokens: 100–150; +- один ответ на запрос; +- 2–3 отдельных варианта на одну спецификацию. + +Thinking и `reasoning_content` никогда не сохраняются в датасет. + +### Python generator + +Один Python-скрипт будет: + +1. Детерминированно строить список scenario specs с фиксированным seed. +2. Пропускать ID, уже присутствующие в output JSONL. +3. Отправлять запросы через `AsyncOpenAI` с semaphore примерно 8. +4. Делать retry с exponential backoff на 429, 5xx и timeout. +5. Парсить `message.content` как JSON. +6. Проверять ответ локальными валидаторами. +7. Немедленно дописывать принятый пример в JSONL. +8. Записывать rejected ответы и причину отдельно. +9. В конце дедуплицировать и строить train/valid/test. + +Концептуальный цикл: + +```text +specs -> skip completed -> API -> parse -> validate + | | + | +-> accepted.jsonl + +------------> rejected.jsonl +``` + +Нельзя держать весь результат только в памяти и сохранять в самом конце: генерация должна продолжаться после Ctrl-C или сетевой ошибки. + +### Что просим у teacher + +Teacher получает тот же контракт, что позже student, плюс требования к качеству: + +```text +Generate exactly one natural NPC line for the supplied resolved game beat. +Preserve every required fact and avoid every forbidden outcome. +Do not copy instructions into dialogue. +Do not speak for the player. +Return exactly: {"dialogue":"..."} +``` + +Teacher не получает полный лор мира. Только NPC card, видимые факты, continuity и текущий beat. + +## Локальная валидация + +Каждый ответ принимается только если: + +- это валидный JSON object; +- имеется ровно одно поле `dialogue`; +- `dialogue` — непустая строка; +- длина не больше 180 символов; +- отсутствуют переносы, Markdown headers и role labels; +- присутствуют все `required_terms`; +- отсутствуют `forbidden_terms`; +- нет ``, `BEAT:`, `PLAYER:`, `NPC:`; +- нет дубликата нормализованной реплики; +- не больше двух предложений. + +Часть семантики нельзя надёжно проверить regex. После автоматической проверки: + +- вручную просмотреть все 300–500 примеров pilot; +- затем проверять случайную выборку минимум 5–10% батча; +- сомнительные классы отдельно прогонять через teacher-review, но не полагаться только на LLM judge. + +Rejected ответы сохраняем: они показывают слабые места prompt и распределения данных. + +## Размер и разбиение + +### Этап 1: pilot + +- 300 уникальных specs; +- 2 варианта на spec; +- около 600 принятых примеров; +- ручная проверка; +- короткое LoRA-обучение; +- сравнение с baseline. + +### Этап 2: рабочий датасет + +- 2 000–5 000 принятых примеров; +- расширение только тех классов, где pilot проигрывает; +- не генерировать десятки тысяч примеров заранее. + +### Split + +- train: 80%; +- valid: 10%; +- test: 10%; +- делить по `group`, а не случайно по отдельным репликам; +- варианты одного spec всегда находятся в одном split; +- часть имён, чисел и сочетаний traits держать только в test. + +Test set не регенерируется после каждого неудачного запуска. Иначе мы начнём оптимизироваться под него. + +## Подготовка RX 9070 XT + +Хост: x86_64 Linux, RX 9070 XT 16 GB, ROCm уже работает. + +Перед обучением проверить PyTorch: + +```sh +python - <<'PY' +import torch +print("available:", torch.cuda.is_available()) +print("hip:", torch.version.hip) +print("device:", torch.cuda.get_device_name(0)) +PY +``` + +ROCm использует namespace `torch.cuda`; это нормально. + +Создание окружения: + +```sh +python -m venv .venv +source .venv/bin/activate +``` + +PyTorch ставится только способом, рекомендованным для установленной версии ROCm. Обычный `pip install torch` поверх рабочего ROCm-окружения может заменить правильную сборку. + +После PyTorch: + +```sh +pip install transformers peft trl datasets accelerate safetensors sentencepiece +``` + +`bitsandbytes` не нужен: 1B LoRA в FP16 помещается в 16 GB, а QLoRA только добавит AMD-специфичные проблемы. + +## Конфигурация LoRA + +Стартовые параметры: + +```text +base model: meta-llama/Llama-3.2-1B-Instruct +dtype: float16 +LoRA rank: 16 +LoRA alpha: 32 +LoRA dropout: 0.05 +bias: none +target modules: + q_proj, k_proj, v_proj, o_proj, + gate_proj, up_proj, down_proj +max sequence length: 512 +learning rate: 1e-4 +batch size: 8 +gradient accumulation: 2 +epochs: 2 +optimizer: adamw_torch +packing: true +completion-only loss: true +gradient checkpointing: false +``` + +Если обучение не помещается или ROCm падает: + +1. batch size уменьшить до 4; +2. затем до 2; +3. включать gradient checkpointing только после этого. + +Не начинать с rank 64, sequence length 4096 или сложных оптимизаторов. Для короткой реплики это лишнее. + +## Training stack + +Используем: + +- `AutoModelForCausalLM` и tokenizer из Transformers; +- `LoraConfig` из PEFT; +- `SFTTrainer` и `SFTConfig` из TRL; +- conversational prompt-completion dataset; +- `completion_only_loss=True`. + +Tokenizer и chat template берутся из официальной Llama 3.2 1B Instruct. Не строим свой ChatML и не обучаем новые special tokens. + +Артефакты PEFT: + +```text +artifacts/npc-lora/ + adapter_config.json + adapter_model.safetensors + tokenizer files if saved +``` + +## Оценка времени + +Очень грубо для RX 9070 XT: + +- pilot, 600 коротких примеров: несколько минут; +- 2 000 примеров, 2–3 эпохи: примерно 10–30 минут; +- 5 000 примеров, 2–3 эпохи: примерно 30–90 минут. + +ROCm setup, загрузка модели и первая компиляция kernels могут занять дольше pilot training. После первых 100–200 steps фиксируем фактические tokens/sec и пересчитываем ETA. + +## Экспорт отдельного LoRA GGUF + +После PEFT training берём актуальный `convert_lora_to_gguf.py` из исходников той же эпохи `llama.cpp`, что используется для runtime: + +```sh +python path/to/llama.cpp/convert_lora_to_gguf.py \ + --base-model-id meta-llama/Llama-3.2-1B-Instruct \ + --outtype f16 \ + --outfile artifacts/npc-lora-f16.gguf \ + artifacts/npc-lora +``` + +Для конвертации нужны `adapter_config.json` и `adapter_model.safetensors`. Полные base weights конвертеру не нужны, но нужны config/tokenizer базы локально или через Hugging Face. + +Запуск без merge: + +```sh +llama-server \ + --model ./llm/model2.gguf \ + --lora ./artifacts/npc-lora-f16.gguf \ + --port 8080 \ + --ctx-size 4096 \ + --parallel 1 \ + -ngl 99 +``` + +Отдельный adapter обычно занимает десятки мегабайт. Runtime всё ещё использует Q5 base model; LoRA не превращает её обратно в FP16. + +Merge оставляем на потом, только если отдельный adapter создаёт проблемы с упаковкой или startup. + +## Проверка результата + +До обучения сохраняем baseline текущей `model2.gguf` на фиксированном test set и seed. + +После обучения сравниваем base и base+LoRA по одинаковым входам: + +- valid JSON rate; +- required facts rate; +- forbidden outcome rate; +- speaker/player confusion rate; +- exact number preservation; +- средняя длина; +- repetition rate; +- ручная оценка naturalness. + +Минимальная цель pilot: + +```text +valid JSON: 100% +required facts: >= 95% +forbidden outcomes: <= 2% +speaker confusion: <= 2% +number preservation: >= 98% +``` + +Если LoRA улучшает стиль, но не эти метрики, её не принимаем. + +После этого прогоняем живую сцену из текущего Odin POC: insist → low offer → noble threat и отдельный путь полной оплаты. + +## Порядок работы + +1. Зафиксировать окончательный prompt contract. +2. Написать generator и deterministic scenario matrix. +3. Получить и вручную проверить pilot из 600 примеров. +4. Создать неизменяемый grouped test set. +5. Записать baseline Llama 3.2 1B. +6. Обучить LoRA rank 16 на 2 эпохи. +7. Проверить PEFT adapter до GGUF-конвертации. +8. Конвертировать adapter в GGUF. +9. Проверить Q5 base + LoRA в `llama-server`. +10. Расширять датасет только по измеренным ошибкам. + +Не делаем сразу большой датасет, full fine-tune, merge модели, thinking traces, отдельные LoRA на каждого NPC или автоматическую генерацию всего world lore. diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..cae97b7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,5 @@ +[tools] +"llama.cpp" = { version = "latest", prerelease = "true", version_prefix = "b" } + +[tasks.serve] +run = "llama serve --model ./llm/model2.gguf --port 8080 -ngl 99 --ctx-size 16384" diff --git a/odinfmt.json b/odinfmt.json new file mode 100644 index 0000000..7ffb1ba --- /dev/null +++ b/odinfmt.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/odinfmt.schema.json", + "character_width": 80, + "tabs": true, + "spaces": 2, + "tabs_width": 4 +} diff --git a/ols.json b/ols.json new file mode 100644 index 0000000..c488e1b --- /dev/null +++ b/ols.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json", + "enable_semantic_tokens": true, + "enable_document_symbols": true, + "enable_hover": true, + "enable_snippets": true, + "enable_checker_only_saved": true, + "enable_inlay_hints_params": true, + "enable_inlay_hints_default_params": true, + "enable_inlay_hints_implicit_return": true, + "enable_inlay_hints_optional_result": true, + "enable_inlay_hints_struct_fields": true +} diff --git a/src/main.odin b/src/main.odin new file mode 100644 index 0000000..d30e0b9 --- /dev/null +++ b/src/main.odin @@ -0,0 +1,216 @@ +package main + +import "base:runtime" +import c "core:c/libc" +import "core:bufio" +import "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:strings" +import curl "vendor:curl" + +API_URL :: "http://localhost:8080/v1/chat/completions" + +REQUEST :: `{ + "messages": [ + { + "role": "system", + "content": "Write one short line spoken by the named NPC. Realize BEAT as dialogue. No narration, labels, explanation, player dialogue, or thinking." + }, + { + "role": "user", + "content": "NPC: Mira, firm innkeeper.\nBEAT: Refuse lodging; the fixed price is eight silver." + }, + { + "role": "assistant", + "content": "{\"dialogue\":\"Eight silver first, or find another roof.\"}" + }, + { + "role": "user", + "content": "NPC: Osric, cowardly toll keeper.\nBEAT: Frightened by Duke Harland; waive the toll and permit passage." + }, + { + "role": "assistant", + "content": "{\"dialogue\":\"No toll for His Grace's people. Go through, quickly.\"}" + }, + { + "role": "user", + "content": "NPC: Captain Ronald, gruff, corrupt gate guard.\nSCENE: Night at Oakhaven's closed gate.\nBEAT: $BEAT" + } + ], + "temperature": 0.3, + "top_p": 0.9, + "top_k": 40, + "max_tokens": 180, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "npc_line", + "strict": true, + "schema": { + "type": "object", + "properties": { + "dialogue": { + "type": "string", + "minLength": 1, + "maxLength": 140 + } + }, + "required": ["dialogue"], + "additionalProperties": false + } + } + } +}` + +Message :: struct { + content: string, +} + +Choice :: struct { + message: Message, +} + +Chat_Response :: struct { + choices: []Choice, +} + +NPC_Line :: struct { + dialogue: string, +} + +write_response :: proc "c" ( + data: [^]byte, + size, count: c.size_t, + user_data: rawptr, +) -> c.size_t { + context = runtime.default_context() + n := int(size * count) + response := cast(^[dynamic]byte)user_data + append(response, ..data[:n]) + return c.size_t(n) +} + +request_dialogue :: proc(headers: ^curl.slist, beat: string) -> (string, bool) { + body, allocated := strings.replace_all(REQUEST, "$BEAT", beat) + defer if allocated { + delete(body) + } + body_cstr := strings.clone_to_cstring(body) + defer delete(body_cstr) + + response := make([dynamic]byte, 0, 1024) + defer delete(response) + + handle := curl.easy_init() + if handle == nil { + return "", false + } + defer curl.easy_cleanup(handle) + + curl.easy_setopt(handle, .URL, API_URL) + curl.easy_setopt(handle, .HTTPHEADER, headers) + curl.easy_setopt(handle, .POSTFIELDS, body_cstr) + curl.easy_setopt(handle, .POSTFIELDSIZE, c.long(len(body))) + curl.easy_setopt(handle, .WRITEFUNCTION, write_response) + curl.easy_setopt(handle, .WRITEDATA, &response) + curl.easy_setopt(handle, .TIMEOUT, c.long(30)) + + result := curl.easy_perform(handle) + if result != .E_OK { + fmt.eprintfln("curl: %s", curl.easy_strerror(result)) + return "", false + } + + chat: Chat_Response + if json.unmarshal(response[:], &chat) != nil || len(chat.choices) == 0 { + fmt.eprintfln("server: %s", string(response[:])) + return "", false + } + defer delete(chat.choices) + defer delete(chat.choices[0].message.content) + + line: NPC_Line + if json.unmarshal_string(chat.choices[0].message.content, &line) != nil { + return "", false + } + return line.dialogue, true +} + +print_menu :: proc() { + fmt.println("\n1. Demand passage") + fmt.println("2. Offer 20 gold") + fmt.println("3. Pay 50 gold") + fmt.println("4. Invoke Lady Marwyn") + fmt.print("> ") +} + +main :: proc() { + if curl.global_init(c.long(curl.GLOBAL_DEFAULT)) != .E_OK { + fmt.eprintln("Could not initialize libcurl") + return + } + defer curl.global_cleanup() + + headers := curl.slist_append(nil, "Content-Type: application/json") + if headers == nil { + fmt.eprintln("Could not create HTTP headers") + return + } + defer curl.slist_free_all(headers) + + reader: bufio.Reader + bufio.reader_init(&reader, os.to_reader(os.stdin)) + defer bufio.reader_destroy(&reader) + + fmt.println("Oakhaven, after dark. Captain Ronald blocks the gate.") + if line, ok := request_dialogue(headers, "Keep the gate closed; demand exactly fifty gold before allowing passage."); ok { + defer delete(line) + fmt.printfln("\nRonald: %s", line) + } else { + fmt.eprintln("LLM request failed. Is llama-server running on port 8080?") + return + } + + actions := [4]string{ + "Open the gate. I don't have time for this.", + "Take twenty gold and let me through.", + "Fine. Here's your fifty gold.", + "Lady Marwyn will hear how you delayed her envoy.", + } + beats := [4]string{ + "Keep the gate closed; demand exactly fifty gold before allowing passage.", + "Keep the gate closed; reject twenty gold and insist on exactly fifty gold.", + "Accept exactly fifty gold, open the gate, and permit passage.", + "Permit passage without payment. Claim a misunderstanding because Ronald fears Lady Marwyn.", + } + + for { + print_menu() + input, err := bufio.reader_read_string(&reader, '\n') + if err != nil && len(input) == 0 { + return + } + defer delete(input) + + if len(input) == 0 || input[0] < '1' || input[0] > '4' { + fmt.println("Choose 1-4.") + continue + } + + choice := int(input[0] - '1') + fmt.printfln("\nYou: %s", actions[choice]) + line, ok := request_dialogue(headers, beats[choice]) + if !ok { + fmt.eprintln("LLM request failed") + continue + } + fmt.printfln("\nRonald: %s", line) + delete(line) + + if choice == 2 || choice == 3 { + fmt.println("\nThe gate opens.") + return + } + } +}