213 lines
8.5 KiB
Go
213 lines
8.5 KiB
Go
// 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
|
||
playerCount number — total number of players
|
||
handCounts number[] — cards held by each player (seat order)
|
||
|
||
card/topCard suit values: "hearts" | "diamonds" | "spades" | "clubs"
|
||
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[]
|
||
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.
|
||
|
||
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.
|
||
- 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
|
||
}
|