// 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": "", "script": "" } If the rule CANNOT be implemented, return: { "rejected": true, "reason": "" } 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 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" 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. - 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 }