prsi/internal/llm/client.go
Pavel Flegr 7b54ed8e53 a
2026-05-07 19:46:01 +02:00

226 lines
9.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package llm provides an OpenRouter client that rewrites the game's rules script
// in response to a natural-language rule description from the winning player.
package llm
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"time"
)
// GenerateRuleRequest is the input to GenerateRule.
type GenerateRuleRequest struct {
CurrentScript string // the JS rules currently active in the room
RulePrompt string // free-text rule description from the winner
WinnerName string // for personalised flavour in the description
}
// GenerateRuleResponse is what the API endpoint returns to the browser.
// When Rejected is true, Script and Description are empty and Reason explains why.
type GenerateRuleResponse struct {
Rejected bool `json:"rejected,omitempty"` // true when the AI refuses to implement the rule
Reason string `json:"reason,omitempty"` // human-readable rejection reason
Script string `json:"script,omitempty"` // validated JS to be stored in the room
Description string `json:"description,omitempty"` // human-readable summary to display in the UI
}
const systemPrompt = `You are a rules engine for the Czech card game "Prší" (a variant of Crazy Eights).
You will be given the current JavaScript rules script and a natural-language description of a NEW rule
the winning player wants to add. Your job is to return a JSON object.
If the rule CAN be implemented, return:
{
"rejected": false,
"description": "<short (≤ 120 chars) Czech or English sentence summarising the new rule>",
"script": "<COMPLETE updated JavaScript source>"
}
If the rule CANNOT be implemented, return:
{
"rejected": true,
"reason": "<clear, friendly explanation of why, and a suggestion for what the player could try instead>"
}
The script MUST export these functions (no classes, no imports):
canPlay(cards, topCard, state) → boolean
Decides whether playing the given array of cards is legal right now.
cards array of { suit, value } objects the player wants to play this turn.
IMPORTANT: engine enforces exactly ONE card, so cards.length is always 1.
topCard { suit, value } — the current top of the discard pile
state object with the following fields:
activeSuit string — currently required suit (may differ from topCard.suit after a wildcard)
penaltyCards number — accumulated draw penalty; 0 = none active
skipNext bool — true when the next player must skip unless they counter with an Ace
chosenSuit string — suit the player chose when playing an Upper (wildcard)
hand Card[] — the acting player's FULL hand, including cards being played
playerHands Card[][] — full hands of all players in seat order
discardPile Card[] — full discard pile snapshot (includes previously played cards)
deck Card[] — current draw deck snapshot
currentPlayerIndex number — acting player seat index
playerCount number — total number of players
handCounts number[] — cards held by each player (seat order)
turnNumber number — increments each time turn advances
roundNumber number — increments when turn wraps to seat 0
lastActionType string — start|play|draw|choice|choice_pending|win
lastActorIndex number — seat index of actor in last action
pendingChoices object[] — pending choice queue (each has targetPlayerIndex,prompt,options)
card/topCard suit values: "hearts" | "diamonds" | "spades" | "clubs"
user-facing suit naming in this app: hearts=červený, diamonds=zelený, spades=kule, clubs=žaludy
card/topCard value values: "7" | "8" | "9" | "10" | "under" | "upper" | "king" | "ace"
onPlayed(cards, state) → {
penaltyCards?: number, — new accumulated draw penalty
skipNext?: bool, — new skip flag
activeSuit?: string, — new active suit
instantWin?: bool, — current player wins immediately (e.g. "king wins the game")
extraTurn?: bool, — current player takes another turn immediately
reverseDir?: bool, — set turn direction: false=clockwise (default), true=counter-clockwise
}
'state' has the same shape as above.
Only returned fields are applied; omitted fields keep their current value.
state.direction is 1 (clockwise) or -1 (counter-clockwise) — useful to toggle on reverseDir.
onChoice(choice, state) → same object shape as onPlayed
Called when onPlayed requests a UI choice via choicePrompt + choiceOptions.
If a script does not need choices, onChoice can just return current state unchanged.
Additional optional fields allowed in onPlayed return:
choicePrompt?: string,
choiceOptions?: string[]
choiceTargetPlayerIndex?: number
When both are provided and choiceOptions has at least 1 item, frontend prompts current player and
backend calls onChoice with selected value before turn advances. If a choice is already pending,
this new one is queued (chained choices).
Advanced state mutation fields allowed in onPlayed/onChoice return:
playerHands?: Card[][]
discardPile?: Card[] (or playedPile as alias)
deck?: Card[]
currentPlayerIndex?: number
These fully replace corresponding engine state fields (validated by backend).
To model "discard one extra card" while keeping one-card input:
discardIndex?: number
If provided (0-based index into the current player's hand AFTER the played card was removed),
engine discards that one extra card and applies onPlayed side effects to it as well.
Use this for rules like "after King, discard one additional compatible card".
Rules about rules:
- Keep all previously implemented rules intact; only ADD the new behaviour.
- Do NOT implement multi-card moves. The engine rejects plays with cards.length != 1.
- If you need an extra discard, use discardIndex in onPlayed instead of cards.length > 1.
- For advanced custom mechanics you may mutate state directly with playerHands/discardPile/deck/currentPlayerIndex.
- You may use state.hand, state.handCounts, state.playerCount for hand-inspection rules.
- Prefer Czech wording in "description" and use suit names červený/zelený/kule/žaludy in user-facing text.
- Do NOT include markdown fences, comments outside the functions, or any code other than function definitions.
- Return ONLY valid JSON — no prose before or after.`
// GenerateRule calls OpenRouter to produce an updated rules script.
func GenerateRule(req GenerateRuleRequest) (GenerateRuleResponse, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
return GenerateRuleResponse{}, fmt.Errorf("OPENROUTER_API_KEY environment variable is not set")
}
userMsg := fmt.Sprintf(
"Current script:\n```javascript\n%s\n```\n\nNew rule proposed by %s:\n%s",
req.CurrentScript, req.WinnerName, req.RulePrompt,
)
body, _ := json.Marshal(map[string]interface{}{
"model": modelName(),
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userMsg},
},
"max_tokens": 1200,
"temperature": 0.3,
"response_format": map[string]string{"type": "json_object"},
})
httpClient := &http.Client{Timeout: 30 * time.Second}
httpReq, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return GenerateRuleResponse{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("HTTP-Referer", "https://github.com/prsi-game")
httpReq.Header.Set("X-Title", "Prší")
resp, err := httpClient.Do(httpReq)
if err != nil {
return GenerateRuleResponse{}, fmt.Errorf("OpenRouter request failed: %w", err)
}
defer resp.Body.Close()
var raw struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return GenerateRuleResponse{}, fmt.Errorf("decode response: %w", err)
}
if raw.Error != nil {
return GenerateRuleResponse{}, fmt.Errorf("OpenRouter: %s", raw.Error.Message)
}
if len(raw.Choices) == 0 {
return GenerateRuleResponse{}, fmt.Errorf("empty response from OpenRouter")
}
content := raw.Choices[0].Message.Content
// Parse the JSON object the model returned.
var result GenerateRuleResponse
if err := json.Unmarshal([]byte(content), &result); err != nil {
// Try stripping accidental markdown fences.
cleaned := stripMarkdownFences(content)
if err2 := json.Unmarshal([]byte(cleaned), &result); err2 != nil {
return GenerateRuleResponse{}, fmt.Errorf("parse LLM output: %w (raw: %s)", err, content)
}
}
if result.Rejected {
if strings.TrimSpace(result.Reason) == "" {
result.Reason = "The AI was unable to implement this rule."
}
return result, nil
}
if strings.TrimSpace(result.Script) == "" {
return GenerateRuleResponse{}, fmt.Errorf("LLM returned empty script")
}
return result, nil
}
// modelName returns the OpenRouter model to use, defaultable via OPENROUTER_MODEL env.
func modelName() string {
if m := os.Getenv("OPENROUTER_MODEL"); m != "" {
return m
}
return "openai/gpt-4o-mini"
}
var reFence = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
func stripMarkdownFences(s string) string {
if m := reFence.FindStringSubmatch(s); len(m) == 2 {
return m[1]
}
return s
}