From 338e85229fe3502b03894056799be7316d28164f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:06:06 +0000 Subject: [PATCH 1/7] Initial plan From 0cb35db99a20851e8ec410ff33f03b0c88847ce9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:12:50 +0000 Subject: [PATCH 2/7] Add bot support and API endpoints for Four Color Card game Co-authored-by: lin594 <20886330+lin594@users.noreply.github.com> --- bot_scheduler.go | 374 +++++++++++++++++++++++++++++++++ collections.go | 33 +++ game_logics/four_color_card.js | 291 +++++++++++++++++++++++++ go.mod | 6 +- go.sum | 8 + main.go | 7 + routes.go | 252 ++++++++++++++++++++++ 7 files changed, 970 insertions(+), 1 deletion(-) create mode 100644 bot_scheduler.go diff --git a/bot_scheduler.go b/bot_scheduler.go new file mode 100644 index 0000000..edb034e --- /dev/null +++ b/bot_scheduler.go @@ -0,0 +1,374 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "math/rand" + "os" + "time" + + "github.com/dop251/goja" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" +) + +// BotScheduler manages automated bot moves +type BotScheduler struct { + app *pocketbase.PocketBase + ticker *time.Ticker + stopCh chan bool + running bool +} + +// NewBotScheduler creates a new bot scheduler +func NewBotScheduler(app *pocketbase.PocketBase) *BotScheduler { + return &BotScheduler{ + app: app, + stopCh: make(chan bool), + running: false, + } +} + +// Start begins the bot scheduler loop +func (bs *BotScheduler) Start() { + if bs.running { + return + } + + bs.running = true + bs.ticker = time.NewTicker(2 * time.Second) // Check every 2 seconds + + go func() { + log.Println("Bot scheduler started") + for { + select { + case <-bs.ticker.C: + bs.processBotTurns() + case <-bs.stopCh: + log.Println("Bot scheduler stopped") + return + } + } + }() +} + +// Stop halts the bot scheduler +func (bs *BotScheduler) Stop() { + if !bs.running { + return + } + bs.running = false + bs.ticker.Stop() + bs.stopCh <- true +} + +// processBotTurns checks for tables where it's a bot's turn and executes their move +func (bs *BotScheduler) processBotTurns() { + // Find all playing tables + tables, err := bs.app.FindRecordsByFilter( + "tables", + "status = 'playing'", + "-created", + 100, + 0, + ) + + if err != nil { + log.Printf("Error fetching tables: %v", err) + return + } + + for _, table := range tables { + bs.processBotTurnForTable(table) + } +} + +// processBotTurnForTable checks and executes bot turn for a specific table +func (bs *BotScheduler) processBotTurnForTable(table *core.Record) { + // Get current game state + currentGameId := table.GetString("current_game") + if currentGameId == "" { + return + } + + gameState, err := bs.app.FindRecordById("game_states", currentGameId) + if err != nil { + return + } + + // Get current player + currentPlayerId := gameState.GetString("current_player_turn") + if currentPlayerId == "" { + return + } + + // Check if current player is a bot + player, err := bs.app.FindRecordById("_pb_users_auth_", currentPlayerId) + if err != nil { + return + } + + isBot := player.GetBool("is_bot") + if !isBot { + return + } + + // Simulate thinking delay + thinkTime := time.Duration(2000+rand.Intn(3000)) * time.Millisecond + time.Sleep(thinkTime) + + // Execute bot decision + bs.executeBotDecision(table, gameState, player) +} + +// executeBotDecision gets bot's decision from game logic and executes it +func (bs *BotScheduler) executeBotDecision(table *core.Record, gameState *core.Record, bot *core.Record) { + // Get game rule + ruleId := table.GetString("rule") + rule, err := bs.app.FindRecordById("game_rules", ruleId) + if err != nil { + log.Printf("Error fetching game rule: %v", err) + return + } + + // Load game logic + logicFile := rule.GetString("logic_file") + logicPath := fmt.Sprintf("game_logics/%s", logicFile) + + logicCode, err := os.ReadFile(logicPath) + if err != nil { + log.Printf("Error reading logic file: %v", err) + return + } + + // Execute bot decision using goja + vm := goja.New() + + // Load the game logic + if _, err := vm.RunString(string(logicCode)); err != nil { + log.Printf("Error loading game logic: %v", err) + return + } + + // Get config + var config map[string]interface{} + if err := json.Unmarshal([]byte(rule.GetString("config_json")), &config); err != nil { + log.Printf("Error parsing config: %v", err) + return + } + + // Get game state data + gameStateData := map[string]interface{}{ + "current_player_turn": gameState.GetString("current_player_turn"), + "player_hands": gameState.Get("player_hands"), + "player_melds": gameState.Get("player_melds"), + "deck": gameState.Get("deck"), + "discard_pile": gameState.Get("discard_pile"), + "last_play": gameState.Get("last_play"), + "game_specific_data": gameState.Get("game_specific_data"), + } + + // Get bot level + botLevel := bot.GetString("bot_level") + if botLevel == "" { + botLevel = "normal" + } + + // Call botDecision function + botDecisionFunc, ok := goja.AssertFunction(vm.Get("botDecision")) + if !ok { + log.Printf("botDecision function not found in game logic") + return + } + + result, err := botDecisionFunc(goja.Undefined(), + vm.ToValue(config), + vm.ToValue(gameStateData), + vm.ToValue(bot.Id), + vm.ToValue(botLevel), + ) + + if err != nil { + log.Printf("Error calling botDecision: %v", err) + return + } + + // Convert result to map + var decision map[string]interface{} + if err := vm.ExportTo(result, &decision); err != nil { + log.Printf("Error exporting decision: %v", err) + return + } + + // Execute the action + bs.executeAction(table, gameState, bot, decision) +} + +// executeAction executes the bot's chosen action +func (bs *BotScheduler) executeAction(table *core.Record, gameState *core.Record, bot *core.Record, decision map[string]interface{}) { + actionType, ok := decision["action_type"].(string) + if !ok { + log.Printf("Invalid action type in decision") + return + } + + actionData, ok := decision["action_data"].(map[string]interface{}) + if !ok { + actionData = make(map[string]interface{}) + } + + // Get the next sequence number + actions, err := bs.app.FindRecordsByFilter( + "game_actions", + fmt.Sprintf("table = '%s'", table.Id), + "-sequence_number", + 1, + 0, + ) + + sequenceNumber := 1 + if err == nil && len(actions) > 0 { + lastSeq := actions[0].GetInt("sequence_number") + sequenceNumber = lastSeq + 1 + } + + // Create action record + collection, err := bs.app.FindCollectionByNameOrId("game_actions") + if err != nil { + log.Printf("Error finding game_actions collection: %v", err) + return + } + + actionRecord := core.NewRecord(collection) + actionRecord.Set("table", table.Id) + actionRecord.Set("game_state", gameState.Id) + actionRecord.Set("player", bot.Id) + actionRecord.Set("sequence_number", sequenceNumber) + actionRecord.Set("action_type", actionType) + + actionDataJson, _ := json.Marshal(actionData) + actionRecord.Set("action_data", string(actionDataJson)) + + if err := bs.app.Save(actionRecord); err != nil { + log.Printf("Error saving bot action: %v", err) + return + } + + log.Printf("Bot %s executed action: %s", bot.GetString("username"), actionType) + + // Apply the action to game state + bs.applyActionToGameState(table, gameState, bot, actionType, actionData) +} + +// applyActionToGameState applies the action and updates game state +func (bs *BotScheduler) applyActionToGameState(table *core.Record, gameState *core.Record, player *core.Record, actionType string, actionData map[string]interface{}) { + // Get game rule + ruleId := table.GetString("rule") + rule, err := bs.app.FindRecordById("game_rules", ruleId) + if err != nil { + log.Printf("Error fetching game rule: %v", err) + return + } + + // Load game logic + logicFile := rule.GetString("logic_file") + logicPath := fmt.Sprintf("game_logics/%s", logicFile) + + logicCode, err := os.ReadFile(logicPath) + if err != nil { + log.Printf("Error reading logic file: %v", err) + return + } + + // Execute apply function using goja + vm := goja.New() + + // Load the game logic + if _, err := vm.RunString(string(logicCode)); err != nil { + log.Printf("Error loading game logic: %v", err) + return + } + + // Get config + var config map[string]interface{} + if err := json.Unmarshal([]byte(rule.GetString("config_json")), &config); err != nil { + log.Printf("Error parsing config: %v", err) + return + } + + // Get game state data + gameStateData := map[string]interface{}{ + "current_player_turn": gameState.GetString("current_player_turn"), + "player_hands": gameState.Get("player_hands"), + "player_melds": gameState.Get("player_melds"), + "deck": gameState.Get("deck"), + "discard_pile": gameState.Get("discard_pile"), + "last_play": gameState.Get("last_play"), + "game_specific_data": gameState.Get("game_specific_data"), + } + + // Call apply function + functionName := fmt.Sprintf("apply%s", capitalize(actionType)) + applyFunc, ok := goja.AssertFunction(vm.Get(functionName)) + if !ok { + log.Printf("Function %s not found in game logic", functionName) + return + } + + result, err := applyFunc(goja.Undefined(), + vm.ToValue(config), + vm.ToValue(gameStateData), + vm.ToValue(player.Id), + vm.ToValue(actionData), + ) + + if err != nil { + log.Printf("Error calling %s: %v", functionName, err) + return + } + + // Convert result to map + var newState map[string]interface{} + if err := vm.ExportTo(result, &newState); err != nil { + log.Printf("Error exporting new state: %v", err) + return + } + + // Update game state + for key, value := range newState { + gameState.Set(key, value) + } + + if err := bs.app.Save(gameState); err != nil { + log.Printf("Error updating game state: %v", err) + return + } +} + +// capitalize capitalizes the first letter of a string +func capitalize(s string) string { + if len(s) == 0 { + return s + } + + // Handle special cases for action names + switch s { + case "play_cards": + return "Play_cards" + case "chi": + return "Chi" + case "peng": + return "Peng" + case "kai": + return "Kai" + case "hu": + return "Hu" + case "draw": + return "Draw" + case "pass": + return "Pass" + default: + return s + } +} diff --git a/collections.go b/collections.go index 587a19a..d572303 100644 --- a/collections.go +++ b/collections.go @@ -8,6 +8,11 @@ import ( // initializeCollections creates all necessary collections for the game platform func initializeCollections(app *pocketbase.PocketBase) error { + // Extend users collection with bot fields + if err := extendUsersCollection(app); err != nil { + return err + } + // Check if collections already exist if _, err := app.FindCollectionByNameOrId("game_rules"); err == nil { // Collections already exist @@ -75,6 +80,8 @@ func initializeCollections(app *pocketbase.PocketBase) error { gameStates.Fields.Add( &core.RelationField{Name: "table", Required: true, CollectionId: tables.Id, MaxSelect: 1, CascadeDelete: true}, &core.NumberField{Name: "round_number", Required: true}, + &core.NumberField{Name: "current_player_index"}, + &core.NumberField{Name: "dealer_index"}, &core.RelationField{Name: "current_player_turn", CollectionId: "_pb_users_auth_", MaxSelect: 1}, &core.JSONField{Name: "player_hands", Required: true}, &core.JSONField{Name: "deck", Required: true}, @@ -124,3 +131,29 @@ func initializeCollections(app *pocketbase.PocketBase) error { return nil } + +// extendUsersCollection adds bot-related fields to the users collection +func extendUsersCollection(app *pocketbase.PocketBase) error { + usersCollection, err := app.FindCollectionByNameOrId("_pb_users_auth_") + if err != nil { + return err + } + + // Check if is_bot field already exists + if usersCollection.Fields.GetByName("is_bot") != nil { + // Fields already added + return nil + } + + // Add bot-related fields + usersCollection.Fields.Add( + &core.BoolField{Name: "is_bot"}, + &core.SelectField{ + Name: "bot_level", + Values: []string{"easy", "normal", "hard"}, + MaxSelect: 1, + }, + ) + + return app.Save(usersCollection) +} diff --git a/game_logics/four_color_card.js b/game_logics/four_color_card.js index ea3c106..e0ef837 100644 --- a/game_logics/four_color_card.js +++ b/game_logics/four_color_card.js @@ -595,3 +595,294 @@ function calculateFinalScores(config, gameState, winnerId) { return scores; } + +/** + * Bot Decision Making AI + * Implements strategic decision making for bot players based on difficulty level + * @param {Object} config - Game configuration + * @param {Object} gameState - Current game state + * @param {string} botId - Bot player ID + * @param {string} botLevel - Difficulty level: 'easy', 'normal', or 'hard' + * @returns {Object} Decision object with action_type and action_data + */ +function botDecision(config, gameState, botId, botLevel) { + const playerHand = gameState.player_hands[botId]; + const playerMelds = gameState.player_melds[botId]; + const lastPlay = gameState.last_play; + const waitingForResponse = gameState.game_specific_data.waiting_for_response; + const isCurrentTurn = gameState.current_player_turn === botId; + + // If waiting for response from this bot + if (waitingForResponse && gameState.game_specific_data.response_allowed_players.includes(botId)) { + return botResponseDecision(config, gameState, botId, botLevel, lastPlay); + } + + // If it's bot's turn to play + if (isCurrentTurn && !waitingForResponse) { + return botPlayDecision(config, gameState, botId, botLevel); + } + + // Default: pass + return { action_type: 'pass', action_data: {} }; +} + +/** + * Bot decision when responding to other player's play + */ +function botResponseDecision(config, gameState, botId, botLevel, lastPlay) { + const playerHand = gameState.player_hands[botId]; + const playerMelds = gameState.player_melds[botId]; + const lastCard = lastPlay.cards[0]; + + // Check for Hu (highest priority) + if (canBotHu(config, gameState, botId, lastCard, botLevel)) { + return { action_type: 'hu', action_data: { card: lastCard } }; + } + + // Check for Kai (if already have peng) + const kaiDecision = checkBotKai(config, gameState, botId, lastCard, botLevel); + if (kaiDecision) return kaiDecision; + + // Check for Peng + const pengDecision = checkBotPeng(config, gameState, botId, lastCard, botLevel); + if (pengDecision) return pengDecision; + + // Check for Chi (only if next player) + const chiDecision = checkBotChi(config, gameState, botId, lastCard, botLevel); + if (chiDecision) return chiDecision; + + // Default: Draw card + return { action_type: 'draw', action_data: {} }; +} + +/** + * Bot decision when it's their turn to play + */ +function botPlayDecision(config, gameState, botId, botLevel) { + const playerHand = gameState.player_hands[botId]; + const playerMelds = gameState.player_melds[botId]; + + if (!playerHand || playerHand.length === 0) { + return { action_type: 'pass', action_data: {} }; + } + + // Choose card to discard based on difficulty + let cardToPlay; + + if (botLevel === 'easy') { + // Easy: random card + cardToPlay = playerHand[Math.floor(Math.random() * playerHand.length)]; + } else if (botLevel === 'normal') { + // Normal: discard least valuable card + cardToPlay = findLeastValuableCard(config, playerHand, playerMelds); + } else { + // Hard: strategic discard considering opponent needs + cardToPlay = findStrategicDiscard(config, gameState, botId, playerHand, playerMelds); + } + + return { + action_type: 'play_cards', + action_data: { cards: [cardToPlay] } + }; +} + +/** + * Check if bot can and should Hu + */ +function canBotHu(config, gameState, botId, card, botLevel) { + const playerHand = gameState.player_hands[botId]; + const playerMelds = gameState.player_melds[botId]; + + // Simple check: if hand is empty or very small, try to hu + if (playerHand.length <= 1) { + // Easy bots only hu on big hands + if (botLevel === 'easy') { + const hasBigMeld = playerMelds.kai.length > 0 || playerMelds.yu.length > 0; + return hasBigMeld; + } + return true; + } + + return false; +} + +/** + * Check if bot can and should Kai + */ +function checkBotKai(config, gameState, botId, card, botLevel) { + const playerHand = gameState.player_hands[botId]; + const playerMelds = gameState.player_melds[botId]; + + // Check if we can upgrade a peng to kai + for (const peng of playerMelds.peng) { + const pengCard = peng.cards[0]; + if (pengCard.suit === card.suit && pengCard.rank === card.rank) { + // Found matching peng, check if we should kai + if (botLevel === 'hard') { + // Hard bots always kai for points + return { action_type: 'kai', action_data: { card: card } }; + } else if (botLevel === 'normal' && card.type === 'jin_tiao') { + // Normal bots kai jin_tiao + return { action_type: 'kai', action_data: { card: card } }; + } + } + } + + return null; +} + +/** + * Check if bot can and should Peng + */ +function checkBotPeng(config, gameState, botId, card, botLevel) { + const playerHand = gameState.player_hands[botId]; + + // Count matching cards + const matchingCards = playerHand.filter(c => + c.suit === card.suit && c.rank === card.rank + ); + + if (matchingCards.length >= 2) { + // Have enough cards to peng + if (botLevel === 'easy') { + // Easy bots only peng jin_tiao + if (card.type === 'jin_tiao') { + return { action_type: 'peng', action_data: {} }; + } + } else { + // Normal and hard bots peng more strategically + return { action_type: 'peng', action_data: {} }; + } + } + + return null; +} + +/** + * Check if bot can and should Chi + */ +function checkBotChi(config, gameState, botId, card, botLevel) { + const playerHand = gameState.player_hands[botId]; + + // Check if we're the next player + const players = Object.keys(gameState.player_hands); + const lastPlayerIndex = players.indexOf(gameState.last_play.player); + const nextPlayerIndex = (lastPlayerIndex + 1) % players.length; + const nextPlayer = players[nextPlayerIndex]; + + if (nextPlayer !== botId) { + return null; // Can only chi from previous player + } + + // Try to find chi patterns + const chiPatterns = config.custom_data.chi_patterns; + + for (const pattern of chiPatterns) { + if (pattern.type === 'sequence') { + // Check for sequence patterns (车马炮, 将士象) + const neededRanks = pattern.ranks.filter(r => r !== card.rank); + const hasNeededCards = neededRanks.every(rank => + playerHand.some(c => c.rank === rank && c.suit === card.suit) + ); + + if (hasNeededCards) { + const cardsToUse = neededRanks.map(rank => + playerHand.find(c => c.rank === rank && c.suit === card.suit) + ); + + // Easy bots don't chi + if (botLevel === 'easy') continue; + + return { + action_type: 'chi', + action_data: { + cards: cardsToUse, + pattern: { type: pattern.type, points: pattern.points } + } + }; + } + } else if (pattern.type === 'single_jiang' && card.rank === '将') { + if (botLevel !== 'easy') { + return { + action_type: 'chi', + action_data: { + cards: [], + pattern: { type: pattern.type, points: pattern.points } + } + }; + } + } else if (pattern.type === 'single_jin_tiao' && card.type === 'jin_tiao') { + // All bots chi jin_tiao + return { + action_type: 'chi', + action_data: { + cards: [], + pattern: { type: pattern.type, points: pattern.points } + } + }; + } + } + + return null; +} + +/** + * Find least valuable card to discard + */ +function findLeastValuableCard(config, playerHand, playerMelds) { + // Priority: discard isolated cards first + const cardCounts = {}; + for (const card of playerHand) { + const key = `${card.suit}-${card.rank}`; + cardCounts[key] = (cardCounts[key] || 0) + 1; + } + + // Find single cards (isolated) + for (const card of playerHand) { + const key = `${card.suit}-${card.rank}`; + if (cardCounts[key] === 1 && card.type !== 'jin_tiao') { + return card; + } + } + + // If no isolated card, return first non-jin_tiao card + for (const card of playerHand) { + if (card.type !== 'jin_tiao') { + return card; + } + } + + // Worst case: discard any card + return playerHand[0]; +} + +/** + * Find strategic card to discard (hard mode) + */ +function findStrategicDiscard(config, gameState, botId, playerHand, playerMelds) { + // For hard mode: remember discarded cards and avoid giving points + const discardPile = gameState.discard_pile || []; + + // Find safest card (least likely to be useful to opponents) + let safestCard = null; + let minRisk = Infinity; + + for (const card of playerHand) { + if (card.type === 'jin_tiao') continue; // Never discard jin_tiao first + + // Check how many of this card are in discard pile + const discardedCount = discardPile.filter(c => + c.suit === card.suit && c.rank === card.rank + ).length; + + // Higher discard count = safer to play + const risk = 4 - discardedCount; // Max 4 of same card + + if (risk < minRisk) { + minRisk = risk; + safestCard = card; + } + } + + return safestCard || findLeastValuableCard(config, playerHand, playerMelds); +} diff --git a/go.mod b/go.mod index 3687a47..319148b 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,21 @@ go 1.24.9 require github.com/pocketbase/pocketbase v0.31.0 +require github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 + require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/disintegration/imaging v1.6.2 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/ganigeorgiev/fexpr v0.5.0 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -32,7 +37,6 @@ require ( golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index e1164bb..d1d5bcf 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -6,8 +8,12 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -20,6 +26,8 @@ github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= diff --git a/main.go b/main.go index 476c9ae..6d0615a 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,9 @@ import ( func main() { app := pocketbase.New() + // Create bot scheduler + botScheduler := NewBotScheduler(app) + // Bootstrap collections and seed data on app serve app.OnServe().BindFunc(func(e *core.ServeEvent) error { if err := initializeCollections(app); err != nil { @@ -19,6 +22,10 @@ func main() { if err := seedFourColorCard(app); err != nil { return err } + + // Start bot scheduler + botScheduler.Start() + return e.Next() }) diff --git a/routes.go b/routes.go index c991258..22f4a6d 100644 --- a/routes.go +++ b/routes.go @@ -1,8 +1,13 @@ package main import ( + "encoding/json" + "fmt" + "net/http" + "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/apis" ) // registerRoutes registers all custom API routes @@ -53,4 +58,251 @@ func registerRoutes(app *pocketbase.PocketBase) { return e.Next() }) + + // Custom API endpoints + app.OnServe().BindFunc(func(e *core.ServeEvent) error { + // POST /api/tables/create - Create a new table + e.Router.POST("/api/tables/create", func(c *core.RequestEvent) error { + if c.Auth == nil { + return apis.NewUnauthorizedError("Authentication required", nil) + } + return createTableHandler(c, app) + }) + + // POST /api/tables/:id/add-bot - Add a bot to a table + e.Router.POST("/api/tables/{id}/add-bot", func(c *core.RequestEvent) error { + if c.Auth == nil { + return apis.NewUnauthorizedError("Authentication required", nil) + } + return addBotHandler(c, app) + }) + + // POST /api/game/action - Execute a game action + e.Router.POST("/api/game/action", func(c *core.RequestEvent) error { + if c.Auth == nil { + return apis.NewUnauthorizedError("Authentication required", nil) + } + return gameActionHandler(c, app) + }) + + return e.Next() + }) +} + +// createTableHandler handles table creation +func createTableHandler(c *core.RequestEvent, app *pocketbase.PocketBase) error { + authRecord := c.Auth + + // Parse request + var data struct { + Name string `json:"name"` + RuleId string `json:"rule_id"` + IsPrivate bool `json:"is_private"` + Password string `json:"password"` + } + + if err := c.BindBody(&data); err != nil { + return apis.NewBadRequestError("Invalid request data", err) + } + + // Create table record + collection, err := app.FindCollectionByNameOrId("tables") + if err != nil { + return apis.NewApiError(500, "Failed to find tables collection", err) + } + + table := core.NewRecord(collection) + table.Set("name", data.Name) + table.Set("rule", data.RuleId) + table.Set("owner", authRecord.Id) + table.Set("status", "waiting") + table.Set("players", []string{authRecord.Id}) + table.Set("is_private", data.IsPrivate) + table.Set("password", data.Password) + table.Set("player_states", map[string]interface{}{ + authRecord.Id: map[string]interface{}{ + "ready": false, + "score": 0, + "seat": 0, + }, + }) + + if err := app.Save(table); err != nil { + return apis.NewApiError(500, "Failed to create table", err) + } + + return c.JSON(http.StatusOK, table) +} + +// addBotHandler handles adding a bot to a table +func addBotHandler(c *core.RequestEvent, app *pocketbase.PocketBase) error { + authRecord := c.Auth + + // Get table ID + tableId := c.Request.PathValue("id") + table, err := app.FindRecordById("tables", tableId) + if err != nil { + return apis.NewNotFoundError("Table not found", err) + } + + // Check if user is table owner + if table.GetString("owner") != authRecord.Id { + return apis.NewForbiddenError("Only table owner can add bots", nil) + } + + // Parse request + var data struct { + SeatIndex int `json:"seat_index"` + Level string `json:"level"` + } + + if err := c.BindBody(&data); err != nil { + return apis.NewBadRequestError("Invalid request data", err) + } + + // Validate level + if data.Level == "" { + data.Level = "normal" + } + if data.Level != "easy" && data.Level != "normal" && data.Level != "hard" { + return apis.NewBadRequestError("Invalid bot level", nil) + } + + // Create bot user + usersCollection, err := app.FindCollectionByNameOrId("_pb_users_auth_") + if err != nil { + return apis.NewApiError(500, "Failed to find users collection", err) + } + + botName := fmt.Sprintf("Bot_%s_%d", data.Level, data.SeatIndex) + botUser := core.NewRecord(usersCollection) + botUser.Set("username", botName) + botUser.Set("email", fmt.Sprintf("%s@bot.local", botName)) + botUser.SetPassword("bot_password_" + botName) + botUser.Set("is_bot", true) + botUser.Set("bot_level", data.Level) + botUser.Set("verified", true) + + if err := app.Save(botUser); err != nil { + return apis.NewApiError(500, "Failed to create bot user", err) + } + + // Add bot to table + players := table.GetStringSlice("players") + players = append(players, botUser.Id) + table.Set("players", players) + + // Update player states + playerStates := table.Get("player_states") + playerStatesMap, ok := playerStates.(map[string]interface{}) + if !ok { + playerStatesMap = make(map[string]interface{}) + } + + playerStatesMap[botUser.Id] = map[string]interface{}{ + "ready": true, // Bots are always ready + "score": 0, + "seat": data.SeatIndex, + } + table.Set("player_states", playerStatesMap) + + if err := app.Save(table); err != nil { + return apis.NewApiError(500, "Failed to update table", err) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "bot_id": botUser.Id, + "message": fmt.Sprintf("Bot added to seat %d", data.SeatIndex), + }) +} + +// gameActionHandler handles game actions +func gameActionHandler(c *core.RequestEvent, app *pocketbase.PocketBase) error { + authRecord := c.Auth + + // Parse request + var data struct { + TableId string `json:"table_id"` + ActionType string `json:"action_type"` + ActionData map[string]interface{} `json:"action_data"` + } + + if err := c.BindBody(&data); err != nil { + return apis.NewBadRequestError("Invalid request data", err) + } + + // Get table + table, err := app.FindRecordById("tables", data.TableId) + if err != nil { + return apis.NewNotFoundError("Table not found", err) + } + + // Check if user is a player at this table + players := table.GetStringSlice("players") + isPlayer := false + for _, p := range players { + if p == authRecord.Id { + isPlayer = true + break + } + } + + if !isPlayer { + return apis.NewForbiddenError("You are not a player at this table", nil) + } + + // Get current game state + currentGameId := table.GetString("current_game") + if currentGameId == "" { + return apis.NewBadRequestError("No active game", nil) + } + + gameState, err := app.FindRecordById("game_states", currentGameId) + if err != nil { + return apis.NewNotFoundError("Game state not found", err) + } + + // Validate and apply action (simplified - should use game logic) + // For now, just create the action record + + // Get the next sequence number + actions, err := app.FindRecordsByFilter( + "game_actions", + fmt.Sprintf("table = '%s'", table.Id), + "-sequence_number", + 1, + 0, + ) + + sequenceNumber := 1 + if err == nil && len(actions) > 0 { + lastSeq := actions[0].GetInt("sequence_number") + sequenceNumber = lastSeq + 1 + } + + // Create action record + collection, err := app.FindCollectionByNameOrId("game_actions") + if err != nil { + return apis.NewApiError(500, "Failed to find game_actions collection", err) + } + + actionRecord := core.NewRecord(collection) + actionRecord.Set("table", table.Id) + actionRecord.Set("game_state", gameState.Id) + actionRecord.Set("player", authRecord.Id) + actionRecord.Set("sequence_number", sequenceNumber) + actionRecord.Set("action_type", data.ActionType) + + actionDataJson, _ := json.Marshal(data.ActionData) + actionRecord.Set("action_data", string(actionDataJson)) + + if err := app.Save(actionRecord); err != nil { + return apis.NewApiError(500, "Failed to save action", err) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Action recorded", + }) } From 5740b17ae490a72d968d54402105464bc3f39b8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:15:56 +0000 Subject: [PATCH 3/7] Add Vue 3 frontend with lobby, room, and game interfaces Co-authored-by: lin594 <20886330+lin594@users.noreply.github.com> --- frontend/.gitignore | 24 +++ frontend/README.md | 150 +++++++++++++ frontend/index.html | 12 ++ frontend/package.json | 21 ++ frontend/src/App.vue | 46 ++++ frontend/src/api/pocketbase.js | 181 ++++++++++++++++ frontend/src/main.js | 31 +++ frontend/src/stores/auth.js | 63 ++++++ frontend/src/views/Game.vue | 384 +++++++++++++++++++++++++++++++++ frontend/src/views/Home.vue | 201 +++++++++++++++++ frontend/src/views/Lobby.vue | 206 ++++++++++++++++++ frontend/src/views/Room.vue | 247 +++++++++++++++++++++ frontend/vite.config.js | 25 +++ 13 files changed, 1591 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/pocketbase.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/views/Game.vue create mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/Lobby.vue create mode 100644 frontend/src/views/Room.vue create mode 100644 frontend/vite.config.js diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1aedba4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,150 @@ +# Four Color Card Game Frontend + +Vue 3 + Vite + Vant 4 frontend for the Four Color Card online multiplayer game. + +## Features + +- 🎮 Mobile-first responsive design +- 👥 Real-time multiplayer gameplay +- 🤖 Support for AI bots with 3 difficulty levels +- 🔐 User authentication (register/login) +- 🎨 Modern UI with Vant components +- ⚡ Fast development with Vite + +## Tech Stack + +- **Vue 3** - Composition API +- **Vite** - Build tool +- **Pinia** - State management +- **Vue Router** - Routing +- **Vant 4** - Mobile UI components +- **PocketBase JS SDK** - Backend API + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── api/ # API services +│ │ └── pocketbase.js +│ ├── components/ # Reusable components +│ ├── stores/ # Pinia stores +│ │ └── auth.js +│ ├── views/ # Page components +│ │ ├── Home.vue # Login/Register +│ │ ├── Lobby.vue # Game lobby +│ │ ├── Room.vue # Waiting room +│ │ └── Game.vue # Game interface +│ ├── App.vue +│ └── main.js +├── index.html +├── package.json +└── vite.config.js +``` + +## Getting Started + +### Prerequisites + +- Node.js 18+ and npm +- Backend server running on http://localhost:8090 + +### Installation + +```bash +cd frontend +npm install +``` + +### Development + +```bash +npm run dev +``` + +The app will be available at http://localhost:3000 + +### Build for Production + +```bash +npm run build +``` + +The built files will be in the `dist` directory. + +## Features Overview + +### 1. Authentication (Home.vue) +- User registration with email and password +- User login +- Bilingual UI (Chinese/English) + +### 2. Game Lobby (Lobby.vue) +- View all available game rooms +- Create new rooms (public/private) +- Join existing rooms +- Real-time room list updates + +### 3. Waiting Room (Room.vue) +- View room details +- Add AI bots to empty seats +- Configure bot difficulty (Easy/Normal/Hard) +- Start game when 4 players ready + +### 4. Game Interface (Game.vue) +- Mobile-adapted game board +- View cards of all 4 players +- Play cards, draw, chi, peng, kai, hu actions +- Real-time game state updates +- Visual feedback for current turn + +## API Integration + +The frontend communicates with the PocketBase backend through: + +1. **PocketBase SDK** - For standard CRUD operations +2. **Custom API endpoints**: + - `POST /api/tables/create` - Create game table + - `POST /api/tables/:id/add-bot` - Add bot to table + - `POST /api/game/action` - Execute game action + +3. **Real-time subscriptions**: + - Table updates + - Game state changes + - Game actions stream + +## Configuration + +Update the PocketBase URL in `src/api/pocketbase.js`: + +```javascript +const pb = new PocketBase('http://localhost:8090') +``` + +For production, use environment variables: + +```javascript +const pb = new PocketBase(import.meta.env.VITE_API_URL || 'http://localhost:8090') +``` + +## Mobile Optimization + +- Responsive layout for all screen sizes +- Touch-friendly controls +- Optimized for portrait orientation +- Card display adapted for small screens +- Viewport meta tag configured + +## Future Enhancements + +- [ ] Game replays and history +- [ ] Player statistics +- [ ] Chat functionality +- [ ] Sound effects +- [ ] Animations for card movements +- [ ] Tutorial/help screens +- [ ] Landscape mode support + +## License + +See main project LICENSE file. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..784d0c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + +Four Color Card Game
+