bla
This commit is contained in:
parent
0736d22ebe
commit
b3f02ea15e
10 changed files with 3567 additions and 0 deletions
213
internal/llm/client.go
Normal file
213
internal/llm/client.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue